จนถึงจุดนี้ การพูดถึงการออกแบบซอฟต์แวร์เน้นไปที่ความซับซ้อน โดยมีเป้าหมายเพื่อทำให้ซอฟต์แวร์เรียบง่ายและเข้าใจได้มากที่สุด แต่จะเกิดอะไรขึ้นถ้าคุณกำลังทำงานกับระบบที่ต้องการความเร็วสูง? การพิจารณาเรื่องประสิทธิภาพควรส่งผลต่อกระบวนการออกแบบอย่างไร? บทนี้พูดถึงวิธีบรรลุประสิทธิภาพสูงโดยไม่ต้องเสียสละการออกแบบที่สะอาด แนวคิดที่สำคัญที่สุดยังคงเป็นความเรียบง่าย: ความเรียบง่ายไม่เพียงแต่ช่วยปรับปรุงการออกแบบของระบบ แต่ยังมักจะทำให้ระบบทำงานได้เร็วขึ้นอีกด้วย

20.1 How to think about performance (วิธีคิดเกี่ยวกับประสิทธิภาพ)

คำถามแรกที่ต้องตอบคือ "คุณควรกังวลเกี่ยวกับประสิทธิภาพมากแค่ไหนในระหว่างกระบวนการพัฒนาปกติ?" ถ้าคุณพยายาม optimize ทุก statement เพื่อความเร็วสูงสุด ก็จะทำให้การพัฒนาช้าลงและสร้างความซับซ้อนที่ไม่จำเป็นมากมาย ยิ่งไปกว่านั้น "optimization" หลายอย่างก็ไม่ได้ช่วยเรื่องประสิทธิภาพจริงๆ ในทางกลับกัน ถ้าคุณละเลยปัญหาเรื่องประสิทธิภาพโดยสิ้นเชิง ก็จะจบลงด้วยความไร้ประสิทธิภาพที่สำคัญจำนวนมากกระจายอยู่ทั่ว code; ระบบที่ได้อาจช้ากว่าที่ควรจะเป็น 5–10 เท่า ในสถานการณ์ "death by a thousand cuts" แบบนี้ เป็นการยากที่จะกลับมาแก้ไขประสิทธิภาพในภายหลัง เพราะไม่มีจุดไหนที่แก้ไขแล้วจะส่งผลกระทบมากพอ

แนวทางที่ดีที่สุดคือสิ่งที่อยู่ระหว่างสองขั้วนี้ โดยใช้ความรู้พื้นฐานเกี่ยวกับประสิทธิภาพเพื่อเลือกทางเลือกในการออกแบบที่ "มีประสิทธิภาพโดยธรรมชาติ" ในขณะที่ยังคงสะอาดและเรียบง่าย กุญแจสำคัญคือการพัฒนาความตระหนักว่าการดำเนินการใดบ้างที่มีค่าใช้จ่ายสูงโดยพื้นฐาน ต่อไปนี้คือตัวอย่างบางส่วนของการดำเนินการที่มีค่าใช้จ่ายค่อนข้างสูงในปัจจุบัน:

  • การสื่อสารผ่านเครือข่าย: แม้แต่ใน datacenter เดียวกัน การส่งข้อความแบบไป-กลับอาจใช้เวลา 10–50 µs ซึ่งเป็นเวลาหลายหมื่น instruction times การสื่อสารแบบไป-กลับในระยะไกลอาจใช้เวลา 10–100 ms
  • I/O ไปยัง secondary storage: disk I/O operations โดยทั่วไปใช้เวลา 5–10 ms ซึ่งเป็นเวลาหลายล้าน instruction times Flash storage ใช้เวลา 10–100 µs หน่วยความจำ nonvolatile ใหม่ที่กำลังเกิดขึ้นอาจเร็วถึง 1 µs แต่ก็ยังคงเป็นเวลาประมาณ 2000 instruction times
  • Dynamic memory allocation ( malloc ใน C, new ใน C++ หรือ Java) มักจะมี overhead สูงทั้งในส่วนของการจัดสรร การคืนหน่วยความจำ และ garbage collection
  • Cache misses: การดึงข้อมูลจาก DRAM เข้าสู่ on-chip processor cache ใช้เวลาสองสามร้อย instruction times; ในหลายๆ โปรแกรม ประสิทธิภาพโดยรวมถูกกำหนดโดย cache misses พอๆ กับต้นทุนการคำนวณ

