Designing for Performance (การออกแบบเพื่อประสิทธิภาพ)

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

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

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

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

  • Network communication: แม้แต่ในศูนย์ข้อมูล การแลกเปลี่ยนข้อความแบบ round-trip สามารถใช้เวลา 10-50 µs ซึ่งเป็นสิบนับพันครั้งของเวลาคำสั่ง Round-trip ขนาดใหญ่สามารถใช้เวลา 10-100 ms
  • I/O to secondary storage: การดำเนินการ disk I/O โดยทั่วไปใช้เวลา 5-10 ms ซึ่งเป็นนับล้านครั้งของเวลาคำสั่ง Flash storage ใช้เวลา 10-100 µs หน่วยความจำที่ไม่ระเหยใหม่ที่กำลังเกิดขึ้นอาจเร็วถึง 1 µs แต่นี่ยังคงเป็นประมาณ 2000 ครั้งของเวลาคำสั่ง
  • Dynamic memory allocation (malloc ใน C, new ใน C++ หรือ Java) โดยทั่วไปเกี่ยวข้องกับค่าใช้จ่ายที่มากมายสำหรับการจัดสรร การเосвобождение และการเก็บขยะ
  • Cache misses: การดึงข้อมูลจาก DRAM เข้าไปใน cache ของตัวประมวลผลบนชิป ใช้เวลาไม่กี่ร้อยครั้งของเวลาคำสั่ง ในโปรแกรมจำนวนมาก ประสิทธิภาพโดยรวมถูกกำหนดเท่าๆ กันโดยการพลาด cache เช่นเดียวกับต้นทุนการคำนวณ

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

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

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

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

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

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

20.2 Measure before modifying (วัดก่อนที่จะปรับเปลี่ยน)

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

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

20.3 Design around the critical path (ออกแบบรอบเส้นทาง Critical)

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

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

เริ่มต้นโดยถามตัวเองว่าโค้ดขั้นต่ำที่จะต้องดำเนินการเพื่อดำเนินการตามที่ต้องการในกรณีทั่วไปคือเท่าไร ไม่สนใจโครงสร้างโค้ดที่มีอยู่ แทนที่จะ imagine ว่าคุณกำลังเขียน method ใหม่ที่นำไปใช้เพียง critical path เท่านั้น ซึ่งเป็นโค้ดขั้นต่ำที่จะต้องดำเนินการในกรณีทั่วไปที่สุด โค้ดปัจจุบันอาจจะ cluttered กับกรณีพิเศษ ละเลยพวกเขาในแบบฝึกหัดนี้ โค้ดปัจจุบันอาจผ่านการเรียก method หลายครั้งบน critical path imagine แทนว่าคุณจะสามารถใส่โค้ดที่เกี่ยวข้องทั้งหมดในวิธีเดียว โค้ดปัจจุบันอาจใช้ตัวแปรและโครงสร้างข้อมูลที่หลากหลาย พิจารณาเพียง data ที่จำเป็นสำหรับ critical path และสมมติว่า whatever data structure มี convenience สำหรับ critical path ตัวอย่างเช่น อาจสมเหตุสมควรที่จะรวม variables หลายตัวเข้ากับค่าเดียว สมมติว่าคุณจะสามารถ redesign อย่างสมบูรณ์ระบบเพื่อลดโค้ดที่จะต้องดำเนินการสำหรับ critical path มาเรียก "ideal" นี้

โค้ด ideal อาจจะ clashes กับโครงสร้าง class ของคุณที่มีอยู่ และอาจจะไม่สำคัญ แต่มันจัดให้มี target ที่ดี: นี่แสดงถึง simplest และ fastest ที่ โค้ดสามารถเป็นได้ เนื่องจากเป็นเมื่อใด ขั้นตอนต่อไปคือการค้นหา design ใหม่ที่ได้รับมาใกล้เท่าที่เป็นไปได้กับ ideal ในขณะที่ยังคงมี clean structure นี่คุณสามารถใช้ design ideas ทั้งหมดจากบท ก่อนหน้า ของหนังสือเล่มนี้ แต่ด้วย constraint เพิ่มเติมในการเก็บ ideal code (ส่วนใหญ่) ไว้ คุณอาจต้องเพิ่มโค้ดเล็กน้อยไปยัง ideal เพื่อให้อนุญาต clean abstractions ตัวอย่างเช่น หากโค้ดเกี่ยวข้องกับ hash table lookup มันมี OK เพื่อแนะนำ method call เพิ่มเติมไปยัง general-purpose hash table class ในประสบการณ์ของผม มันเกือบจะเป็นไปได้เสมอที่จะหา design ที่สะอาดและเรียบง่าย แต่ได้มาใกล้เท่าที่เป็นไปได้กับ ideal

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

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

