- Rust से बनी वेबसाइट को Docker में बार-बार build करते समय build time की समस्या सामने आई
- डिफ़ॉल्ट Docker सेटअप में हर बार पूरी dependency दोबारा rebuild होती है, जिससे 4 मिनट से ज़्यादा लगते हैं
cargo-chef और caching tools इस्तेमाल करने पर भी अंतिम binary build में अब भी काफी समय लगता है
- profiling के नतीजे में पता चला कि ज़्यादातर समय LTO(link-time optimization) और LLVM module optimization में खर्च होता है
- optimization options, debug information, और LTO settings को adjust करके कुछ सुधार संभव है, लेकिन अंतिम binary compile होने में कम-से-कम 50 सेकंड तो लगते ही हैं
समस्या और पृष्ठभूमि
- Rust में बनी निजी वेबसाइट को हर बार बदलने पर statically linked binary build करके server पर कॉपी करना और फिर restart करना एक बार-बार होने वाला झंझट था
- Docker या Kubernetes जैसी container-based deployment में जाना चाहा, लेकिन Rust की Docker build speed एक बड़ी समस्या बनकर सामने आई
- Docker के अंदर छोटे code change पर भी सब कुछ शुरू से rebuild करना पड़ता था, जिससे काफ़ी inefficiency होती थी
Docker में Rust build – बुनियादी तरीका
- आम Dockerfile तरीका यह है कि सभी dependencies और source code को copy करने के बाद cargo build चलाया जाए
- इस स्थिति में caching का फ़ायदा नहीं मिलता और पूरा rebuild बार-बार होता है
- लेखक की वेबसाइट के हिसाब से full build में लगभग 4 मिनट लगते थे—dependency download में अलग से समय लगता था
Docker build caching में सुधार – cargo-chef
cargo-chef tool का इस्तेमाल करने पर केवल dependencies को अलग layer में पहले से cache किया जा सकता है
- इससे code change होने पर dependency build दोबारा इस्तेमाल हो सकती है और build speed बेहतर होने की उम्मीद रहती है
- वास्तविक उपयोग में, कुल समय का सिर्फ 25% dependency build पर गया, और अंतिम web service binary build में अब भी काफ़ी समय लगा (2 मिनट 50 सेकंड~3 मिनट)
- प्रमुख dependencies (
axum, reqwest, tokio-postgres आदि) और लगभग 7,000 lines के अपने code के बावजूद, rustc की एक single run में 3 मिनट लग रहे थे
rustc build time analysis: cargo --timings
cargo --timings का उपयोग करके हर crate (compilation unit) का build time देखा जा सकता है
- नतीजे में साफ़ हुआ कि अंतिम binary build ही कुल समय का बड़ा हिस्सा ले रहा था
- यह अधिक बारीक वजहें खोजने में मदद करता है, लेकिन compiler के अंदर क्या हो रहा है, यह पूरी तरह नहीं बताता
rustc self-profiling (-Zself-profile) का उपयोग
- rustc की self-profiling सुविधा को
-Zself-profile flag से चालू कर के अंदरूनी चरणों का समय मापा गया
- इसके लिए environment variable के ज़रिए profiling enable की गई
- summarize tool से analysis करने पर पता चला कि LLVM LTO(link-time optimization) और LLVM module code generation कुल समय का 60% से ज़्यादा ले रहे थे
- flamegraph visualization में भी
codegen_module_perform_lto चरण में कुल समय का 80% खर्च होता दिखा
LTO(link-time optimization) और build optimization options
- Rust build आम तौर पर पहले अलग-अलग codegen unit में बाँटी जाती है, फिर LTO द्वारा बाद के चरण में global optimization लागू होती है
- LTO में off, thin, fat जैसे कई options होते हैं, जिनका performance और final output पर असर पड़ता है
- लेखक के project में
Cargo.toml में LTO thin पर और debug symbols full पर सेट थे
- अलग-अलग LTO/debug symbol combinations को test करने पर:
- full debug symbols से build time बढ़ता है, और fat LTO में build लगभग 4 गुना धीमा हो जाता है
- LTO और debug symbols हटाने पर भी कम-से-कम 50 सेकंड का build time बना रहता है
अतिरिक्त optimization और विचार
- लगभग 50 सेकंड लेखक की कम-traffic वाली साइट के लिए कोई बड़ी समस्या नहीं थी, लेकिन तकनीकी जिज्ञासा के कारण आगे analysis किया गया
- अगर incremental compilation को Docker के साथ अच्छी तरह इस्तेमाल किया जाए, तो build और तेज़ हो सकती है, लेकिन इसके लिए clean build environment और Docker cache का सही मेल ज़रूरी है
LLVM चरण की विस्तृत profiling
- LTO और debug symbols हटाने के बाद भी
LLVM_module_optimize चरण में लगभग 70% समय जा रहा था
- release profile में
opt-level का डिफ़ॉल्ट मान (3) optimization cost बढ़ा रहा था; इसलिए केवल binary के लिए opt-level कम करने का तरीका test किया गया
- अलग-अलग optimization combinations के प्रयोग में, optimization बंद (
opt-level=0) होने पर build time लगभग 15 सेकंड, जबकि optimization (1~3) पर लगभग 50 सेकंड रहा
LLVM trace events का गहरा analysis
- rustc के अतिरिक्त flags (
-Z time-llvm-passes, -Z llvm-time-trace) से LLVM के हर चरण का विस्तृत timing trace किया जा सकता है
-Z time-llvm-passes का output बहुत बड़ा होता है, इसलिए कई बार Docker की log limit पार हो जाती है और log settings बदलनी पड़ती हैं
- logs को file में save करके analysis करने पर हर LLVM optimization pass का execution time अलग-अलग देखा जा सकता है
-Z llvm-time-trace option chrome tracing format में बहुत बड़ा JSON output बनाता है, जिसे सामान्य text editor या analysis tools में संभालना मुश्किल होता है
- इसे newline-आधारित processing (
jsonl) में बाँटकर CLI/script environment में analyze किया जा सकता है
मुख्य insight और निष्कर्ष
- Docker में जटिल Rust project build करते समय build speed का bottleneck मुख्यतः अंतिम binary build और उससे जुड़े LLVM optimization चरणों में होता है
- LTO, debug symbols, और
opt-level को adjust करते समय build time और binary size के बीच स्पष्ट trade-off दिखाई देता है
- optimization options को आक्रामक रूप से adjust करने पर build time काफ़ी कम हो सकता है, लेकिन optimization बंद करने पर performance गिरने की संभावना रहती है
- अगर बड़े crate dependencies और production environment में build efficiency महत्वपूर्ण है, तो profiling का सक्रिय उपयोग करके विस्तृत bottleneck को ठोस रूप से समझना बेहतर रणनीति है
- Rust build pipeline design करते समय LTO,
opt-level, debug symbols, और cache strategy के बारीक संयोजन की योजना बनाना ज़रूरी है
1 टिप्पणियां
Hacker News की राय
Rust प्रोजेक्ट्स अक्सर ऊपर से छोटे दिखते हैं, इसलिए यह दिलचस्प लगता है। पहला, dependency हमेशा codebase के वास्तविक आकार से जुड़ी नहीं होती। C++ में बड़े प्रोजेक्ट की dependencies को अक्सर vendoring कर लिया जाता है या फिर बिल्कुल इस्तेमाल नहीं किया जाता, इसलिए अगर 4 लाख lines of code में बहुत-सा हिस्सा धीमा हो तो कोई सोच सकता है, "कोड बहुत है, इसलिए धीमा होना स्वाभाविक है।" दूसरा, इससे भी बड़ी समस्या macro हैं। 10 या 100 लाइनों को बार-बार expand करने वाले macro, 10 हज़ार लाइनों के प्रोजेक्ट को बहुत जल्दी 10 लाख लाइनों जैसा बना सकते हैं। तीसरा generic हैं। हर generic instantiation CPU resources खाती है। फिर भी थोड़ा बचाव करूँ तो, इन्हीं फीचर्स की वजह से C में 1 लाख लाइन, C++ में 25 हज़ार लाइन वाला काम Rust में कुछ हज़ार लाइनों में सिमट सकता है। लेकिन यह भी सच है कि इन फीचर्स के अत्यधिक इस्तेमाल से ecosystem धीमा दिखता है। उदाहरण के लिए, हमारी कंपनी async-graphql इस्तेमाल करती है; library खुद शानदार है, लेकिन procedural macro पर इसकी निर्भरता बहुत ज़्यादा है। performance से जुड़े issues कई सालों से खुले पड़े हैं, और हर बार नया data type जोड़ते समय compiler के साफ़ तौर पर धीमा होने का एहसास होता है
Ryan Fleury ने Epic RAD Debugger को C में 2,78,000 लाइनों के unity build तरीके से बनाया है, जिसमें सारा code एक ही file में एक single compilation unit के रूप में जाता है, और Windows पर clean compile में सिर्फ 1.5 सेकंड लगते हैं। केवल इस उदाहरण से ही दिखता है कि compilation बेहद तेज़ हो सकती है; इसलिए उत्सुकता है कि Rust या Swift में ऐसा वैसा ही क्यों नहीं किया जा सकता
foo(char*)के लिएfoo(int)जैसी अजीब calls भी नहीं पकड़तामुझे बहुत अच्छा लगता है कि Go ने optimization से ज़्यादा compile speed को प्राथमिकता दी। server, networking, और glue code जैसे कामों में तेज़ compilation सबसे अहम होती है। मैं कुछ हद तक type safety भी चाहता हूँ, लेकिन इतनी नहीं कि loosely prototype करने में बाधा बने। GC होना भी सुविधाजनक है। मुझे लगता है Google ने बड़े पैमाने पर development के अनुभव के बाद यह निष्कर्ष निकाला कि सरल types, GC, और बेहद तेज़ compilation, runtime speed या semantic perfection से कहीं ज़्यादा महत्वपूर्ण हैं। Go में बने बड़े networking और infrastructure software के उदाहरण देखें तो यह चुनाव एकदम सही बैठता है। बेशक, जहाँ GC स्वीकार्य नहीं है या पूर्ण शुद्धता ज़्यादा महत्वपूर्ण है, वहाँ Go न चुना जाए; लेकिन मेरे काम के माहौल में Go के ये चुनाव आदर्श हैं
मुझे यह दावा समझ नहीं आता कि single static binary install करना container management से सरल है
मेरे laptop (Mac M4 Pro) पर Deno (एक बड़ा Rust project) की पूरी compilation में 2 मिनट लगते हैं। command के हिसाब से debug लगभग 1 मिनट 54 सेकंड और release लगभग 8 मिनट 17 सेकंड लेता है। यह माप incremental compilation के बिना ली गई है। वैसे production/deployment build तो CI/CD system में चलती है, इसलिए मुझे खुद बैठकर इंतज़ार नहीं करना पड़ता
Cranelift की चर्चा कहाँ है? मेरी नज़र में Rust में game development करते हुए compile time इतना लंबा हो गया था कि मैं लगभग छोड़ने वाला था। जाँच करने पर पता चला कि LLVM optimization level से अलग भी धीमा है। Jai language के developers इस बात की ओर हमेशा इशारा करते रहे हैं। मैंने Cranelift के साथ build time को 16 सेकंड से 4 सेकंड तक गिरते देखा है। Cranelift team कमाल की है!
subsecondनाम का tool इस्तेमाल किया था, और नाम की तरह ही इसने system hot reload को 1 सेकंड से कम कर दिया, जिससे UI prototyping में बहुत मदद मिली। https://github.com/TheBevyFlock/bevy_simple_subsecond_systemमुझे Rust धीमा नहीं लगता। अपनी श्रेणी की languages की तुलना में यह काफ़ी तेज़ है, और 15-15 मिनट लेने वाली C++/Scala compilation के मुकाबले तो बहुत तेज़ है
एक पुराने C++ developer के रूप में मुझे यह दावा ठीक से समझ नहीं आता कि Rust build धीमी है
incremental compilation सच में बहुत ताकतवर है। शुरुआती build के बाद incremental cache snapshot को स्थिर कर दिया जाए, तो अगर बदलाव न हों, वही चीज़ बहुत तेज़ी से build/deploy में इस्तेमाल हो सकती है। docker के साथ भी इसकी अच्छी जोड़ी बनती है। compiler version या बड़े website updates को छोड़ दें तो image build layers को छूने की ज़रूरत नहीं पड़ती। अगर सिर्फ code बदला है, तो layers को इस तरह सेट किया जा सकता है कि बेकार में rebuild न हों, और यह काफ़ी efficient है
मेरी homepage का build time 73ms है। static site generator 17ms में फिर से compile हो जाता है। असल generator run भी सिर्फ 56ms लेता है। zig build log का output भी संलग्न है
cargo watchके साथ incremental build में लगभग 1.25 सेकंड लेती है।subsecond[0] जैसी incremental linking और hotpatching चीज़ें जोड़ें तो यह और तेज़ हो जाती है। Zig जितनी नहीं, लेकिन काफ़ी करीब है। अगर ऊपर बताई गई 331ms clean build थी, यानी बिना cache के, तो वह मेरी website की 12 सेकंड clean build से कहीं तेज़ है। [0]: https://news.ycombinator.com/item?id=44369642