วิธีที่ดีที่สุดในการเรียนรู้ว่าสิ่งไหนมีค่าใช้จ่ายสูงคือการรัน micro-benchmarks (โปรแกรมขนาดเล็กที่วัดต้นทุนของการดำเนินการเดี่ยวๆ แยกออกมา) ในโปรเจกต์ RAMCloud เราได้สร้างโปรแกรมง่ายๆ ที่เป็น framework สำหรับ microbenchmarks ใช้เวลาสองสามวันในการสร้าง framework แต่ framework ทำให้สามารถเพิ่ม micro-benchmarks ใหม่ได้ในห้าหรือสิบนาที สิ่งนี้ทำให้เราสามารถสะสม micro-benchmarks ได้หลายสิบตัว เราใช้ทั้งเพื่อทำความเข้าใจประสิทธิภาพของ libraries ที่มีอยู่แล้วใน RAMCloud และเพื่อวัดประสิทธิภาพของ classes ใหม่ที่เขียนขึ้นสำหรับ RAMCloud

เมื่อคุณมีความเข้าใจทั่วไปว่าสิ่งไหนแพงและสิ่งไหนถูก คุณสามารถใช้ข้อมูลนั้นเพื่อเลือกใช้การดำเนินการที่ถูกเมื่อเป็นไปได้ ในหลายกรณี แนวทางที่มีประสิทธิภาพกว่าก็เรียบง่ายพอๆ กับแนวทางที่ช้ากว่า ตัวอย่างเช่น เมื่อต้องเก็บ collection ของ objects ขนาดใหญ่ที่ต้องค้นหาด้วย key value คุณสามารถใช้ hash table หรือ ordered map ก็ได้ ทั้งสองแบบมีให้ใช้งานทั่วไปใน library packages และทั้งสองแบบก็เรียบง่ายและสะอาดในการใช้งาน อย่างไรก็ตาม hash tables สามารถเร็วได้ง่ายๆ 5–10 เท่า ดังนั้น คุณควรใช้ hash table เสมอ เว้นแต่คุณต้องการคุณสมบัติการเรียงลำดับที่ map มีให้

อีกตัวอย่างหนึ่ง ลองพิจารณาการ allocate array ของ structures ในภาษาเช่น C หรือ C++ มีสองวิธีที่คุณสามารถทำได้ วิธีหนึ่งคือให้ array เก็บ pointers ไปยัง structures ซึ่งในกรณีนี้คุณต้อง allocate space สำหรับ array ก่อน แล้วจึง allocate space สำหรับแต่ละ structure การเก็บ structures ไว้ใน array โดยตรงจะมีประสิทธิภาพมากกว่า เพราะคุณแค่ allocate block ใหญ่ก้อนเดียวสำหรับทุกอย่าง

ถ้าวิธีเดียวที่จะปรับปรุงประสิทธิภาพคือการเพิ่มความซับซ้อน การตัดสินใจก็จะยากขึ้น ถ้าการออกแบบที่มีประสิทธิภาพกว่าเพิ่มความซับซ้อนเพียงเล็กน้อย และถ้าความซับซ้อนนั้นถูกซ่อนไว้จนไม่ส่งผลกระทบต่อ interfaces ใดๆ ก็อาจจะ คุ้มค่า (แต่ระวัง: ความซับซ้อนเป็นสิ่งที่เพิ่มขึ้นทีละน้อย) ถ้าการออกแบบที่เร็วกว่าเพิ่มความซับซ้อนในการ implement มาก หรือทำให้ interfaces ซับซ้อนขึ้น ก็อาจจะดีกว่าถ้าเริ่มต้นด้วยแนวทางที่เรียบง่ายกว่าและค่อย optimize ทีหลังถ้าประสิทธิภาพกลายเป็นปัญหา อย่างไรก็ตาม ถ้าคุณมีหลักฐานชัดเจนว่าประสิทธิภาพจะมีความสำคัญในสถานการณ์ใดสถานการณ์หนึ่ง ก็ควร implement แนวทางที่เร็วกว่าทันที

