ทำไมคอมไพเลอร์ Rust ถึงช้านัก?
(sharnoff.io)- ในการบิลด์เว็บไซต์ Rust สำหรับดีพลอยด้วย Docker แม้จะแคช dependency แล้ว ก็ยังใช้เวลา ราว 175 วินาที กับ final crate เพียงตัวเดียว โดยคอขวดถูกตีวงแคบลงไปที่ภายใน
rustcและขั้นตอน optimization ของ LLVM - หลังจากลองใช้
cargo-chef,cargo --timings,-Zself-profile, และmeasuremeตามลำดับ ก็พบว่าไม่ได้เป็นแค่ปัญหา dependency ธรรมดา แต่เป็นต้นทุนของ LTO และ LLVM code generation ที่ครองเวลาบิลด์ - ค่า
lto = "thin"และdebug = "full"ในCargo.tomlที่ค้างมาจากการตั้งค่าเก่า ส่งผลอย่างมาก และเมื่อปิดทั้งสองอย่าง เวลาบิลด์ไบนารีสุดท้ายลดจาก 172.2 วินาที เหลือราว 50 วินาที - ในการติดตามของ LLVM พบว่า
OptFunction,InlinerPass,core::ptr::drop_in_place, ฟังก์ชัน async ขนาดใหญ่ และ generic monomorphization เป็นตัวกินต้นทุนหลัก และการลดการ inline, แยกฟังก์ชัน, ใช้Pin<Box<dyn Future>>, และลดการใช้ generic ก็ช่วยปรับปรุงเพิ่มเติมได้ - สุดท้ายเมื่อเปิดใช้
-Zshare-genericsและเปลี่ยนไปบิลด์บน Debian เวลา compile ก็ลดจาก 29.1 วินาที เหลือ 9.1 วินาที แสดงให้เห็นว่านอกจากโครงสร้างโค้ดแล้ว allocator และการใช้ target แบบ musl ก็มีผลต่อเวลาบิลด์อย่างมาก
คอขวดที่เผยออกมาในการบิลด์ด้วย Docker
- เว็บไซต์นี้ให้บริการหลัก ๆ เป็น Rust binary ตัวเดียว โดยเดิมจะบิลด์ไบนารีแบบ static link แล้วคัดลอกไปที่เซิร์ฟเวอร์ก่อนรีสตาร์ต service
- เมื่อต้องย้ายไปใช้การดีพลอยแบบ container กลับพบว่าการตั้งค่าให้ Docker บิลด์ Rust ได้เร็วเป็นเรื่องที่ยากกว่าที่คิด
- Dockerfile พื้นฐานจะบิลด์ทุกอย่างใหม่ทั้งหมดทุกครั้งที่ source เปลี่ยน
- ใช้
rust:1.87-alpine3.22เป็น builder และบิลด์ด้วย targetx86_64-unknown-linux-musl - image สุดท้ายคัดลอกเฉพาะไบนารีไปไว้บน Alpine
- การทำ clean build ด้วยวิธีนี้ใช้เวลา 3 นาที 51 วินาที รวม 10 วินาทีสำหรับดาวน์โหลด crates
- ใช้
แยกการแคช dependency ด้วย cargo-chef แล้ว แต่ยังไม่พอ
- cargo-chef สร้างไฟล์ recipe แบบย่อจาก workspace และใช้ไฟล์นั้นบิลด์ dependency ล่วงหน้าแยกไว้ใน Docker cache layer
- เนื่องจากเว็บไซต์นี้ใช้ dependency หลายร้อยตัว จึงคาดหวังว่าผลของการแคชน่าจะมาก
- แต่จากการวัดจริง การบิลด์ dependency ใช้เวลา 1 นาที 7 วินาที และการบิลด์ไบนารีสุดท้ายโดยใช้ dependency ที่แคชแล้วใช้เวลา 2 นาที 50 วินาที
- มีเวลาเพียงราว 25% ของทั้งหมดที่ใช้กับ dependency ส่วนที่เหลือส่วนใหญ่ถูกใช้ไปกับการเรียก
rustcครั้งเดียวของ final crateweb-http-server
cargo --timings และ self-profile ของ rustc
cargo build --release --timingsแสดงเวลา compile ราย crate โดย final crate ใช้เวลา 174.1 วินาที ซึ่งใกล้เคียงกับ 2 นาที 54 วินาทีที่เห็นจาก output ของcargo build- เพราะคอขวดไปรวมอยู่ที่ final crate ตัวเดียว จึงยากจะรู้สาเหตุเชิงลึกด้วย
cargo --timingsอย่างเดียว - จึงใช้ฟังก์ชัน self-profile ของ
rustcผ่าน-Zself-profile- ใช้
RUSTC_BOOTSTRAP=1เพื่อให้ใช้ flag ไม่เสถียร-Zบน stable compiler ได้ - ใช้
RUSTFLAGS='-Zself-profile'แทนcargo rustc -- -Z self-profileเพื่อหลีกเลี่ยงการทำให้แคชของcargo-chefใช้ไม่ได้
- ใช้
- ใช้เครื่องมือ
summarize,flamegraph,croxจาก measureme เพื่อวิเคราะห์ข้อมูล self-profile - รายการบนสุดจาก
summarizeกระจุกอยู่ที่งานฝั่ง LLVMLLVM_lto_optimize: 851.95 วินาที, 33.389%LLVM_module_codegen_emit_obj: 674.94 วินาที, 26.452%LLVM_thin_lto_import: 317.75 วินาที, 12.453%LLVM_module_optimize: 189.00 วินาที, 7.407%
- ใน flamegraph นั้น
codegen_module_perform_ltoกินเวลา ราว 80% ของทั้งหมด
ผลกระทบของการตั้งค่า LTO และ debug symbol
- Rust compiler จะแบ่ง crate ออกเป็น codegen unit แล้วส่งต่อให้ LLVM เป็นโมดูลแยกกัน
- LTO เป็นตัวเลือกที่ทำ inline และ optimization ระหว่าง codegen unit หรือระหว่าง crate ตอนลิงก์
- ตัวเลือก LTO ของ Cargo และ
rustcมีดังนี้- ปิด LTO
"thin"LTO"fat"LTO- ถ้าไม่ระบุ จะใช้ “thin local LTO” แบบจำกัดอยู่ภายใน crate เดียว
- ใน
Cargo.tomlเดิมยังมีค่าที่ตั้งไว้เมื่อหลายปีก่อนค้างอยู่lto = "thin"debug = "full"
debug = "full"จะเปิด debug symbol แบบเต็ม ซึ่งปกติถูกปิดไว้ใน release profile- เมื่อวัดชุดค่าผสมของ
ltoและdebugหลายแบบ พบว่าต่างกันมาก- ปิด LTO,
debug=none: 50.0 วินาที / 21.0MiB - Thin local LTO,
debug=full: 88.2 วินาที / 256.8MiB "thin"LTO,debug=full: 172.2 วินาที / 197.5MiB"fat"LTO,debug=full: 287.1 วินาที / 155.9MiB
- ปิด LTO,
- debug symbol แบบเต็มเพิ่มเวลาคอมไพล์ขึ้น 30~50% และ fat LTO ใช้เวลานานกว่าการปิด LTO ทั้งหมดประมาณ 4 เท่า
- แม้จะปิด LTO และ debug symbol แล้ว ก็ยังต้องใช้เวลาราว 50 วินาทีในการคอมไพล์ไบนารีสุดท้ายเพียงตัวเดียว
เหตุผลที่ยังคงใช้ Docker cache แทน incremental compilation
- ในการพัฒนาแบบ local สามารถเมานต์ไดเรกทอรี
/targetเป็น cache mount ใน Dockerfile และเก็บไว้ระหว่างการบิลด์เพื่อใช้ incremental compilation ได้ - แต่เพื่อคงคุณสมบัติที่
docker buildสามารถเริ่มจากสภาพแวดล้อมที่สะอาดทุกครั้ง และยังใช้ระบบแคชของ Docker เองได้ จึงยังคงใช้cargo-chefต่อไป
ต้นทุน optimization ของ LLVM ที่ยังเหลือหลังจากปิด LTO
- แม้ปิด LTO และ debug symbol แล้ว การคอมไพล์ไบนารีสุดท้ายก็ยังใช้เวลาราว 50 วินาที
- เมื่อดู self-profile อีกรอบ พบว่าราว 70% ของเวลาไปอยู่ที่
LLVM_module_optimizeซึ่งเป็นขั้นตอนที่ LLVM ทำ optimization กับโค้ด - จึงทดลองลด
opt-level = 3ซึ่งเป็นค่า default ของ release profile ลงเฉพาะกับไบนารีสุดท้ายให้ optimize น้อยลง- dependency ยังคงถูกแคชอยู่ จึงคง
opt-level = 3ไว้ในprofile.release.package."*" - ลด
opt-levelเฉพาะ final crate
- dependency ยังคงถูกแคชอยู่ จึงคง
- ผลที่วัดได้ต่างกันมากตามระดับ optimization
- final
opt-level=0: ราว 15 วินาที - final
opt-level=1: ราว 48 วินาที - final
opt-level=2หรือ3: ราว 50~55 วินาที - final
opt-level="z": ราว 42 วินาที
- final
- เมื่อเปิด optimization แบบใดก็ตามกับไบนารีสุดท้าย จะเกิดเส้นฐานที่ราว 50 วินาที และถ้าปิด optimization ทั้งหมดจะเร็วขึ้นเป็นราว 15 วินาที
ความยากในการเก็บข้อมูล trace ของ LLVM
rustcมี flag สำหรับดูข้อมูลของ LLVM-Z time-llvm-passes: แสดงข้อมูลโปรไฟล์ของ LLVM เป็นข้อความธรรมดา-Z llvm-time-trace: ส่งออกโปรไฟล์ LLVM ในรูปแบบ Chrome tracing
-Z time-llvm-passesไปติดข้อจำกัด log เริ่มต้นของ Docker BuildKitBUILDKIT_STEP_LOG_MAX_SIZEBUILDKIT_STEP_LOG_MAX_SPEED
- ตัวแปรสภาพแวดล้อมเหล่านี้ต้องตั้งที่ Docker daemon ไม่ใช่ที่คำสั่ง
docker buildและบน Linux สามารถตั้งให้docker.serviceผ่านsystemddrop-in ได้ - เมื่อปลดข้อจำกัดแล้ว ก็มีข้อความออกมาราว 200,000 บรรทัด ซึ่งจัดการโดยตรงได้ยาก
-Z llvm-time-traceสร้างไฟล์*.llvm_timings.jsonแต่ไฟล์ trace ของไบนารีสุดท้ายเป็น JSON บรรทัดเดียวขนาด 1.4GiB- ทั้ง Firefox Profiler, Perfetto UI และ
chrome://tracingของ Chromium ต่างมีปัญหาในการจัดการไฟล์นี้ - จึงแปลง JSON ให้เป็น JSONL เพื่อใช้กับเครื่องมือทั่วไป
- แยก array
traceEventsของ JSON object เดียวออกเป็นหนึ่งบรรทัดต่อหนึ่ง event - หลังแปลงแล้วมีทั้งหมด 7,301,865 บรรทัดของ event
- แยก array
คอขวดที่เห็นจาก event ของ LLVM
- event ของ LLVM ส่วนใหญ่เป็น complete event ที่มี
"ph":"X"โดย fielddurแสดง duration ในหน่วยไมโครวินาที "ph":"M"เป็น metadata event ซึ่งในการวิเคราะห์นี้ไม่ได้ให้ข้อมูลที่เป็นประโยชน์มากนัก- ใน event แบบ aggregate รายการที่ใช้เวลามากมีดังนี้
Total ModuleInlinerWrapperPass: 665.37 วินาทีTotal ModuleToPostOrderCGSCCPassAdaptor: 656.47 วินาทีTotal DevirtSCCRepeatedPass: 632.44 วินาทีTotal OptFunction: 189.62 วินาทีTotal InlinerPass: 182.25 วินาที
- การรันครั้งนี้ใช้เวลาราว 110 วินาที บนเครื่อง 16 คอร์ จึงมีบาง pass ที่ถูกนับเวลาซ้ำ
- แกนหลักของปัญหาคือการ optimize ฟังก์ชันอย่าง
OptFunctionและการ inline อย่างInlinerPass
การปรับ threshold ของการ inline
- ตัวเลือก inline ของ LLVM สามารถส่งให้
rustcผ่าน-C llvm-argsได้ - ณ เดือนมิถุนายน 2025 คำสั่ง
rustc -C llvm-args='--help-list-hidden'แสดงตัวเลือกเกี่ยวกับ inline ราว 100 รายการ - ตัวเลือกที่ใช้ในการทดลองมี 3 ตัว
--inlinedefault-threshold=225--inline-threshold=225--inlinehint-threshold=325
- threshold โดยคร่าว ๆ คือยอมให้ inline ฟังก์ชันที่มีต้นทุนต่ำกว่าค่านั้น ดังนั้นเมื่อปรับให้ต่ำลง การ inline ก็จะลดลง
- เมื่อลด threshold ทั้งสามเหลือ 50 เวลา compile ลดจาก 48.8 วินาที เหลือ 42.2 วินาที
- สำหรับกรณีใช้งานเป็นเว็บไซต์ส่วนตัวที่แทบไม่มีโหลด ผู้เขียนมองว่า threshold 10 ก็น่าสนใจเช่นกัน
OptFunction และ generic monomorphization
- ใน event ของ
OptFunctionค่าargs.detailจะมี mangled symbol ของฟังก์ชันที่กำลังถูก optimize - หาก demangle ด้วย rustfilt ก็จะเห็น Rust symbol เดิม เช่น
__rustc::__rust_allocserde_json::value::to_value
- เหตุที่
serde_json::value::to_valueเดียวกันปรากฏหลาย hash เป็นเพราะ generic function ถูก monomorphize ด้วย type parameter ที่ต่างกัน - ฟังก์ชันจาก crate อื่นก็ถูก optimize ใน final crate ด้วย เพราะตำแหน่งที่ฟังก์ชันถูก monomorphize เป็น type เฉพาะนั้นอยู่ในบริบทของ crate ที่เรียกใช้
- ตัวอย่างฟังก์ชันที่ใช้เวลา optimize สูง ได้แก่
- closure ภายใน
web_http_server::photos::PhotosState::new - closure ภายใน
web_http_server::run tokio_postgres::connect_raw- generic function ขนาดราว 500 บรรทัดของ
pulldown_cmark core::ptr::drop_in_placeของ concrete type หลายแบบ
- closure ภายใน
- หากรวมแบบหยาบตามชื่อ crate ชั้นนอก
coreมากที่สุดที่ 61.53 วินาที และในนั้น 84% คือการ parameterize ของcore::ptr::drop_in_place
ใช้ v0 symbol mangling เพื่อดูตำแหน่งของ async function ให้ชัดขึ้น
- legacy symbol mangling แบบ default ทำให้แยก closure ได้ยาก
- เมื่อเพิ่ม
-C symbol-mangling-version=v0จะเห็นหมายเลข closure และข้อมูล generic type ได้ชัดขึ้น - ตัวอย่างเช่น สามารถเห็น generic argument ทั้งหมดของ
serde_json::value::to_valueที่ถูก monomorphize ด้วย type ของweb_http_server - รายการที่แพงใน output แบบ v0 มีดังนี้
<web_http_server::photos::PhotosState>::new::{closure#0}: 1.99 วินาทีweb_http_server::run::{closure#0}: 1.56 วินาทีcore::ptr::drop_in_place::<axum::routing::Endpoint<web_http_server::AppState>>: 1.22 วินาที
- แม้ภายนอกจะดูเป็น closure เล็ก ๆ แต่เมื่อ dump LLVM IR กลับพบว่า async function และ async block ถูกแทนภายในเป็น nested closure
- ใน Rust มี open issue เกี่ยวกับ mangling ของ async function/block อยู่แล้ว
async function ขนาดใหญ่และ Pin<Box<dyn Future>>
- รายการที่แพงจริง ๆ ไม่ใช่ตัว closure เอง แต่เป็น เนื้อในของ async function ที่มีขนาดใหญ่
- เวลา optimization ที่เกี่ยวกับ
PhotosState::newตอนแรกมีรวม 5.3 วินาที - การลองแยกฟังก์ชันแบบง่าย ๆ ครั้งแรก ลดลงได้เพียงเล็กน้อยเป็น 4.66 วินาที
- การรวม
.awaitที่อยู่ติดกันเพื่อลดจำนวน.awaitจาก 10 จุดเหลือ 3 จุด กลับแย่ลงเป็น 6.24 วินาที - เนื่องจาก async function ถูกลดรูปภายในเป็น state machine ที่ซับซ้อน จึงลองลบรายละเอียด implementation ออกจากฝั่ง caller ด้วยการ erase
Futureให้เป็น trait object - ฟังก์ชันที่ใช้มีรูปแบบห่อ
impl Future<Output = T>เป็นPin<Box<dyn Send + Future<Output = T>>> - เมื่อนำไปใช้ตามจุด
.awaitเช่นerase(get_img_candidates()).await?ได้ผลดังนี้- เวลาที่เกี่ยวกับ
PhotosState::newลดลงเหลือ 2.14 วินาที - เวลาบิลด์รวมแบบไม่เปิด profiling ลดจาก 48.8 วินาที เหลือ 46.8 วินาที
- เวลาที่เกี่ยวกับ
- มีการลอง
#[inline(never)]และปิดการ inline ของฟังก์ชัน poll ด้วย แต่ผลไม่ดีเท่าการทำ boxing
ผลลัพธ์เมื่อรวมหลายการเปลี่ยนแปลง
- แนวทางที่นำมาใช้มี 3 อย่าง
- ลดการ inline ผ่าน LLVM args
- แยกฟังก์ชันที่แพงใน main crate และทำ boxing ให้ async Future
- ลด generic ใน API ของ dependency เพื่อลดส่วนที่ต้องคอมไพล์ซ้ำใน final crate
- Dockerfile สุดท้ายใส่
RUSTFLAGSที่ลด threshold ของการ inline ทั้งสามตัวเหลือ 10 ให้กับทั้งcargo chef cookและcargo build - ใน main crate มีการเปลี่ยนแปลงรวม เพิ่ม 898 บรรทัด ลบ 657 บรรทัด กระจายใน 10 ไฟล์
- ฝั่ง dependency ก็มีการปรับด้วย
- PR เพื่อทำให้ generic function ของ
pulldown-cmarkกลายเป็น non-generic - local crate ที่เปิดเผย API รุ่น non-generic สำหรับสิ่งที่ใช้ใน
lol_html,deadpool_postgres
- PR เพื่อทำให้ generic function ของ
- เมื่อรวมทั้งหมดนี้ เวลา compile สุดท้ายเหลือ 32.3 วินาที
อัปเดต 2025-06-27: -Zshare-generics และการเลิกใช้ Alpine
- จากข้อเสนอใน Bluesky และ Lobsters จึงมีการทดลองเพิ่มอีก 2 อย่าง
- เปิดใช้
-Zshare-generics - เลิกใช้ Alpine
- เปิดใช้
-Zshare-genericsเป็น flag สำหรับ reuse generic instance จาก crate dependency- ไม่ได้เปิดเป็นค่า default ใน release build
- แต่เปิดใช้อยู่ใน dev build ของ stable toolchain
- flag นี้ใช้ได้เฉพาะบน nightly
- เมื่อเปิด
-Zshare-genericsเวลา compile รวมลดจาก 32.3 วินาที เหลือ 29.1 วินาที - แม้ยังมีการ compile instance ของ
drop_in_placeจำนวนมากอยู่ แต่เวลา optimization ของส่วนนี้ลดจาก 21.7 วินาที เหลือ 17.4 วินาที - เมื่อเปลี่ยนจาก Alpine เป็น Debian และเอา
--target=x86_64-unknown-linux-muslออก เวลา compile รวมลดลงมากจาก 29.1 วินาที เหลือ 9.1 วินาที - เบื้องหลังของข้อเสนอนี้คือ allocator ค่า default อาจมีผลต่อเวลาบิลด์อย่างมาก
ตัวเลขสุดท้ายและโจทย์ที่ยังเหลือ
- การเปลี่ยนแปลงสุดท้ายมีดังนี้
- จุดเริ่มต้น: ราว 175 วินาที
- ปิด LTO และ debug symbol: 51 วินาที, -71%
- final crate
opt-level = 1: 48.8 วินาที, -4% - ลดการ inline ด้วย
-C llvm-args: 40.7 วินาที, -16% - เปลี่ยนโค้ดภายใน: 37.7 วินาที, -7%
- เปลี่ยน dependency: 32.3 วินาที, -14%
-Zshare-generics: 29.1 วินาที, -10%- เลิกใช้ Alpine: 9.1 วินาที, -69%
- ตลอดกระบวนการวิเคราะห์ เครื่องมือและเอกสารต่าง ๆ ใช้งานได้ดีพอที่จะนำไปสู่การปรับปรุงจริง
- แต่ก็ยังมีปัญหาซับซ้อนบางอย่างค้างอยู่
- เวลาคอมไพล์ของ async call graph ที่ลึกยังควรได้รับการปรับปรุงเพิ่ม
- แนวทางทำ special-case ให้
core::ptr::drop_in_place<T>ถูก compile ที่ crate ที่นิยามTอาจช่วยได้ในบางกรณี แต่ใช้กับ generic type ได้ยาก และเสี่ยงคอมไพล์ drop glue ที่ไม่ได้ใช้งานด้วย -Zshare-genericsช่วยได้ แต่ยังไม่ใช่คำตอบทั้งหมด- อาจต้องมีเครื่องมือเพิ่มเพื่อแยกให้เห็นว่า codebase ส่วนไหนใช้เวลาคอมไพล์มาก และเสนอวิธีบรรเทาได้
- ในทางปฏิบัติ การตั้ง
opt-level = 0ให้ final crate ก็อาจเป็นตัวเลือกที่เพียงพอได้เช่นกัน
ยังไม่มีความคิดเห็น