ความท้าทายของกฎการเขียน

หน้านี้จะให้ภาพรวมระดับสูงเกี่ยวกับปัญหาและความท้าทายเฉพาะในการเขียนกฎ Bazel ที่มีประสิทธิภาพ

ข้อกำหนดโดยสรุป

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

สมมติฐาน

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

มุ่งเน้นที่ความถูกต้อง อัตราการส่งข้อมูล ความง่ายในการใช้งาน และเวลาในการตอบสนอง

เราสมมติว่าระบบบิลด์ต้องถูกต้องเป็นอันดับแรกในส่วนที่เกี่ยวกับการบิลด์แบบเพิ่มทีละส่วน สำหรับโครงสร้างแบบต้นฉบับที่กำหนด เอาต์พุตของการบิลด์เดียวกันควรเหมือนกันเสมอ ไม่ว่าโครงสร้างแบบเอาต์พุตจะเป็นอย่างไร ในการประมาณครั้งแรก หมายความว่า Bazel ต้องทราบอินพุตทุกรายการที่เข้าสู่ขั้นตอนการบิลด์ที่กำหนด เพื่อให้สามารถเรียกใช้ขั้นตอนนั้นซ้ำได้หากอินพุตใดๆ เปลี่ยนไป Bazel มีข้อจำกัดในเรื่องความถูกต้อง เนื่องจากระบบจะแสดงข้อมูลบางอย่าง เช่น วันที่ / เวลาของการบิลด์ และละเว้นการเปลี่ยนแปลงบางประเภท เช่น การเปลี่ยนแปลงแอตทริบิวต์ของไฟล์ การแซนด์บ็อกซ์ ช่วยให้มั่นใจในความถูกต้องโดยป้องกันการอ่านไฟล์อินพุตที่ไม่ได้ประกาศ นอกเหนือจากข้อจำกัดโดยธรรมชาติของระบบแล้ว ยังมีปัญหาด้านความถูกต้องที่ทราบกันอยู่บ้าง ซึ่งส่วนใหญ่เกี่ยวข้องกับ Fileset หรือกฎ C++ ซึ่งเป็นปัญหาที่แก้ไขได้ยาก เรากำลังพยายามแก้ไขปัญหาเหล่านี้ในระยะยาว

เป้าหมายที่ 2 ของระบบบิลด์คือการมีอัตราการส่งข้อความปริมาณมาก เรากำลังพยายามอย่างต่อเนื่องที่จะขยายขอบเขตของสิ่งที่ทำได้ภายในทรัพยากรเครื่องที่จัดสรรไว้ในปัจจุบันสำหรับบริการการดำเนินการจากระยะไกล หากบริการการดำเนินการจากระยะไกลทำงานหนักเกินไป ทุกคนก็จะทำงานไม่ได้

ความง่ายในการใช้งานเป็นเป้าหมายถัดไป จากแนวทางที่ถูกต้องหลายแนวทางซึ่งมีร่องรอยการใช้งานบริการการดำเนินการจากระยะไกลเหมือนกัน (หรือคล้ายกัน) เราจะเลือกแนวทางที่ใช้งานง่ายกว่า

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

โปรดทราบว่าเป้าหมายเหล่านี้มักจะทับซ้อนกัน เวลาในการตอบสนองขึ้นอยู่กับอัตราการส่งข้อมูลของบริการการดำเนินการจากระยะไกล เช่นเดียวกับความถูกต้องที่เกี่ยวข้องกับความง่ายในการใช้งาน

ที่เก็บข้อมูลขนาดใหญ่

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

ภาษาคำอธิบายคล้ายกับ BUILD

ในบริบทนี้ เราสมมติว่าภาษาการกำหนดค่าคล้ายกับไฟล์ BUILD ในการประกาศกฎไลบรารีและกฎไบนารี รวมถึงการพึ่งพากัน ไฟล์ BUILD สามารถอ่านและแยกวิเคราะห์ได้อย่างอิสระ และเราจะหลีกเลี่ยงการดูไฟล์ต้นฉบับทุกครั้งที่ทำได้ (ยกเว้นการตรวจสอบการมีอยู่)

ประวัติ