ในโปรเจกต์ RAMCloud หนึ่งในเป้าหมายโดยรวมของเราคือการให้ latency ต่ำที่สุดเท่าที่จะเป็นไปได้สำหรับ client machines ที่เข้าถึง storage system ผ่านเครือข่าย datacenter ด้วยเหตุนี้ เราจึงตัดสินใจใช้ hardware พิเศษสำหรับเครือข่าย ซึ่งทำให้ RAMCloud สามารถ bypass kernel และสื่อสารโดยตรงกับ network interface controller เพื่อส่งและรับ packets เราตัดสินใจเช่นนี้แม้ว่าจะเพิ่มความซับซ้อน เพราะเรารู้จากการวัดก่อนหน้านี้ว่า kernel-based networking จะช้าเกินไปที่จะตอบสนองความต้องการของเรา ในส่วนอื่นๆ ส่วนใหญ่ของระบบ RAMCloud เราสามารถออกแบบเพื่อความเรียบง่าย; การทำให้ประเด็นใหญ่ประเด็นนี้ "ถูกต้อง" ทำให้หลายๆ อย่างอื่นง่ายขึ้น

โดยทั่วไปแล้ว code ที่เรียบง่ายมักจะทำงานได้เร็วกว่า code ที่ซับซ้อน ถ้าคุณได้นิยาม special cases และข้อยกเว้นออกไปแล้ว ก็ไม่ต้องใช้ code ในการตรวจสอบกรณีเหล่านั้น และระบบก็ทำงานได้เร็วขึ้น Deep classes มีประสิทธิภาพมากกว่า shallow classes เพราะทำงานได้มากกว่าในแต่ละ method call Shallow classes ส่งผลให้เกิด layer crossings มากขึ้น และแต่ละ layer crossing ก็เพิ่ม overhead

20.2 Measure before modifying (วัดก่อนแก้ไข)

แต่สมมติว่าระบบของคุณยังทำงานช้าเกินไป แม้ว่าคุณจะออกแบบมันตามที่กล่าวไว้ข้างต้นแล้วก็ตาม มันน่าดึงดูดใจที่จะรีบเร่งและเริ่มปรับแต่ง performance ตามสัญชาตญาณของคุณเกี่ยวกับสิ่งที่ช้า อย่าทำอย่างนั้น! สัญชาตญาณของโปรแกรมเมอร์เกี่ยวกับประสิทธิภาพนั้นไม่น่าเชื่อถือ สิ่งนี้เป็นจริงแม้กระทั่งสำหรับนักพัฒนาที่มีประสบการณ์ ถ้าคุณเริ่มเปลี่ยนแปลงตามสัญชาตญาณ คุณจะเสียเวลาไปกับสิ่งที่ไม่ได้ช่วยปรับปรุงประสิทธิภาพจริงๆ และคุณอาจจะทำให้ระบบซับซ้อนมากขึ้นในกระบวนการ

ก่อนที่จะทำการเปลี่ยนแปลงใดๆ ให้วัดพฤติกรรมปัจจุบันของระบบก่อน การทำเช่นนี้มีวัตถุประสงค์สองประการ ประการแรก การวัดจะระบุจุดที่การปรับแต่งประสิทธิภาพจะส่งผลกระทบมากที่สุด การวัดแค่ประสิทธิภาพระดับบนสุดของระบบนั้นไม่เพียงพอ สิ่งนี้อาจบอกคุณว่าระบบช้าเกินไป แต่มันจะไม่บอกคุณว่าทำไม คุณจะต้องวัดในระดับที่ลึกขึ้นเพื่อระบุรายละเอียดปัจจัยที่ส่งผลต่อประสิทธิภาพโดยรวม; เป้าหมายคือการระบุสถานที่เฉพาะเจาะจงจำนวนน้อย ที่ระบบกำลังใช้เวลามาก และที่คุณมีแนวคิดในการปรับปรุง วัตถุประสงค์ที่สองของการวัดคือการให้ baseline เพื่อให้คุณสามารถวัดประสิทธิภาพซ้ำได้หลังจากทำการเปลี่ยนแปลงเพื่อให้แน่ใจว่าประสิทธิภาพดีขึ้นจริง ถ้าการเปลี่ยนแปลงไม่ได้สร้างความแตกต่างที่วัดได้ในประสิทธิภาพ ก็ให้ย้อนกลับการเปลี่ยนแปลง (เว้นแต่มันจะทำให้ระบบเรียบง่ายขึ้น) ไม่มีประโยชน์ที่จะเก็บความซับซ้อนไว้เว้นแต่มันจะช่วยเพิ่มความเร็วอย่างมีนัยสำคัญ

