FFI की स्पीड बढ़ाने के लिए Tiny JITs
(railsatscale.com)- Ruby में native functions को कॉल करते समय FFI सुविधाजनक है, लेकिन
strlenbenchmark में इसका call overhead C extension से ज्यादा होता है और performance bottleneck बन जाता है - बेसलाइन benchmark में
String#bytesizedirect 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 में
strlencall को 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 का
strlencall - Ruby method
B.strlenमेंx.bytesizeका indirect call strlenRuby Gem से बना C extension callstr.bytesizedirect call
- FFI से C का
- Ruby 3.5.0dev, ARM64 environment के results:
ruby-direct: 39.879M i/s, 25.08 ns/istrlen-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#bytesizedirect 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], :intcall के समय जरूरी जानकारी पहले से मौजूद होती है- call किया जाने वाला function name:
strlen - argument type: string
- return type: int
- call किया जाने वाला function name:
- इस जानकारी का इस्तेमाल करके Ruby values को native types में unwrap करने, external function call करने, और return value को फिर से Ruby object में wrap करने वाला machine code generate किया जा सकता है
- जरूरी components तीन हैं
- AArch64 gem: ARM64 machine code generation
- Fisk gem: x86_64 machine code generation
- JITBuffer gem: executable memory allocation
- सिर्फ 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 से
strlenattach करता हैmodule Cextend FJITattach_function :strlen, [:string], :int
attach_functioncall होने पर FJIT Ruby string को unwrap करता है, C काstrlencall करता है, और 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/istrlen-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#bytesizedirect call अब भी सबसे तेज है- FJIT द्वारा generate किया गया machine code दूसरा सबसे तेज था, और
strlenC 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-disableflags के साथ चलाना पड़ता है- Kokubun का feature आने पर यह condition अनावश्यक हो जाने की उम्मीद है
- proof of concept उस समय के current Ruby head पर ही चलता है
- update के अनुसार Ruby head से RJIT हट चुका है, इसलिए script चलाने के लिए Ruby को
f32d5071b7b01f258eb45cf533496d82d5c0f6a1commit पर checkout करना होगा
1 टिप्पणियां
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
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
Rails At Scale और byroot के लेखों की वजह से Ruby internals और performance पर गहराई से बात करने वाले लेख पढ़ने के लिए यह वाकई अच्छा समय है। हाल में Ruby और Rails में हुए improvements को देखें तो Ruby developer बने रहने के लिए भी यह काफी अच्छा दौर है
कुछ खास तरह की 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 में रखना चाहेंगे
एक 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 काम घटाना बेहतर है, या राजनीतिक/तकनीकी वजहों से अभी तुरंत इसकी जरूरत है
एक बार चलकर खत्म होने वाले कामों के लिए यह 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...
इस trend को कभी-कभी “self-hosting” कहा जाता है, और browsers भी इसका खूब इस्तेमाल करते हैं—ऐसे हिस्सों को privileged JavaScript में ले जाकर जिन्हें पहले शायद C/C++ में लिखा जाता। standard library का काफी बड़ा हिस्सा आश्चर्यजनक रूप से native code नहीं है
C# जैसी कहीं तेज और लगभग zero interop cost वाली language में भी call cost रहती है, और कभी-कभी VM के state flags बदलने या garbage collection transition की cost भी देनी पड़ती है
अगर Ruby YJIT measurable factor बनने लगा है, तो ऊपर वाला rule भी धीरे-धीरे और महत्वपूर्ण हो जाएगा
eachmethod को Ruby में फिर से क्यों लिखा गया, इस पर JPCamara ने बेहतरीन लेख लिखा है: https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182tender love का bonus लेख भी है: https://railsatscale.com/2023-08-29-ruby-outperforms-c/
संक्षेप में बात यह है कि JIT जीतता है
FFI Foreign Function Interface है, यानी Ruby से C को call करने का तरीका
फिर Ruby से terminal में उस C program को flags या data के साथ चलाएं, तो Ruby C code execute कर सकता है
समझ नहीं आता कि इसे JIT compile क्यों करना चाहिए। अगर C में लिखा जा सकता है, तो load time पर बस compile क्यों नहीं कर सकते?
इससे 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 में आप किसी 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 हो कि
arg1Ruby 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 करने की जरूरत नहीं पड़ती
इस लेख की खासियत यह है कि user जब
attach_functioncall करता है, तब दी गई जानकारी के आधार पर type unboxing code generated machine code में effectively cache हो जाता हैsibling comment में linked
tramp.c“reverse FFI”, यानी dynamic user work को function pointer के रूप में expose करने के लिए है, और वहां का JIT precompiled code को call करने के लिए कुल 3 instructions के स्तर का हैhttps://github.com/libffi/libffi/blob/master/src/tramp.c