2 पॉइंट द्वारा GN⁺ 2024-04-20 | 1 टिप्पणियां | WhatsApp पर शेयर करें

यह लेख Rust भाषा की Calling Convention को बेहतर बनाने के तरीकों को विस्तार से समझाता है

Rust की मौजूदा Calling Convention की समस्याएँ

  • Rust में अभी calling convention स्पष्ट रूप से परिभाषित नहीं है
  • व्यवहार में यह LLVM की default C calling convention का उपयोग करता है
  • Rust अभी सावधानी से ऐसे LLVM function signatures बनाने की कोशिश करता है, जैसे Clang बनाता
    • debugger के साथ compatibility के लिए
    • LLVM bugs से बचने के लिए
  • लेकिन यह ज़रूरत से ज़्यादा conservative है, इसलिए साधारण functions के लिए भी खराब code बनता है
fn extract(arr: [i32; 3]) -> i32 { arr[1] }
  • ऊपर वाला code registers के जरिए जाना चाहिए, लेकिन pointer से पास होता है
  • Rust, C ABI से भी ज़्यादा conservative है. extern "C" देने पर यह register से पास होता है.

नई Calling Convention का प्रस्ताव

  • extern "Rust" functions के लिए मौजूदा calling convention बनाए रखी जाए
  • -Zcallconv flag जोड़ा जाए ताकि extern "Rust" functions की calling convention सेट की जा सके
    • -Zcallconv=legacy मौजूदा तरीका है
    • -Zcallconv=fast नया डिज़ाइन किया जाने वाला तरीका है
  • मौजूदा calling convention क्यों बनाए रखनी चाहिए?
    • debugging को आसान रखने के लिए इसे C ABI क्रम में नहीं रखा जाता
    • WASM जैसे कुछ targets इसे support नहीं कर सकते
    • debug builds में इसका कोई मतलब न हो सकता है
  • function pointers और extern "Rust" {} blocks से जुड़ी सावधानियाँ
    • यह crate-level flag है, इसलिए function pointers पर लागू नहीं किया जा सकता
    • function pointer calls धीमे और कम होते हैं, इसलिए -Zcallconv=legacy इस्तेमाल किया जाए
    • ज़रूरत पड़ने पर calling convention बदलने के लिए shim बनाया जाए
    • extern "Rust" { fn my_func() -> i32; } की तरह सीधे call करने पर
      • सिर्फ non-mangled symbols को call किया जा सकता है
      • #[no_mangle] functions पुरानी calling convention का उपयोग करेंगी

LLVM का उपयोग कैसे किया जा सकता है

  • आदर्श रूप से LLVM में calling convention सीधे specify की जा सके तो अच्छा होगा, लेकिन व्यवहार में यह मुश्किल है
  • इसे नीचे दिए गए तरीके से workaround किया जा सकता है
    • दिए गए target के लिए register से पास हो सकने वाली अधिकतम values की संख्या पता करें
    • return value कैसे पास होगी, यह तय करें. अगर register में फिट हो तो वैसे ही, नहीं तो reference से पास करें
    • value के रूप में पास किए गए arguments में से किन्हें reference से पास करना है, यह चुनें
      • जो register से पास होने वाली space से बड़े हों
      • x86 पर यह लगभग 176 bytes है
    • register space का अधिकतम उपयोग करने के लिए तय करें कि कौन से arguments register से जाएँगे
      • यह NP-hard समस्या है, इसलिए heuristic चाहिए
      • बाकी stack से पास किए जाएँ
    • LLVM IR में function signature बनाएँ
      • register से पास होने वाले arguments को i64, ptr, double, <2 x i64> जैसी non-aggregate representations में दिखाया जाए
      • stack से पास होने वाले arguments "register inputs" का अनुसरण करें
    • function prologue बनाएँ
      • Rust-level arguments को register inputs से decode करके वही %ssa values बनाई जाएँ जो -Zcallconv=legacy में बनती हैं
      • function body के लिए calling convention से स्वतंत्र होकर वही code generation संभव है
      • अनावश्यक decoding code, DCE से हट जाएगा
    • function return block बनाएँ
      • -Zcallconv=legacy जैसी ही return types के लिए phi instructions शामिल हों
      • ज़रूरी output format में encode करके ret से लौटाया जाए
      • ret की जगह इस block में branch करना होगा
    • अगर कोई non-polymorphic, non-inline function है जिसे function pointer के रूप में इस्तेमाल किया जा सकता है
      • crate के बाहर expose किया गया हो या function pointer के रूप में पास किया जाता हो
      • तो -Zcallconv=legacy इस्तेमाल करने वाला shim बनाया जाए और असली implementation को Tail Call किया जाए
      • function pointer equality बनाए रखने के लिए यह ज़रूरी है