20.3 Design around the critical path (ออกแบบรอบ critical path)

ณ จุดนี้ สมมติว่าคุณได้วิเคราะห์ประสิทธิภาพอย่างละเอียดและระบุชิ้นส่วนของ code ที่ช้าพอที่จะส่งผลต่อประสิทธิภาพโดยรวมของระบบ วิธีที่ดีที่สุดในการปรับปรุงประสิทธิภาพคือการเปลี่ยนแปลงแบบ "fundamental" เช่น การนำ cache มาใช้ หรือการใช้ algorithmic approach ที่แตกต่าง (balanced tree vs. list เป็นต้น) การตัดสินใจของเราที่จะ bypass kernel สำหรับการสื่อสารเครือข่ายใน RAMCloud เป็นตัวอย่างของการแก้ไขแบบ fundamental ถ้าคุณสามารถระบุการแก้ไขแบบ fundamental ได้ คุณก็สามารถ implement โดยใช้เทคนิคการออกแบบที่กล่าวถึงในบทก่อนหน้า

น่าเสียดายที่บางครั้งอาจเกิดสถานการณ์ที่ไม่มีการแก้ไขแบบ fundamental สิ่งนี้พาเรามาถึงประเด็นหลักของบทนี้ ซึ่งก็คือวิธีการออกแบบชิ้นส่วนของ code ที่มีอยู่ใหม่ให้ทำงานเร็วขึ้น นี่ควรเป็นทางเลือกสุดท้าย และไม่ควรเกิดขึ้นบ่อย แต่ก็มีกรณีที่มันสามารถสร้างความแตกต่างอย่างมากได้ แนวคิดสำคัญคือการออกแบบ code รอบ critical path

เริ่มต้นด้วยการถามตัวเองว่า code จำนวนน้อยที่สุดที่ต้องถูก execute เพื่อทำงานที่ต้องการในกรณีทั่วไปคือเท่าไหร่ มองข้ามโครงสร้าง code ที่มีอยู่ ลองนึกภาพว่าคุณกำลังเขียน method ใหม่ที่ implement แค่ critical path ซึ่งเป็นจำนวน code ขั้นต่ำที่ต้องถูก execute ในกรณีที่พบบ่อยที่สุด code ปัจจุบันอาจจะรกไปด้วย special cases; ให้มองข้ามมันไปในแบบฝึกหัดนี้ code ปัจจุบันอาจจะผ่าน method calls หลายครั้งบน critical path; ลองนึกภาพว่าคุณสามารถใส่ code ที่เกี่ยวข้องทั้งหมดไว้ใน method เดียว code ปัจจุบันอาจใช้ตัวแปรและ data structures ที่หลากหลาย; ให้พิจารณาเฉพาะข้อมูลที่จำเป็นสำหรับ critical path และสมมติ data structure ที่สะดวกที่สุดสำหรับ critical path ตัวอย่างเช่น มันอาจจะสมเหตุสมผลที่จะรวมหลาย variables เข้าเป็นค่าเดียว สมมติว่าคุณสามารถออกแบบระบบใหม่ทั้งหมดเพื่อลด code ที่ต้อง execute สำหรับ critical path ให้เหลือน้อยที่สุด เรียก code นี้ว่า "the ideal"

