serde_json स्ट्रिंग प्रोसेसिंग 20% तेज
(purplesyringa.moe)- Rust ecosystem में बेसिक JSON parser की तरह इस्तेमाल होने वाला serde_json स्ट्रिंग-भारी benchmarks में छोटे internal optimizations से भी 10%, 23%, 32% performance improvement हासिल करता है
- error position calculation में स्ट्रिंग के शुरुआती हिस्से को सीधे scan करने के बजाय memchr से line count और आख़िरी newline खोजने के लिए बदलाव किया गया, जिससे success path की तुलना में 2 गुना से ज़्यादा धीमे error path का अंतर कम हुआ
- string parsing का core loop
\\,\", control characters को अलग-अलग खोजने के बजाय SWAR से एक बार में जाँच करता है, जिससे असली SIMD के बिना भी छोटी strings में low latency मिलती है \\uescape decoding में LUT, signed integers, और pre-shift table से branch और shift cost घटाई गई, और रूसी War and Peace JSON parsing 284 MB/s से 344 MB/s तक तेज हुई- इसके बाद bottleneck बने UTF-8 encoding को
char::encode_utf8औरVec::extend_from_sliceकी initialization·copy cost से बचाने वाली manual UTF-8 generation से संभाला गया, जिससे वही benchmark 374 MB/s तक पहुँचा
serde_json optimization का बड़ा असर क्यों पड़ता है
serdeRust का प्रतिनिधि serialization·deserialization framework है, औरserde_jsonJSON handling के लिए उसका widely used official combination है- पोस्ट के समय के अनुसार
serde_jsonकी reverse dependencies 26,916 थीं, जबकिsimd-jsonकी 66 थीं - इस स्तर के usage में
serde_jsonके अंदर का छोटा सुधार भी पूरे Rust ecosystem में cumulative effect दे सकता है - जब बहुत से users किसी दूसरे JSON parser पर आसानी से switch नहीं करते, तब मौजूदा library के भीतर low-risk optimization व्यावहारिक होता है
error position calculation: simple loop से memchr तक
- error path benchmark में
serde_jsonउसी data के success path से 2 गुना से ज़्यादा धीमा थाcanada,citm_catalog,twitterdatasets में error path, success path की तुलना में -48%~-77% धीमा था
- bottleneck
position_of_index()function था, जो error message formatting के लिए index को line/column में बदलता था- पुराना implementation
self.slice[..i]को byte unit में iterate करता था और हर\nपर line और column update करता था
- पुराना implementation
- calculation को दो stages में बाँटने पर
memchrलागू किया जा सकाself.slice[..i]के भीतर\nकी count से 0-based line number निकाला गया- आख़िरी
\nकी position खोजकर उसेiसे घटाकर column number निकाला गया
- memchr single-character search और count के लिए optimized implementation देता है, और अंदर से SIMD इस्तेमाल करता है
- PR #1160 merge होने के बाद error path performance काफ़ी सुधरी
canadaDOM मानक के अनुसार error path 122 MB/s से 216 MB/s तक बढ़ाcitm_catalogstruct मानक के अनुसार 195 MB/s से 736 MB/s तक बढ़ा- error path अब भी success path से धीमा था, लेकिन अंतर कम हुआ
string escape search: तीन conditions एक साथ ढूँढना
- string parsing का पुराना core loop
ESCAPEtable को देखते हुए non-escape bytes को skip करता था- JSON string में handle किए जाने वाले escape targets
\\,\", और0x1Fसे कम या बराबर ASCII control characters हैं - JSON spec के अनुसार string के अंदर control codes allowed नहीं हैं
- JSON string में handle किए जाने वाले escape targets
- पहली कोशिश
memchr2से पहले\\या\"खोजने और फिर control characters को अलग से check करने की थी- इससे string को एक बार तेज़ और एक बार धीमे, यानी दो बार iterate करना पड़ता था
- सिर्फ़ आधे हिस्से को vectorize करके बाकी scalar छोड़ने से overall improvement नहीं मिला
- दूसरी कोशिश
memchr2और manual SIMD से control characters की अनुपस्थिति जाँचने वाली structure थी- छोटी strings में runtime dispatch function call की cost भारी पड़ती थी
- लंबी strings में memory को दो बार पढ़ने की समस्या बनी रही
- अंतिम implementation को
\\,\", और control characters को एक ही pass में खोजने की दिशा में व्यवस्थित किया गया
SWAR से SIMD-जैसी processing
- platform-specific SIMD code अलग से न जोड़ने के लिए SWAR(SIMD Within A Register) technique का इस्तेमाल किया गया
- 128-bit SIMD के बजाय 64-bit word को 8 bytes की तरह treat किया गया
- bit operations से हर byte का condition match एक साथ जाँचा गया
- control character check को
c >= 0 && c < 0x20condition के bit-operation form में बदला गया- 8-byte chunk में
!c & (c - 0x2020202020202020) & 0x8080808080808080जैसे mask से check किया गया - 64-bit subtraction का borrow ऊपरी bytes तक propagate हो सकता है, लेकिन सबसे निचली position के control character को खोजने के उद्देश्य में यह समस्या नहीं थी
- 8-byte chunk में
\\और\"को भी इसी तरह check करके एक expression में जोड़ा गया- पूरी expression 9 bit operations से बनी थी
- x86 SIMD में 7 operations काफ़ी होते, इसलिए throughput कम था, लेकिन छोटी strings में latency ज़्यादा महत्वपूर्ण थी
- json-benchmark में यह SWAR code असली SIMD code से ज़्यादा efficient था
- बहुत छोटी strings में SWAR scalar code से धीमा हो सकता था
- लगभग 5-character strings में regression दिखा
- empty string
""और लगातार escape\r\n,\uD801\uDC37जैसे सामान्य patterns को सुरक्षित रखने के लिए SWAR loop में जाने से पहले पहले character के escape होने की जाँच की गई
- string search optimization के नतीजे dataset के अनुसार अलग थे
twitterstruct 638 MB/s से 785 MB/s तक, यानी +23%twitterDOM 305 MB/s से 335 MB/s तक, यानी +10%citm_catalogstruct 865 MB/s से 905 MB/s तक, यानी +5%- empty string कुछ microbenchmarks में अब भी 2% के भीतर धीमी रही
Unicode escape decoding optimization
serde_jsonraw Unicode strings और\\uescapes दोनों handle करता है- उदाहरण:
"🥺"और"\\ud83e\\udd7a"
- उदाहरण:
- पुराना hex decoding 256-entry LUT से हर character को 0~15 value में map करता था
'0'..='9','A'..='F','a'..='f'को handle करता था- invalid characters को sentinel value से mark किया जाता था
\\uescape में 4 hex digits पढ़े जाते हैं, इसलिए पुराने तरीके में हर iteration पर shift, add, compare और conditional branch शामिल थे- बेहतर implementation में loop के दौरान हर बार error return नहीं किया गया, बल्कि सभी 4 digits process करने के बाद validity check की गई
- अंतिम implementation
[i8; 256]LUT से invalid digit को-1रखकर 32-bit integer में calculation करता है- success पर result negative नहीं होता, और failure पर negative हो जाता है
- x86 पर memory load और sign extension को
movsxमें combine किया जा सकता है
- shift latency घटाने के लिए दो tables इस्तेमाल किए गए
HEX0: original valueHEX1: value को पहले से 4 bits left shift किया हुआ table
- loop को साफ़ तौर पर unroll किए गए
decode_four_hex_digits()implementation में व्यवस्थित किया गया - इस optimization से रूसी War and Peace को JSON-encoded form में parse करने की performance 284 MB/s से 344 MB/s तक पहुँची, यानी 21% improvement
UTF-8 encoding bottleneck और manual generation
- Unicode escape decoding के बाद bottleneck UTF-8 encoding पर शिफ्ट हो गया
- UTF-8 codepoint को उसकी length के अनुसार 1~4 bytes में encode करता है
- 1 byte:
0xxxxxxx - 2 bytes:
110xxxxx 10xxxxxx - 3 bytes:
1110xxxx 10xxxxxx 10xxxxxx - 4 bytes:
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
- 1 byte:
- Rust standard library का
char::encode_utf8&mut [u8]buffer में लिखता है, इसलिए buffer में पहले से validu8values होनी चाहिए- इसे uninitialized buffer में सीधे नहीं लिखा जा सकता
- UTF-8 variable-length encoding है, इसलिए LLVM के लिए अनावश्यक zeroization हटाना मुश्किल होता है
serde_jsonइसेscratch.extend_from_slice(c.encode_utf8(&mut [0u8; 4]).as_bytes())तरीके से handle करता था- local
[0u8; 4]buffer सैद्धांतिक रूप से alias analysis में मदद कर सकता था - लेकिन व्यवहार में 1~4 byte variable-length copy के कारण LLVM
memcpycall generate करता था
- local
memsetऔरmemcpycalls से बचने के लिए UTF-8 को सीधे generate किया गया- algorithm ख़ुद simple था, लेकिन इसके लिए unsafe की ज़रूरत पड़ी
- कुछ छोटे बदलावों के साथ War and Peace benchmark 344 MB/s से 374 MB/s तक, अतिरिक्त +9% सुधरा
अंतिम परिणाम
- string-heavy JSON benchmarks में
serde_jsonperformance क्रमशः 10%, 23%, 32% सुधरी - बहुत-सा JSON data strings से भरा होता है, इसलिए यह optimization
serde_jsonइस्तेमाल करने वाले पूरे Rust codebase को प्रभावित कर सकती है - सुधार error position calculation, string escape search, Unicode escape decoding, और UTF-8 encoding जैसे अलग-अलग bottlenecks को क्रमवार हटाकर हासिल किए गए
1 टिप्पणियां
Hacker News राय
UTF-8 ट्रिक को लेकर थोड़ी बेचैनी है, क्योंकि parser confusion attacks बहुत देखे हैं
serde को लोग speed से ज़्यादा correctness के लिए इस्तेमाल करते हैं, इसलिए उम्मीद है कि उन्होंने ढेर सारी invalid UTF-8 strings डालकर अंत तक fuzzing की होगी
इसका मतलब यह नहीं कि bug नहीं हो सकता, लेकिन parser की internal state बड़ी नहीं होनी चाहिए और exhaustive test भी संभव लगता है
serde का इतनी तेजी से आगे बढ़ना शानदार है
अभी simdutf8 देख रहा था, और पता चला कि SIMD-supported UTF-8 parsing वाला PR लगभग 5 साल से पड़ा है
https://github.com/rust-lang/rust/issues/68455
इस व्यक्ति के blog में मजबूत jart वाली vibe लगी, इसलिए पढ़ना मजेदार रहा
“wheel को फिर से invent करना पड़ेगा, लेकिन सोचें तो यह काफी बढ़िया है” वाला हिस्सा सच में कहा गया है या irony है, समझ नहीं आया
पढ़कर author पर हंसते हुए हंसी आई, लेकिन बाकी page में self-deprecation काफी मजबूत दिखती है
“सोचना कैसे है यह सिखाना coding सिखाने जितना ही जरूरी है, लेकिन यह लगभग होता ही नहीं” — इसमें तो अहंकार झलकता है कि सामने वाला सोचता ही नहीं
यह मानना नहीं है कि दूसरे लोग सोचते नहीं; बल्कि यह observation है कि ज्यादातर blog posts और how-to articles सिर्फ final result दिखाते हैं, वहां तक पहुंचने के steps नहीं दिखाते
सोचने का तरीका सिखाने का मतलब है कि research करनी पड़ती है और उस step को skip नहीं किया जा सकता; और research कर लेने से खुद निष्कर्ष निकालने की जिम्मेदारी भी खत्म नहीं होती
दोनों में से कोई एक skip करना आसान है, लेकिन गलत तरीका है
serde_json में debug build और release build एक-एक बार करने पर dependencies 3GB तक हो जाती हैं
कुछ active projects में serde इस्तेमाल करो तो disk space खत्म हो जाता है
JSON parsing के लिए 3GB dependencies क्यों चाहिए, समझ नहीं आता; code reuse से सहमत हूं, लेकिन serde की JSON-side dependencies काफी messy लगती हैं
उन dependencies में से किसी एक में exploit आ गया तो Rust ecosystem का आधा हिस्सा vulnerable हो जाएगा
Rust में JSON built-in होना चाहिए
indexmap = { version = "2.2.3", optional = true }itoa = "1.0"memchr = { version = "2", default-features = false }ryu = "1.0"serde = { version = "1.0.194", default-features = false }जब आप 3GB dependencies कह रहे हैं, तो संभव है कि आपने असल में वह चीज़ measure नहीं की हो
आप बिना evidence के इसे fact की तरह कह रहे हैं, इसलिए पक्का नहीं कह सकता, लेकिन अंदाजा लगाऊं तो शायद आपने
#[derive(Serialize, Deserialize)]बहुत इस्तेमाल किया, जिससे भारी मात्रा में code generate हुआ, और फिर पूरेtargetdirectory का size measure कर रहे हैंsimple build, जैसा दूसरे comments ने दिखाया, कुछ दर्जन MB के आसपास आता है
वह सिर्फ bloat को भीतर लाने का तरीका होगा, और standard library stability guarantees के अलावा दूसरी crates से अलग नहीं है, इसलिए कोई खास फायदा नहीं
आखिर में बस library release cycle को compiler release cycle से बांधने का असर होगा
वास्तव में पहले
rustc-serializebuilt-in के करीब था, और Rust ने वह दिशा पहले ही आजमा ली हैऔर default state में serde_json बड़ा नहीं है
न serde_json बड़ा है, न serde; दोनों बहुत low MSRV maintain करते हैं जिसे दूसरी crates अच्छे से support नहीं करतीं, इसलिए व्यवहार में इनके पास बहुत सारी dependencies हो भी नहीं सकतीं
थोड़े भी बड़े Rust project की dependency tree जल्दी ही काफी डरावनी हो जाती है, इन सभी dependencies का audit करना realistic नहीं है, और उनमें से कई पर भरोसा भी कम है
कई साल Rust में काम करने के बाद लगता है कि ecosystem के काफी mature होने तक शायद Rust को फिर हाथ नहीं लगाऊंगा
वह maturity बड़े enterprise adoption से आएगी; नहीं तो no-std, no-deps, pure C replacement projects में ही इस्तेमाल करूंगा
हालांकि Zig stable हो गया तो इस use case में भी Rust को हटा सकता है
यह इतना अजीब तरह से बड़ा है कि मुझे लगता है यह बस bug होगा
आप कुछ भी compile करें,
targetfolder में gigabytes भर जाते हैं, लेकिन यह debug info हटाने या कम से कम कम detailed setting इस्तेमाल करने के बाद final artifact को represent नहीं करताtargetfolder सचमुच हमेशा कम-से-कम कुछ GB का ही लगता है