มาพิจารณาตัวอย่าง ซึ่ง Buffer class ของระบบ RAMCloud storage ได้รับการปรับปรุง ประสิทธิภาพเพื่อให้ได้ speedup ประมาณ 2x สำหรับการดำเนินการทั่วไปที่สุด

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

img

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

Buffer class เอง แสดงถึง "fundamental fix" ในที่ที่ลบ expensive memory copies ที่จะได้รับ required โดยไม่มีมัน ตัวอย่างเช่น เมื่อ assembling response message ที่มี short header และของปัจจุบัน ของ large object ในระบบ RAMCloud storage Buffer ใช้ chunks สอง Buffer ด้วย chunk แรกเป็นหนึ่ง internal ที่มี header second chunk เป็น external หนึ่งที่ refers ไปยัง object contents ใน storage system RAMCloud Response สามารถ collected ใน Buffer โดยไม่ copy large object

นอกจากวิธีพื้นฐานของการ allowing discontiguous chunks เราไม่ได้พยายามที่จะ optimize โค้ดของ Buffer class ในการใช้ original implementation ตลอดเวลา แต่ เราสังเกต Buffers ถูก used ในมากขึ้นและมากขึ้นสถานการณ์ ตัวอย่างเช่น อย่างน้อย four Buffers ถูก created ระหว่างการดำเนินการของแต่ละ remote procedure call ในที่สุด มันกลาย obvious ว่า speeding ขึ้นในการใช้ Buffer class อาจมีผล noticeable บน system performance โดยรวม เราตัดสินใจที่จะดู ถ้าเราสามารถปรับปรุงประสิทธิภาพของ Buffer class

การดำเนินการทั่วไปที่สุดสำหรับ Buffer คือการจัดสรรพื้นที่สำหรับเล็กน้อย ข้อมูล ใหม่ โดยใช้ internal chunk นี่เกิดขึ้น ตัวอย่างเช่น เมื่อสร้าง headers สำหรับ request และ response messages เราตัดสินใจใช้การดำเนินการนี้เป็น critical path สำหรับการ optimize ในกรณี simplest ที่เป็นไปได้ พื้นที่สามารถ allocated โดยการ enlarging last existing chunk ใน Buffer อย่างไรก็ตาม นี่เป็นไปได้เฉพาะ ถ้า last existing chunk เป็น internal และหาก มีพื้นที่เพียงพอใน allocation ของมันสำหรับ accommodate ข้อมูล ใหม่ ideal code จะดำเนินการ single check เพื่อ confirm ว่า simple approach เป็นไปได้ จากนั้นมันจะ adjust ขนาดของ existing chunk

Figure 20.2 แสดงโค้ด original สำหรับ critical path ซึ่ง starts ด้วย method Buffer::alloc ในกรณี fastest ที่เป็นไปได้ Buffer::alloc calls Buffer::allocateAppend ซึ่ง calls Buffer::Allocation::allocateAppend จาก performance standpoint โค้ดนี้มีปัญหา two ปัญหา first คือว่า numerous special cases ถูก checked เป็นแต่ละรายการ

  • Buffer::allocateAppend checks เพื่อดูว่า Buffer currently มี allocations
  • โค้ด checks twice เพื่อดู ถ้า current allocation มีพอ room สำหรับข้อมูล ใหม่: once ใน Buffer::Allocation::allocateAppend และ again เมื่อ return value ของมันเป็น tested โดย Buffer::allocateAppend
  • Buffer::alloc tests return value จาก Buffer::allocAppend เพื่อ confirm yet again ว่า allocation succeeded

นอกจากนี้ มากกว่า trying ที่จะ expand last chunk directly โค้ด allocates หน่วยความจำ ใหม่ โดยไม่มี consideration ของ last chunk จากนั้น Buffer::alloc checks เพื่อดู ถ้า พื้นที่ adjacent นั้น ไปยัง last chunk ซึ่ง case นี้มัน merges หน่วยความจำ ใหม่ กับ existing chunk สิ่งนี้ส่งผลให้มี checks เพิ่มเติม โดยรวม โค้ดนี้ tests 6 distinct conditions ใน critical path