code ที่เป็น ideal น่าจะขัดแย้งกับโครงสร้าง class ที่มีอยู่ และอาจจะไม่สามารถปฏิบัติได้จริง แต่มันให้เป้าหมายที่ดี: นี่คือ code ที่เรียบง่ายที่สุดและเร็วที่สุดเท่าที่จะเป็นไปได้ ขั้นตอนต่อไปคือมองหาการออกแบบใหม่ที่ใกล้เคียงกับ ideal มากที่สุดในขณะที่ยังคงมีโครงสร้างที่สะอาด คุณสามารถใช้แนวคิดการออกแบบทั้งหมดจากบทก่อนหน้าของหนังสือเล่มนี้ แต่มีข้อจำกัดเพิ่มเติมในการรักษา ideal code ไว้ (ส่วนใหญ่) คุณอาจต้องเพิ่ม code พิเศษเล็กน้อยเข้าไปใน ideal เพื่อให้มี clean abstractions; ตัวอย่างเช่น ถ้า code เกี่ยวข้องกับการค้นหา hash table ก็ไม่เป็นไรที่จะแนะนำ method call พิเศษไปยัง general-purpose hash table class จากประสบการณ์ของฉัน มันเกือบจะเป็นไปได้เสมอที่จะหาการออกแบบที่สะอาดและเรียบง่าย แต่ใกล้เคียงกับ ideal มาก

หนึ่งในสิ่งที่สำคัญที่สุดที่เกิดขึ้นในกระบวนการนี้คือการลบ special cases ออกจาก critical path เมื่อ code ช้า มักจะเป็นเพราะมันต้องจัดการกับสถานการณ์ที่หลากหลาย และ code ถูกจัดโครงสร้างเพื่อลดความซับซ้อนในการจัดการกับทุกกรณีที่แตกต่างกัน แต่ละ special case เพิ่ม code เล็กน้อยให้กับ critical path ในรูปแบบของ conditional statements และ/หรือ method calls เพิ่มเติม แต่ละการเพิ่มเติมเหล่านี้ทำให้ code ช้าลงเล็กน้อย เมื่อออกแบบใหม่เพื่อประสิทธิภาพ ให้พยายามลดจำนวน special cases ที่คุณต้องตรวจสอบ ตามอุดมคติแล้ว ควรมี if statement เดียวที่จุดเริ่มต้น ซึ่งตรวจจับ special cases ทั้งหมดด้วยการทดสอบเดียว ในกรณีปกติ จะต้องทำการทดสอบนี้เพียงครั้งเดียว หลังจากนั้น critical path สามารถถูก execute ได้โดยไม่ต้องมีการทดสอบเพิ่มเติมสำหรับ special cases ถ้าการทดสอบเริ่มต้นล้มเหลว (ซึ่งหมายถึงเกิด special case) code สามารถแยกไปยังตำแหน่งที่แยกต่างหากนอก critical path เพื่อจัดการกับมัน ประสิทธิภาพไม่สำคัญเท่าสำหรับ special cases ดังนั้นคุณจึงสามารถจัดโครงสร้าง special-case code เพื่อความเรียบง่ายแทนประสิทธิภาพ

20.4 An example: RAMCloud Buffers (ตัวอย่าง: RAMCloud Buffers)

ลองพิจารณาตัวอย่างที่ Buffer class ของ RAMCloud storage system ถูก optimized เพื่อให้ได้ความเร็วเพิ่มขึ้นประมาณ 2 เท่าสำหรับการดำเนินการที่พบบ่อยที่สุด

RAMCloud ใช้ Buffer objects เพื่อจัดการ variable-length arrays ของหน่วยความจำ เช่น request และ response messages สำหรับ remote procedure calls Buffers ถูกออกแบบมาเพื่อลด overheads จากการ copy หน่วยความจำและการจัดสรร storage แบบ dynamic Buffer เก็บสิ่งที่ดูเหมือน linear array ของ bytes แต่เพื่อประสิทธิภาพ มันอนุญาตให้ storage พื้นฐานถูกแบ่งออกเป็นหลาย chunks ของหน่วยความจำที่ไม่ต่อเนื่องกัน ดังที่แสดงใน Figure 20.1 Buffer ถูกสร้างขึ้นโดยการ append chunks ของข้อมูล แต่ละ chunk เป็น external หรือ internal ถ้า chunk เป็น external storage จะเป็นของ caller; Buffer เก็บ reference ไปยัง storage นี้ External chunks มักใช้สำหรับ chunks ขนาดใหญ่เพื่อหลีกเลี่ยง memory copies ถ้า chunk เป็น internal Buffer จะเป็นเจ้าของ storage สำหรับ chunk; ข้อมูลที่ caller ส่งมาจะถูก copy เข้าไปใน internal storage ของ Buffer แต่ละ Buffer มี allocation ในตัวขนาดเล็ก ซึ่งเป็น block ของหน่วยความจำที่มีไว้สำหรับเก็บ internal chunks ถ้าพื้นที่นี้หมด Buffer จะสร้าง allocations เพิ่มเติม ซึ่งต้องถูก freeing เมื่อ Buffer ถูกทำลาย Internal chunks สะดวกสำหรับ chunks เล็กๆ ที่ต้นทุนการ copy หน่วยความจำไม่สำคัญ Figure 20.1 แสดง Buffer ที่มี 5 chunks: chunk แรกเป็น internal, สองถัดไปเป็น external, และสอง chunk สุดท้ายเป็น internal

