1 पॉइंट द्वारा GN⁺ 2 시간 전 | 1 टिप्पणियां | WhatsApp पर शेयर करें
  • 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 ज़्यादा होना इस दायरे में शामिल नहीं है
    • यह समस्या पहले से ज्ञात है, और इसे आंशिक रूप से संबोधित करने वाला PR खुला है: https://github.com/rust-lang/rust/pull/135527

generated future की संरचना

  • उदाहरण code में foo() async { 5 } लौटाता है, और bar() foo().await + foo().await करता है
    • Godbolt उदाहरण: godbolt
  • 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 के लिए समर्थन अनुरोध

  • compiler में यह काम आगे बढ़ाने के लिए इसे Project Goal के रूप में जमा किया गया है: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
  • funding के बिना बहुत काम आगे बढ़ाना मुश्किल है, इसलिए उन companies या organizations के आंशिक या पूर्ण समर्थन की ज़रूरत है जिन्हें इस काम से लाभ होगा
  • संपर्क: dion@tweedegolf.com
  • काम का दायरा और ज़रूरी funding लचीली है, लेकिन अनुमान है कि €30k में पूरा या बड़ा हिस्सा पूरा किया जा सकता है

1 टिप्पणियां

 
GN⁺ 2 시간 전
Lobste.rs की राय
  • सिर्फ़ शीर्षक देखकर जितना सोचा था, उससे कहीं ज़्यादा रचनात्मक लेख था

    • मुझे तो यह लगभग तथ्य के क़रीब लगता है। MVP रिलीज़ के 7 साल बाद भी language design या compiler implementation में लगभग कोई प्रगति नहीं हुई, और जिन लोगों ने मुख्य रूप से MVP बनाया था उन्होंने भी लगभग उसी समय प्रोजेक्ट में अपनी भागीदारी कम कर दी, इसलिए उसके बाद प्रगति रुक-सी गई।
      उम्मीद है कि जो लोग इस पर काम करना चाहते हैं, उन्हें ज़रूरी support मिले
  • I want to work on this in the compiler and as such have submitted it as a Project Goal

    Stop generating statemachines that don’t have to be there
    Make the compiler’s job easier by removing panic paths and branches
    Make statemachines smaller

    अच्छा लगा कि इस समस्या पर काम हो रहा है। मैंने कई बार ऐसे लेख देखे हैं जिनमें कहा गया है कि अभी rustc LLVM को बहुत ज़्यादा code सौंप देता है और उम्मीद करता है कि optimizer सब संभाल लेगा, और खासकर यह लेख इस काम के लिए funding भी माँग रहा है

  • हे भगवान, मैं कितना मूर्ख था
    मैं हमेशा सोचता था कि async किसी न किसी रूप में runtime, task tracking, और completion की जाँच के लिए polling माँगता है, इसलिए वह मूल रूप से “भारी” ही होगा। क्योंकि वह overhead शून्य तो नहीं है
    मैं समझता था कि यहाँ “zero-cost abstraction” language feature की बात है, जो जोड़े गए runtime से अलग है
    LLVM को देने से पहले rustc वास्तव में क्या emit करता है, यह देखने का ख़याल ही कभी नहीं आया

  • जो लोग async Rust से परिचित नहीं हैं, उनके लिए:

    It's amazing how we can write executor agnostic code that can run concurrently on huge servers and tiny microcontrollers.

    यह बात सच में सही है। async calls की nested tree भी अगर पूरी optimization से गुज़रे तो आख़िर में अंदर state machine रखने वाले एक single struct में सिमट जाती है। यह वाक़ई बहुत चतुर तरीका है

  • अगर release build में यह case आ जाए, तो क्या इससे किसी तरह का deadlock बनता है? या फिर हमेशा Pending रहने वाले कामों का इंतज़ार कर रहे tasks की वजह से leak भी हो सकता है?

    • हाँ। ऐसे future रुकी हुई अवस्था में चले जाते हैं और कभी पूरा नहीं होते। लेकिन ऐसी स्थिति तक पहुँचना आम तौर पर सिर्फ़ buggy low-level async code में ही संभव होता है, और जो code completed future को ठीक से track नहीं कर पाता वह शायद पहले से ही leak और deadlock पैदा कर रहा होगा
      .await के साथ गलत polling नहीं की जा सकती
  • कुछ विचार आए:

    1. यह लेख मानो यह कह रहा है कि और optimization logic को LLVM के बाहर निकालकर MIR layer में लाना चाहिए। उदाहरण के लिए, यह समझ आता है कि async function inlining, LLVM की तुलना में MIR में आसान है। अगर async के लिए यह MIR में किया जा सकता है, तो क्या उस logic को synchronous functions पर भी generalize किया जा सकता है और LLVM के कुछ optimization passes हटाए जा सकते हैं? मुझे पता है यह बहुत बड़ा काम है, और यह व्यावहारिक सवाल से ज़्यादा दिशा से जुड़ा विचार है। शायद जब frontend/middle-end compiler एक स्तर की जटिलता तक पहुँच जाए, तब LLVM की काफ़ी generic optimizations को कहीं और ले जाना बेहतर हो
    2. मुझे अब भी 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 होना चाहिए ऐसे ब्लॉग को कोई इस हद तक कैसे बिगाड़ सकता है?

    • लहजा थोड़ा बेवजह रूखा और आक्रामक लग रहा है। वेबसाइटों में भी bug हो सकते हैं, और उसकी रिपोर्ट करना उपयोगी है, लेकिन यह टिप्पणी कुछ ज़्यादा ही चिड़चिड़ी लगती है
      वैसे मैंने भी शायद वही reproduction steps आज़माए, लेकिन मुझे एक बार भी 404 नहीं मिला। मैंने phone और desktop दोनों पर, JavaScript on और off दोनों हालत में कोशिश की। इसलिए लगता है कि जो व्यवहार देखा गया वह दिखने से ज़्यादा जटिल रहा होगा।