Rust कंपाइलर इतना धीमा क्यों है?
(sharnoff.io)- Docker deployment के लिए Rust वेबसाइट build करते समय dependencies cache करने के बाद भी final crate में ही लगभग 175 सेकंड लगे, और bottleneck
rustcके अंदर तथा LLVM optimization stage तक सीमित हो गया cargo-chef,cargo --timings,-Zself-profile,measuremeको क्रम से लागू करने पर पता चला कि यह सिर्फ dependency समस्या नहीं थी, बल्कि LTO और LLVM code generation की लागत build time पर हावी थी- पुराने
Cargo.tomlconfiguration में बचेlto = "thin"औरdebug = "full"का बड़ा असर था; दोनों बंद करने पर final binary build 172.2 सेकंड से घटकर लगभग 50 सेकंड रह गया - LLVM tracing में
OptFunction,InlinerPass,core::ptr::drop_in_place, बड़े async functions और generic monomorphization मुख्य लागत के रूप में दिखे; inlining घटाने, functions अलग करने,Pin<Box<dyn Future>>और generics हटाने से अतिरिक्त सुधार मिला - अंत में
-Zshare-genericsऔर Debian-based build पर जाने से compile time 29.1 सेकंड से 9.1 सेकंड तक गिर गया, जिससे पता चला कि code structure के साथ-साथ allocator और musl target होना भी build time पर बड़ा असर डालता है
Docker build में सामने आया bottleneck
- वेबसाइट मुख्य रूप से एक single Rust binary के रूप में दी जाती है; पहले static linked binary build करके server पर copy किया जाता था और फिर service restart की जाती थी
- container-based deployment पर जाने की कोशिश में Docker में तेज Rust build setup करना उम्मीद से ज्यादा मुश्किल निकला
- basic Dockerfile source बदलने पर हर बार सब कुछ फिर से build करता है
- builder के रूप में
rust:1.87-alpine3.22इस्तेमाल किया गया औरx86_64-unknown-linux-musltarget के लिए build किया गया - final image में Alpine पर सिर्फ binary copy की गई
- इस तरीके का clean build, crates download के 10 सेकंड सहित, 3 मिनट 51 सेकंड लगा
- builder के रूप में
cargo-chef से dependency cache अलग किया, लेकिन यह पर्याप्त नहीं था
- cargo-chef workspace में एक simplified recipe file बनाता है और उसके आधार पर dependencies को अलग Docker cache layer में पहले से build करता है
- वेबसाइट सैकड़ों dependencies इस्तेमाल करती है, इसलिए cache का असर बड़ा होने की उम्मीद थी
- वास्तविक measurement में dependency build 1 मिनट 7 सेकंड, और cached dependencies का इस्तेमाल करते हुए final binary build 2 मिनट 50 सेकंड रहा
- कुल समय का केवल लगभग 25% dependencies पर लगा, और बाकी ज्यादातर
web-http-serverfinal crate के singlerustccall में खर्च हुआ
cargo --timings और rustc self-profile
cargo build --release --timingscrate-wise compile time दिखाता है; final crate time 174.1 सेकंड था, जोcargo buildoutput के 2 मिनट 54 सेकंड से मोटे तौर पर मेल खाता है- bottleneck एक final crate में केंद्रित था, इसलिए सिर्फ
cargo --timingsसे detailed cause समझना मुश्किल था rustcकी self-profile capability इस्तेमाल करने के लिए-Zself-profileइस्तेमाल किया गया- stable compiler में unstable
-Zflag इस्तेमाल करने के लिएRUSTC_BOOTSTRAP=1इस्तेमाल किया गया cargo-chefcache invalidation से बचने के लिएcargo rustc -- -Z self-profileके बजायRUSTFLAGS='-Zself-profile'इस्तेमाल किया गया
- stable compiler में unstable
- measureme के
summarize,flamegraph,croxtools से self-profile data analyze किया गया summarizeकी top entries LLVM-related कामों में clustered थींLLVM_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ने कुल time का लगभग 80% लिया
LTO और debug symbols settings का असर
- Rust compiler crate को codegen units में बांटता है और LLVM को अलग modules के रूप में सौंपता है
- LTO link time पर codegen units या crates के बीच inlining और optimization करने का option है
- Cargo और
rustcके LTO options ये हैं- LTO बंद
"thin"LTO"fat"LTO- अगर explicitly न बताया जाए तो सिर्फ single crate के अंदर सीमित “thin local LTO”
- मौजूदा
Cargo.tomlमें कुछ साल पहले set की गई values बची हुई थींlto = "thin"debug = "full"
debug = "full"release profile में default रूप से excluded full debug symbols को enable करता है- अलग-अलग
ltoऔरdebugcombinations measure करने पर बड़ा फर्क दिखा- 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 बंद,
- full debug symbols ने compile time 30~50% बढ़ाया, और fat LTO पूरी तरह LTO बंद करने की तुलना में लगभग 4 गुना ज्यादा समय लगा
- LTO और debug symbols बंद करने के बाद भी final binary compile करने में अभी भी लगभग 50 सेकंड बचे
incremental compilation के बजाय Docker cache बनाए रखने की वजह
- local development में
/targetdirectory को Dockerfile के cache mount से जोड़कर और builds के बीच बनाए रखकर incremental compilation इस्तेमाल किया जा सकता है - हालांकि
docker buildहर बार clean environment रख सकता है, यह बात बनाए रखने और Docker के अपने cache system का उपयोग करने के लिएcargo-chefजारी रखा गया
LTO के बाद भी बची LLVM optimization cost
- LTO और debug symbols बंद करने के बाद भी final binary compilation में लगभग 50 सेकंड लगे
- self-profile फिर देखने पर समय का लगभग 70%
LLVM_module_optimizeमें लगा था, जो LLVM द्वारा code optimize करने का stage है - release profile के default
opt-level = 3को घटाकर सिर्फ final binary को कम optimize करने की setting experiment की गई- dependencies cached हैं, इसलिए
profile.release.package."*"मेंopt-level = 3बनाए रखा गया - केवल final crate का
opt-levelघटाया गया
- dependencies cached हैं, इसलिए
- measurement results optimization on/off के आधार पर काफी अलग थे
- final
opt-level=0: लगभग 15 सेकंड - final
opt-level=1: लगभग 48 सेकंड - final
opt-level=2या3: लगभग 50~55 सेकंड - final
opt-level="z": लगभग 42 सेकंड
- final
- final binary में कोई भी optimization on करने पर लगभग 50 सेकंड की baseline बनती है, और optimization पूरी तरह बंद करने पर यह लगभग 15 सेकंड तक तेज हो जाता है
LLVM tracing data collect करने की कठिनाई
rustcमें LLVM information देखने के लिए flags हैं-Z time-llvm-passes: LLVM profile information को plain text में output करता है-Z llvm-time-trace: LLVM profile को Chrome tracing format में output करता है
-Z time-llvm-passesDocker BuildKit की default log limit में अटक गयाBUILDKIT_STEP_LOG_MAX_SIZEBUILDKIT_STEP_LOG_MAX_SPEED
- ये environment variables
docker buildcall पर नहीं, बल्कि Docker daemon में set करने होते हैं; Linux में इन्हेंsystemddrop-in सेdocker.serviceमें set किया जा सकता है - limit हटाने पर लगभग 2 लाख lines का text output हुआ, जिसे सीधे handle करना मुश्किल था
-Z llvm-time-traceने*.llvm_timings.jsonfiles बनाईं, लेकिन final binary trace file 1.4GiB की single-line JSON थी- Firefox Profiler, Perfetto UI, और Chromium के
chrome://tracingसभी को इस file को handle करने में समस्या हुई - JSON को JSONL में बदलकर सामान्य tools से process किया गया
- single JSON object के
traceEventsarray को प्रति event एक line में split किया गया - conversion के बाद event count 7,301,865 lines था
- single JSON object के
LLVM events में दिखे bottlenecks
- LLVM trace events मुख्य रूप से
"ph":"X"वाले complete events थे, औरdurfield microseconds में duration दिखाता है "ph":"M"metadata event था, और इस analysis में ज्यादा useful information नहीं थी- aggregate events में जहां ज्यादा time लगा, वे items ये थे
Total ModuleInlinerWrapperPass: 665.37 सेकंडTotal ModuleToPostOrderCGSCCPassAdaptor: 656.47 सेकंडTotal DevirtSCCRepeatedPass: 632.44 सेकंडTotal OptFunction: 189.62 सेकंडTotal InlinerPass: 182.25 सेकंड
- यह run 16-core machine पर लगभग 110 सेकंड चला, इसलिए कुछ pass times overlap होकर count हुए
- बड़े axis function optimization यानी
OptFunctionऔर inlining यानीInlinerPassथे
inlining thresholds adjust करना
- LLVM inlining options
rustcके-C llvm-argsसे pass किए जा सकते हैं - जून 2025 के आधार पर
rustc -C llvm-args='--help-list-hidden'में inlining-related options लगभग 100 हैं - experiment में इस्तेमाल options तीन थे
--inlinedefault-threshold=225--inline-threshold=225--inlinehint-threshold=325
- threshold मोटे तौर पर उस value से कम cost वाले functions की inlining allow करता है, इसलिए value घटाने पर inlining कम होती है
- तीनों thresholds को 50 तक घटाने पर समय 48.8 सेकंड से 42.2 सेकंड हो गया
- लगभग load-free personal website जैसे use case में threshold 10 भी promising option माना गया
OptFunction और generic monomorphization
OptFunctionevent केargs.detailमें उस function का mangled symbol होता है जिसे optimize किया जा रहा है- rustfilt से demangle करने पर original Rust symbols दिखते हैं
__rustc::__rust_allocserde_json::value::to_value
- एक ही
serde_json::value::to_valueकई hashes के साथ दिखने की वजह यह है कि generic function अलग-अलग type parameters के लिए monomorphize होता है - दूसरे crates के functions भी final crate में optimize होते हैं, क्योंकि function किसी specific type में monomorphize कहाँ होता है, यह calling crate के context पर निर्भर करता है
- ज्यादा optimization time लेने वाले functions के examples ये हैं
web_http_server::photos::PhotosState::newके अंदर closureweb_http_server::runके अंदर closuretokio_postgres::connect_rawpulldown_cmarkका 500-line scale generic functioncore::ptr::drop_in_placeके कई concrete types
- outer crate name से roughly aggregate करने पर
core61.53 सेकंड के साथ सबसे बड़ा था, और उसमें से 84%core::ptr::drop_in_placeके parameterization से था
v0 symbol mangling से async function location और साफ दिखी
- default legacy symbol mangling में closures को distinguish करना मुश्किल था
-C symbol-mangling-version=v0जोड़ने पर closure numbers और generic type information बेहतर दिखाई देती है- उदाहरण के लिए
serde_json::value::to_valueकिसweb_http_servertype के लिए monomorphize हुआ है, उसके पूरे generic arguments देखे जा सके - v0 output में महंगे items ये थे
<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 सेकंड
- ऊपर से देखने पर ये छोटे closures लगते थे, लेकिन LLVM IR dump करने पर पता चला कि async functions और async blocks internally nested closures के रूप में represent किए गए थे
- Rust में async function/block mangling से जुड़ा open issue पहले से था
बड़े async functions और Pin<Box<dyn Future>>
- महंगे items closure खुद नहीं, बल्कि बड़े async function bodies थे
PhotosState::newसे जुड़े optimization time की शुरुआत में कुल लागत 5.3 सेकंड थी- सिर्फ function split करने की पहली कोशिश से यह थोड़ा घटकर 4.66 सेकंड हुआ
- पास-पास के
.awaitको group करके.awaitcount 10 से 3 करने की कोशिश उल्टा 6.24 सेकंड तक बढ़ गई - async function internally complex state machine में lower होता है, इसलिए caller से implementation details छिपाने के लिए
Futureको trait object में erase करने का तरीका आजमाया गया - इस्तेमाल function
impl Future<Output = T>कोPin<Box<dyn Send + Future<Output = T>>>में wrap करने के रूप में था - हर
.awaitpoint परerase(get_img_candidates()).await?की तरह apply करने का परिणाम:PhotosState::newrelated time 2.14 सेकंड तक घटा- profiling के बिना overall build time 48.8 सेकंड से 46.8 सेकंड तक घटा
#[inline(never)]और poll function inlining disabling भी try किया गया, लेकिन boxing जितना अच्छा नहीं था
कई changes combine करने का परिणाम
- apply किए गए approaches तीन थे
- LLVM args से inlining reduction
- main crate के expensive functions को अलग करना और async Future boxing
- dependency API में generics घटाकर final crate में फिर compile होने वाले parts कम करना
- final Dockerfile में तीन inlining thresholds को 10 तक घटाने वाले
RUSTFLAGSकोcargo chef cookऔरcargo buildदोनों पर apply किया गया - main crate में 10 files में 898 lines added, 657 lines deleted हुए
- dependency-side changes भी साथ में शामिल किए गए
pulldown-cmarkके generic function को non-generic करने वाला PRlol_html,deadpool_postgresमें इस्तेमाल APIs के non-generic versions expose करने वाला local crate
- इस combination से final compile time 32.3 सेकंड हो गया
2025-06-27 update: -Zshare-generics और Alpine हटाना
- Bluesky और Lobsters से मिले suggestions के आधार पर दो और experiments किए गए
-Zshare-genericsenable करना- Alpine से बाहर आना
-Zshare-genericscrate dependencies के generic instances को reuse करने वाला flag है- release build में default रूप से enable नहीं होता
- stable toolchain के dev build में enable होता है
- यह flag केवल nightly में available है
-Zshare-genericson करने पर total compile time 32.3 सेकंड से 29.1 सेकंड तक घट गयाdrop_in_placeinstances अभी भी बहुत compile हुए, लेकिन उनका optimization time 21.7 सेकंड से 17.4 सेकंड तक घटा- Alpine से Debian पर switch करने और
--target=x86_64-unknown-linux-muslहटाने पर total compile time 29.1 सेकंड से 9.1 सेकंड तक बहुत घट गया - suggestion के background में यह बात थी कि default allocator build time पर बड़ा असर डाल सकता है
final numbers और बाकी काम
- final changes इस प्रकार हैं
- starting point: लगभग 175 सेकंड
- LTO और debug symbols disable: 51 सेकंड, -71%
- final crate
opt-level = 1: 48.8 सेकंड, -4% -C llvm-argsसे inlining reduction: 40.7 सेकंड, -16%- local code changes: 37.7 सेकंड, -7%
- dependency changes: 32.3 सेकंड, -14%
-Zshare-generics: 29.1 सेकंड, -10%- Alpine removal: 9.1 सेकंड, -69%
- analysis process में tools और documentation इतने अच्छे से काम आए कि वास्तविक improvements बन सके
- हालांकि कुछ complex issues बाकी हैं
- deep async function call graphs का compile time और बेहतर करने की जरूरत है
core::ptr::drop_in_place<T>कोTdefine करने वाले crate में compile करवाने के लिए special treatment कुछ cases में मदद कर सकता है, लेकिन generic types पर apply करना कठिन है और unused drop glue तक compile होने का जोखिम है-Zshare-genericsमदद करता है, लेकिन complete solution नहीं है- codebase के कौन से हिस्से ज्यादा compile time लेते हैं, उन्हें isolate करने और mitigations suggest करने वाले tools की और जरूरत हो सकती है
- practical तौर पर final crate में
opt-level = 0set करना भी पर्याप्त option हो सकता है
अभी कोई टिप्पणी नहीं है.