img

Figure 20.1 (รูป 20.1): Buffer object ใช้ collection ของ memory chunks เพื่อเก็บสิ่งที่ดูเหมือน linear array ของ bytes Internal chunks เป็นของ Buffer และจะถูก freeing เมื่อ Buffer ถูกทำลาย; external chunks ไม่ได้เป็นของ Buffer

Buffer class เองแสดงถึง "fundamental fix" ตรงที่มันกำจัดการ copy หน่วยความจำที่มีค่าใช้จ่ายสูงซึ่งจำเป็นหากไม่มีมัน ตัวอย่างเช่น เมื่อประกอบ response message ที่มี header สั้นและเนื้อหาของ object ขนาดใหญ่ใน RAMCloud storage system RAMCloud ใช้ Buffer ที่มีสอง chunks chunk แรกเป็น internal chunk ที่มี header; chunk ที่สองเป็น external chunk ที่อ้างถึงเนื้อหาของ object ใน RAMCloud storage system response สามารถถูกรวบรวมใน Buffer ได้โดยไม่ต้อง copy object ขนาดใหญ่

นอกเหนือจากแนวทาง fundamental ในการอนุญาตให้มี chunks ที่ไม่ต่อเนื่องกันแล้ว เราไม่ได้พยายาม optimize code ของ Buffer class ในการ implement ครั้งแรก อย่างไรก็ตาม เมื่อเวลาผ่านไป เราสังเกตเห็นว่า Buffers ถูกใช้ในสถานการณ์มากขึ้นเรื่อยๆ; ตัวอย่างเช่น มีการสร้าง Buffers อย่างน้อยสี่ตัวระหว่างการ execute ของ remote procedure call แต่ละครั้ง ในที่สุด มันก็ชัดเจนว่าการทำให้การ implement ของ Buffer เร็วขึ้นสามารถส่งผลกระทบที่สังเกตได้ต่อประสิทธิภาพโดยรวมของระบบ เราตัดสินใจดูว่าเราสามารถปรับปรุงประสิทธิภาพของ Buffer class ได้หรือไม่

การดำเนินการที่พบบ่อยที่สุดสำหรับ Buffer คือการ allocate space สำหรับข้อมูลใหม่จำนวนเล็กน้อย โดยใช้ internal chunk สิ่งนี้เกิดขึ้น ตัวอย่างเช่น เมื่อสร้าง headers สำหรับ request และ response messages เราตัดสินใจใช้การดำเนินการนี้เป็น critical path สำหรับ optimization ในกรณีที่ง่ายที่สุด space สามารถถูก allocate ได้โดยการขยาย chunk สุดท้ายที่มีอยู่ใน Buffer อย่างไรก็ตาม สิ่งนี้เป็นไปได้ก็ต่อเมื่อ chunk สุดท้ายที่มีอยู่เป็น internal และมีพื้นที่เพียงพอใน allocation ของมันที่จะรองรับข้อมูลใหม่ code ที่เป็น ideal จะทำการตรวจสอบเพียงครั้งเดียวเพื่อยืนยันว่าแนวทางง่ายๆ เป็นไปได้ จากนั้นก็ปรับขนาดของ chunk ที่มีอยู่

