2 पॉइंट द्वारा GN⁺ 2025-02-14 | 1 टिप्पणियां | WhatsApp पर शेयर करें
  • Ruby में native functions को कॉल करते समय FFI सुविधाजनक है, लेकिन strlen benchmark में इसका call overhead C extension से ज्यादा होता है और performance bottleneck बन जाता है
  • बेसलाइन benchmark में String#bytesize direct call 39.879M i/s, C extension 30.661M i/s, Ruby indirect call 28.697M i/s, और FFI 15.682M i/s के साथ सबसे धीमा था
  • मुख्य idea यह है कि attach_function के समय पहले से पता function name, argument types और return type का उपयोग करके external function call के लिए machine code runtime पर बनाया जाए
  • proof-of-concept FJIT ने Ruby 3.5.0dev ARM64 environment में strlen call को 32.508M i/s तक पहुंचाया, जो FFI से 2x से भी ज्यादा तेज और C extension से भी थोड़ा तेज था
  • अभी इसमें ARM64, single argument·single return, सीमित types, --rjit --rjit-disable, और किसी खास Ruby head commit पर निर्भरता जैसी सीमाएं हैं, इसलिए real-world use से पहले implementation को expand करना होगा

Ruby में FFI कहाँ धीमा पड़ता है

  • मूल दिशा यह है कि Ruby code जितना ज्यादा हो सके इस्तेमाल किया जाए, और native code को सिर्फ तभी call किया जाए जब सच में जरूरत हो
  • YJIT Ruby code को optimize कर सकता है, लेकिन C code को optimize नहीं कर सकता
  • अगर native library की जरूरत हो, तो actual function के आसपास एक thin C extension wrapper रखना और ज्यादातर काम Ruby में संभालना ideal तरीका है
  • ऐसे simple APIs FFI के साथ अच्छी तरह fit होते हैं, लेकिन मौजूदा FFI C extension जैसी performance नहीं दे पाता

strlen बेसलाइन benchmark

  • तुलना के लिए चार targets हैं
    • FFI से C का strlen call
    • Ruby method B.strlen में x.bytesize का indirect call
    • strlen Ruby Gem से बना C extension call
    • str.bytesize direct call
  • Ruby 3.5.0dev, ARM64 environment के results:
    • ruby-direct: 39.879M i/s, 25.08 ns/i
    • strlen-cext: 30.661M i/s, 32.61 ns/i, direct call से 1.30x धीमा
    • strlen-ruby: 28.697M i/s, 34.85 ns/i, direct call से 1.39x धीमा
    • strlen-ffi: 15.682M i/s, 63.77 ns/i, direct call से 2.54x धीमा
  • String#bytesize direct call सबसे तेज है, और अतिरिक्त indirect call overhead जोड़ता है
  • ruby-direct और strlen-ruby का फर्क stack frame push/pop cost दिखाता है, और ऐसा overhead हटाना YJIT जैसे JIT compiler का मजबूत क्षेत्र है
  • strlen-cext और strlen-ffi की दूरी दिखाती है कि FFI से native function call करते समय extra cost काफी ज्यादा है

FFI calls को JIT से replace करने का तरीका

  • attach_function :strlen, [:string], :int call के समय जरूरी जानकारी पहले से मौजूद होती है
    • call किया जाने वाला function name: strlen
    • argument type: string
    • return type: int
  • इस जानकारी का इस्तेमाल करके Ruby values को native types में unwrap करने, external function call करने, और return value को फिर से Ruby object में wrap करने वाला machine code generate किया जा सकता है
  • जरूरी components तीन हैं
  • सिर्फ machine code generation काफी नहीं है; Ruby को उस machine code पर jump कर पाना चाहिए, तभी FFI overhead को skip किया जा सकेगा