Bazel เวอร์ชันต่างๆ มีความแตกต่างกันซึ่งก่อให้เกิดความท้าทาย และเราได้อธิบายความแตกต่างบางส่วนไว้ในส่วนต่อไปนี้

การแยกการโหลด การวิเคราะห์ และการดำเนินการออกจากกันอย่างชัดเจนนั้นล้าสมัยแล้ว แต่ยังคงส่งผลต่อ API

ในทางเทคนิคแล้ว กฎเพียงแค่ต้องทราบไฟล์อินพุตและเอาต์พุตของการดำเนินการก่อนที่จะส่งการดำเนินการไปยังการดำเนินการจากระยะไกล อย่างไรก็ตาม ฐานของโค้ด Bazel ดั้งเดิมมีการแยกการโหลดแพ็กเกจ การวิเคราะห์กฎโดยใช้การกำหนดค่า (โดยพื้นฐานแล้วคือแฟล็กบรรทัดคำสั่ง) และการเรียกใช้การดำเนินการใดๆ อย่างชัดเจน การแยกความแตกต่างนี้ยังคงเป็นส่วนหนึ่งของ Rules API ในปัจจุบัน แม้ว่าแกนหลักของ Bazel จะไม่จำเป็นต้องใช้การแยกความแตกต่างนี้อีกต่อไป (ดูรายละเอียดเพิ่มเติมด้านล่าง)

ซึ่งหมายความว่า Rules API ต้องมีคำอธิบายแบบประกาศของอินเทอร์เฟซกฎ (แอตทริบิวต์ที่มี ประเภทของแอตทริบิวต์) มีข้อยกเว้นบางกรณีที่ API อนุญาตให้โค้ดที่กำหนดเองทำงานในระยะการโหลดเพื่อคำนวณชื่อโดยนัยของไฟล์เอาต์พุตและค่าโดยนัยของแอตทริบิวต์ ตัวอย่างเช่น กฎ java_library ที่ชื่อ "foo" จะสร้างเอาต์พุตที่ชื่อ "libfoo.jar" โดยนัย ซึ่งสามารถอ้างอิงจากกฎอื่นๆ ในกราฟบิลด์ได้

นอกจากนี้ การวิเคราะห์กฎยังอ่านไฟล์ต้นฉบับหรือตรวจสอบเอาต์พุตของการดำเนินการไม่ได้ แต่ต้องสร้างกราฟสองส่วนแบบมีทิศทางบางส่วนของขั้นตอนการบิลด์และชื่อไฟล์เอาต์พุตที่กำหนดจากกฎเองและทรัพยากร Dependency เท่านั้น

คุณสมบัติโดยธรรมชาติ

คุณสมบัติโดยธรรมชาติบางอย่างทำให้การเขียนกฎเป็นเรื่องท้าทาย และเราได้อธิบายคุณสมบัติที่พบบ่อยที่สุดบางอย่างไว้ในส่วนต่อไปนี้

การดำเนินการและการแคชจากระยะไกลทำได้ยาก

การดำเนินการและการแคชจากระยะไกลช่วยลดเวลาในการบิลด์ในที่เก็บข้อมูลขนาดใหญ่ได้ประมาณ 2 ระดับเมื่อเทียบกับการเรียกใช้การบิลด์ในเครื่องเดียว อย่างไรก็ตาม ขนาดที่ต้องดำเนินการนั้นน่าทึ่งมาก บริการการดำเนินการจากระยะไกลของ Google ได้รับการออกแบบมาเพื่อจัดการคำขอจำนวนมากต่อวินาที และโปรโตคอลจะหลีกเลี่ยงการเดินทางไปกลับที่ไม่จำเป็น รวมถึงการทำงานที่ไม่จำเป็นในฝั่งบริการอย่างระมัดระวัง

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

การใช้ข้อมูลการเปลี่ยนแปลงสำหรับการบิลด์แบบเพิ่มทีละส่วนที่ถูกต้องและรวดเร็วต้องใช้รูปแบบการเขียนโค้ดที่ผิดปกติ