Figure 20.2 แสดง code ดั้งเดิมสำหรับ critical path ซึ่งเริ่มต้นด้วย method Buffer::alloc ในกรณีที่เร็วที่สุด Buffer::alloc เรียก Buffer::allocateAppend ซึ่งเรียก Buffer::Allocation::allocateAppend จากมุมมองด้านประสิทธิภาพ code นี้มีปัญหาสองประการ ปัญหาแรกคือมี special cases จำนวนมากที่ถูกตรวจสอบทีละรายการ:

  • Buffer::allocateAppend ตรวจสอบว่า Buffer มี allocations ใดๆ ในขณะนี้หรือไม่
  • code ตรวจสอบสองครั้งว่า allocation ปัจจุบันมีพื้นที่เพียงพอสำหรับข้อมูลใหม่หรือไม่: ครั้งแรกใน Buffer::Allocation::allocateAppend และอีกครั้งเมื่อ return value ของมันถูกทดสอบโดย Buffer::allocateAppend
  • Buffer::alloc ทดสอบ return value จาก Buffer::allocAppend เพื่อยืนยันอีกครั้งว่า allocation สำเร็จ

ยิ่งไปกว่านั้น แทนที่จะพยายามขยาย chunk สุดท้ายโดยตรง code จะ allocate space ใหม่โดยไม่คำนึงถึง chunk สุดท้าย จากนั้น Buffer::alloc ตรวจสอบว่า space นั้นเกิดขึ้นอยู่ติดกับ chunk สุดท้ายหรือไม่ ซึ่งในกรณีนี้它将รวม space ใหม่เข้ากับ chunk ที่มีอยู่ ส่งผลให้มีการตรวจสอบเพิ่มเติม โดยรวมแล้ว code นี้ทดสอบ 6 เงื่อนไขที่แตกต่างกันใน critical path

ปัญหาที่สองของ code ดั้งเดิมคือมันมี layers มากเกินไป ซึ่งทั้งหมดเป็น shallow layers นี่เป็นทั้งปัญหา performance และปัญหา design critical path มี method calls เพิ่มเติมสอง call นอกเหนือจากการเรียก Buffer::alloc ครั้งแรก แต่ละ method call ใช้เวลาเพิ่มเติม และผลลัพธ์ของแต่ละ call ต้องถูกตรวจสอบโดย caller ซึ่งส่งผลให้มี special cases ที่ต้องพิจารณามากขึ้น Chapter 7 พูดถึงว่า abstractions ควรเปลี่ยนแปลงเมื่อคุณผ่านจาก layer หนึ่งไปยังอีก layer หนึ่ง แต่ทั้งสาม methods ใน Figure 20.2 มี signatures ที่เหมือนกันและให้ abstraction ที่เหมือนกันโดยพื้นฐาน; นี่คือ red flag Buffer::allocateAppend เกือบจะเป็น pass-through method; สิ่งเดียวที่มันทำคือสร้าง allocation ใหม่ถ้าจำเป็น layers ที่เกินมาทำให้ code ทั้งช้าลงและซับซ้อนมากขึ้น

เพื่อแก้ไขปัญหาเหล่านี้ เรา refactor Buffer class เพื่อให้การออกแบบของมันเน้นไปที่ paths ที่มีผลต่อประสิทธิภาพมากที่สุด เราไม่เพียงแค่พิจารณา allocation code ข้างต้น แต่ยังรวมถึง paths อื่นๆ ที่ถูก execute บ่อย เช่น การดึงจำนวน bytes ทั้งหมดของข้อมูลที่เก็บอยู่ใน Buffer ในปัจจุบัน สำหรับแต่ละ critical paths เหล่านี้ เราพยายามระบุจำนวน code ที่น้อยที่สุดที่ต้องถูก execute ในกรณีทั่วไป จากนั้นเราออกแบบส่วนที่เหลือของ class รอบ critical paths เหล่านี้ เรายังใช้หลักการออกแบบจากหนังสือเล่มนี้เพื่อทำให้ class เรียบง่ายขึ้นโดยทั่วไป ตัวอย่างเช่น เรากำจัด shallow layers และสร้าง internal abstractions ที่ลึกขึ้น class ที่ refactor แล้วมีขนาดเล็กกว่า version ดั้งเดิม 20% (1476 บรรทัดของ code เทียบกับ 1886 บรรทัดใน version ดั้งเดิม)

