1 पॉइंट द्वारा GN⁺ 2024-12-19 | 1 टिप्पणियां | WhatsApp पर शेयर करें
  • Ruby का default json gem profiling के जरिए bottlenecks हटाते हुए इस दिशा में सुधरा है कि सिर्फ speed के कारण oj पर migrate करने का व्यावहारिक दबाव कम हो
  • लक्ष्य हर हाल में oj को हराना नहीं, बल्कि Oj.mimic_JSON और Oj.optimize_rails जैसी monkey patching के बिना भी पर्याप्त तेज और predictable JSON processing देना है
  • oj कुछ benchmarks में तेज था, लेकिन script_safe option को 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.json 467KiB generation benchmark में अलग-अलग changes से 3%, 8%, 15%, 30% सुधार मिले, और छोटे Hash generation में सिर्फ setup cost घटाने से 1.51x speedup आया

json gem को तेज बनाने की पृष्ठभूमि

  • हाल में json gem का 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 लिए
  • कई use cases में धीमा हिस्सा JSON serialization खुद नहीं, बल्कि Active Record models को Ruby Hash और Array में बदलने वाला ऊपरी layer होता है
  • oj Shopify codebase सहित कई projects में इस्तेमाल हुआ, और इसकी popularity शायद speed के कारण थी
  • monkey patching की API inconsistency

    • Oj.mimic_JSON अक्सर json gem को, और Oj.optimize_rails ActiveSupport::JSON को monkey patch करने के तरीके के रूप में इस्तेमाल होते हैं
    • JSON.dump(data, script_safe: true) JSON को <script> tag के अंदर सुरक्षित रखने के लिए </script> को <\/script> के रूप में escape कर सकता है
    • oj script_safe option को नहीं जानता और उसे ignore करता है, इसलिए कोई gem जो अकेले में सुरक्षित था, वह भी Oj.mimic_JSON call करने वाली 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 में oj Ruby crashes के प्रमुख कारणों में से एक था, और grpc के बाद सबसे ज्यादा problematic था
    • native gem लिखने के लिए Ruby VM और खासकर GC की समझ जरूरी है; वरना crashes या memory corruption हो सकते हैं
    • oj codebase में ऐसे 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 दिखे

benchmarks और profiling से bottlenecks ढूंढना

  • लक्ष्य था कि ruby/json real 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.json payload के साथ JSON.dump profile करने पर 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_p call होता था, और फिर string को manually scan करके UTF-8 validity फिर से check की जाती थी—यह duplicate work था
  • बदलाव के बाद, पहले से calculated coderange compare करके UTF-8 validity तय की जाती है
    • Ruby expression में यह structure ऐसा है: अगर string.ascii_only? नहीं है, तो string.encoding != Encoding::UTF_8 या !string.valid_encoding? होने पर JSON::GeneratorError raise किया जाए
    • #ascii_only? और #valid_encoding? दोनों cached coderange का इस्तेमाल करते हैं, इसलिए string scan अधिकतम एक बार ही होता है
  • expected 9% के उलट actual improvement करीब 3% ही था
    • isLegalUTF8 में खर्च होने वाला काफी time convert_UTF8_to_JSON में shift हो गया
    • कारण पक्का नहीं है, लेकिन संभव है कि 9% में से बड़ा हिस्सा string bytes को RAM से CPU cache में लाने की cost था
    • twitter.json generation benchmark 1077.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->capa 0 होगा, इसलिए यह required > fb->capa check से भी आंशिक रूप से duplicate था
  • fix के बाद सबसे common case—“buffer capacity sufficient है”—पहले check किया गया, और RB_LIKELYRB_UNLIKELY से CPU branch prediction को hints दिए गए
  • function को inline mark किया गया, जिससे call cost कम हुई, और ज्यादातर cases में जरूरी काम subtraction और comparison जितना छोटा रह गया
  • इस change ने twitter.json generation 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/json optimization में हिस्सा लिया, और कई 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.generate pretty 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_get call हटाना था
  • 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.json generation 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.json generation benchmark 1258.1 i/s से 1630.2 i/s तक पहुंचा और 1.30x तेज हुआ

आगे की optimizations

  • अभी और optimizations बाकी हैं, इसलिए follow-up post की बात कही गई
  • इसके बाद part two publish हुआ