ปัญหา second กับโค้ด original คือว่า มันมี too many layers ทั้งหมดที่เป็น shallow นี่เป็นทั้ง performance problem และ design problem critical path ทำ two additional method calls เพิ่มเติมไปไปยัง original invocation ของ Buffer::alloc แต่ละ method call ใช้เวลาเพิ่มเติม และ result ของแต่ละ call ต้องเป็น checked โดยที่ caller ของมัน ซึ่ง results ใน more special cases เพื่อพิจารณา Chapter 7 discussed วิธี abstractions ควร normally เปลี่ยน ตามที่คุณ pass จากหนึ่ง layer ไปยัง another แต่ทั้งสาม ของ methods ใน Figure 20.2 มี identical signatures และ พวกเขา provide ส่วนสำคัญ identical abstraction นี่คือ red flag Buffer::allocateAppend เป็น nearly pass-through method มันมี only contribution คือการ สร้าง allocation ใหม่ ถ้า needed extra layers ทำให้โค้ด both slower และ more complicated

เพื่อ fix ปัญหาเหล่านี้ เราทำการ refactored Buffer class ดังนั้นว่า design ของมัน centered รอบ most performance-critical paths ที่สุด เราพิจารณา ไม่ใช่เพียงแค่ code allocation ข้างต้น แต่ several other commonly executed paths เช่น retrieving total number ของ bytes ของ data currently stored ใน Buffer สำหรับแต่ละ critical paths เหล่านี้ เราพยายาม identify smallest amount ของ code ที่จะต้อง executed ใน common case จากนั้น เราออกแบบส่วนที่เหลือ class รอบ critical paths เหล่านี้ เรายัง applied design principles จากหนังสือเล่มนี้เพื่อลด simplify class โดยทั่วไป ตัวอย่างเช่น เราลบ shallow layers และสร้าง deeper internal abstractions refactored class คือ 20% smaller กว่า original version (1476 lines ของ code versus 1886 lines ใน original)

img

Figure 20.2: โค้ด original สำหรับ allocating หน่วยความจำ ใหม่ ที่ end ของ Buffer ใช้ internal chunk

img

Figure 20.3: โค้ด ใหม่ สำหรับ allocating หน่วยความจำ ใหม่ ใน internal chunk ของ Buffer

Figure 20.3 แสดง critical path ใหม่สำหรับ allocating internal space ใน Buffer โค้ด ใหม่ไม่เพียงแต่เร็วเท่านั้น แต่ก็อ่านได้ง่ายขึ้น เนื่องจากอหลีกเลี่ยง shallow abstractions entire path ถูก handled ใน single method และมัน uses single test เพื่อ rule out ทั้งหมด special cases โค้ด ใหม่ introduces instance variable ใหม่ extraAppendBytes เพื่อลด simplify critical path ตัวแปรนี้เก็บ track ของ how much unused space มี available ทันทีหลัง last chunk ใน Buffer ถ้ามีพื้นที่ไม่มี available หรือถ้า last chunk ใน Buffer ไม่ใช่ internal chunk หรือถ้า Buffer ประกอบด้วย ไม่มี chunks จากนั้น extraAppendBytes เป็น zero โค้ดใน Figure 20.3 แสดง least possible amount ของ code เพื่อ handle case common นี้

Note: อัปเดต ไปยัง totalLength สามารถได้รับ eliminated โดย recomputing total Buffer length จาก individual chunks เมื่อใด ก็ที่มันเป็น needed อย่างไรก็ตาม วิธี นี้จะ expensive สำหรับ large Buffer ที่มี many chunks และ fetching total Buffer length คือ another common operation ดังนั้น เราเลือก เพื่อเพิ่ม small amount ของ extra overhead ไปยัง alloc เพื่อให้แน่ใจว่า Buffer length มั always immediately available

โค้ด ใหม่เป็นประมาณ twice เร็วเท่า เก่า code: total time เพื่อ append 1-byte string ไปยัง Buffer โดยใช้ internal storage dropped จาก 8.8 ns ไปยัง 4.75 ns หลายอื่น ๆ Buffer operations ก็ speeded ขึ้น เนื่องจาก revisions ตัวอย่างเช่น เวลา ที่จะ construct Buffer ใหม่ append small chunk ใน internal storage และ destroy Buffer dropped จาก 24 ns ไปยัง 12 ns

20.5 Conclusion (บทสรุป)

บทเรียน important โดยรวมที่สุดจากบทนี้คือ clean design และ high performance เข้ากันได้ Buffer class rewrite ปรับปรุง performance ของมันโดย factor ของ 2 ในขณะเดียวกัน simplifying design ของมัน และ reducing code size โดย 20% Complicated code tends เป็น slow เพราะว่า มันทำ extraneous หรือ redundant work ในทางกลับกัน หากคุณ write clean simple code ระบบของคุณจะ probably เร็วพอว่า คุณไม่ต้อง worry มากเกี่ยวกับ performance ในสถานที่แรก ในไม่กี่ case ที่คุณได้ต้อง optimize performance gist key คือ simplicity again: find critical paths ที่ most important สำหรับ performance และทำให้พวกเขา as simple เท่าที่เป็นไปได้