- Async Rust executor-स्वतंत्र कोड को server और microcontroller पर साथ चलाने देता है, लेकिन compiler द्वारा बनाए गए state machine की वजह से खासकर embedded में binary size बढ़ना साफ़ दिखता है
bar() जैसे साधारण उदाहरण में भी, जहाँ 2 await points हैं, 360 lines of MIR और Unresumed, Returned, Panicked, Suspend0, Suspend1 states बनते हैं, जबकि synchronous version को सिर्फ 23 lines चाहिए
- completed future को दोबारा poll करने पर
panic की जगह Poll::Pending लौटाने से unsafe behavior के बिना contract पूरा किया जा सकता है, और प्रयोग में embedded firmware की binary size 2%~5% कम हुई
- await के बिना
async { 5 } भी अभी default 3-state state machine बनाता है, लेकिन हर बार Poll::Ready(5) लौटाने के लिए optimize करने पर embedded binary size 0.2% कम हुई
- प्रस्तावित Project Goal का उद्देश्य compiler में release mode में completion के बाद panic हटाना, await-रहित async block का state machine हटाना, single-await future inlining, और identical state folding को आगे बढ़ाना है
Async Rust की compiler-स्तरीय bloat समस्या
- Async Rust executor-स्वतंत्र कोड को server और microcontroller पर एक साथ चलाने देता है, लेकिन छोटे microcontroller पर binary size बढ़ना खास तौर पर नज़र आता है
- Rust ब्लॉग ने async/await को zero-cost abstraction के रूप में पेश किया था, लेकिन async वास्तव में काफी bloat पैदा करता है; यही समस्या desktop और server पर भी होती है, बस वहाँ memory और compute resources ज़्यादा होने से कम दिखती है
- async code लिखते समय bloat से बचने के workaround के बाद, अब इस समस्या को compiler में हल करने के लिए Project Goal जमा किया गया है
- future का ज़रूरत से बड़ा हो जाना और copy ज़्यादा होना इस दायरे में शामिल नहीं है
generated future की संरचना
- उदाहरण code में
foo() async { 5 } लौटाता है, और bar() foo().await + foo().await करता है
bar में 2 await points हैं, इसलिए state machine में कम-से-कम 2 states चाहिए, लेकिन वास्तव में इससे ज़्यादा states बनते हैं
- Rust compiler कई passes में MIR dump कर सकता है, और
coroutine_resume pass आख़िरी async-विशेष MIR pass है
- async MIR में मौजूद रहता है, लेकिन LLVM IR में नहीं, इसलिए async का state machine में रूपांतरण MIR pass में होता है
bar function 360 lines of MIR बनाता है, जबकि synchronous version सिर्फ 23 lines इस्तेमाल करता है
- compiler द्वारा output किया गया
CoroutineLayout असल में enum-जैसे state set के बराबर है
Unresumed: शुरुआती state
Returned: completed state
Panicked: panic के बाद की state
Suspend0: पहला await point, जहाँ foo future स्टोर होता है
Suspend1: दूसरा await point, जहाँ पहला result और दूसरा foo future स्टोर होता है
Future::poll एक safe function है, इसलिए future पूरा होने के बाद भी अगर उसे फिर बुलाया जाए तो वह UB नहीं पैदा करना चाहिए
- अभी
Suspend1 के बाद यह Ready लौटाता है और future को Returned state में बदल देता है
- इस state में दोबारा poll करने पर panic होता है
Panicked state शायद इसीलिए है कि async function panic होने के बाद, अगर उसे catch_unwind से पकड़ा जाए, तो उस future को फिर poll न किया जा सके
- panic के बाद future अधूरी state में हो सकता है, इसलिए दोबारा poll करना UB तक ले जा सकता है
- यह mechanism mutex poisoning से काफ़ी मिलता-जुलता है
Panicked state की यह व्याख्या पूरी तरह दस्तावेज़ित नहीं मिली, इसलिए इस पर लगभग 90% भरोसा बताया गया है
completion के बाद poll पर क्या panic ज़रूरी है?
Returned state का future अभी panic करता है, लेकिन यह अनिवार्य नहीं है
- ज़रूरी शर्त सिर्फ यह है कि UB न हो
- panic अपेक्षाकृत महँगा है, और ऐसा path जोड़ता है जिसमें side effects होते हैं जिन्हें optimize करके हटाना कठिन है
- completed future को फिर poll करने पर
Poll::Pending लौटाने से unsafe behavior के बिना Future type का contract पूरा किया जा सकता है
- compiler में यह बदलाव प्रयोग के तौर पर करने पर async embedded firmware में 2%~5% binary size reduction देखी गई
- इस behavior को integer overflow के
overflow-checks = false जैसी switch के रूप में देने का प्रस्ताव है
- debug builds में गलत behavior तुरंत दिखाने के लिए panic जारी रहेगा
- release builds में छोटे future मिल सकते हैं
panic=abort उपयोग करने पर Panicked state को ही हटाया जा सकने की संभावना है, लेकिन उसके असर की और जाँच चाहिए
await न होने पर भी हमेशा state machine बनता है
foo() सिर्फ async { 5 } लौटाता है, इसलिए manual implementation का सबसे optimized रूप ऐसा future होगा जिसमें कोई state न हो और जो हमेशा Poll::Ready(5) लौटाए
- लेकिन compiler-generated MIR में अभी भी
Unresumed, Returned, Panicked नाम की 3 base states मौजूद रहती हैं
- poll के समय current state का discriminant देखा जाता है और उसी के हिसाब से branch होती है
- completion के बाद फिर poll करने पर
`async fn` resumed after completion assert से panic होता है
- इस स्थिति में state machine बनाए बिना हर बार
Poll::Ready(5) लौटाने के लिए optimize किया जा सकता है
- compiler में इसे प्रयोगात्मक रूप से लागू करने पर embedded binary size 0.2% कम हुई
- बचत बड़ी नहीं है, लेकिन optimization सरल है इसलिए इसे अपनाना उपयोगी हो सकता है
- यह optimization behavior थोड़ा बदलती है, लेकिन असर सिर्फ उन executors पर पड़ेगा जो contract का पालन नहीं करते
- अभी compiler बाद के poll पर panic करता है
- optimization के बाद future हमेशा
Ready लौटाएगा
सिर्फ LLVM काफ़ी नहीं है
- कभी-कभी MIR output अक्षम होने पर भी LLVM बहुत कुछ साफ़ कर सकता है, लेकिन इसके लिए सीमित शर्तें हैं
- future काफ़ी simple होना चाहिए
opt-level=3 इस्तेमाल करना चाहिए
- future जटिल होने पर LLVM इन्हें हटा नहीं पाता, और idiomatic async Rust code में future गहराई से nested होते हैं, इसलिए complexity तेज़ी से बढ़ती है
- embedded या wasm जैसे environments में, जहाँ size optimization आम है, LLVM सब कुछ optimize नहीं कर पाता
- Godbolt उदाहरण: https://godbolt.org/z/58ahb3nne
- generated assembly में LLVM यह जानता है कि
foo 5 लौटाता है, लेकिन bar के answer को 10 तक optimize नहीं कर पाता
foo के poll function call भी बचे रहते हैं
- वजह वे संभावित panic paths हैं जिन्हें compiler पूरी तरह समझ नहीं पाता
- LLVM यह नहीं जानता कि
foo वास्तव में सिर्फ एक बार call होगा और panic नहीं करेगा
- IR में panic branch को comment out करने पर optimization बेहतर हो जाती है: https://godbolt.org/z/38KqjsY8E
- LLVM से बाद में optimization की उम्मीद करने के बजाय, compiler को LLVM को बेहतर input देना चाहिए
future inlining ठीक से नहीं होती
- inlining महत्वपूर्ण है क्योंकि उसके बाद और optimization passes संभव होते हैं, लेकिन generated Rust futures अभी शुरुआती चरण में inline नहीं होतीं
- हर future की implementation बनने के बाद LLVM और linker को inline करने का मौका मिलता है, लेकिन ऊपर बताई समस्याओं के कारण तब तक बहुत देर हो चुकी होती है
- सबसे सीधा inlining अवसर वह रूप है जहाँ
bar() सिर्फ foo(blah).await करता है
- abstraction के लिए trait उपयोग करते समय यह pattern अक्सर दिखता है
- अभी compiler
bar के लिए state machine बनाता है और उसके भीतर foo state machine को call करता है
- ज़्यादा कुशल रूप में
bar खुद foo future बन सकता है
- preamble और postamble होने पर मामला अधिक जटिल है
- उदाहरण:
bar(input) पहले input > 10 से blah बनाता है, फिर foo(blah).await करता है, और result पर * 2 लागू करता है
- async function को दूसरे signature में बदलते समय, खासकर trait implementations में, यह रूप आम है
- इस तरह के
bar को भी अपनी अलग async state की ज़रूरत नहीं होती
- single await point के पार संरक्षित होने वाला data,
foo में captured value के अलावा कुछ नहीं है
- हालांकि
bar सीधे foo नहीं बन सकता, लेकिन अपनी ज़्यादातर state foo पर निर्भर कर सकता है
- manual implementation में
BarFut के पास Unresumed { input } और Inlined { foo: FooFut } states हो सकती हैं
- पहले poll पर preamble चलाकर
foo(blah) बनाया जाता है और state को Inlined में बदला जाता है
- उसके बाद
foo.poll(cx) के result पर postamble लागू किया जाता है
- अगर पहले await point तक का code पहले से चलाया जा सके, तो
Unresumed state भी हटाई जा सकती है, लेकिन यह guarantee है कि future poll होने से पहले कुछ नहीं करता, इसलिए इसे बदला नहीं जा सकता
- अगर poll हो रहे future के गुणों के बारे में पूछताछ की जा सके, तो और inlining optimizations संभव हैं
- उदाहरण के लिए, अगर यह पता हो कि future पहले poll पर हमेशा ready लौटाता है, तो caller future को उस await point की state बनाने की ज़रूरत नहीं होगी
- इस तरह की optimization को recursive ढंग से लागू करने पर कई futures को बहुत सरल state machines में समेटा जा सकता है
- मौजूदा
rustc संरचना में, हर async block अलग-अलग transform होता है और बाद में संबंधित data सुरक्षित नहीं रहता, इसलिए ऐसी queries संभव नहीं दिखतीं
- future inlining का अभी प्रयोग नहीं हुआ है, लेकिन उम्मीद है कि यह binary size और performance दोनों में बड़ा लाभ देगी
identical state folding
- async block के हर await point पर state machine में एक अतिरिक्त state बनती है
- नीचे जैसा code स्वाभाविक है, लेकिन दोनों branches में वही async function await होने से 2 identical states बनती हैं
CommandId::A => send_response(123).await
CommandId::B => send_response(456).await
- इस स्थिति में
CoroutineLayout में send_response के उसी coroutine type को रखने वाले _s0, _s1 अलग-अलग बनते हैं, और Suspend0, Suspend1 दो states तैयार होती हैं
- इस function की MIR 456 lines है, और कई basic blocks वस्तुतः duplicate हैं
- अगर code को पहले सिर्फ response value calculate करने और फिर एक बार
send_response(response).await करने के लिए manually refactor किया जाए, तो duplicate states हट जाती हैं
CommandId::A के लिए 123
CommandId::B के लिए 456
- उसके बाद
send_response(response).await
- refactor के बाद
CoroutineLayout में सिर्फ एक stored future बचता है और सिर्फ एक Suspend0 state रहती है
- कुल MIR length घटकर 302 lines रह जाती है और duplication हट जाती है
- इसलिए ऐसे optimization pass उपयोगी लगते हैं जो identical code paths और states को पहचानकर एक में fold कर सकें
- यह optimization future inlining pass के साथ अच्छी तरह जुड़ सकती है
प्रयोग लिंक और अतिरिक्त benchmark
Project Goal के लिए समर्थन अनुरोध
1 टिप्पणियां
Lobste.rs की राय
सिर्फ़ शीर्षक देखकर जितना सोचा था, उससे कहीं ज़्यादा रचनात्मक लेख था
उम्मीद है कि जो लोग इस पर काम करना चाहते हैं, उन्हें ज़रूरी support मिले
अच्छा लगा कि इस समस्या पर काम हो रहा है। मैंने कई बार ऐसे लेख देखे हैं जिनमें कहा गया है कि अभी rustc LLVM को बहुत ज़्यादा code सौंप देता है और उम्मीद करता है कि optimizer सब संभाल लेगा, और खासकर यह लेख इस काम के लिए funding भी माँग रहा है
हे भगवान, मैं कितना मूर्ख था
मैं हमेशा सोचता था कि async किसी न किसी रूप में runtime, task tracking, और completion की जाँच के लिए polling माँगता है, इसलिए वह मूल रूप से “भारी” ही होगा। क्योंकि वह overhead शून्य तो नहीं है
मैं समझता था कि यहाँ “zero-cost abstraction” language feature की बात है, जो जोड़े गए runtime से अलग है
LLVM को देने से पहले rustc वास्तव में क्या emit करता है, यह देखने का ख़याल ही कभी नहीं आया
जो लोग async Rust से परिचित नहीं हैं, उनके लिए:
यह बात सच में सही है। async calls की nested tree भी अगर पूरी optimization से गुज़रे तो आख़िर में अंदर state machine रखने वाले एक single struct में सिमट जाती है। यह वाक़ई बहुत चतुर तरीका है
अगर release build में यह case आ जाए, तो क्या इससे किसी तरह का deadlock बनता है? या फिर हमेशा
Pendingरहने वाले कामों का इंतज़ार कर रहे tasks की वजह से leak भी हो सकता है?.awaitके साथ गलत polling नहीं की जा सकतीकुछ विचार आए:
panic=unwindपसंद नहीं है। कुछ test harnesses को छोड़कर मैंने बहुत कम ऐसे फ़ायदे देखे हैं जोpanic=abortकी तुलना में इसकी लागत को सही ठहरा सकें। यहाँ तक कि test harness में भी Linux पर कुछ उलझे हुए तरीके सेcloneका इस्तेमाल करकेpthread_joinकी जगह executing thread कोwaitकरने जैसी मिलती-जुलती पसंद लागू की जा सकती है। हो सकता है इस हिस्से में मैं ग़लत हूँक्या अभी किसी और के लिए भी लिंक टूट गया?
संपादन: blog post लगभग आधे सेकंड तक दिखती है, फिर 404 page पर चली जाती है
संपादन 2: मैं ब्लॉग पोस्ट सूची में गया, इधर-उधर क्लिक किया, और सूची में मौजूद उसी लेख को खोलने पर भी 404 page मिला। static page, या कम से कम static होना चाहिए ऐसे ब्लॉग को कोई इस हद तक कैसे बिगाड़ सकता है?
वैसे मैंने भी शायद वही reproduction steps आज़माए, लेकिन मुझे एक बार भी 404 नहीं मिला। मैंने phone और desktop दोनों पर, JavaScript on और off दोनों हालत में कोशिश की। इसलिए लगता है कि जो व्यवहार देखा गया वह दिखने से ज़्यादा जटिल रहा होगा।