लो-लेवल ऑप्टिमाइज़ेशन और Zig
(alloc.dev)- लो-लेवल ऑप्टिमाइज़ेशन वह काम है जिससे compiler code के इरादे और constraints को बेहतर समझ पाता है, और Zig इस उद्देश्य के लिए अच्छी तरह फिट बैठता है क्योंकि इसमें type, alignment, aliasing और compile-time जानकारी को स्पष्ट करना आसान है
- LLVM जैसे optimizing compiler भी हमेशा सर्वश्रेष्ठ code नहीं बनाते, इसलिए bottleneck वाले हिस्सों में generated code की जाँच और code adjustment अब भी ज़रूरी है
- Zig में
noalias,align, fixed array size, और element type को compile time पर पास करके JavaScript example की तुलना में छोटा vectorized code बनाया जा सकता है comptimeसामान्य Zig code को compile time पर चलाकर constants generation, generics implementation, type reflection, और string comparison optimization जैसी metaprogramming को संभव बनाता है- Zig की ताकत AST को सीधे बदलने वाले macro की बजाय भाषा में integrated compile-time execution में है, और कुछ runtime values को भी compile-time specialization functions के जरिए dispatch किया जा सकता है
Compiler पर भरोसा करें, लेकिन verify करना क्यों ज़रूरी है
- Optimization सिर्फ़ तेज़ program बनाने की technique नहीं है; यह cost reduction, बेहतर scalability, और system simplicity बनाए रखने से भी जुड़ी है
- Modern compilers LLVM जैसे backend के साथ impressive results देते हैं, लेकिन कुछ स्थितियों में वे अब भी suboptimal code generate करते हैं
- Low-level languages तेज़ इसलिए नहीं होतीं कि उनमें garbage collection या interpreter overhead कम होता है, बल्कि इसलिए भी कि वे compiler के समझने लायक intent information ज़्यादा express कर सकती हैं
- Compiler algorithms या programming paradigm को खुद नहीं बदल सकता, और आम तौर पर loop जैसे सीमित दायरे में optimization करता है
JavaScript और Zig में array maximum का example
- JavaScript example में
x[i] = y[i] > x[i] ? y[i] : x[i]के रूप में दो arrays के element-wise maximum कोxमें store किया जाता है - इंसानों के लिए यह code स्पष्ट है, लेकिन V8 द्वारा generated bytecode फूला हुआ है
- Zig example function arguments में optimization के लिए ज़रूरी जानकारी को अधिक specific रूप से बताता है
noalias x:xकिसी दूसरे pointer का alias नहीं है*align(64): 64-byte alignment[65536]f64: array size और element typeconst: read-only argument
- इस जानकारी की वजह से compiler बेहतर code बना सकता है, और example में vectorized assembly generate होती है
- बराबर Rust code भी लगभग वही assembly generate करता है
Optimization में Zig के फायदे और सीमाएँ
- Zig verbose expressions की अनुमति देता है, जिससे LLVM को code information काफ़ी मात्रा में दी जा सकती है
- Optimization से संबंधित Zig द्वारा दिए जाने वाले मुख्य elements ये हैं
- Rust का memory model compiler को यह हमेशा मानने देता है कि function arguments aliases नहीं बनाते, लेकिन Zig में इसे खुद specify करना पड़ता है
- अगर compiler को Zig function arguments के no-alias होने की जानकारी नहीं मिलती, तो annotation रहित Zig function Rust function से धीमा हो सकता है
- अच्छी तरह annotated LLVM IR को ही मानक मानें, तब भी Zig अच्छे results देता है, लेकिन इसकी बड़ी ताकत compile-time execution में है
comptime की भूमिका
- Zig का
comptimecompile time पर code generation के लिए feature है - Compile time पर किए जा सकने वाले काम ये हैं
- constants generate करके binary में शामिल करना
- कई data types के लिए समान hashmap structure को बार-बार न लिखना
- compile time पर ज्ञात data के आधार पर अनावश्यक code हटाने के लिए optimization को guide करना
- types को inspect, reflect, और generate करके generics implement करना
comptimecode सामान्य Zig code है जो compile time पर execute होता है, और इसमें network IO जैसे side effects नहीं हो सकते- Compile-time emulation machine compile target से match करती है
- लगभग सारा Zig code
comptimeके जरिए compile time पर execute किया जा सकता है, और सभी types को compile time पर inspect, reflect, और generate किया जा सकता है
Macro से फर्क
comptimeका उद्देश्य macros जैसा है, लेकिन इसका काम करने का तरीका अलग है- कुछ macros raw text बदलते हैं, और कुछ program के AST को सीधे modify करते हैं
- Zig का
comptimeAST को सीधे change नहीं करता, और इसमें token-pasting macro जैसी functionality भी नहीं है - Zig का लक्ष्य readable language होना है, इसलिए असंबंधित scopes में variables बनाने या modify करने वाली macro style से यह मेल नहीं खाता
- Macros जो कर सकते हैं, लेकिन Zig
comptimeसीधे नहीं कर सकता, वे काम ये हैं- दूसरे macros define करना
- AST बदलना
- mini-language या DSL को सीधे implement करना
- हालांकि Zig में भी DSL बनाया जा सकता है, और Zig का
printfunctioncomptimeसे format string parse करके data serialize करने वाले function graph को construct करता है - उदाहरण के तौर पर TigerBeetle account testing DSL, comath, zilliam हैं
comptime string comparison optimization
- सामान्य string comparison में length अलग होने पर
falseलौटाया जाता है, और length समान होने पर हर byte को क्रम से compare किया जाता है - इस तरीके में दोनों strings से अलग-अलग byte read करके compare करना पड़ता है
- अक्सर एक string compile time पर पहले से ज्ञात होती है, इसलिए Zig में एक argument को
comptimeहोना require किया जा सकता हैfn staticEql(comptime a: []const u8, b: []const u8) bool
"Hello!\n"जैसी static string से compare करते समय compiler length comparison और हर byte के constant comparison से बना code generate करता है- इस section का उद्देश्य सिर्फ़ यह दिखाना नहीं है कि compiler अपने-आप कौन-सा optimization कर सकता है, बल्कि यह दिखाना है कि
comptimeसे transformation को force करके compiler को न दिखने वाले अवसर खोले जा सकते हैं
बड़े unit comparison और SIMD का उपयोग
- सरल
comptimestring comparison अब भी byte-level comparison करता है - बेहतर version
std.simd.suggestVectorLength(u8)या@sizeOf(usize)का उपयोग करके comparison block size तय करता है - पहले string length verify करने के बाद, compare किए जा सकने वाले बड़े blocks की संख्या और remaining bytes की संख्या calculate की जाती है
- हर block को
std.meta.Int(.unsigned, block_len * 8)से बने integer type में@bitCastकरके compare किया जाता है - बचे हुए bytes को भी अलग integer type से compare किया जाता है
"Hello, World!\n"example की generated assembly बड़े registers का उपयोग करती है और conditional branches की संख्या घटाती है- बड़े string comparison में बड़े SIMD registers का उपयोग करने वाली assembly generate होती है
Runtime values और compile-time specialization को साथ इस्तेमाल करना
- Zig का
comptimeसिर्फ़ compile time पर ज्ञात data तक सीमित नहीं है - सरल cases में compile time पर कई procedures generate कर दिए जाते हैं, और runtime value के अनुसार सही procedure पर dynamic dispatch किया जा सकता है
- Example code में
switch (runtime_val)मेंinline 0...100range की values कोstaticFn(comptime_val)में भेजा जाता है, और बाकी कोruntimeFn(runtime_val)से handle किया जाता है - अगर binary size में वृद्धि नहीं चाहिए, तो full runtime implementation पर fallback किया जा सकता है
निष्कर्ष
- Zig का
comptimetemplates, macros, generics, और manual code generation का विकल्प निभाता है - दूसरी languages में भी ऐसा ही काम किया जा सकता है, लेकिन Zig में
comptimelanguage में अधिक स्वाभाविक रूप से integrated है - Zig वास्तविक उपयोगी परिस्थितियों में high-performance code लिखना आसान बनाता है, और यह उस Turing tar-pit से उलट है जहाँ सब कुछ संभव होता है लेकिन दिलचस्प काम कठिन होते हैं
- Language wars पर, एक ओर यह व्यापक दृष्टिकोण बचा रहता है कि Turing completeness ही पर्याप्त है, और साथ ही यह रुख भी कि लोगों की पसंदीदा languages हो सकती हैं
- “C Python से तेज़ है” जैसे भाषा को ही benchmark target मानने वाले कथन गलत हो सकते हैं; असली benchmark target भाषा नहीं, बल्कि specific code और implementation होता है
1 टिप्पणियां
Hacker News की टिप्पणियाँ
Zig में जो बात सबसे ज़्यादा आकर्षित करती है, वह है build system की आसानी, cross compile करना, और तेज़ iteration speed को लक्ष्य बनाना
गेम डेवलपर होने के कारण performance की ज़रूरत तो है, लेकिन मुझे लगता है कि ज़्यादातर भाषाएँ ज़रूरी performance पर्याप्त रूप से दे देती हैं, इसलिए भाषा चुनने में यह मेरी पहली प्राथमिकता नहीं है
किसी भी भाषा में दमदार code लिखा जा सकता है, लेकिन असली बात है ऐसा future-proof framework चुनना जो दशकों तक modular code को maintain कर सके
C/C++ इसलिए default जवाब था क्योंकि इसे हर जगह support मिलता है, और लगता है कि Zig भी उस स्तर तक पहुँच सकता है
Zig encapsulation के खिलाफ-सा है, और struct members को private नहीं बनाया जा सकता: https://github.com/ziglang/zig/issues/9909#issuecomment-9426...
मुख्य उद्धरण का रुख यह है कि “private fields और getter/setter Java द्वारा popular किया गया anti-pattern हैं; fields मौजूद data हैं, इसलिए उन्हें सावधानी से नाम दें और public API के हिस्से के रूप में document करें”
अगर internal representation को छिपाया नहीं जा सकता, तो software modularity की बुनियाद यानी API contract ठीक से बनाना मुश्किल है, और user code तोड़े बिना internal representation बदल पाने की क्षमता होनी चाहिए
Zig का रुख यह दिखता है कि internal representation नाम की कोई अलग चीज़ नहीं होनी चाहिए, बल्कि representation को ही public, documented और guaranteed होना चाहिए; उम्मीद है कि कभी वे यह फैसला पलटकर private fields support करेंगे
बहुत-सी चीज़ें सीधे चल गईं, और पुराने GDB से अजीब bugs तक debug कर पाया
मैं भी Zig से convince हो गया, और इसके बारे में यहाँ लिखा: https://news.ycombinator.com/item?id=44211041
समझ नहीं आता लोग इससे इतनी नफ़रत क्यों करते हैं
देखने में खराब लगने वाले generics C# या TypeScript में भी हैं, और borrow checker अगर आपने low-level काम किया है तो समझ में आने वाला concept है
Zig पर बने tools में जो सचमुच प्रभावित करता है वह bun है, और bun इस्तेमाल करने के बाद ज़िंदगी काफी सरल हो गई
Rust में बने uv के बारे में भी कुछ ऐसा ही कहा जा सकता है
आम तौर पर consoles C/C++ के अलावा चीज़ें पसंद नहीं करते, लेकिन Zig को C में translate किया जा सकता है, इसलिए शायद इसे पूरी तरह बाहर न रखा जाए
“नए compilers भी language spec तोड़ते हैं (Clang मानता है कि बिना side effects वाले सभी loops terminate होते हैं)” इस दावे पर: मुझे शक नहीं कि compilers कभी-कभी spec तोड़ते हैं, लेकिन इस मामले में Clang कम-से-कम C11 के बाद के मानक के हिसाब से सही है
C11 में कहा गया है कि जिन loops की controlling expression constant expression नहीं है, और जो input/output, volatile access, synchronization या atomic operations नहीं करते, उनके बारे में implementation यह मान सकता है कि वे terminate होंगे
इसलिए C का साधारण infinite loop
for (;;);सचमुच infinite loop के रूप में compile होना चाहिए, और Rust का कम opaqueloop {}भी ऐसा ही होना चाहिएलेकिन LLVM ऐसे लोगों द्वारा बनाया गया है जो कभी-कभी भूल जाते हैं कि वे हमेशा C++ compiler नहीं बना रहे; इसलिए एक समय ऐसा था जब Rust ने “मुझे infinite loop दीजिए” कहा तो LLVM ने “C++ के हिसाब से ऐसी कोई चीज़ नहीं होती, इसलिए optimize कर दूँगा” कह दिया, और यह दूसरी भाषा पर गलत तरह से लागू करना था
string comparison को inline और unroll करने के लिए comptime अनिवार्य नहीं है
C में भी यह संभव है: https://godbolt.org/z/6edWbqnfT
typo ठीक कर दिया है
बेहतर उदाहरण है https://github.com/RetroDev256/comptime_suffix_automaton
हालांकि link किए गए godbolt code में सच में दो खराब examples में से एक दिख भी रहा है
JavaScript उदाहरण और Zig/Rust उदाहरणों की तुलना करना मुझे अच्छी तुलना नहीं लगता
Zig और Rust compiler को तो बहुत आधुनिक target CPU चुनने को कहा गया है, लेकिन V8 के लिए वही शर्तें नहीं लगतीं
optimizing JIT भी, अगर conditions सही हों, तो vectorization कर सकता है
संदर्भ के लिए, ज़्यादातर modern languages strings पर वही optimization करती हैं, और C++ example यहाँ है: https://godbolt.org/z/TM5qdbTqh
Zig example fixed-size के known type arrays का इस्तेमाल करता है, जबकि JS code runtime पर “generic” है, इसलिए
xऔरyकोई भी object हो सकते हैंJS में वह cost चुकानी पड़ती है, यह सही है, लेकिन विडंबना यह है कि इस specific example में JIT को type information और बेहतर तरीके से दी जा सकती है
अगर इस function को हमेशा same size की Float64Array के साथ call कराया जाए, तो JIT इसे जानकर ज्यादा तेज loop बना सकता है। vectorization न भी हो, तब भी यह काफी बेहतर होगा
हालांकि typed arrays की initialization cost ज्यादा होती है, इसलिए जब तक कोई बड़ा typed array एक बार allocate करके बहुत बार reuse न किया जाए, उन्हें असल में ज्यादा नहीं इस्तेमाल किया जाता
साथ ही लेख में कहा गया कि JS bytecode काफी फूल गया था; शायद इसका बड़ा हिस्सा guards जोड़ने से आता होगा, क्योंकि JIT यह guarantee नहीं कर सकता कि
65536दोनों arrays की length के बराबर हैफिर भी असल में कोई भी
forloop इस तरह नहीं लिखेगा, बल्किi < x.lengthलिखेगा, और इस case में JIT कम-से-कम एक array check तो हटा देता हैtargetको किसी पुराने CPU पर बदला जा सकता हैJS target की limitations के बारे में न सोच पाने के लिए माफ़ी
linked C++ example यह दिखाने का अच्छा example है कि Clang C++ में क्या कर सकता है, लेकिन यह ध्यान में रखते हुए भी कि Zig किसी specific CPU के लिए compile करता है, generated assembly थोड़ी निराशाजनक लगती है
https://github.com/RetroDev256/comptime_suffix_automaton का C++ port देखना बहुत दिलचस्प होगा
यह comptime का ऐसा use है जिसे C++ compiler साफ़-साफ़ infer नहीं कर सकता
“high-level languages में low-level languages में मौजूद समृद्ध intent की कमी होती है” यह वाक्य सच में सही है या नहीं, पता नहीं
intent expression high-level/low-level spectrum का factor नहीं लगता; बल्कि जितने ज्यादा तरीके intent को अधिक detail में express करने के हों, उतना ही वह high-level के करीब होना चाहिए
यह इस बारे में intent है कि machine से क्या करवाना है, न कि क्या हासिल करना है
purchase.calculate_tax().await.map_err(|e| TaxCalculationError { source: e })?;जैसा code intent से भरा है, लेकिन आखिर में कौन-सा machine code निकलेगा, यह पता नहीं चलतावह
forloop syntax भयानक हैक्या मतलब है कि दो lists साथ-साथ हैं, और एक list में item की position दूसरी list में item की position से correspond करती है?
देखते ही आँखें दुखने लगती हैं
लगता है modern languages गलत रास्ते पर चली गई हैं, जब उन्होंने parser में तरह-तरह का “magic” जोड़ना और code में जगह-जगह छोटे symbols बिखेरना शुरू किया
यह ऐसा रूप नहीं है जिसे घंटों तक देखते रहने का मन करे
इसलिए Zig का ऐसा syntax देना स्वाभाविक है जो ठीक यही काम आसान बनाता है और साथ ही साफ़ दिखाता है कि क्या हो रहा है
व्यक्तिगत रूप से मुझे यह काफी अच्छा लगा; उत्सुक हूँ कि इसमें आँखें क्यों दुखती हैं
Zig का allocator model मुझे सच में पसंद है
अच्छा होता अगर Go में भी garbage collection के बजाय request-level allocator जैसी चीज़ इस्तेमाल की जा सकती
भाषा में ही ownership rules express और enforce करने का तरीका नहीं है, इसलिए अंत में यह बस थोड़े अलग syntax वाला C लिखकर सब ठीक रहने की उम्मीद करने जैसा बन जाता है
garbage collection न होने की शर्त में C++ भी Go से कहीं ज्यादा safe है
Rust का memory model compiler को यह हमेशा मानने देता है कि function arguments कभी aliasing references नहीं करते, लेकिन Zig में इसे manually specify करना पड़ता है
इस तरह की aliasing specification से बचा जा रहा है
क्योंकि इसे समझने वाले कम हैं, और गलत इस्तेमाल करने पर code में ऐसे bugs आ सकते हैं जिन्हें समझना कठिन होता है
इस दावे के बारे में कि Zig की comptime flexibility ने दूसरी languages में अच्छे improvements लाए, compile-time function execution और constant arguments लेने वाले functions D ने 2007 में introduce किए थे और कई languages को कुछ ऐसा ही अपनाने के लिए प्रेरित किया था
https://dlang.org/spec/function.html#interpretation
मुझे Zig पसंद है, लेकिन “मुझे Zig की verbosity पसंद है” कहना अजीब लगता है
यह सही है कि C कई जगहों पर बहुत ढीला है, लेकिन मौजूदा मानकों से Zig उल्टी दिशा में थोड़ा ज़्यादा झूल गया है, जिससे कई बार comment जैसी type annotation noise बहुत बढ़ जाती है
खासकर expressions में explicit integer casting के मामले में ऐसा है, और इस पर मैंने यहाँ थोड़ा लिखा है: https://floooh.github.io/2024/08/24/zig-and-emulators.html
performance के लिहाज से अगर Zig code समान C code से तेज़ है, तो आम तौर पर वजह Zig की ज़्यादा aggressive LLVM optimization settings होती हैं
उदाहरण के लिए Zig default रूप से
-march=nativeऔर whole-program optimization का उपयोग करता है, और project के सारे Zig code को एक ही compilation unit के रूप में compile करता हैunreachableको optimization hint की तरह इस्तेमाल करने जैसे लगभग सभी “tricks” C में भी संभव हैं, बस कभी-कभी non-standard language extensions की ज़रूरत पड़ती हैC compilers, खासकर Clang भी constant folding बहुत aggressive तरीके से करते हैं, और गहरी call stack होने पर भी constant में fold हो सकने वाले बड़े code regions को छोटा कर सकते हैं
इसलिए code generation के लिहाज से कई बार Zig के comptime से बड़ा फर्क नहीं होता
comptime का फायदा यह है कि वह चुपचाप runtime code में वापस नहीं गिरता, और non-comptime code भी C की तरह वही constant folding optimization का target बनता है
जैसे, अगर कोई “pure” non-comptime function constant arguments के साथ call हो, तो compiler फिर भी function call को उसके result से replace कर देता है
संक्षेप में, अगर C code Zig code से धीमा है, तो C compiler settings जांचनी चाहिए। आखिर optimization का सारा भारी काम नीचे LLVM में ही होता है
fn signExtendCast(comptime T: type, x: anytype) T { const ST = std.meta.Int(.signed, @bitSizeOf(T)); const SX = std.meta.Int(.signed, @bitSizeOf(@TypeOf(x))); return @bitCast(@as(ST, @as(SX, @bitCast(x)))); }export fn addi8(addr: u16, offset: u8) u16 { return addr +% signExtendCast(u16, offset); }यह उसी assembly में compile होता है, reusable है, और intent भी साफ हो जाता है
मैं इससे सहमत हूँ
Virgil में 2006 से पूरी language को compile time पर इस्तेमाल किया जा सकता था और वह whole-program compilation भी support करता था
लेकिन Virgil LLVM को target नहीं करता, इसलिए speed comparison आखिरकार दो compiler backends की तुलना बन जाता है
Virgil इस compilation model से संभव होने वाली reachability analysis और specialization optimizations पर काफी निर्भर करता है
उदाहरण के लिए, यह method calls को aggressive तरीके से devirtualize करता है, unreachable fields और objects को हटाता है, fields और heap objects के जरिए constants को promote करता है, और polymorphic code को पूरी तरह monomorphize करता है
वजह सिर्फ यह है कि इससे AI के लिए उन्हें संभालना आसान हो जाता है
coding में AI का उपयोग करना अच्छा idea है या नहीं, और किस हद तक अच्छा है, यह अलग मुद्दा है, लेकिन कई developers ऐसा मानते हैं और languages उन्हें accommodate करने की कोशिश करेंगी