1 टिप्पणियां

 
GN⁺ 2024-12-19
Hacker News की टिप्पणियाँ
  • मुझे byroot का काम सच में बहुत पसंद है। सिर्फ योगदान का प्रकार ही नहीं, बल्कि उनकी productivity का पैमाना भी हमेशा हैरान कर देता है
    मैंने Ruby core पर कुछ बार काम शुरू करने की कोशिश की, लेकिन अपनी क्षमता के मुताबिक ऐसा कुछ नहीं ढूँढ पाया जिसमें सकारात्मक योगदान दे सकूँ, और कुछ हफ्तों तक नतीजा न मिले तो मेरी motivation खत्म हो जाती है। क्योंकि पोस्ट में साझा किए गए जैसे संदर्भ को तैयार करना सच में बहुत मुश्किल है
    अगर Ruby C पर काम करने वाले लोग थोड़ा ज़्यादा लिखें, तो शायद Ruby को और बेहतर बनाने की क्षमता रखने वाले लोग भी बढ़ेंगे। C profiler की सलाह भी अच्छी थी, और इससे लगा कि C code वाले किसी Ruby gem को पकड़कर optimization पर फिर से काम शुरू किया जा सकता है

    • Peter Zhu की शानदार series भी है: https://blog.peterzhu.ca/ruby-c-ext/
      यह C extension पर है, लेकिन कुछ concepts समझने में मदद मिलती है
    • “बेहद productive” सही है, लेकिन वे सच में बेहद ही बुद्धिमान व्यक्ति भी हैं। मैंने Shopify में उनके साथ एक ही office में काम किया है, और वे ऐसे स्तर के इंसान हैं जो पहुँच से बाहर लगते हैं
  • 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 करने में कितना समय लेता है?

  • शानदार लेख है और काम भी बढ़िया है। आगे चलकर क्या Oj इस्तेमाल करने की कोई वजह अभी भी बचती है?

    • मैं लेखक हूँ
      Oj में बहुत बड़ा API है जिसे default json gem में नकल करने का कोई इरादा नहीं है। उदाहरण के लिए “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 का इस्तेमाल क्यों नहीं किया गया

    • इसका कुछ जवाब https://news.ycombinator.com/item?id=42450085 में दिया गया है
      संक्षेप में, ruby/json Ruby के साथ ship होता है, इसलिए उसे Ruby की constraints के साथ compatible होना पड़ता है, और फिलहाल इसका मतलब है pure C99 और बिना C++ के। simdjson का Apache 2 license भी समस्या हो सकता है, हालांकि पक्का नहीं हूँ
      कुल मिलाकर मैं dragonbox जैसी शानदार C++ libraries इस्तेमाल करना चाहूँगा, लेकिन कर नहीं सकता
      और आख़िरी बार जब मैंने देखा था, simdjson सिर्फ parser देता था। जबकि ruby/json gem parsing और encoding दोनों करता है, इसलिए वह problem space के सिर्फ आधे हिस्से में ही मदद करेगा
    • इस लेख की अच्छी बात यह है कि यह मौजूदा codebase पर किया गया असल engineering work है। थोड़ा और तेज़ होने के लिए सब कुछ बदल देने या library बदलने की कोशिश नहीं की गई, बल्कि असली code में उतरकर सिर्फ speed ही नहीं बल्कि efficiency भी सच में बेहतर करने की कोशिश की गई है
      modern projects में इस तरह का काम बहुत कम होता है। अगर यह काम और नियमित रूप से हुआ होता, तो शायद simdjson या oj जैसी libraries की ज़रूरत ही नहीं पड़ती। यह problem space उतना कठिन भी नहीं है
  • क्या Ruby JSON intrinsics का इस्तेमाल करता है? क्या कर सकता है?
    और यह अलग-अलग JITs के साथ कैसे interact करता है?

    • मुझे ठीक-ठीक नहीं पता कि intrinsics से आपका क्या मतलब है
      json gem 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 पर बेकार होते हैं

    • modern CPU पर वे बेकार हो गए थे, लेकिन कुछ CPUs पर फिर से कुछ हद तक उपयोगी हो गए हैं। https://www.phoronix.com/news/GCC-Clang-Intel-x86-Branch-Hin...
      “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 भी जोड़े हैं”