2GB/s से अधिक पर protobuf parsing: C में tail call का उपयोग करने वाला high-speed interpreter design (2021)
(blog.reverberate.org)- Clang में
musttailजुड़ने से C-परिवार की भाषाओं में guaranteed tail call का उपयोग संभव हुआ, और इसे protobuf parser पर लागू करके 2GB/s से अधिक प्रदर्शन दिखाया गया - मुख्य विचार यह है कि function call को
callके बजायjmpके ज्यादा करीब बनाया जाए, ताकि लगातार calls में stack उपयोग O(n) से O(1) तक घटे और उसे loop की तरह संभाला जा सके - protobuf wire format में tag/value को interpret करते हुए arbitrary order के fields पर branch करना पड़ता है, इसलिए पारंपरिक
while+switchसंरचना को interpreter opcode dispatch जैसी optimization समस्याएँ झेलनी पड़ती हैं - upb के experimental parser में एक बड़े function के बजाय छोटे parser functions को tail call से जोड़ा गया, जिससे fast path में stack usage, register spill, prologue/epilogue से बचा जा सके
- इस तरीके में अगर non-tail call मिल जाए तो code quality काफी खराब हो जाती है, और
musttailएक non-standard extension है, इसलिए fast parser को वास्तव में deploy करने के लिए calling convention और portability की रणनीति चाहिए
Clang musttail से संभव हुई high-speed protobuf parsing
- Clang main branch में
[[clang::musttail]]/__attribute__((musttail))statement attribute जोड़ा गया, जिससे C, C++, Objective-C में tail call guarantee मिल सकती है - यहाँ tail call का उपयोग functional programming technique के रूप में नहीं, बल्कि parser और interpreter में branch cost कम करने वाले optimization tool के रूप में किया गया है
- protobuf parsing में इस technique को लागू करने पर
upbpull/310 में 2GB/s से अधिक parsing performance दिखाई गई- इसे पहले के सर्वोत्तम स्तर से दोगुने से भी अधिक तेज बताया गया
- कई techniques ने साथ मिलकर योगदान दिया, इसलिए यह कहना सही नहीं कि “सिर्फ tail call से 2 गुना तेजी मिली”
- tail call इस performance gain को संभव बनाने वाले मुख्य तत्वों में से एक था
- बाद के बदलाव A Tail Calling Interpreter For Python (And Other Updates) में दिए गए हैं
Tail call loop की तरह क्यों काम करता है
- tail call वह आखिरी function call है जो function के return होने से ठीक पहले किया जाता है
- जब tail call optimization लागू होती है, तो compiler सामान्य
callकी जगहjmpinstruction बनाता है- नया stack frame बनाना या return address save करना छोड़ दिया जाता है
- caller
f()सीधे calleeg()पर jump करता है g()फिर सीधे उस function को return करता है जिसनेf()को call किया था
- इसी गुण की वजह से tail call loop जैसी संरचना का विकल्प बन सकता है
- लगातार
ntail calls में भी stack usage O(n) से O(1) तक घट जाती है calloverhead हटने से function call को सामान्य branch की तरह लिया जा सकता है
- लगातार
- यह विचार नया नहीं है; इसकी पृष्ठभूमि Guy Steele के 1977 के paper और 1975~1980 के “Lambda Papers” तक जाती है
- Clang पहले से
-O2जैसी optimized build में tail call optimize कर सकता था, लेकिन पुराना व्यवहार best-effort के करीब था- non-optimized build में इसके वास्तविक
callके रूप में compile होने की संभावना अधिक थी - tail call को loop संरचना की तरह सुरक्षित रूप से इस्तेमाल करने के लिए सभी build modes में optimization की guarantee चाहिए
musttailयही guarantee देता है
- non-optimized build में इसके वास्तविक
Interpreter loop और protobuf parser की समान bottleneck समस्या
- LuaJIT के Mike Pall ने LuaJIT 2.x interpreter को C के बजाय assembly में लिखा था, और इसे fast interpreter का एक बड़ा कारण माना
- C compiler को interpreter main loop में खास तौर पर दो समस्याएँ आती हैं
- function जितना बड़ा और control flow जितना जटिल होगा, register allocator के लिए महत्वपूर्ण data को register में बनाए रखना उतना कठिन होगा
- जब fast path और slow path एक ही function में मिले हों, तो slow path fast path की code quality भी गिरा देता है
- protobuf wire format की संरचना भी interpreter जैसी है
- wire format, tag/value pairs की एक श्रृंखला है
- tag में field number और wire type शामिल होते हैं
- tag उस opcode जैसा काम करता है जो बताता है कि संबंधित field data को कैसे parse करना है
- field number arbitrary order में आ सकते हैं, इसलिए code को किसी भी हिस्से में dispatch करने की तैयारी रखनी पड़ती है
- पारंपरिक protobuf parser आमतौर पर
whileloop के अंदरswitchरखते थे, और protobuf के अधिकांश इतिहास में यही सर्वोत्तम तरीका माना गया - वास्तविक parsing में wire type mismatch, corrupted data, buffer end तक पहुँचना जैसी exceptions लगभग हर चरण में हो सकती हैं
- fast path को जितना संभव हो उतना छोटा और स्थिर रखना चाहिए
- कठिन मामलों के लिए बड़ा और जटिल fallback code चाहिए, और कभी-कभी out-of-line function call भी करने पड़ते हैं
Tail call आधारित upb parser design
- upb का experimental parser एक बड़ा parsing function रखने के बजाय हर operation को एक छोटे function में बाँटता है
- हर function अगले operation को tail call से बुलाता है
- x86-64 calling convention की वजह से common parsing arguments registers के जरिए पास होते हैं
- सभी parsing functions एक ही argument set का उपयोग करते हैं, जिससे calls के बीच value movement कम होता है
- उदाहरण में 4-byte fixed-width field parser function का flow इस तरह है
dataसे field information decode की जाती है- wire type मेल न खाने पर
fallback()कोMUSTTAIL returnकिया जाता है - tag को skip करके data को message में store किया जाता है
- अगला tag पढ़ने के बाद सही field parser पर branch करने के लिए
dispatch()को tail call किया जाता है
- Clang द्वारा generated assembly में fast path पर prologue, epilogue, register spill, stack usage नहीं होता
- exit points सिर्फ
fallbackयाdispatchकी ओर जाने वालेjmpहैं - arguments पहले से सही registers में होते हैं, इसलिए अलग parameter passing code भी नहीं चाहिए
- exit points सिर्फ
- इस संरचना में बड़े interpreter loop को वैचारिक रूप से एक जटिल function माना जाता है, लेकिन implementation में उसे basic-block स्तर के functions में बाँटकर tail call से control flow आगे बढ़ाया जाता है
- fast path और slow path को अलग functions में बाँटने से fallback code में बदलाव के कारण fast path की code quality बिगड़ने की संभावना घटती है
- जरूरत पड़ने पर
noinlineसे inlining रोकी जा सकती है - fast path की assembly sequence को लगभग स्थिर रखा जा सकता है
- जरूरत पड़ने पर
LuaJIT उदाहरण में दिखने वाली C code generation quality
- LuaJIT उदाहरण पर भी यही pattern लागू करने से, hand-written assembly के करीब परिणाम C code से मिल सकते हैं
- उदाहरण
ADDVNfunction ये operations करता है- instruction से register और constant index निकाले जाते हैं
- type check fail होने पर fallback पर जाया जाता है
- register value में constant जोड़ा जाता है
- अगला opcode पढ़कर opcode table के function को tail call किया जाता है
- generated assembly में बचे हुए सुधार अपेक्षाकृत छोटे हैं
- conditional branch के बाद अलग
jmpबनता है jmp qword ptr [rsi + 8*rax]के बजायraxमें load करने के बादjmp raxइस्तेमाल होता है
- conditional branch के बाद अलग
- ऐसे हिस्सों को Clang में सुधारे जा सकने वाले छोटे code generation issues के रूप में देखा गया
non-tail call और portability की सीमाएँ
- इस तरीके में सबसे बड़ा सावधानी-बिंदु यह है कि अगर function के अंदर non-tail call आ जाए तो assembly quality काफी खराब हो जाती है
- एक non-tail call भी stack frame बनवाने के लिए मजबूर कर देता है
- बहुत-सा data stack पर spill हो सकता है
- इससे बचने के लिए या तो दूसरे function calls को inline करना होगा या सिर्फ tail call के रूप में करना होगा
- protobuf parsing में varint handling एक प्रमुख कठिनाई है
- सामान्य और तेज मामला 1-byte varint का होता है
- लंबा varint error नहीं है, लेकिन अपेक्षाकृत कम सामान्य मामला है
- अगर इस exception handling को inline किया जाए तो fast path की code quality बिगड़ सकती है
- अगर
fallbackfunction को tail call किया जाए, तो processing के बाद मूल operation को आसानी से resume नहीं किया जा सकता, इसलिए fallback को operation अंत तक संभालना पड़ता है - नतीजतन code duplication और complexity बढ़ती है
- 2025-01-27 update में calling convention के जरिए इस समस्या को कम करने का तरीका जोड़ा गया
__attribute__((preserve_most))fallback functions के लिए उपयोगी calling convention है, जो लगभग सभी register preservation की जिम्मेदारी callee पर डालकर spill cost को fallback side पर भेजता है- इस attribute से जुड़ा Clang crash bug 2023 में ठीक किया गया
__attribute__((preserve_none))tail calling functions के लिए उपयोगी calling convention है, जो register preservation burden हटाता है और arguments के लिए अधिक registers देता है- दोनों में
preserve_noneको कम intrusive होने के कारण बेहतर विकल्प माना गया
- एक और सीमा यह है कि
musttailnon-standard compiler extension है- उम्मीद है कि यह GCC, Visual C++ आदि तक फैले और standardize हो, लेकिन निकट भविष्य में ऐसा दिखता नहीं
musttailन होने पर conceptual loop की हर iteration पर कम-से-कम एक वास्तविकreturnचाहिए- upb में यह fallback अभी implement नहीं किया गया है, और अनुमान है कि
musttailउपलब्ध होने पर dispatch को tail call करने, या अन्यथा सामान्य return करने वाले macro की जरूरत होगी
upb में वर्तमान स्थिति और विस्तार की संभावना
- 2GB/s से अधिक वाला parser, C में लिखी गई छोटी protobuf library
upbमें submit किया गया - वह code पूरी तरह काम करता है और protobuf conformance test भी सभी pass करता है, लेकिन लिखे जाने के समय तक कहीं rollout नहीं हुआ था
- C++ version protobuf में यह design implement नहीं किया गया था
- बाद में
upbकोmusttailउपयोग के लिए update किया गया, जिससे fast parser को production में लाने की एक बड़ी बाधा दूर हुई - यही technique C में लिखे प्रमुख language interpreters जैसे Python, Ruby, PHP, Lua आदि को भी महत्वपूर्ण performance लाभ दे सकती है
1 टिप्पणियां
Hacker News टिप्पणियाँ
C स्टैंडर्ड प्रस्ताव में tail call के लिए syntax है, और उसका रूप
return goto (expression);हैstandard
[[musttail]]की तुलना में इसमें मुझे जो बात पसंद है, वह यह है कि local object की lifetime खत्म होना guaranteed है। इसलिए इसे व्यापक escape analysis के बिना भी implement किया जा सकता है[0] https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3266.htm#5...
return gotoको implement करना ज्यादा आसान क्यों है।[[musttail]]भी ऊपर से देखने पर local object की lifetime खत्म करता हुआ लगता हैजल्दी से देखने पर लिखा है कि tail position में call किया गया function, callee के साथ same type का होना चाहिए। यह return value conversion की जरूरत खत्म करने और argument passing space तथा calling convention बने रहने की गारंटी के लिए शर्त है
Clang में मैंने
[[musttail]]implement किया था, और उसके बारे में जो शिकायत सबसे ज्यादा देखी, वह यह थी कि यह constraint बेवजह बहुत सख्त है। कुछ architectures type पूरी तरह match न होने पर भी tail call allow करते हैं: https://github.com/llvm/llvm-project/issues/54964“तो फिर code portable नहीं रहेगा” — यह बात सही है, लेकिन tail call optimization खुद ही मूल रूप से portable नहीं है। उदाहरण के लिए, WASM जैसे कुछ targets जिनमें tail call extension नहीं है, वे मूलतः tail call optimization support ही नहीं करते
कुछ बदलाव, additions, और यहाँ तक कि clarify किए जाने वाले ideas ऐसे हैं जो सच में जरूरी लगते हैं, इसलिए उम्मीद भी है, लेकिन C++ का aggressive update cycle आखिरकार ऊपर से और परतें चढ़ती जाने जैसा बन गया लगता है
खासकर तब समस्या होती है जब features आपस में उम्मीद से बहुत पहले बुरी तरह interact करने लगते हैं। उम्मीद है standardization process सिर्फ rationale documents पर निर्भर न रहे, बल्कि बड़े और विविध codebases में features को अच्छी तरह test करके बहुत conservative ढंग से चुने
अगर Rust में रुचि है, तो guaranteed tail call optimization देने के लिए
becomekeyword जोड़ने का एक पुराना RFC हैमूल रूप से इसे 2018 edition goals पर फोकस करने के लिए टाल दिया गया था, और वह फैसला सही था, लेकिन हाल में इस idea को फिर से review किया गया है। यह वापस आ सकता है
[0]: https://github.com/rust-lang/rfcs/pull/1888
[1]: https://github.com/rust-lang/rfcs/pull/3407
C++ में interpreter आम तौर पर इस तरह की speedup computed goto का इस्तेमाल करके पाते हैं। तब एक opcode से अगले opcode तक जाने वाले रास्ते में calling convention से जुड़ा शोर नहीं रहता
computed goto तरीका हो या tail call तरीका, classical
switchloop की तुलना में इनके तेज होने की मुख्य वजह branch predictor पर दबाव कम करना है। static रूप से opcode प्रति एक indirect branch बनती है, यह ऐसी संरचना नहीं रहती जिसमें static रूप से सिर्फ एक indirect branch होअगर हर function छोटा हो और महत्वपूर्ण variables को arguments के रूप में ले, तो register allocation काफी कम नाजुक हो जाती है
लेकिन interpreter का आकार बड़ा होने पर भी क्या यह बात सही रहती है, इसे लेकर जिज्ञासा है
context switching में tail call इस्तेमाल करने की बची हुई समस्या यह है कि इसमें calling convention का उपयोग करने वाले functions ही इस्तेमाल करने पड़ते हैं। दुर्भाग्य से function exit पर state restore करने के लिए registers बरबाद होते हैं
विस्तार से analysis और एक intermediate compiler वाले alternative पर LuaJIT remake ब्लॉग में लिखा है: https://sillycross.github.io/2022/11/22/2022-11-22/
कंप्यूटर साइंस की बाकी लगभग हर चीज़ की तरह, जब अलग-अलग तरह के operations की cost balance बदलती है, तो सबसे अच्छा algorithm 15 या 20 साल पहले इस्तेमाल होने वाले तरीके पर वापस जा सकता है। इसी वजह से programming में trends जैसी चीज़ दिखाई देती है। किसी चीज़ को फिर से ज़िंदा करने का मतलब यह नहीं कि उसकी वजह नहीं है, लेकिन यह भूल जाना कि पिछली बार वह universal cure क्यों नहीं थी, फिर भी समस्या है
अगर main JIT तेज या धीमा होता है, तो execution cost के मुकाबले उसका लाभ बदल जाता है, और उसे trigger करने वाली threshold भी adjust होती है। फिर दूसरे layer में execute होने वाले code की मात्रा बदलती है, और उस layer की amortized cost भी खराब हो सकती है। यह double pendulum का balance बनाने जैसा है
अगर JIT layer को पर्याप्त तेज और rough बनाया जा सके, तो interpreter को पूरी तरह skip किया जा सकता है। बाहर से देखने पर interpreter और करीब दो JITs के बीच हिसाब मिलाने का cognitive load इतना बड़ा लगता है कि कुछ भाषाओं ने शायद interpreter को hold पर रखकर output speed की जगह compile time के लिए optimized JIT अपनाया
याद नहीं कि कौन-सी भाषाएँ थीं, लेकिन कम-से-कम एक टीम ने इस balance problem की वजह से intermediate compiler भी आखिरकार हटा दिया था। तीन चीज़ों को संभालने की बजाय दो पर ध्यान देना बेहतर था
नाम हर बार गड़बड़ा जाता है, शायद
preserve_allयाpreserve_noneहै। समस्या यह है कि preservation किसके नज़रिए से कहा जा रहा हैमुझे लगता है
musttailattribute GCC में जोड़ा जा रहा है। patch review में है, और semantics Clang के साथ compatible हैंpreserve_mostattribute का क्या होगा। क्या GCC में भी ऐसा कुछ आने की संभावना है? इसके बिना non-tail calls interpreter को बिगाड़ देते हैंऐसा लगता है कि Clang
musttailcalls के लिए calling sequence बदलने वाली heuristics रखता है। उदाहरण के लिए i686 पर यहnopltcall में बदल देता है। Clang documentation में इसका ज़िक्र नहीं है: https://clang.llvm.org/docs/AttributeReference.html#musttailव्यावहारिक रूप से संभव बात शायद यही है कि जब compiler tail call generate नहीं कर सके, तब वह diagnostic message दे। बहुत से users के लिए शायद इतना ही काफी होगा। Scheme की तरह tail call की guarantee मिलना मुश्किल लगता है
C++ support का भी उल्लेख है, लेकिन C++ में शायद tail calls बहुत कम होंगे
उदाहरण के लिए
foo() { auto a = SomeClassWithADestructor(); return bar(); }tail call नहीं है, क्योंकिbar()call के बादaका destruction होता हैbarचलाने से पहले destructor call नहीं कर सकता?यह जानने की उत्सुकता है कि क्या C++ standard सचमुच destructor को block के अंत में ही call करने को कहता है, या variable का उपयोग खत्म होते ही उसे call करना भी मान्य है
हो सकता है उदाहरण बहुत सरल हो, लेकिन अच्छे code generation के लिए
__attribute__((musttail))अनिवार्य नहीं लगताअगर error handling function rare path पर है, तो call speed भी शायद उतनी महत्वपूर्ण नहीं होगी
if (unlikely(malformed)) return error(); switch (data_type) { case x: return handle_x(); case y: return handle_y(); }जैसी संरचना काफी स्थिर रूप से अच्छा jump table बनाती दिखती हैनहीं तो यह संरचना काम नहीं करेगी और stack तुरंत overflow हो जाएगा।
[[musttail]]का पूरा मतलब यही है कि tail call elimination अनिवार्य है। compiler के पास कोई दूसरा विकल्प नहीं होना चाहिएबेशक, “मजबूर करता है” कहना शायद पूरी तरह सटीक न हो। compiler के लिए यह तय नहीं है कि function के सभी execution paths में एक ही stack frame structure होना चाहिए, और न ही यह तय है कि जिन internal linkage functions या anonymous namespace functions का address नहीं लिया गया है, उनके लिए standard ABI ही इस्तेमाल करनी होगी। लेकिन मैंने जितने compilers देखे हैं, Clang समेत, वे व्यवहार में ऐसा ही करते हैं। इसलिए ऐसा कोई तरीका चाहिए जो यह बता सके कि ABI की चिंता छोड़ो और calls के बीच register preservation पर समय बर्बाद मत करो
jump table तो निश्चित ही अच्छी तरह बन जाता है। लेकिन अगर उसके result को
perf reportजैसी किसी चीज़ से देखें, और test bytecode किसी छोटे loop का प्रतिनिधित्व न कर रहा हो, तो आम तौर पर दो में से एक चीज़ दिखेगी। या तो हर dispatch पर branch prediction miss होगा, या compiler यह समझकर कि “शायद interpreter लिखा जा रहा है”, indirect jump को हर case के अंत में खिसका देगा। Clang में मैंने ऐसा देखा है। दोनों ही स्थितियों में generated code का register allocation अक्सर बहुत खराब होने की संभावना हैयह जानने की जिज्ञासा है कि trampoline, यानी अगला function function pointer के रूप में return करना और उसे outer loop में call करना, कितना तेज़ होगा। इसका फायदा यह है कि यह portable C है
Scheme programming language यह मांग करती है कि सभी tail calls stack को न बढ़ाएँ। इसलिए implementers ने trampoline सहित कई techniques पर काम किया है
मेरे पास उद्धृत करने के लिए सामग्री नहीं है, लेकिन Scheme को C में compile करने वाले papers में इसका उत्तर मिल सकता है। अगर target language tail call optimization की guarantee नहीं देती, तो generated program धीमा होगा
अतिरिक्त रूप से, high-level language implementers के JavaScript spec से tail call optimization हटाए जाने पर नाराज़ होने का यही एक बड़ा कारण है। tail call optimization और stack inspection दोनों को बनाए रखने वाले समाधान भी मौजूद हैं
https://github.com/schemedoc/bibliography/blob/master/page8....
function pointer से jump करने पर शायद वह उतना predictable नहीं होगा, और वही लाभ मिलना कठिन होगा
बेशक, इसे मापकर देखना होगा, और मैंने अभी तक ऐसा नहीं किया है
मैंने C में Protobuf decoder/encoder, IML parser, और Python bindings लिखकर देखे हैं, और parsing speed के measurement पर कुछ कहना है
अगर यह library managed languages के लिए सिर्फ bindings के रूप में दी जाए, तो performance के मामले में एक अतिरिक्त variable आ जाता है जो बाकी सब पर भारी पड़ सकता है। Ruby या PHP के बारे में नहीं जानता, लेकिन Python में enumerator का उपयोग न करने पर मैंने नाटकीय speedup देखा। Protobuf enumerator को Python enumerator में बदलते ही C code से मिलने वाला कोई भी फायदा तरह-तरह के Python objects बनाने में लगने वाले समय के नीचे दब जाता है। अंतर कई orders of magnitude का है। इससे भी आगे बढ़कर, सभी auxiliary data structures को C में implement किया जा सकता है और Python को सिर्फ minimal interface expose किया जा सकता है। ऐसी तुलना Python built-in structures का उपयोग करने वाले code के मुकाबले कितनी fair है, इसका जवाब देना मुश्किल है
Google का Python के लिए Protobuf parser अभी भी 2GB/s से “तेज़” हो सकता है। वजह यह है कि top-level message के अलावा वह वास्तव में कुछ भी parse नहीं करता। Message की internal structure जरूरत पड़ने पर parse होती है। अगर code parse की गई चीज़ों को तुरंत पूरा पढ़ ले, तो यह 2GB/s से धीमा होने की संभावना है, लेकिन सवाल यह है कि इन दोनों approaches की व्यावहारिक तुलना आखिर कैसे की जाए। Application के स्वभाव के अनुसार वास्तविक नतीजे बदलते हैं, इसलिए कोई साफ़ जवाब नहीं है
सामान्य स्थिति में Protobuf parsing duplicate processing की वजह से streaming नहीं हो सकती। वास्तव में Protobuf content को parse करने वाला code I/O bottleneck से टकराता है। क्योंकि parsing शुरू करने से पहले message के अंत तक इंतज़ार करना पड़ता है। अलग बात यह है कि application के typical Protobuf messages के आधार पर parsing को parallelize करना संभव हो सकता है, और तब उसके single-threaded parser से आगे निकलने की संभावना काफ़ी होती है। लेकिन ऊपर के उदाहरणों की तरह, इसे भी आम तौर पर जीतने वाली strategy नहीं कहा जा सकता
आम तौर पर parsing और domain object creation को साथ जोड़ना कहीं ज़्यादा efficient होता है। Application को लगभग हमेशा इस चरण से गुजरना ही पड़ता है। Parser इस functionality को कैसे approach कर सकता है, कई मामलों में यही तय करता है कि कौन-सा parser जीतेगा
निष्कर्ष यह है कि Protobuf, और शायद parsers सामान्य रूप से भी, speed measurement और comparison target के रूप में अच्छे नहीं हैं। ये बहुत low-level हैं और इनका design भी इतना अच्छा नहीं कि इन्हें performance benchmark के मानक के रूप में लेना आसान हो
अगर आप विस्तार से समझाएँ कि last field wins rule streaming parsing को कैसे रोकता है, तो अच्छा होगा
GCC और Clang में काफ़ी समय से
-foptimize-sibling-callsoption मौजूद है, इसलिए debug builds में भी tail calls मिल सकती थींबेशक, इसका standardize होना, guarantee होना, और function level पर control किया जा सकना एक बड़ा सुधार है
[1] https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
[2] https://clang.llvm.org/docs/ClangCommandLineReference.html#t...