img

Figure 20.2 (รูป 20.2): code ดั้งเดิมสำหรับการ allocate space ใหม่ที่ส่วนท้ายของ Buffer โดยใช้ internal chunk

img

Figure 20.3 (รูป 20.3): code ใหม่สำหรับการ allocate space ใหม่ใน internal chunk ของ Buffer

Figure 20.3 แสดง critical path ใหม่สำหรับการ allocate internal space ใน Buffer code ใหม่ไม่เพียงแต่เร็วกว่า แต่ยังอ่านง่ายกว่า เนื่องจากหลีกเลี่ยง shallow abstractions ทั้ง path ถูกจัดการใน method เดียว และใช้การทดสอบเพียงครั้งเดียวเพื่อตัด special cases ทั้งหมดออกไป code ใหม่แนะนำ instance variable ใหม่ extraAppendBytes เพื่อทำให้ critical path ง่ายขึ้น ตัวแปรนี้ติดตามว่ามีพื้นที่ว่างที่ไม่ได้ใช้เท่าใดอยู่ถัดจาก chunk สุดท้ายใน Buffer ทันที ถ้าไม่มีพื้นที่ว่าง หรือถ้า chunk สุดท้ายใน Buffer ไม่ใช่ internal chunk หรือถ้า Buffer ไม่มี chunks เลย extraAppendBytes ก็จะเป็นศูนย์ code ใน Figure 20.3 แทนจำนวน code น้อยที่สุดที่เป็นไปได้ในการจัดการกับกรณีทั่วไปนี้

หมายเหตุ: การอัปเดต totalLength อาจถูกกำจัดได้โดยการคำนวณความยาว Buffer ทั้งหมดจากแต่ละ chunks ใหม่ทุกครั้งที่ต้องการ อย่างไรก็ตาม วิธีการนี้จะมีค่าใช้จ่ายสูงสำหรับ Buffer ขนาดใหญ่ที่มีหลาย chunks และการดึง ความยาว Buffer ทั้งหมดเป็นการดำเนินการทั่วไปอีกอย่างหนึ่ง ดังนั้น เราจึงเลือกที่จะเพิ่ม overhead เล็กน้อยให้กับ alloc เพื่อให้แน่ใจว่าความยาวของ Buffer พร้อมใช้งานได้ทันทีเสมอ

code ใหม่เร็วกว่า code เก่าประมาณสองเท่า: เวลาทั้งหมดในการ append string ขนาด 1 ไบต์ไปยัง Buffer โดยใช้ internal storage ลดลงจาก 8.8 ns เหลือ 4.75 ns การดำเนินการ Buffer อื่นๆ อีกมากมายก็เร็วขึ้นเช่นกันเนื่องจากการแก้ไข ตัวอย่างเช่น เวลาในการสร้าง Buffer ใหม่ append chunk เล็กๆ ใน internal storage และทำลาย Buffer ลดลงจาก 24 ns เหลือ 12 ns

20.5 Conclusion (บทสรุป)

บทเรียนสำคัญที่สุดจากบทนี้คือการออกแบบที่สะอาดและประสิทธิภาพสูงนั้นเข้ากันได้ การเขียน Buffer class ใหม่ทำให้ประสิทธิภาพดีขึ้นเป็น 2 เท่า ในขณะที่ทำให้การออกแบบเรียบง่ายขึ้นและลดขนาด code ลง 20% code ที่ซับซ้อนมักจะช้าเพราะมันทำงานที่เกินจำเป็นหรือซ้ำซ้อน ในทางกลับกัน ถ้าคุณเขียน code ที่สะอาดและเรียบง่าย ระบบของคุณก็อาจจะเร็วพอจนคุณไม่ต้องกังวลเกี่ยวกับประสิทธิภาพมากนัก ในกรณีไม่กี่ครั้งที่คุณต้อง optimize ประสิทธิภาพ กุญแจสำคัญก็คือความเรียบง่ายอีกครั้ง: หา critical paths ที่สำคัญที่สุดสำหรับประสิทธิภาพและทำให้มันเรียบง่ายที่สุดเท่าที่จะทำได้