RJIT का उपयोग करने वाला path

  • RJIT Ruby में लिखा गया Ruby के लिए JIT compiler है और Ruby के साथ distribute होता है
  • इसकी internal structure YJIT जैसी है, लेकिन यह production use को लक्ष्य बनाकर नहीं बनाया गया था, इसलिए YJIT जितना widely known नहीं है
  • Kokubun ने RJIT को Gem के रूप में extract करने की feature request submit की
  • यह proposal third-party Ruby JIT compilers को आसानी से बनाने के लिए दो foundations देता है
    • RJIT को Gem के रूप में अलग करना
    • Ruby internal types को Ruby data structures के रूप में generate करना, ताकि third-party JIT को Ruby data types को wrap और unwrap करने के लिए जरूरी जानकारी मिल सके
  • एक और बदलाव यह है कि अगर JIT entry function pointer हो, तो उसे हमेशा execute किया जाए
    • third-party JIT machine code register करे तो Ruby अपने-आप उस code पर jump कर सके
  • ये दो elements हों तो FFI interface की तरह काम करने वाला छोटा, single-purpose JIT compiler बनाया जा सकता है

FJIT proof of concept

  • FJIT proof of concept “FFI JIT” का संक्षिप्त रूप है, और runtime पर external functions को call करने वाला machine code generate करता है
  • example FFI जैसी interface से strlen attach करता है
    • module C
    • extend FJIT
    • attach_function :strlen, [:string], :int
  • attach_function call होने पर FJIT Ruby string को unwrap करता है, C का strlen call करता है, और string length को Ruby object के रूप में return करने वाला machine code generate करता है

FJIT benchmark results

  • Ruby 3.5.0dev, +RJIT +PRISM, ARM64 environment के results:
    • ruby-direct: 41.907M i/s, 23.86 ns/i
    • strlen-fjit: 32.508M i/s, 30.76 ns/i, direct call से 1.29x धीमा
    • strlen-cext: 29.778M i/s, 33.58 ns/i, direct call से 1.41x धीमा
    • strlen-ruby: 28.851M i/s, 34.66 ns/i, direct call से 1.45x धीमा
    • strlen-ffi: 15.629M i/s, 63.98 ns/i, direct call से 2.68x धीमा
  • String#bytesize direct call अब भी सबसे तेज है
  • FJIT द्वारा generate किया गया machine code दूसरा सबसे तेज था, और strlen C extension से थोड़ा बेहतर result दिया
  • FJIT FFI call से 2x से भी ज्यादा तेज है, और Ruby indirect call से भी तेज है
  • यह result संकेत देता है कि C extension जैसी या उससे बेहतर speed हासिल करते हुए भी “Ruby को जितना ज्यादा हो सके लिखने” वाला तरीका बनाए रखा जा सकता है

वास्तविक उपयोग से पहले बची सीमाएं

  • proof-of-concept JIT compiler फिलहाल ARM64 platform तक सीमित है
    • actual implementation में expand करने के लिए x86_64 backend जोड़ना होगा
  • यह अभी सभी argument types और return types handle नहीं करता
    • सभी argument types support करना संभव माना गया है और workload अत्यधिक नहीं होगा
  • अभी यह केवल single argument लेने और single value return करने वाले functions ही handle करता है
  • फिलहाल Ruby को --rjit --rjit-disable flags के साथ चलाना पड़ता है
    • Kokubun का feature आने पर यह condition अनावश्यक हो जाने की उम्मीद है
  • proof of concept उस समय के current Ruby head पर ही चलता है
  • update के अनुसार Ruby head से RJIT हट चुका है, इसलिए script चलाने के लिए Ruby को f32d5071b7b01f258eb45cf533496d82d5c0f6a1 commit पर checkout करना होगा

