- เมื่อต้อง build เว็บไซต์ที่เขียนด้วย Rust ซ้ำ ๆ ด้วย Docker ก็พบปัญหาเรื่องเวลาในการ build
- ในการตั้งค่า Docker แบบพื้นฐาน จะเกิดการ build dependency ทั้งหมดใหม่ ทุกครั้ง ทำให้ใช้เวลามากกว่า 4 นาที
- แม้จะใช้
cargo-chef และเครื่องมือแคชแล้ว ก็ยังต้องใช้เวลา มากอยู่ดีในการ build ไบนารีสุดท้าย
- จากการทำ profiling พบว่าใช้เวลาส่วนใหญ่ไปกับ LTO (link-time optimization) และ LLVM module optimization
- แม้จะปรับตัวเลือก optimization, ข้อมูลดีบัก และการตั้งค่า LTO เพื่อช่วยให้ดีขึ้นได้บางส่วน แต่ก็ยังพบว่าการคอมไพล์ไบนารีสุดท้ายต้องใช้เวลาอย่างน้อย 50 วินาที
การตั้งคำถามและเบื้องหลัง
- ทุกครั้งที่แก้ไขเว็บไซต์ส่วนตัวที่เขียนด้วย Rust ต้องทำงานซ้ำ ๆ ที่ยุ่งยากคือ build ไบนารีแบบ static link แล้วคัดลอกไปยังเซิร์ฟเวอร์ก่อนรีสตาร์ต
- แม้ตั้งใจจะเปลี่ยนไปใช้การ deploy แบบคอนเทนเนอร์ เช่น Docker หรือ Kubernetes แต่ ความเร็วในการ build Rust บน Docker กลับกลายเป็นปัญหาใหญ่
- ใน Docker ต่อให้แก้โค้ดเพียงเล็กน้อย ก็ต้อง build ใหม่ทั้งหมดตั้งแต่ต้น ทำให้เกิดความไม่มีประสิทธิภาพ
การ build Rust บน Docker – แนวทางพื้นฐาน
- แนวทาง Dockerfile ทั่วไปคือคัดลอก dependency และซอร์สโค้ดทั้งหมด แล้วจึงรัน cargo build
- ในกรณีนี้จะไม่ได้ประโยชน์จากแคช จึงเกิดการ build ใหม่ทั้งหมด ซ้ำไปซ้ำมา
- สำหรับเว็บไซต์ของผู้เขียนเอง การ build ทั้งหมดใช้เวลา ประมาณ 4 นาที—และยังต้องใช้เวลาเพิ่มในการดาวน์โหลด dependency
ปรับปรุงแคชการ build บน Docker – cargo-chef
- เครื่องมือ
cargo-chef ช่วยให้สามารถ แคช เฉพาะ dependency แยกไว้ล่วงหน้าในคนละเลเยอร์ได้
- ด้วยวิธีนี้ เมื่อโค้ดเปลี่ยนก็สามารถนำการ build dependency เดิมกลับมาใช้ซ้ำได้ จึงคาดหวังผลเรื่อง การปรับปรุงความเร็วในการ build
- เมื่อนำมาใช้จริง พบว่าในเวลารวมทั้งหมด มีเพียง 25% เท่านั้นที่อยู่กับการ build dependency และยังคงใช้เวลามากกับการ build ไบนารีเว็บเซอร์วิสตัวสุดท้าย อยู่ดี (2 นาที 50 วินาทีถึง 3 นาที)
- แม้จะประกอบด้วย dependency หลักอย่าง axum, reqwest, tokio-postgres ฯลฯ และโค้ดที่เขียนเองราว 7,000 บรรทัด แต่การรัน rustc เพียงครั้งเดียวก็ยังใช้เวลาถึง 3 นาที
วิเคราะห์เวลา build ของ rustc: cargo --timings
- สามารถใช้
cargo --timings เพื่อตรวจสอบเวลา build ของแต่ละ crate (compilation unit) ได้
- ผลลัพธ์ยืนยันว่า การ build ไบนารีสุดท้าย กินเวลาส่วนใหญ่ของทั้งหมด
- แม้จะช่วยวิเคราะห์สาเหตุได้ละเอียดขึ้น แต่ก็ยังไม่เพียงพอสำหรับการมองเห็นการทำงานภายในของคอมไพเลอร์อย่างเจาะจง
ใช้การ profiling ภายในของ rustc เอง (-Zself-profile)
- เปิดใช้ความสามารถ profiling ภายในของ rustc ด้วยแฟล็ก -Zself-profile เพื่อวัดเวลาการทำงานแบบละเอียด
- โดยเปิดใช้งาน profiling ผ่านตัวแปรสภาพแวดล้อม
- เมื่อนำผลไปวิเคราะห์ด้วยเครื่องมือสรุปผล (summarize) ก็พบว่า LLVM LTO (link-time optimization) และ LLVM module code generation กินเวลามากกว่า 60% ของทั้งหมด
- การแสดงผลแบบ flamegraph ก็ยืนยันเช่นกันว่าในขั้น codegen_module_perform_lto มีการใช้เวลารวมถึง 80%
LTO (link-time optimization) และตัวเลือก optimization ของการ build
- การ build ของ Rust โดยปกติจะถูกแบ่งตาม codegen unit ก่อน จากนั้น LTO จึงค่อยนำ optimization ระดับทั้งโปรแกรมมาใช้ในช่วงค่อนข้างท้าย
- LTO มีหลายตัวเลือก เช่น off, thin, fat ซึ่งแต่ละแบบส่งผลต่อทั้งประสิทธิภาพและผลลัพธ์สุดท้าย
- โปรเจ็กต์ของผู้เขียนตั้งค่า LTO เป็น thin ใน
Cargo.toml และตั้ง debug symbol เป็น full
- เมื่อลองทดสอบชุดค่าผสมต่าง ๆ ของ LTO/debug symbol พบว่า:
- debug symbol แบบ full ทำให้เวลา build เพิ่มขึ้น และ fat LTO ทำให้ build ช้าลงราว 4 เท่า
- ต่อให้เอา LTO และ debug symbol ออก ก็ยังต้องใช้เวลา อย่างน้อย 50 วินาที ในการ build
การปรับแต่งเพิ่มเติมและข้อสังเกต
- เวลาราว 50 วินาทีอาจไม่ใช่ปัญหาใหญ่นักสำหรับเว็บไซต์ของผู้เขียนที่แทบไม่มีภาระใช้งานจริง แต่ด้วยความอยากรู้อยากเห็นทางเทคนิคจึงพยายามวิเคราะห์ต่อ
- หากนำ incremental compilation มาใช้กับ Docker ได้อย่างเหมาะสม ก็อาจทำให้ build ได้เร็วขึ้นอีก แต่ต้องผสานทั้งความสะอาดของสภาพแวดล้อมการ build และแคชของ Docker ให้ดี
การ profiling เชิงลึกของขั้นตอน LLVM
- ต่อให้ปิด LTO และ debug symbol แล้ว ขั้น LLVM_module_optimize ก็ยังใช้เวลาเกือบ 70%
- จึงตระหนักว่าค่าเริ่มต้นของ opt-level (3) ใน release profile มีต้นทุนด้าน optimization สูง และทดลองลด opt-level เฉพาะกับไบนารี
- จากการทดลองชุดค่าผสมของ optimization แบบต่าง ๆ พบว่า เมื่อไม่ใช้ optimization เลย (
opt-level=0) จะใช้เวลาราว 15 วินาที แต่เมื่อเปิด optimization (1~3) จะใช้เวลาราว 50 วินาที
วิเคราะห์เชิงลึกของ LLVM trace event
- สามารถใช้แฟล็กเพิ่มเติมของ rustc (
-Z time-llvm-passes, -Z llvm-time-trace) เพื่อติดตามเวลาในแต่ละขั้นของ LLVM ได้อย่างละเอียด
-Z time-llvm-passes ให้ผลลัพธ์จำนวนมากจนเกินข้อจำกัดของ log ใน Docker ได้บ่อย จึงอาจต้องปรับการตั้งค่า log
- หากบันทึก log ลงไฟล์เพื่อนำมาวิเคราะห์ ก็จะสามารถตรวจสอบเวลาแยกตาม LLVM optimization pass ได้ทีละรายการ
- ตัวเลือก
-Z llvm-time-trace จะสร้างเอาต์พุต JSON ขนาดใหญ่มากในรูปแบบ chrome tracing ทำให้เปิดด้วยเครื่องมือแก้ไข/วิเคราะห์ข้อความทั่วไปได้ยาก
- แต่สามารถแยกประมวลผลเป็นหน่วยบรรทัด (jsonl) เพื่อวิเคราะห์ในสภาพแวดล้อมแบบ CLI/สคริปต์ได้
ประเด็นสำคัญและบทสรุป
- เมื่อ build โปรเจ็กต์ Rust ที่ซับซ้อนด้วย Docker คอขวดของความเร็วในการ build มักอยู่ที่การ build ไบนารีสุดท้าย และขั้น optimization ของ LLVM ที่เกี่ยวข้อง
- เมื่อปรับ LTO, debug symbol และ opt-level จะเห็น trade-off ระหว่างเวลา build กับขนาดไบนารีอย่างชัดเจน
- หากปรับตัวเลือก optimization อย่างจริงจัง ก็สามารถ ลดเวลา build ได้มาก แต่หากไม่ใช้ optimization เลย ก็อาจทำให้ประสิทธิภาพลดลงได้
- หากต้องให้ความสำคัญกับประสิทธิภาพการ build ในสภาพแวดล้อม production หรือในโปรเจ็กต์ที่มี dependency ระดับใหญ่ ควรใช้ profiling อย่างจริงจังเพื่อ ระบุคอขวดเชิงลึกให้ชัดเจน
- ในการออกแบบ build pipeline ของ Rust จำเป็นต้องวาง ชุดค่าผสมของ LTO, opt-level, debug symbol และกลยุทธ์แคช อย่างละเอียดรอบคอบ
1 ความคิดเห็น
ความเห็นบน Hacker News
โปรเจ็กต์ Rust มักดูเล็กกว่าความเป็นจริงจึงน่าสนใจอยู่บ่อย ๆ อย่างแรกคือ dependency ไม่ได้เชื่อมโยงตรงกับขนาดจริงของ codebase เสมอไป ใน C++ โปรเจ็กต์ใหญ่ ๆ มัก vendor dependency เข้ามาเองหรือไม่ใช้เลยก็มี ดังนั้นถ้ามีโค้ด 400,000 บรรทัดแล้วมันช้า ก็อาจคิดได้ว่า "โค้ดเยอะก็ช้าเป็นธรรมดา" อย่างที่สอง ส่วนที่เป็นปัญหากว่ามากคือ macro ถ้า macro ขยายซ้ำทีละ 10 หรือ 100 บรรทัด โปรเจ็กต์ 10,000 บรรทัดก็อาจกลายเป็นล้านบรรทัดได้อย่างรวดเร็ว อย่างที่สามคือ generic ทุกครั้งที่มีการ instantiate generic จะกินทรัพยากร CPU ด้วย แต่จะขอแก้ต่างให้นิดหนึ่งว่า ฟีเจอร์พวกนี้ทำให้โค้ดที่ถ้าเขียนด้วย C อาจยาว 100,000 บรรทัด หรือ C++ 25,000 บรรทัด ลดเหลือเพียงหลักพันบรรทัดใน Rust ได้ อย่างไรก็ดี ก็เป็นความจริงว่าการใช้ฟีเจอร์เหล่านี้มากเกินไปทำให้ ecosystem ดูช้าลง ตัวอย่างเช่นบริษัทของเราใช้ async-graphql ซึ่งตัวไลบรารีเองยอดเยี่ยมมาก แต่พึ่งพา procedural macro อย่างหนัก มี issue ด้านประสิทธิภาพที่เปิดค้างมาหลายปีแล้ว และทุกครั้งที่เพิ่ม data type ก็รู้สึกได้ชัดว่าคอมไพเลอร์ช้าลง
Ryan Fleury สร้าง Epic RAD Debugger ด้วย C จำนวน 278,000 บรรทัด โดยใช้วิธี build แบบ unity (โค้ดทั้งหมดอยู่ในไฟล์เดียวเป็น compilation unit เดียว) และบน Windows ใช้เวลาเพียง 1.5 วินาทีในการ clean compile แค่กรณีนี้ก็แสดงให้เห็นแล้วว่าการคอมไพล์สามารถเร็วมากได้ เลยสงสัยว่าทำไม Rust หรือ Swift ถึงทำแบบเดียวกันไม่ได้
ดีใจมากที่ Go เลือกให้ความสำคัญกับความเร็วคอมไพล์มากกว่าการ optimize สำหรับงานเซิร์ฟเวอร์, networking, และ glue code การคอมไพล์ที่เร็วจริง ๆ สำคัญที่สุด ผมก็อยากได้ type safety ในระดับพอเหมาะ แต่ไม่อยากให้มันขัดขวางการทำ prototype แบบหลวม ๆ การมี GC ก็สะดวกด้วย ผมคิดว่าหลังจาก Google ผ่านประสบการณ์การพัฒนาขนาดใหญ่มาแล้ว ก็สรุปได้ว่า type ที่เรียบง่าย, GC, และการคอมไพล์ที่เร็วมาก สำคัญกว่าความเร็วตอนรันหรือความสมบูรณ์ของ semantics เสียอีก แค่มองกรณีซอฟต์แวร์ networking และ infrastructure ขนาดใหญ่ที่สร้างด้วย Go ก็เห็นได้เลยว่าการเลือกนี้แม่นมาก แน่นอนว่าในสภาพแวดล้อมที่ยอมรับ GC ไม่ได้ หรือที่ความถูกต้องสมบูรณ์สำคัญกว่า ก็อาจไม่ใช้ Go แต่กับสภาพงานของผม การเลือกของ Go เหมาะที่สุด
ผมไม่เข้าใจข้ออ้างที่ว่าการติดตั้งแบบ single static binary ง่ายกว่าการจัดการ container
บนโน้ตบุ๊กของผม (Mac M4 Pro) การคอมไพล์ Deno ทั้งโปรเจ็กต์ (โปรเจ็กต์ Rust ขนาดใหญ่) ใช้เวลา 2 นาที ถ้าวัดเป็นคำสั่ง debug ใช้ประมาณ 1 นาที 54 วินาที ส่วน release ใช้ประมาณ 8 นาที 17 วินาที ตัวเลขนี้วัดโดยไม่ใช้งาน incremental compile จริง ๆ แล้ว build สำหรับ deploy ก็ไปรันบนระบบ CI/CD อยู่แล้ว จึงไม่จำเป็นต้องนั่งรอเอง
แล้วเรื่อง Cranelift อยู่ตรงไหน? ผมเองตอนพัฒนาเกมด้วย Rust เกือบจะเลิกไปแล้วเพราะเวลา compile นานเกินไป พอไปหาข้อมูลก็พบว่า LLVM ช้าไม่ว่า optimization level จะเป็นเท่าไร ซึ่งเป็นสิ่งที่นักพัฒนาภาษา Jai ชี้ให้เห็นมาตลอด ผมเคยเจอว่าพอใช้ Cranelift แล้วเวลา build ลดจาก 16 วินาทีเหลือ 4 วินาที ต้องยกนิ้วให้ทีม Cranelift จริง ๆ!
ผมไม่คิดว่า Rust ช้าเลย เมื่อเทียบกับภาษาระดับเดียวกันก็ถือว่าเร็วพอ และถ้าเทียบกับการคอมไพล์ C++/Scala ที่กินเวลา 15 นาที ก็เร็วกว่ามาก
ในฐานะอดีตนักพัฒนา C++ ผมไม่ค่อยเข้าใจคำกล่าวอ้างที่ว่า Rust build ช้า
incremental compile ทรงพลังมาก หลัง build ครั้งแรกแล้ว สามารถตรึง snapshot ของ incremental cache ไว้ และถ้าไม่มีอะไรเปลี่ยนก็เอาไปใช้ build/deploy ต่อได้อย่างรวดเร็ว เข้ากันได้ดีกับ docker ด้วย นอกจากการเปลี่ยนเวอร์ชันคอมไพเลอร์หรืออัปเดตเว็บไซต์ขนาดใหญ่แล้ว ปกติจะไม่ไปแตะ layer ของ image ถ้ามีแค่โค้ดเปลี่ยน ก็สามารถตั้งค่าไม่ให้ layer นั้นถูก rebuild ได้อย่างมีประสิทธิภาพ
เวลา build หน้าโฮมเพจของผมคือ 73ms ส่วน static site generator ใช้เวลา recompile แค่ 17ms การรัน generator จริง ๆ ใช้เพียง 56ms เท่านั้น แนบ zig build log มาด้วย
cargo watchและถ้าใช้เครื่องมืออย่าง subsecond[0] ที่มีทั้ง incremental linking และ hotpatch ก็จะเร็วยิ่งขึ้น แม้จะไม่เร็วเท่า Zig แต่ก็ใกล้เคียงมาก ถ้า 331ms ที่พูดถึงข้างบนเป็น clean build จริง ๆ (ไม่มี cache) ก็ยังเร็วกว่า clean build ของเว็บไซต์ผมที่ใช้ 12 วินาทีมาก [0]: https://news.ycombinator.com/item?id=44369642