• 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.toml configuration में बचे 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-musl target के लिए build किया गया
    • final image में Alpine पर सिर्फ binary copy की गई
    • इस तरीके का clean build, crates download के 10 सेकंड सहित, 3 मिनट 51 सेकंड लगा

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-server final crate के single rustc call में खर्च हुआ

cargo --timings और rustc self-profile

  • cargo build --release --timings crate-wise compile time दिखाता है; final crate time 174.1 सेकंड था, जो cargo build output के 2 मिनट 54 सेकंड से मोटे तौर पर मेल खाता है
  • bottleneck एक final crate में केंद्रित था, इसलिए सिर्फ cargo --timings से detailed cause समझना मुश्किल था
  • rustc की self-profile capability इस्तेमाल करने के लिए -Zself-profile इस्तेमाल किया गया
    • stable compiler में unstable -Z flag इस्तेमाल करने के लिए RUSTC_BOOTSTRAP=1 इस्तेमाल किया गया
    • cargo-chef cache invalidation से बचने के लिए cargo rustc -- -Z self-profile के बजाय RUSTFLAGS='-Zself-profile' इस्तेमाल किया गया
  • measureme के summarize, flamegraph, crox tools से 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 और debug combinations 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
  • full debug symbols ने compile time 30~50% बढ़ाया, और fat LTO पूरी तरह LTO बंद करने की तुलना में लगभग 4 गुना ज्यादा समय लगा
  • LTO और debug symbols बंद करने के बाद भी final binary compile करने में अभी भी लगभग 50 सेकंड बचे

incremental compilation के बजाय Docker cache बनाए रखने की वजह

  • local development में /target directory को 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 घटाया गया
  • 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 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-passes Docker BuildKit की default log limit में अटक गया
    • BUILDKIT_STEP_LOG_MAX_SIZE
    • BUILDKIT_STEP_LOG_MAX_SPEED
  • ये environment variables docker build call पर नहीं, बल्कि Docker daemon में set करने होते हैं; Linux में इन्हें systemd drop-in से docker.service में set किया जा सकता है
  • limit हटाने पर लगभग 2 लाख lines का text output हुआ, जिसे सीधे handle करना मुश्किल था
  • -Z llvm-time-trace ने *.llvm_timings.json files बनाईं, लेकिन 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 के traceEvents array को प्रति event एक line में split किया गया
    • conversion के बाद event count 7,301,865 lines था

LLVM events में दिखे bottlenecks

  • LLVM trace events मुख्य रूप से "ph":"X" वाले complete events थे, और dur field 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

  • OptFunction event के args.detail में उस function का mangled symbol होता है जिसे optimize किया जा रहा है
  • rustfilt से demangle करने पर original Rust symbols दिखते हैं
    • __rustc::__rust_alloc
    • serde_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 के अंदर closure
    • web_http_server::run के अंदर closure
    • tokio_postgres::connect_raw
    • pulldown_cmark का 500-line scale generic function
    • core::ptr::drop_in_place के कई concrete types
  • outer crate name से roughly aggregate करने पर core 61.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_server type के लिए 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 करके .await count 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 करने के रूप में था
  • हर .await point पर erase(get_img_candidates()).await? की तरह apply करने का परिणाम:
    • PhotosState::new related 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 करने वाला PR
    • lol_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-generics enable करना
    • Alpine से बाहर आना
  • -Zshare-generics crate dependencies के generic instances को reuse करने वाला flag है
    • release build में default रूप से enable नहीं होता
    • stable toolchain के dev build में enable होता है
    • यह flag केवल nightly में available है
  • -Zshare-generics on करने पर total compile time 32.3 सेकंड से 29.1 सेकंड तक घट गया
  • drop_in_place instances अभी भी बहुत 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> को T define करने वाले 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 = 0 set करना भी पर्याप्त option हो सकता है

अभी कोई टिप्पणी नहीं है.

अभी कोई टिप्पणी नहीं है.