- การทำงานแบบขนานของคอมไพเลอร์ Rust ที่ผ่านมาพึ่งพา Cargo และแบ็กเอนด์ LLVM เป็นหลัก แต่ตอนนี้มี การรันฟรอนต์เอนด์แบบขนาน เพิ่มเข้ามา จึงช่วยลดคอขวดช่วงท้ายของการบิลด์ได้
- ใน nightly สามารถเปิดฟีเจอร์ทดลองด้วย
-Z threads=8 ได้ โดยค่าเริ่มต้นยังคงเป็น โหมดเธรดเดียว ดังนั้นหากไม่ตั้งค่าอย่างชัดเจนก็จะไม่มีความเร็วที่ดีขึ้น
- ฟรอนต์เอนด์ใหม่ใช้ Rayon เป็นฐาน เพื่อแบ่งงานคอมไพล์ละเอียดขึ้น และจากการวัดในตัวอย่าง เวลาฟรอนต์เอนด์ลดจาก 10.2 วินาทีเหลือ 5.9 วินาที
- ในการวัดกับโค้ดจริง เวลาคอมไพล์ลดลงได้สูงสุด 50% แต่มีความแตกต่างมากตามลักษณะโค้ดและการตั้งค่าบิลด์ และการใช้หน่วยความจำอาจเพิ่มขึ้นได้สูงสุด 35%
- ฟีเจอร์นี้ยังอยู่ในขั้นทดลอง และกำลังผลักดันให้
-Z threads เสถียร รวมถึงให้ stable รันแบบหลายเธรดเป็นค่าเริ่มต้น โดยมีเป้าหมายในปี 2024
แกนใหม่ของการคอมไพล์ Rust แบบขนาน
- ฟรอนต์เอนด์ของคอมไพเลอร์ Rust ตอนนี้สามารถรันแบบขนานเพื่อลดเวลาคอมไพล์ได้
- หากส่งออปชัน
-Z threads=8 ให้คอมไพเลอร์ nightly จะสามารถทดลองใช้ ฟรอนต์เอนด์แบบหลายเธรด ได้
- ฟีเจอร์นี้ยังเป็นการทดลอง และกำหนดเป้าหมายให้รวมอยู่ในคอมไพเลอร์ stable ในปี 2024
การปรับแต่งเดิมและคอขวดที่ยังเหลือ
- Compiler Performance Working Group ได้ปรับปรุงประสิทธิภาพของคอมไพเลอร์ Rust มาหลายปีแล้ว
- ในช่วง 10 เดือนแรกของปี 2023 เวลาเฉลี่ยในการคอมไพล์ตามเครื่องมือวัดประสิทธิภาพลดลง 13%
- การใช้หน่วยความจำสูงสุดลดลง 15%
- ขนาดไบนารีเล็กลง 7%
- เนื่องจากคอมไพเลอร์ถูกปรับแต่งไปมากแล้ว โอกาสปรับปรุงครั้งใหญ่ที่เหลือจึงใกล้เคียงกับการ ขยายความเป็นขนาน มากกว่า
ข้อจำกัดของการทำงานแบบขนานใน Cargo และแบ็กเอนด์
- เมื่อบิลด์โปรแกรม Rust, Cargo จะรันโปรเซส
rustc หลายตัวเพื่อคอมไพล์ crate แบบขนาน
- หากปิดการทำงานแบบขนานนี้ด้วยแฟล็ก
-j1 เวลาคอมไพล์ของโปรแกรม Rust ขนาดใหญ่จะเพิ่มขึ้นมาก
- แฟล็ก
--timings ของ Cargo จะสร้างแผนภูมิไทม์ไลน์เวลาคอมไพล์ของ crate
- ตัวอย่างการบิลด์ ripgrep บนเครื่องที่มีคอร์เสมือน 28 คอร์ แสดงบรรทัดโปรเซส 60 บรรทัด
- ส่วนใหญ่คือ
rustc และบางส่วนเป็น build script
- โปรเซส 20 ตัวแรกไม่มี dependency ระหว่าง crate จึงเริ่มพร้อมกันได้
- เมื่อเข้าสู่ช่วงท้ายของการบิลด์ dependency ระหว่าง crate เพิ่มขึ้น ทำให้ความเป็นขนานลดลง
- ด้วย pipelined compilation สามารถทำให้การคอมไพล์ crate ที่เป็น dependency ซ้อนทับกันได้บางส่วน แต่ในโปรแกรม Rust ขนาดใหญ่ การรันแบบขนานช่วงท้ายของการบิลด์จะน้อยลงมาก
หน้าที่ของฟรอนต์เอนด์และแบ็กเอนด์
- คอมไพเลอร์ Rust แบ่งกว้าง ๆ เป็น ฟรอนต์เอนด์ และ แบ็กเอนด์
- ฟรอนต์เอนด์ทำหน้าที่ parsing, type checking, borrow checking เป็นต้น
- ฟรอนต์เอนด์เดิมไม่สามารถใช้การรันแบบขนานได้
- แบ็กเอนด์รับผิดชอบการสร้างโค้ด โดยสร้างโค้ดเป็นหน่วย “codegen units” แล้ว LLVM ประมวลผลหน่วยเหล่านั้นแบบขนาน
- จำนวน codegen unit ค่าเริ่มต้นของ release build คือ 16 หน่วย ดังนั้นในโปรไฟล์ตัวอย่างจะเห็นเธรด LLVM 16 เธรด
คอขวดที่เห็นจากโปรไฟล์แบ็กเอนด์เดิม
- ในตัวอย่างที่วัดด้วย Samply ตอน release build crate สุดท้ายของ Cargo ฟรอนต์เอนด์ใช้เวลา 10.2 วินาที
- แบ็กเอนด์ใช้เวลา 6.2 วินาที และเธรด LLVM รันอยู่ 5.9 วินาที จากเวลานั้น
- การสร้างโค้ดแบบขนานบน LLVM มีประสิทธิผล แต่แม้บนเครื่อง 28 คอร์ เธรด LLVM ทั้ง 16 เธรดก็ไม่ได้รันพร้อมกันทั้งหมด
- main thread ทำงานแปลง MIR เป็น LLVM IR แบบลำดับ ทำให้ส่วนเริ่มต้นของ codegen thread มีรูปแบบเป็นขั้นบันได
- ฟรอนต์เอนด์ที่เคยทำงานแบบลำดับทั้งหมดจึงยังเป็นจุดปรับปรุงที่ใหญ่ที่สุด
วิธีติดตั้งฟรอนต์เอนด์แบบขนานใหม่
- ฟรอนต์เอนด์ใหม่ใช้ Rayon เพื่อทำงานคอมไพล์แบบขนานที่ละเอียดขึ้น
- โครงสร้างข้อมูลหลายตัวซิงโครไนซ์ด้วย mutex และ read-write lock และใช้ atomic type ในจุดที่จำเป็น
- งานของฟรอนต์เอนด์จำนวนมากถูกทำให้ขนานได้ แต่การเปลี่ยนแปลงกระจุกอยู่ในจุดสำคัญที่มีจำนวนค่อนข้างน้อย
- โค้ดฟรอนต์เอนด์ส่วนใหญ่ไม่จำเป็นต้องเปลี่ยน
ผลการวัดเมื่อตั้งค่า 8 เธรด
- ในตัวอย่างเดียวกันเมื่อเปิดใช้ฟรอนต์เอนด์แบบขนานและใช้ 8 เธรด เวลารันฟรอนต์เอนด์ลดจาก 10.2 วินาทีเป็น 5.9 วินาที
- เวลาแบ็กเอนด์ลดจาก 6.2 วินาทีเป็น 5.3 วินาที และเวลาเธรด LLVM ลดจาก 5.9 วินาทีเป็น 4.9 วินาที
- ในฟรอนต์เอนด์มีเธรดเพิ่มเติม 7 เธรดที่แสดงเป็น
rustc ทำงานอยู่
- การใช้เธรดยังไม่สม่ำเสมอ และทั้ง 8 เธรดมีช่วงที่ไม่ทำงาน จึงยังมีพื้นที่ให้ปรับปรุงเพิ่ม
- เหตุผลที่เธรด LLVM 8 เธรดเริ่มพร้อมกัน คือเธรด
rustc 8 เธรดสร้าง LLVM IR ของ codegen unit 8 หน่วยแบบขนาน
- หากเปลี่ยนจำนวนเธรดฟรอนต์เอนด์เป็น 16 เธรด รูปแบบขั้นบันไดจะหายไปทั้งหมด แต่เวลารันสุดท้ายของกรณีนั้นแทบไม่เปลี่ยน
การผสานการทำงานแบบขนานระหว่างโปรเซสและภายในโปรเซส
- การคอมไพล์ Rust ได้ประโยชน์จาก การทำงานแบบขนานระหว่างโปรเซส ของ Cargo และการทำงานแบบขนานภายในโปรเซสของแบ็กเอนด์มานานแล้ว
- ตอนนี้ฟรอนต์เอนด์ก็สามารถใช้ประโยชน์จากการทำงานแบบขนานภายในโปรเซสได้เช่นกัน
- เมื่อมีโปรเซส
rustc หลายตัวรันพร้อมกัน และแต่ละโปรเซสสร้างหลายเธรด jobserver protocol จะจำกัดจำนวนเธรด
- หากความเป็นขนานระหว่างโปรเซสสูง ความเป็นขนานภายในโปรเซสจะลดลงให้สอดคล้องกัน และจำนวนเธรดรวมจะไม่เกินจำนวนคอร์
วิธีใช้งาน
- คอมไพเลอร์ nightly มีฟรอนต์เอนด์แบบขนาน รวมอยู่แล้ว
- ค่าเริ่มต้นเป็น โหมดเธรดเดียว ดังนั้นหากใช้ตามเดิม เวลาคอมไพล์จะไม่ลดลง
- ต้องเปิดโหมดหลายเธรดอย่างชัดเจนด้วยออปชัน
-Z threads
RUSTFLAGS="-Z threads=8" cargo build --release
- หากต้องการตั้งค่าสำหรับหนึ่งโปรเจกต์ขึ้นไปด้วย
config.toml ให้เพิ่มดังนี้
[build]
rustflags = ["-Z", "threads=8"]
- เหตุผลที่โหมดเธรดเดียวเป็นค่าเริ่มต้นคือเพื่อการปล่อยใช้งานอย่างระมัดระวัง
- ฟรอนต์เอนด์แบบขนานมีโค้ดใหม่จำนวนมาก
- โหมดเธรดเดียวรันโค้ดใหม่ส่วนใหญ่ แต่ตัดความเป็นไปได้ของบั๊กด้าน threading เช่น deadlock ออกไป
- แม้ใน Rust โปรแกรมแบบขนานก็ยังเขียนให้ถูกต้องได้ยากกว่าโปรแกรมแบบลำดับ
- ด้วยเหตุนี้ ฟรอนต์เอนด์แบบขนานจะยังไม่รวมอยู่ใน beta หรือ stable release ในระยะหนึ่ง
ผลกระทบด้านประสิทธิภาพและหน่วยความจำ
- ในโหมดเธรดเดียว ฟรอนต์เอนด์แบบขนานโดยทั่วไปช้ากว่าฟรอนต์เอนด์แบบลำดับเดิม 0%~2%
- ในโหมดหลายเธรด
-Z threads=8 จาก การวัดกับโค้ดจริง เวลาคอมไพล์อาจลดลงได้สูงสุด 50%
- ผลด้านประสิทธิภาพแตกต่างกันมากตามลักษณะโค้ดและการตั้งค่าบิลด์
- development build มีแนวโน้มเห็นการปรับปรุงมากกว่า release build
- เพราะ release build มักใช้เวลากับการปรับแต่งของแบ็กเอนด์มากกว่า
- โปรแกรมขนาดเล็กบางส่วนที่คอมไพล์ได้เร็วอยู่แล้ว อาจทำงานในโหมดหลายเธรดช้ากว่าโหมดเธรดเดียว
- ค่าที่แนะนำคือ 8 เธรด
- เป็นค่าที่ทดสอบมากที่สุดและเป็นที่ทราบว่าให้ผลลัพธ์ดี
- ค่าที่ต่ำกว่า 8 ให้ประโยชน์น้อยกว่า แต่เหมาะกับฮาร์ดแวร์ที่มีคอร์น้อยกว่า 8 คอร์
- ค่าที่สูงกว่า 8 มีผลตอบแทนลดลง และอาจทำให้ประสิทธิภาพแย่ลงได้
- เหตุผลที่เพิ่มจาก 1 เป็น 8 เธรดแล้วยังปรับปรุงได้ระดับ 50% คือฟรอนต์เอนด์เป็นเพียงส่วนหนึ่งของเวลาคอมไพล์ทั้งหมด และแบ็กเอนด์ถูกทำให้ขนานอยู่แล้ว
- ในโหมดหลายเธรด การใช้หน่วยความจำอาจเพิ่มขึ้นมาก โดยพบว่าสูงสุดเพิ่มขึ้น 35%
ความถูกต้องและฟีดแบ็ก
- คาดว่าโหมดเธรดเดียวจะมีความน่าเชื่อถือสูง
- โหมดหลายเธรดยังมีบั๊กที่ทราบอยู่ รวมถึง deadlock
- หากการคอมไพล์ค้าง อาจเป็นไปได้ว่าพบบั๊กที่ทราบอยู่ตัวใดตัวหนึ่ง
- ไม่ว่าจะใช้ฟรอนต์เอนด์แบบใด ไบนารีที่คอมไพเลอร์สร้างควรเหมือนกัน และหากมีความแตกต่างจะถือว่าเป็นบั๊ก
- หากมีปัญหา ให้ตรวจสอบ
WG-compiler-parallel label issues ก่อน และถ้าไม่มี issue เดิมที่ตรงกัน จึงเปิด issue ใหม่ได้
- ฟีดแบ็กทั่วไปสามารถส่งได้ที่ wg-parallel-rustc Zulip channel และสนใจเป็นพิเศษต่อผลด้านประสิทธิภาพกับโค้ดจริง
เป้าหมาย stable ในปี 2024
- งานปรับปรุงประสิทธิภาพของฟรอนต์เอนด์แบบขนานกำลังดำเนินอยู่
- อย่างที่เห็นในโปรไฟล์ อัตราการใช้เธรดของฟรอนต์เอนด์ยังมีพื้นที่ให้ปรับปรุง
- บั๊กที่เหลือในโหมดหลายเธรดก็กำลังถูกจัดการอยู่
- เป้าหมายของการทำให้ออปชัน
-Z threads เสถียร และการให้ฟรอนต์เอนด์แบบขนานเป็นค่าเริ่มต้นแบบหลายเธรดใน stable release คือปี 2024
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
รู้ว่ายังอยู่ในช่วงเริ่มต้น แต่ผมมองว่าจุดอ่อนของ Rust คือ ความเร็วในการคอมไพล์
ตอนเคยทำงานกับ Rust monorepo สิ่งที่บ่นมากที่สุดคือความเร็วคอมไพล์ มันเพิ่มต้นทุน CI/CD และเมื่อจำเป็นต้องล้างแคชก็ทำให้เวลาในการพัฒนาช้าลงมาก
ต้นเหตุไม่ใช่ Cargo แต่เป็นบั๊กของ Docker ถึงอย่างนั้นความคืบหน้าแบบนี้ก็ยังน่ายินดี
มันถูกปรับแต่งมาเยอะแล้ว และตอนนี้คอมไพเลอร์ Rust ก็มี ความขนาน สูงกว่าคอมไพเลอร์กระแสหลักแทบทั้งหมด
ตัวการออกแบบภาษาของ Rust เองก็ทำให้คอมไพล์ยากกว่าภาษาอย่าง Go ที่ถูกออกแบบมาให้คอมไพล์เร็ว
ไม่แน่ใจว่างานนี้จะนำไปใช้ตรง ๆ ได้มากแค่ไหน แต่ก็หวังว่าจะช่วยตรงนั้นได้มากเหมือนกัน
มันเป็นส่วนที่จำเป็นชัดเจนสำหรับการรองรับ IDE สมัยใหม่
ผมเป็นผู้ดูแลหลักของโปรเจกต์ Rust โอเพนซอร์สขนาดกลาง [1] และรู้สึกมาตลอดว่าเวลาคอมไพล์ Rust บนเครื่องโลคัลเร็วอย่างน่าประหลาดใจ
บน MacBook Pro การ build แบบ debug ใช้เวลาแค่ไม่กี่วินาที ส่วน build แบบ release และ CI/CD จะช้ากว่า แต่ตั้งแต่เริ่มใช้ Rust เมื่อ 2 ปีก่อน ผมก็รู้สึกว่ามันคอมไพล์เร็วมาก
เพื่อให้แฟร์ งานหลักของผมคือ Java/Kotlin กับ Gradle ซึ่งตรงนั้นพูดได้เลยว่าเวลาในการคอมไพล์ช้าราวกับธารน้ำแข็ง
ในโปรเจกต์ Rust โอเพนซอร์สของผม ผมลด dependency ให้เหลือน้อยที่สุด ไม่ใช้แมโครนอกจากพวก
derive[Debug, Clone]และใช้ generic อย่างประหยัดมากถ้าใครลอง build โปรเจกต์นี้ด้วย
cargo buildแล้วให้ฟีดแบ็กเรื่องเวลาคอมไพล์ได้ก็คงดี[1]: https://github.com/Orange-OpenSource/hurl
และสงสัยด้วยว่าได้แยกโปรเจกต์ออกเป็นหลาย crate ในจุดที่เหมาะสมหรือเปล่า
การ build monorepo ใช้เวลากี่นาที?
พูดตรง ๆ ได้เลย ผมใช้ GHC เป็นคอมไพเลอร์หลัก เลยไม่ค่อยตกใจง่าย ๆ
อาจเป็นคำถามงี่เง่า แต่ backend ต้องรอให้ frontend จบ การตรวจสอบการยืม ก่อนหรือเปล่า? ถ้าใช่ ทำไมถึงต้องรอ?
ไม่ได้หมายความว่ามันผิดอะไร แค่อยากรู้ว่าการตรวจสอบการยืมได้ตั้ง invariant บางอย่างที่ backend ต้องพึ่งพาไว้นอกเหนือจากความถูกต้องพื้นฐานหรือไม่
เช่น ถ้าเกิดข้อผิดพลาดจากการตรวจสอบการยืม มีเหตุผลไหมที่เราไม่สามารถทำงาน backend แบบ speculative ที่ทิ้งได้ในภายหลัง
เพียงแต่อาจไม่ได้สร้างโค้ดที่ถูกปรับแต่งประสิทธิภาพดีที่สุด
เท่าที่ผมรู้ มี optimization ที่อาศัยข้อมูลซึ่งถูกยืนยันระหว่างการตรวจสอบการยืม เช่น optimization
noaliasอันลือลั่น ซึ่งกว่าจะเปิดใช้ได้ก็ต้องพยายามกันหลายรอบ[1]อีกอย่างผมก็ไม่แน่ใจความเกี่ยวข้องกับ NLL (non-lexical lifetimes) แต่ถ้าจะตั้งข้อมูลที่ backend สนใจ อย่างน้อยก็น่าจะต้องมีตัวตรวจสอบการยืมแบบพื้นฐาน
แต่ mrustc ก็ยังคอมไพล์ Rust เวอร์ชันที่มี NLL ได้โดยไม่มีตัวตรวจสอบการยืม ดังนั้นมันจึงดูเหมือนเป็นเรื่องของ optimization มากกว่าจะเป็นสิ่งจำเป็น
[0]: https://github.com/thepowersgang/mrustc
[1]: https://stackoverflow.com/a/57259339
มีวิธีให้มันใช้ จำนวนคอร์ CPU แทนการใส่ค่าคงที่ลงไปในไฟล์ตั้งค่าที่ใช้ข้ามเครื่องต่าง ๆ ไหม?
ผมคาดว่าค่าเริ่มต้นตอน stable น่าจะเป็นจำนวนคอร์
ไม่แน่ใจว่าตอนนี้งานนี้ไปถึงไหนแล้ว แต่ช่วงหนึ่งเคยมีแนวคิดจะประสานกันผ่าน jobserver ระหว่างการเรียก rustc ของ Cargo ซึ่งถ้าเป็นแบบนั้นก็จะใช้จำนวนงานของ Cargo ที่มีค่าเริ่มต้นเป็นจำนวนคอร์
Cargo ยังรองรับค่าติดลบเพื่อหักออกจากจำนวนคอร์ด้วย
RUSTFLAGSได้ และในบทความก็มีบอกไว้:ดีเลย! ตอนใช้ Rust เมื่อนานมากแล้ว แม้แต่ตัวอย่างเล่น ๆ ยังใช้เวลาคอมไพล์ค่อนข้างช้า แต่พอกลับมาลองอีกครั้งช่วงหลัง Rust ดีขึ้นมากจริง ๆ และตอนนี้ผมแทบไม่ต้องคิดเรื่องเวลาในการคอมไพล์เลย ใช้มันทุกที่ที่ทำได้
แต่มีโปรเจกต์หนึ่งที่เริ่มใหญ่ขึ้นนิดหน่อย ซึ่งแม้แต่การเปลี่ยนแปลงเล็ก ๆ ก็เริ่มใช้เวลาคอมไพล์ เกิน 5 วินาที ทำให้ความทรงจำเก่า ๆ กลับมาอีกครั้ง
ถึงขั้นอยากเลื่อนการกดบันทึกออกไป เพื่อไม่ให้ตัว analyzer เริ่มทำงานจนกว่าจะจัดการอย่างอื่นเสร็จ ก่อนที่โน้ตบุ๊กจะทำเสียงเหมือนเครื่องยนต์เครื่องบิน
สำหรับผม นี่คือจุดเจ็บที่ใหญ่ที่สุด ดังนั้นความคืบหน้าแบบไหนก็น่ายินดีมาก
เยี่ยม! ต่างจาก ecosystem ของ library crate, binary crate ของผมมักมีขนาดใหญ่และกลายเป็นก้อนเดียวโดยธรรมชาติ
ตอนนี้เลยกำลังแยกมันออกเป็นหลาย library crate
นั่นหมายความว่าไม่เพียงแต่ช่วงท้ายของการคอมไพล์จะไม่สามารถทำงานแบบขนานได้ แต่ crate ที่ใหญ่ที่สุดยังถูกประมวลผลแบบเรียงลำดับอีกด้วย ดังนั้นการเปลี่ยนแปลงครั้งนี้จึงน่ายินดีมาก
หลังจากห่าง Rust ไปครึ่ง ๆ กลาง ๆ อยู่หลายปีแล้วไปทำงานในโลกของ Python หรือ TypeScript ช่วงนี้เพิ่งกลับมาใช้มันกับโปรเจกต์หนึ่ง และ ความเร็วในการคอมไพล์ แทบจะจบแทบจะทันที
ดีขึ้นอีกเมื่อไรก็ย่อมดีเสมอ แต่ตอนนี้มันก็ดีมากอยู่แล้ว
ทุกวันนี้ยังมีตัวโกงอย่าง ChatGPT ที่ช่วยให้ปัญหา Rust ยาก ๆ ซึ่งเมื่อหลายปีก่อนอาจทำให้ผมไปต่อไม่ได้ แทบทั้งหมดพอฝ่าผ่านไปได้ จึงมองว่าอนาคตของ Rust ดูดีไม่น้อย
ตอนนั้นการ build Docker image ใช้เวลา 60–90 นาที และทำให้ตระหนักชัดว่าทั้งโปรเจกต์มี dependency มากแค่ไหน
มีวิธีปิด ตัวเลือกคอมไพเลอร์แบบขนาน โดยไม่ต้อง build คอมไพเลอร์ใหม่ไหม?
ผมไม่ได้ต้องใช้มัน ตั้งค่า codegen units เป็น 1 อยู่แล้ว และดูเหมือนมันจะทำให้เกิด ICE ที่ผมไม่อยากเสียเวลาตามดีบัก
รู้ว่าค่าเริ่มต้นคือ 1 เธรด แต่อยากปิดมันไปเลยทั้งหมด
“โหมดมัลติเธรดมีบั๊กที่ทราบอยู่แล้ว รวมถึง deadlock ด้วย ถ้าการคอมไพล์ค้าง คุณอาจเจอหนึ่งในนั้นเข้าแล้ว”
ถ้าอย่างนั้นผมคงต้องรออีกหน่อยก่อนจะใช้
-Z threads:)