1 टिप्पणियां

 
GN⁺ 2025-02-14
Hacker News की राय
  • Java constraint solver Timefold को CPython में defined functions call कराने के लिए FFI के साथ काफी काम किया, लेकिन performance की ज्यादातर समस्याएं host language और external language के बीच proxy के जरिए आना-जाना करने पर पैदा हुईं
    JNI या नए external interface से direct FFI call करने पर यह Java method को directly call करने जितना तेज़ था, लेकिन CPython और Java के garbage collectors एक-दूसरे से ठीक से match नहीं करते, इसलिए synchronization के लिए black magic जैसी चीज़ें चाहिए होती हैं
    दूसरी तरफ JPype या GraalPy जैसे proxies में parameters और return values को convert करना पड़ता है, और वे उलटी दिशा में extra FFI calls भी trigger कर सकते हैं। CPython object को Java में pass करें तो Java उस object का proxy बनाता है, और उस proxy को फिर CPython में pass करें तो उसे unwrap करने के बजाय proxy का proxy बना देता है
    नतीजा यह रहा कि JPype proxy, CPython को FFI से directly call करने की तुलना में 1402% धीमा था, और GraalPy proxy 453% धीमा था
    आखिर में CPython bytecode को Java bytecode में translate किया गया, और इस्तेमाल की गई CPython classes के equivalent Java data structures बनाए गए, जिससे proxy की तुलना में 100 गुना speedup मिला। साथ में, CPython bytecode बहुत unstable है, documentation कमजोर है, और VM की characteristics भी tricky हैं, इसलिए उसे दूसरे bytecode में directly map करना मुश्किल है; बेहतर है कि उसे translate या read करने की कोशिश न करें
    details लेख मेंまとめ किए हैं: https://timefold.ai/blog/java-vs-python-speed

    • बिना direct experience के कहूं तो Python और Java का अपने-अपने स्तर पर C तक जाने वाला FFI काफी बेहतर लगता है, इसलिए एक छोटे C bridge से दोनों को जोड़ना शायद common solution बन सकता है
    • cgo is not Go भी देखने लायक है
      Go code और C code को address space, signal handlers, thread TLS slots जैसे resources कैसे share करने हैं, इस पर तालमेल बिठाना पड़ता है; असल में इसका मतलब अक्सर यह होता है कि Go को C code की assumptions को bypass करना पड़ता है। C code यह मान सकता है कि वह सिर्फ एक thread पर चलेगा, या multi-threaded environment के लिए बिल्कुल तैयार न हो
      C को Go की calling convention या growing stacks के बारे में पता नहीं होता, इसलिए C code call करने के लिए goroutine stack details record करनी पड़ती हैं, C stack पर switch करना पड़ता है, और फिर ऐसा C code चलाना पड़ता है जिसे यह नहीं पता कि उसे कैसे call किया गया है या पूरा Go runtime क्या है
      Python, JNI इस्तेमाल करने वाला Java, libffi इस्तेमाल करने वाली language, या cgo इस्तेमाल करने वाला Go—किसी से भी binding करें या C code wrap करें, आखिरकार आप C की दुनिया में ही रहते हैं
      https://dave.cheney.net/2016/01/18/cgo-is-not-go / https://archive.vn/GZoMK
    • ऐसे cases में queue, file, HTTP जैसी inter-process communication methods कहां fit बैठेंगी, यह जानने की उत्सुकता है
  • Rails At Scale और byroot के लेखों की वजह से Ruby internals और performance पर गहराई से बात करने वाले लेख पढ़ने के लिए यह वाकई अच्छा समय है। हाल में Ruby और Rails में हुए improvements को देखें तो Ruby developer बने रहने के लिए भी यह काफी अच्छा दौर है

    • मुझे शक है कि सच में ऐसा है। Ruby गिरावट पर दिखती है https://www.tiobe.com/tiobe-index/ruby/
      कुछ खास तरह की applications में यह अब भी popular है, लेकिन इसका peak काफी पीछे लगता है, और recent improvements अच्छे हैं, पर 2025 में JIT technically इतना interesting है या नहीं, पता नहीं
  • “third-party library call करने के बजाय external function call करने के लिए जरूरी code को बस JIT क्यों न कर दें?” वाला approach LuaJIT FFI की बुनियाद जैसा लगता है: https://luajit.org/ext_ffi.html
    इसलिए शायद LuaJIT का FFI बहुत तेज़ है

  • “जितना हो सके Ruby में लिखो। खासकर YJIT Ruby code को optimize कर सकता है, लेकिन C code को नहीं” यह बात अच्छी तरह समझ नहीं आती
    Ruby तो काफी धीमी language नहीं है? अगर native तक जा रहे हैं, तो लगता है जितना संभव हो उतना हिस्सा native code में रखना चाहेंगे

    • जब Java को ठीक-ठाक JIT मिल रहा था, उस दौर में ऐसा एक छोटा drama हुआ था
      एक major release में किसी UI element के behavior को handle करने वाला Java code bottleneck पाया गया, इसलिए अगले major release में उसे C में दोबारा लिखा गया
      बाद में जब JIT सच में उपयोगी हो गया, तो FFI overhead, hand-tuned C code और JIT से निकले code के अंतर से भी बड़ा हो गया, और अगले major release में उसे फिर से pure Java implementation पर लौटा दिया गया
      उस generation की languages के हिसाब से Java का FFI काफी तेज था, लेकिन कुछ releases बाद यह एक बेहतर तरीके में बदल गया, और तब तक मैं Java UI code ज्यादा नहीं कर रहा था, इसलिए आगे follow नहीं किया। उसी समय platform-specific code और सामान्य Java UI code के बीच interface भी साफ किया जा रहा था, इसलिए आखिर में क्या हुआ यह पक्का नहीं है
      ऐसे कामों में इस see-saw effect पर लगातार नजर रखनी पड़ती है। देखना पड़ता है कि कुछ milestones इंतजार करके hand-tuning काम घटाना बेहतर है, या राजनीतिक/तकनीकी वजहों से अभी तुरंत इसकी जरूरत है
    • इसी बिंदु पर JIT सामने आता है। आदर्श रूप से JIT code को फिर से बेहतर स्थिति में optimize कर सकता है
      एक बार चलकर खत्म होने वाले कामों के लिए यह inefficient हो सकता है, लेकिन long-lived desktop/server workloads में पूरी application के नजरिए से payoff मिलता है
      उदाहरण के लिए Dalvik का JIT काफी कमजोर था, इसलिए math functions को C में call करना तेज था, लेकिन ART के बाद ऐसा करने की जरूरत नहीं रही और JIT C call की cost से आगे निकल सकता था
      https://developer.android.com/reference/android/util/FloatMa...
    • managed languages जिनमें JIT या AOT compiler होता है, उनमें अक्सर ज्यादा code उसी language में लिखना ideal होता है। इससे C call boundary पार करते समय असंभव होने वाली inlining और दूसरी optimizations संभव हो जाती हैं
      इस trend को कभी-कभी “self-hosting” कहा जाता है, और browsers भी इसका खूब इस्तेमाल करते हैं—ऐसे हिस्सों को privileged JavaScript में ले जाकर जिन्हें पहले शायद C/C++ में लिखा जाता। standard library का काफी बड़ा हिस्सा आश्चर्यजनक रूप से native code नहीं है
    • FFI code के बीच opaque और optimize न हो सकने वाली boundary बनाता है। इस तरह बार-बार round-trip करने वाला code महंगा पड़ता है
      C# जैसी कहीं तेज और लगभग zero interop cost वाली language में भी call cost रहती है, और कभी-कभी VM के state flags बदलने या garbage collection transition की cost भी देनी पड़ती है
      अगर Ruby YJIT measurable factor बनने लगा है, तो ऊपर वाला rule भी धीरे-धीरे और महत्वपूर्ण हो जाएगा
    • Ruby के each method को Ruby में फिर से क्यों लिखा गया, इस पर JPCamara ने बेहतरीन लेख लिखा है: https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182
      tender love का bonus लेख भी है: https://railsatscale.com/2023-08-29-ruby-outperforms-c/
      संक्षेप में बात यह है कि JIT जीतता है
  • FFI Foreign Function Interface है, यानी Ruby से C को call करने का तरीका

    • पूरी तरह safe और समझदार approach यह है कि ऐसा C code लिखा जाए जो run time पर command line से data ले और result को command line या memory page में उगल दे
      फिर Ruby से terminal में उस C program को flags या data के साथ चलाएं, तो Ruby C code execute कर सकता है
  • समझ नहीं आता कि इसे JIT compile क्यों करना चाहिए। अगर C में लिखा जा सकता है, तो load time पर बस compile क्यों नहीं कर सकते?

    • पता नहीं “C में लिखा जा सकता है” से आपका क्या मतलब है। FFI libraries native extension लिखे बिना Ruby से library methods को dynamically bind करके execute करने देती हैं
      इससे productivity काफी बढ़ती है, और वही code CRuby, JRuby, TruffleRuby में share किया जा सकता है
      अगर boot time पर सभी bindings statically पता लगाए जा सकें, तो stubs लिखकर method table में डाले जा सकते हैं, लेकिन फिर भी यह runtime पर होने वाली चीज है, इसलिए JIT ही कहलाएगी। और system से गुजरने वाले types के अनुसार adapt नहीं कर पाएंगे, इसलिए accepted values या optimization में conservative रहना पड़ेगा, जो आजकल libffi करता है उसके काफी करीब है
      AOT approach native extension लिखना है
  • थोड़ा अलग मुद्दा है, लेकिन आम तौर पर FFI खुद से ज्यादा FFI इस्तेमाल करने वाले gem से बचता हूं। compilation अक्सर बहुत झंझट भरा होता था, इसलिए Rubygems/bundler के intermediate step को छोड़कर सीधे build करना ज्यादा आसान लगता था

  • थोड़ा संबंधित बात: यह library JVMCI का इस्तेमाल करके JNI के बिना native libraries call करने के लिए arm64/amd64 code मौके पर generate करती है: https://github.com/apangin/nalim

  • क्या यह ठीक वही काम नहीं है जो libffi करता है?

    • libffi धीमा है, और मेरी जानकारी में यह JIT नहीं करता
      libffi में आप किसी function के लिए descriptor object बनाते हैं, और यह runtime data structure arguments और return value के types को दर्शाता है
      FFI call के समय जिन values को पास करना है उनके pointers की array और descriptor पास करना पड़ता है। अंदर शायद value array और descriptor को साथ-साथ traverse करते हुए type के हिसाब से values को stack पर रखता होगा, और function खत्म होने पर return type के अनुसार value निकालता होगा। इस प्रक्रिया में जगह-जगह type-specific branches आने की संभावना काफी है
      भले ही libffi call mechanism JIT हो जाए, arguments की array तैयार करने का काम फिर भी धीमा रहेगा। यह FFI JIT की तुलना में कम direct है, जो बीच की array से गुजरे बिना arguments तक सीधे पहुंचता है
      FFI JIT code arguments की values सीधे लेता है, Ruby type से C type में convert करता है, हर value के लिए inline code से उसे stack या registers में सही जगह रखता है, फिर function call करता है और return value को Ruby type में बदलता है। असल में यह हाथ से extension code लिखने जैसा ही है
      type inference हो तो conversion code में type checks छोड़े जा सकते हैं। उदाहरण के लिए, अगर यह guarantee हो कि arg1 Ruby string है, तो conversion function का तेज unsafe version इस्तेमाल किया जा सकता है
      JIT code को worst case में सिर्फ Ruby type जितना reflect करना होता है, और arguments से जुड़ी कोई array या list जरूरी नहीं होती। किस C type में convert होना है यह code में hardcode होता है, इसलिए runtime पर C side को describe करने वाले data structure को traverse करने की जरूरत नहीं पड़ती
    • libffi को यह नहीं पता कि Ruby क्या है, इसलिए वह Ruby types को unbox करना नहीं जान सकता
      इस लेख की खासियत यह है कि user जब attach_function call करता है, तब दी गई जानकारी के आधार पर type unboxing code generated machine code में effectively cache हो जाता है
    • libffi FFI calls को JIT नहीं करता, और फिर भी argument values को सीधे place करना पड़ता है। उदाहरण के लिए, string argument हो तो Ruby string object को C string pointer में बदलने वाला code अलग से लिखना पड़ेगा। और libffi काफी धीमा है
      sibling comment में linked tramp.c “reverse FFI”, यानी dynamic user work को function pointer के रूप में expose करने के लिए है, और वहां का JIT precompiled code को call करने के लिए कुल 3 instructions के स्तर का है
    • मुझे लगा था कि मुझे पता है libffi क्या करता है; सोचा था GOT जैसी किसी चीज़ से खेलता होगा। लेकिन लगता है तुम सही हो
      https://github.com/libffi/libffi/blob/master/src/tramp.c