LLVM की register passing limits पता करने का तरीका

  • LLVM program जो LLVM द्वारा अनुमति दी गई register passing की अधिकतम संख्या पता करता है
  • x86 पर 6 integer, 8 SSE vector inputs और 3 integer, 4 SSE vector outputs संभव हैं
  • aarch64 पर 8 integer, 8 vector inputs और outputs दोनों के लिए समान हैं
  • इससे आगे जाने पर values stack पर पास होती हैं

Rust में structs और enums की handling

  • माना गया है कि rustc पहले ही इन्हें primitive aggregates और unions में बदल चुका है
  • return value handling
    • struct का size नहीं, बल्कि padding हटाने के बाद actual data size महत्वपूर्ण है
    • [(u64, u32); 2] 32 bytes है, लेकिन 8 bytes padding हटाने पर 24 bytes रह जाता है
    • type का effective size परिभाषित किया जाता है
      • padding को छोड़कर undefined bits की संख्या
      • [(u64, u32); 2] 192 bits है
      • bool 1 bit है
    • अगर effective size output register space से कम है, तो value के रूप में return करें
    • x86 पर 3 integers + 4 SSE = 88 bytes = 704 bits
  • argument registers की handling
    • यह Knapsack समस्या है, इसलिए NP-hard है
    • एक सरल heuristic
      • अगर effective size कुल input register space से बड़ा है, तो reference से पास करें
      • enum को discriminant-union pair से बदलें
      • union uninitialized bits को छू सकता है, इसलिए इसे u8 array या किसी एक non-empty variant के रूप में पास करें
      • pointers, integers, floating point, booleans आदि सबसे बुनियादी elements तक flatten करें
      • effective size के आधार पर ascending order में sort करें
      • जितना बड़ा prefix संभव हो उसे registers को दें, बाकी stack पर भेजें
      • अगर stack पर जाने वाले input का कोई हिस्सा pointer size के छोटे multiple से बड़ा है, तो stack के pointer से पास करें
      • बाकी को sort से पहले वाले क्रम में सीधे stack पर पास करें
      • register से जाने वाली चीज़ों को size के descending order में assign करें
      • booleans को 64-64 के समूह में bit-pack करें

GN+ की राय

  • व्यक्तिगत रूप से, Rust की मौजूदा calling convention काफी निराशाजनक लगती है. इसमें C++ से कहीं बेहतर performance देने की क्षमता है, लेकिन अभी तक ऐसा नहीं हो पा रहा
  • Go भाषा ने यह तरीका बहुत पहले ही लागू कर लिया था
  • Rust के इसे लागू न कर पाने के कारण
    • ABI code generation जटिल है और LLVM इसमें ज़्यादा मदद नहीं करता
    • compiler team में LLVM को अच्छी तरह जानने वाले लोग बहुत कम हैं
    • compile time को लेकर चिंता है, लेकिन इसे सिर्फ optimized builds में इस्तेमाल किया जाएगा, इसलिए यह बड़ी समस्या नहीं है
  • लेखक के पास इसे खुद ठीक करने का समय नहीं है, लेकिन LLVM की विशेषज्ञता के आधार पर वह Rust compiler team की मदद करने के इच्छुक हैं
  • या फिर सीधे extern "C" या extern "fastcall" पर जाना भी एक विकल्प हो सकता है

1 टिप्पणियां

 
GN⁺ 2024-04-20
Hacker News राय

सारांश:

  • optimized calling convention बनाते समय performance को सीधे मापना महत्वपूर्ण है। जो कोड अजीब दिखता है, वही वास्तव में सबसे तेज़ हो सकता है।
  • आज के CPU, C compiler द्वारा बनाए गए instruction traces को optimize करते हैं, इसलिए C compiler की तरह stack पर बार-बार pass करना मददगार हो सकता है।
  • inlining अक्सर सफल हो जाती है, इसलिए function call एक दुर्लभ boundary बन जाती है; इस वजह से दूसरी चीज़ों को सरल बनाने के लिए boundary पर थोड़ी irregularity स्वीकार की जा सकती है।
  • Rust के struct को fields के references देने होते हैं, इसलिए उनका आकार C से बड़ा हो सकता है। 8 Option<u8> fields वाला struct, Rust में 16 bytes और C में 9 bytes का होता है।
  • Rust में हाथ से C के बराबर implementation बनाया जा सकता है, लेकिन इसे &Option<T> या &mut Option<T> से map नहीं किया जा सकता।
  • Rust में अभी तक Rust-स्तर की semantics के लिए calling convention नहीं है। Apple के पास इसे बनाने की motivation थी, लेकिन Rust को ऐसा support नहीं मिला है।
  • Go और Rust के बीच interoperability फिलहाल बीच में Zig का उपयोग करके हासिल की जा सकती है।
  • मौजूदा Rust compiler aggressive inlining और optimization करता है, इसलिए यह सवाल है कि क्या इस समस्या को हल करना वास्तव में सार्थक है।
  • debugging के लिए Cargo.toml flags का उपयोग करके चिंताओं से बचा जा सकता है। fields को size के अनुसार arrange करना एक आसान optimization है, और इसे repr से बंद किया जा सकता है।