- Ruby का default
jsongem profiling के जरिए bottlenecks हटाते हुए इस दिशा में सुधरा है कि सिर्फ speed के कारणojपर migrate करने का व्यावहारिक दबाव कम हो - लक्ष्य हर हाल में
ojको हराना नहीं, बल्किOj.mimic_JSONऔरOj.optimize_railsजैसी monkey patching के बिना भी पर्याप्त तेज और predictable JSON processing देना है ojकुछ benchmarks में तेज था, लेकिनscript_safeoption को ignore करना, Rails serialization में अंतर, Ruby crashes आदि के कारण production stability और API compatibility का बोझ पैदा करता था- मुख्य optimizations में duplicate UTF-8 checks हटाना, common conditions पहले check करना, generator setup cost घटाना, encoding pointer chasing से बचना, और lookup table आधारित escape checks शामिल थे
twitter.json467KiB generation benchmark में अलग-अलग changes से 3%, 8%, 15%, 30% सुधार मिले, और छोटे Hash generation में सिर्फ setup cost घटाने से 1.51x speedup आया
json gem को तेज बनाने की पृष्ठभूमि
- हाल में
jsongem का maintainer बनने के बाद पुराने bugs ठीक करते हुए performance improvements पर भी focus किया गया, और नतीजा यह हुआ कि ज्यादातर benchmarks में यह Ruby के लिए सबसे तेज JSON parser और generator बन गया - performance patches में से ज्यादातर किसी खास trick से ज्यादा profiling के जरिए bottlenecks ढूंढने और साधारण waste घटाने जैसे काम थे
- मुख्य motivation यह था कि
ruby/jsonइतना तेज हो जाए कि users को speed के कारण alternative gem चुनने की जरूरत न पड़े
oj को alternative के रूप में इस्तेमाल करने से बना बोझ
json 2.7.2औरojके बीच अंतर कुछ real-size के करीब benchmarks में बहुत बड़ा नहीं था- 100 tweets वाले 467KiB JSON document को parse करने में
json 2.7.2ने1.9ms,ojने1.6msलिए - वही document generate करने में
json 2.7.2ने0.8ms,ojने0.4msलिए
- 100 tweets वाले 467KiB JSON document को parse करने में
- कई use cases में धीमा हिस्सा JSON serialization खुद नहीं, बल्कि Active Record models को Ruby Hash और Array में बदलने वाला ऊपरी layer होता है
ojShopify codebase सहित कई projects में इस्तेमाल हुआ, और इसकी popularity शायद speed के कारण थी-
monkey patching की API inconsistency
Oj.mimic_JSONअक्सरjsongem को, औरOj.optimize_railsActiveSupport::JSONको monkey patch करने के तरीके के रूप में इस्तेमाल होते हैंJSON.dump(data, script_safe: true)JSON को<script>tag के अंदर सुरक्षित रखने के लिए</script>को<\/script>के रूप में escape कर सकता हैojscript_safeoption को नहीं जानता और उसे ignore करता है, इसलिए कोई gem जो अकेले में सुरक्षित था, वह भीOj.mimic_JSONcall करने वाली application के अंदर XSS attack की संभावना बना सकता हैOj.optimize_railsभी object serialization में subtle differences पैदा कर सकता हैActiveSupport::JSON::Encoding.time_precision = 0होने परActiveSupport::JSON.encode(t)seconds-level string बना सकता हैOj.optimize_railsऔरOj.mimic_JSONके बाद milliseconds वाली string output होने का example है- यह case load order के कारण बनने वाला corner case है, लेकिन पहले इससे भी ज्यादा behavior बदलता था
-
production environment stability की समस्या
- large-scale environments में
ojRuby crashes के प्रमुख कारणों में से एक था, औरgrpcके बाद सबसे ज्यादा problematic था - native gem लिखने के लिए Ruby VM और खासकर GC की समझ जरूरी है; वरना crashes या memory corruption हो सकते हैं
ojcodebase में ऐसे hacks थे जिनसे भरोसा करना मुश्किल होता था, और एक समय bugs से बचने के लिए कुछ situations में GC disable किया गया था- GC को फिर से enable करते समय major GC cycle trigger हो सकता है
- ऐसा code microbenchmarks के लिए फायदेमंद हो सकता है, लेकिन असली production performance घटा सकता है
- इसी अनुभव के कारण Shopify के monolith से
Ojहटाया गया, और इस process मेंOj.mimic_JSONऔर असलीjsonके बीच subtle differences दिखे
- large-scale environments में
benchmarks और profiling से bottlenecks ढूंढना
- लक्ष्य था कि
ruby/jsonreal usage और microbenchmarks दोनों मेंojजैसा behave करे, ताकि speed के कारणOj.mimic_JSONइस्तेमाल करने का आकर्षण कम हो - पहला कदम benchmark suite बनाना था
- इसमें microbenchmarks और ज्यादा realistic benchmarks दोनों शामिल किए गए
- John Hawthorn के rapidjson-ruby gem में मौजूद benchmark suite को आधार बनाया गया और कुछ additions किए गए
- C profiler के रूप में samply इस्तेमाल किया गया
- Firefox Profiler compatible reports output करना, जिन्हें share करना आसान है, इसका advantage है
duplicate UTF-8 checks हटाना
twitter.jsonpayload के साथJSON.dumpprofile करने पर JSON के अपनेisLegalUTF8में9%, औरrb_enc_str_asciionly_pमें1.9%खर्च हो रहा था- Ruby String में
coderangeनाम की internal property होती है, जो string encoding state या ASCII-only status को एक बार scan करने के बाद cache करती हैENC_CODERANGE_UNKNOWN: अभी scan नहीं हुआENC_CODERANGE_VALID: encoding valid हैENC_CODERANGE_7BIT: encoding valid है और सिर्फ ASCII characters हैंENC_CODERANGE_INVALID: encoding valid नहीं है
- पुराने
convert_UTF8_to_JSON_ASCIIमें शुरुआत मेंrb_enc_str_asciionly_pcall होता था, और फिर string को manually scan करके UTF-8 validity फिर से check की जाती थी—यह duplicate work था - बदलाव के बाद, पहले से calculated
coderangecompare करके UTF-8 validity तय की जाती है- Ruby expression में यह structure ऐसा है: अगर
string.ascii_only?नहीं है, तोstring.encoding != Encoding::UTF_8या!string.valid_encoding?होने परJSON::GeneratorErrorraise किया जाए #ascii_only?और#valid_encoding?दोनों cachedcoderangeका इस्तेमाल करते हैं, इसलिए string scan अधिकतम एक बार ही होता है
- Ruby expression में यह structure ऐसा है: अगर
- expected 9% के उलट actual improvement करीब 3% ही था
isLegalUTF8में खर्च होने वाला काफी timeconvert_UTF8_to_JSONमें shift हो गया- कारण पक्का नहीं है, लेकिन संभव है कि 9% में से बड़ा हिस्सा string bytes को RAM से CPU cache में लाने की cost था
twitter.jsongeneration benchmark1077.3 i/sसे1113.3 i/sतक जाकर1.03xतेज हुआ
ज्यादा सस्ती और ज्यादा probable conditions पहले check करना
fbuffer_inc_capaपूरे runtime का5.7%दिखा, और ज्यादातर time यह check करने में जा रहा था कि buffer पहले से allocated है या नहीं- यह function buffer में कुछ भी लिखते समय हर बार call होता है, लेकिन पहले call के बाद buffer हमेशा पहले से allocated होता है
- पुराना structure पहले ऐसी condition check करता था जो लगभग कभी match नहीं करती थी, जिससे काफी waste होता था; और अगर buffer अभी allocated नहीं है तो
fb->capa0होगा, इसलिए यहrequired > fb->capacheck से भी आंशिक रूप से duplicate था - fix के बाद सबसे common case—“buffer capacity sufficient है”—पहले check किया गया, और
RB_LIKELYवRB_UNLIKELYसे CPU branch prediction को hints दिए गए - function को
inlinemark किया गया, जिससे call cost कम हुई, और ज्यादातर cases में जरूरी काम subtraction और comparison जितना छोटा रह गया - इस change ने
twitter.jsongeneration benchmark को1068.6 i/sसे1224.7 i/sतक पहुंचाकर 1.15x तेज किया - यही principle Ruby code पर भी लागू हो सकता है: सबसे सस्ती और सबसे likely condition पहले check करें
JSON generator की setup cost घटाना
- Ruby committer Yusuke Endoh aka Mame ने भी
ruby/jsonoptimization में हिस्सा लिया, और कई optimizations वाला पुराना PR मौजूद था - कई changes JSON generation से पहले जरूरी setup cost घटाने पर focused थे
- argument parsing
- generator और related structs allocation
- generation work शुरू करने से पहले की preparation
ruby/jsonमें यह setup cost alternative implementations से ज्यादा थी, इसलिए microbenchmarks में यह खराब दिखता थाJSON.generatepretty JSON generation के लिएarray_nl,object_nl,indent,spaceजैसे options ले सकता है- पहले provided strings से separator buffers precompute किए जाते थे
- जैसे:
",#{opts[:array_nl]}",",#{opts[:object_nl]}",":#{opts[:space]}" - intention एक लंबा fragment append करने का था, लेकिन actual saved work छोटा था
- ज्यादातर cases में ये options इस्तेमाल नहीं होते थे, इसलिए precomputation cost ज्यादा भारी पड़ती थी
- जैसे:
- Mame ने इस optimization को असल में revert करके setup cost काफी घटाई
- बड़े benchmarks में difference ज्यादा नहीं था
- छोटे Hash 65 bytes generation benchmark में
2,112,189.3 i/sसे3,199,311.0 i/sतक जाकर 1.51x speedup आया
pointer chasing से बचकर encoding index compare करना
- Mame का एक और optimization
rb_enc_getcall हटाना था - JSON को अक्सर check करना पड़ता था कि string UTF-8 compatible है या नहीं; पहले
rb_enc_get(obj)सेrb_encoding *लिया जाता था और फिर US-ASCII या UTF-8 से compare किया जाता था rb_enc_getएक defensive high-level API है, इसलिए कई type checks करता है- यह String, Symbol, Regexp, File, Data जैसे अलग-अलग objects handle कर सकता है
- conditions ज्यादा हैं, और CPU branch prediction गलत हो तो cost बढ़ सकती है
- Ruby String conceptually encoding reference रखता है, लेकिन असल में 64-bit pointer के बजाय हर String के internal bitmap में छोटा 7-bit encoding index store करता है
- पूरा encoding object pointer पाने के लिए VM के internal global array के जरिए actual encoding ढूंढनी पड़ती है, जो low-level code में pointer chasing है
- अगर यह पहले से CPU cache में है तो तेज है, लेकिन RAM से लाना पड़े तो CPU को wait करना पड़ता है
jsonपहले से जानता है कि target String है, और जरूरत भी सिर्फ ASCII या UTF-8 status की है, इसलिएRB_ENCODING_GETसे encoding index सीधे compare किया जा सकता है- इस change ने
twitter.jsongeneration benchmark को1159.6 i/sसे1253.3 i/sतक बढ़ाकर 1.08x तेज किया
lookup table से string escaping तेज करना
- JSON string dump महंगा है, क्योंकि हर character के लिए check करना पड़ता है कि वह as-is copy हो सकता है या escape चाहिए
- naive तरीका हर character पर कई conditions check करता है
- ASCII control character है या नहीं
\n,\r,\t,\f,\bहै या नहीं"या\है या नहीं
- lookup table approach इस decision को static array में पहले से calculate कर देता है, और हर character पर कई comparisons के बजाय dynamic offset से boolean read करता है
- थोड़ी ज्यादा static memory इस्तेमाल करने के बदले loop काफी तेज हो जाता है
- यह मानते हुए कि ज्यादातर strings में escaping की जरूरत वाले characters नहीं होते, Mame ने पहले fast path है या नहीं यह सस्ते में check करने और match होने पर पूरी string को एक बार में buffer में copy करने की precondition जोड़ी
- Mame का patch C code होने के कारण ज्यादा complex है, लेकिन वही pattern इस्तेमाल करता है
- सिर्फ इस change से
twitter.jsongeneration benchmark1258.1 i/sसे1630.2 i/sतक पहुंचा और 1.30x तेज हुआ
आगे की optimizations
- अभी और optimizations बाकी हैं, इसलिए follow-up post की बात कही गई
- इसके बाद part two publish हुआ
1 टिप्पणियां
Hacker News की टिप्पणियाँ
मुझे byroot का काम सच में बहुत पसंद है। सिर्फ योगदान का प्रकार ही नहीं, बल्कि उनकी productivity का पैमाना भी हमेशा हैरान कर देता है
मैंने Ruby core पर कुछ बार काम शुरू करने की कोशिश की, लेकिन अपनी क्षमता के मुताबिक ऐसा कुछ नहीं ढूँढ पाया जिसमें सकारात्मक योगदान दे सकूँ, और कुछ हफ्तों तक नतीजा न मिले तो मेरी motivation खत्म हो जाती है। क्योंकि पोस्ट में साझा किए गए जैसे संदर्भ को तैयार करना सच में बहुत मुश्किल है
अगर Ruby C पर काम करने वाले लोग थोड़ा ज़्यादा लिखें, तो शायद Ruby को और बेहतर बनाने की क्षमता रखने वाले लोग भी बढ़ेंगे। C profiler की सलाह भी अच्छी थी, और इससे लगा कि C code वाले किसी Ruby gem को पकड़कर optimization पर फिर से काम शुरू किया जा सकता है
यह C extension पर है, लेकिन कुछ concepts समझने में मदद मिलती है
Part 2 भी आ चुका है: https://byroot.github.io/ruby/json/2024/12/18/optimizing-rub...
यहाँ एक बात ज़रूर उल्लेखनीय है कि Rails का default usage pattern jbuilder है। jbuilder खुद JSON serialization वाला हिस्सा नहीं है, लेकिन अगर Ruby/Rails में JSON rendering को धीमा करने वाली चीज़ों की सूची बनाऊँ, तो यह मेरी सूची में सबसे ऊपर होगा
jbuilder से बहुत सारे partials render करने पर यह बेहद धीमा हो जाता है
इस विषय पर यह लेख follow करना आसान था, और इसने मुझे अपना Ruby code भी benchmark और optimize करने का मन कराया। लेख भी अच्छा था और काम भी
शायद मैंने मिस कर दिया हो, लेकिन क्या कहीं यह बताया गया है कि सारी optimizations लागू करने के बाद नया version Twitter JSON dump को parse/encode करने में कितना समय लेता है?
https://github.com/ruby/json/releases/tag/v2.7.3
https://github.com/ruby/json/releases/tag/v2.8.0
शानदार लेख है और काम भी बढ़िया है। आगे चलकर क्या Oj इस्तेमाल करने की कोई वजह अभी भी बचती है?
Oj में बहुत बड़ा API है जिसे default
jsongem में नकल करने का कोई इरादा नहीं है। उदाहरण के लिए “SAJ” (SAX-style parsing), कई तरह के escaping modes वगैरहमेरा लक्ष्य बस इतना है कि लगभग 95% use cases में Oj की ज़रूरत न रहे, इसलिए कई उपयोगों में Oj अभी भी काम का रहेगा
यह लेख आने के बाद, मुझे जिज्ञासा है कि अब यह unmaintained implementation इसके मुकाबले कितनी तेज़ या धीमी है
https://netflixtechblog.com/fast-json-api-serialization-with...
मुझे यह pure Ruby implementation काफ़ी साफ-सुथरी लगी थी, लेकिन production में मैंने इसे कभी इस्तेमाल नहीं किया। इसे काफ़ी पहले छोड़ दिया गया था
कुल मिलाकर pure Ruby implementations की स्थिति भी जानने की जिज्ञासा है। लगता है
json_pureको हटा दिया गया, तो यह थोड़ा अफ़सोस की बात है। क्या किसी को इसके पीछे की जानकारी है? लेख का सबसे दिलचस्प हिस्सा मेरे लिए C optimization से ज़्यादा Ruby optimization थामज़े से पढ़ा। लेकिन एक बात सोचने वाली है कि Ruby-specific न होने वाली optimizations, जैसे escape characters के लिए lookup table, के मामले में simdjson जैसी मौजूदा library का इस्तेमाल क्यों नहीं किया गया
संक्षेप में,
ruby/jsonRuby के साथ ship होता है, इसलिए उसे Ruby की constraints के साथ compatible होना पड़ता है, और फिलहाल इसका मतलब है pure C99 और बिना C++ के। simdjson का Apache 2 license भी समस्या हो सकता है, हालांकि पक्का नहीं हूँकुल मिलाकर मैं dragonbox जैसी शानदार C++ libraries इस्तेमाल करना चाहूँगा, लेकिन कर नहीं सकता
और आख़िरी बार जब मैंने देखा था, simdjson सिर्फ parser देता था। जबकि ruby/json gem parsing और encoding दोनों करता है, इसलिए वह problem space के सिर्फ आधे हिस्से में ही मदद करेगा
modern projects में इस तरह का काम बहुत कम होता है। अगर यह काम और नियमित रूप से हुआ होता, तो शायद simdjson या oj जैसी libraries की ज़रूरत ही नहीं पड़ती। यह problem space उतना कठिन भी नहीं है
क्या Ruby JSON intrinsics का इस्तेमाल करता है? क्या कर सकता है?
और यह अलग-अलग JITs के साथ कैसे interact करता है?
jsongem C में implement किया गया है, इसलिए YJIT, यानी reference implementation के JIT, के नज़रिए से यह एक black box हैTruffleRuby JIT पहले sulong के ज़रिए C extensions को interpret करके language boundaries के पार भी JIT कर सकता था, लेकिन मेरी जानकारी में compatibility issues की वजह से उसने हाल में यह तरीका छोड़ दिया है
और TruffleRuby में JSON parser C में implement किया गया है, लेकिन encoder pure Ruby में है: https://github.com/ruby/json/blob/e1f6456499d497f33f69ae4c1a...
अगर मुझे सही याद है, तो branch prediction hints modern CPU पर बेकार होते हैं
“Redwood Cove microarchitecture से, अगर predictor के पास किसी branch के बारे में stored information नहीं है और उस branch पर Intel SSE2 branch taken hint, यानी instruction prefix 3EH, मौजूद है, तो जब codec branch को decode करता है तब branch prediction को not-taken से taken में flip कर देता है। इसके बाद front-end pipeline को flush करके pipeline को taken path पर ले जाता है
...
इस hint का इस्तेमाल तभी होता है जब predictor के पास उस branch के लिए stored information न हो। code bloat और instruction fetch bandwidth में कमी से बचने के लिए, hot code की branches, जैसे बहुत बार दोहराए जाने वाले loop के अंदर की branches, पर hints नहीं जोड़ने चाहिए। क्योंकि संभावना है कि predictor के पास उस branch की information पहले से ही stored हो। आदर्श रूप से hints सिर्फ उन branches पर जोड़ने चाहिए जो कम चलती हैं लेकिन ज़्यादातर बार taken होती हैं, हालांकि ऐसी branches की पहचान करना मुश्किल हो सकता है। जब compiler किसी एक execution path को fall-through के रूप में रख नहीं सकता, तब profile-guided optimization के हिस्से के रूप में hint जोड़ना recommended है। Redwood Cove microarchitecture ने hint placement को guide करने के लिए नए performance monitoring events भी जोड़े हैं”