ด้านบน เราได้กล่าวว่า Bazel ต้องทราบไฟล์อินพุตทั้งหมดที่เข้าสู่ขั้นตอนการบิลด์เพื่อตรวจหาว่าขั้นตอนการบิลด์นั้นยังคงเป็นเวอร์ชันล่าสุดหรือไม่ การโหลดแพ็กเกจและการวิเคราะห์กฎก็เช่นกัน และเราได้ออกแบบ Skyframe เพื่อจัดการเรื่องนี้โดยทั่วไป Skyframe เป็นไลบรารีกราฟและเฟรมเวิร์กการประเมินที่รับโหนดเป้าหมาย (เช่น "บิลด์ //foo ด้วยตัวเลือกเหล่านี้") และแยกโหนดเป้าหมายออกเป็นส่วนประกอบต่างๆ จากนั้นจะประเมินและรวมส่วนประกอบเหล่านั้นเพื่อให้ได้ผลลัพธ์นี้ Skyframe จะอ่านแพ็กเกจ วิเคราะห์กฎ และดำเนินการต่างๆ ซึ่งเป็นส่วนหนึ่งของกระบวนการนี้

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

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

Bazel จึงใช้พูลเธรดขนาดคงที่แทน อย่างไรก็ตาม นั่นหมายความว่าหากโหนดประกาศทรัพยากร Dependency ที่ยังไม่พร้อมใช้งาน เราอาจต้องยกเลิกการประเมินนั้นและรีสตาร์ท (อาจอยู่ในเทรดอื่น) เมื่อทรัพยากร Dependency พร้อมใช้งาน ซึ่งหมายความว่าโหนดไม่ควรทำเช่นนี้มากเกินไป โหนดที่ประกาศการพึ่งพาอาศัยกัน N รายการแบบอนุกรมอาจต้องรีสตาร์ท N ครั้ง ซึ่งใช้เวลา O(N^2) เราจึงมุ่งเน้นที่การประกาศการพึ่งพาอาศัยกันแบบกลุ่มล่วงหน้า ซึ่งบางครั้งต้องมีการจัดระเบียบโค้ดใหม่ หรือแม้แต่แยกโหนดออกเป็นหลายโหนดเพื่อจำกัดจำนวนการรีสตาร์ท

โปรดทราบว่าเทคโนโลยีนี้ยังไม่พร้อมใช้งานใน Rules API ในปัจจุบัน แต่ Rules API ยังคงกำหนดโดยใช้แนวคิดเดิมของระยะการโหลด การวิเคราะห์ และการดำเนินการ อย่างไรก็ตาม ข้อจำกัดพื้นฐานคือการเข้าถึงโหนดอื่นๆ ทั้งหมดต้องผ่านเฟรมเวิร์กเพื่อให้เฟรมเวิร์กติดตามการพึ่งพาอาศัยกันที่เกี่ยวข้องได้ ไม่ว่าระบบบิลด์จะใช้ภาษาใดในการติดตั้งใช้งานหรือกฎจะเขียนขึ้นด้วยภาษาใด (ไม่จำเป็นต้องเป็นภาษาเดียวกัน) ผู้เขียนกฎต้องไม่ใช้ไลบรารีหรือรูปแบบมาตรฐานที่ข้าม Skyframe สำหรับ Java หมายความว่าต้องหลีกเลี่ยง java.io.File รวมถึงการสะท้อนทุกรูปแบบ และไลบรารีใดก็ตามที่ทำอย่างใดอย่างหนึ่ง ไลบรารีที่รองรับการแทรกทรัพยากร Dependency ของอินเทอร์เฟซระดับต่ำเหล่านี้ยังคงต้องตั้งค่าอย่างถูกต้องสำหรับ Skyframe

การดำเนินการนี้แสดงให้เห็นอย่างชัดเจนว่าควรหลีกเลี่ยงการให้ผู้เขียนกฎเข้าถึงรันไทม์ภาษาแบบเต็มตั้งแต่แรก ความเสี่ยงของการใช้ API ดังกล่าวโดยไม่ตั้งใจนั้นสูงเกินไป ข้อบกพร่องหลายอย่างของ Bazel ในอดีตเกิดจากกฎที่ใช้ API ที่ไม่ปลอดภัย แม้ว่ากฎเหล่านั้นจะเขียนขึ้นโดยทีม Bazel หรือผู้เชี่ยวชาญด้านโดเมนอื่นๆ

การหลีกเลี่ยงการใช้เวลาและหน่วยความจำแบบกำลังสองทำได้ยาก

สิ่งที่แย่กว่านั้นคือ นอกเหนือจากข้อกำหนดที่กำหนดโดย Skyframe ข้อจำกัดทางประวัติศาสตร์ของการใช้ Java และความล้าสมัยของ Rules API แล้ว การใช้เวลาหรือหน่วยความจำแบบกำลังสองโดยไม่ตั้งใจยังเป็นปัญหาพื้นฐานในระบบบิลด์ใดก็ตามที่อิงตามกฎไลบรารีและกฎไบนารี มีรูปแบบที่พบบ่อย 2 รูปแบบที่ทำให้เกิดการใช้หน่วยความจำแบบกำลังสอง (และดังนั้นจึงทำให้เกิดการใช้เวลาแบบกำลังสอง)

  1. กฎไลบรารีแบบลูกโซ่ - พิจารณากรณีของกฎไลบรารีแบบลูกโซ่ที่ A พึ่งพา B, B พึ่งพา C และอื่นๆ จากนั้น เราต้องการคำนวณพร็อพเพอร์ตี้บางอย่างผ่านการปิดแบบส่งผ่านของกฎเหล่านี้ เช่น คลาสพาธรันไทม์ Java หรือคำสั่ง Linker C++ สำหรับแต่ละไลบรารี เราอาจใช้การติดตั้งใช้งานรายการมาตรฐานอย่างง่ายๆ แต่การดำเนินการนี้จะทำให้เกิดการใช้หน่วยความจำแบบกำลังสองแล้ว โดยไลบรารีแรกมีรายการเดียวในคลาสพาธ ไลบรารีที่ 2 มี 2 รายการ ไลบรารีที่ 3 มี 3 รายการ และอื่นๆ รวมเป็น 1+2+3+...+N = O(N^2) รายการ

  2. กฎไบนารีที่พึ่งพากฎไลบรารีเดียวกัน - พิจารณากรณีที่ไบนารีชุดหนึ่งพึ่งพากฎไลบรารีเดียวกัน เช่น หากคุณมีกฎการทดสอบจำนวนหนึ่งที่ทดสอบโค้ดไลบรารีเดียวกัน สมมติว่าจากกฎ N ข้อ กฎครึ่งหนึ่งเป็นกฎไบนารี และอีกครึ่งหนึ่งเป็นกฎไลบรารี ตอนนี้พิจารณาว่าไบนารีแต่ละรายการสร้างสำเนาของพร็อพเพอร์ตี้บางอย่างที่คำนวณผ่านการปิดแบบส่งผ่านของกฎไลบรารี เช่น คลาสพาธรันไทม์ Java หรือบรรทัดคำสั่ง Linker C++ ตัวอย่างเช่น อาจขยายการแสดงสตริงบรรทัดคำสั่งของการดำเนินการ Link C++ สำเนา N/2 รายการขององค์ประกอบ N/2 รายการคือหน่วยความจำ O(N^2)

คลาสคอลเล็กชันที่กำหนดเองเพื่อหลีกเลี่ยงความซับซ้อนแบบกำลังสอง

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

น่าเสียดายที่การใช้ depset ไม่ได้แก้ปัญหาทั้งหมดโดยอัตโนมัติ โดยเฉพาะอย่างยิ่ง แม้เพียงแค่การวนซ้ำ depset ในแต่ละกฎก็ทำให้เกิดการใช้เวลาแบบกำลังสองอีกครั้ง ภายใน NestedSets ยังมีเมธอดตัวช่วยบางอย่างเพื่ออำนวยความสะดวกในการทำงานร่วมกันกับคลาสคอลเล็กชันปกติ แต่การส่ง NestedSet ไปยังเมธอดใดเมธอดหนึ่งเหล่านี้โดยไม่ตั้งใจจะทำให้เกิดพฤติกรรมการคัดลอก และทำให้เกิดการใช้หน่วยความจำแบบกำลังสองอีกครั้ง