- डोमेन-विशिष्ट compressor के optimal encoding path को traverse करने वाले loop में सिर्फ एक condition जोड़कर, synthetic benchmark का execution time 320µs से 80µs तक घटा दिया गया
- हर iteration में memory address पिछले iteration के
j पर निर्भर करता है, इसलिए एक अकेला mov instruction भी instruction-level parallelism का लाभ नहीं ले पाता और memory access latency से बंधा रहता है
next_j[i][j] अधिकतर मामलों में मौजूदा j के बराबर रहता है; इस गुण को branch prediction में परिलक्षित करने पर CPU iterations के बीच की dependency को bypass करके कई iterations को speculative execution से चला सकता है
- compiler अर्थ की दृष्टि से अनावश्यक
if को हटाए नहीं, इसके लिए volatile cast का उपयोग किया गया। LLVM में [[unlikely]] या __builtin_expect(..., 0) भी काम करते हैं, लेकिन volatile बेहतर code बनाता है और GCC भी इसे support करता है
- अधिक यथार्थपरक प्रयोगों में लगभग 2 गुना performance improvement मिला, जो LLVM के suboptimal code generation की वजह से हो सकता है; और अगर
j का अनुमान लगाना कठिन हो, तो 1-cycle latency वाला pshufb एक विकल्प हो सकता है
compressor के optimal path का traversal
- input string को कई chunks में बाँटा जाता है और हर chunk के लिए सबसे छोटा encoding चुनना होता है
- हर encoding अलग-अलग characters को बेहतर compress करता है, इसलिए chunk boundaries तुरंत तय करना आसान नहीं होता
- पिछली पोस्ट का algorithm इस समस्या को grid पर shortest path search में बदल देता है
- हर cell में अगला optimal cell दर्ज किया जाता है जहाँ जाना है
- पहले cell से आखिरी cell तक references को follow करने पर optimal encoding sequence मिल जाता है
next_j भरने वाला पहला loop पहले से ही SIMD से optimize किया गया है
- असली bottleneck नीचे जैसा सरल path-traversal loop है
uint8_t encoding[n_symbols];
uint8_t j = 0;
for (int i = 0; i < n_symbols; i++) {
j = next_j[i][j];
encoding[i] = j;
}
- write को छोड़ दें तो मुख्य operation
j = next_j[i][j] एक single mov instruction में compile होता है
single instruction भी dependency से धीमा हो सकता है
- आधुनिक CPU instruction-level parallelism का उपयोग करके कई instructions और अलग-अलग iterations के काम को एक साथ चला सकते हैं
- इसी वजह से सामान्य loop में
i < n_symbols जाँच और i++ की cost दूसरे काम को नहीं रोकती
- लेकिन एक-दूसरे पर निर्भर instructions साथ में execute नहीं हो सकते
- मौजूदा iteration का memory address
next_j[i][j] निकालने के लिए पिछले iteration से मिला j चाहिए
- अगला iteration, पिछले iteration का result आने तक शुरू नहीं हो सकता; इसलिए data cache में होने पर भी memory access latency का असर पड़ता है
branch prediction को value speculation की तरह इस्तेमाल करना
- इस compressor में chunks की संख्या बहुत अधिक न होने की उम्मीद है, इसलिए
next_j[i][j] अधिकतर समय पुराने j के बराबर रहता है
- CPU को सीधे यह नहीं बताया जा सकता कि address calculation में
j वही रहेगा, लेकिन branch prediction का उपयोग करके लगभग वही असर बनाया जा सकता है
for (int i = 0; i < n_symbols; i++) {
if (j != next_j[i][j]) {
j = next_j[i][j];
}
encoding[i] = j;
}
- अगर CPU यह predict करे कि
if body नहीं चलेगी, तो वह j को update करने वाली inter-iteration dependency को नज़रअंदाज़ करके कई iterations को speculative execution से चला सकता है
- जब condition वास्तव में true होती है, तो branch misprediction recovery होती है
- गलत speculation में की गई writes को discard कर दिया जाता है
- सही
j के साथ execution फिर से शुरू होता है
- जब तक
j लगभग बदलता नहीं, loop का bottleneck latency से throughput में बदल जाता है
compiler को if हटाने से रोकना
- compiler की नज़र में condition वाला code और मूल code अर्थ की दृष्टि से समान हैं
- अगर
j memory में होता, तो अनावश्यक write या read-only memory पर write से बचा जा सकता था, लेकिन यहाँ यह register value है
- common subexpression elimination (CSE) जैसी optimizations condition और duplicate access को हटा सकती हैं
- सामान्य compiler hints का उपयोग branch हटाने के लिए किया जाता है, लेकिन यहाँ उल्टा branch-free code को branch वाले code के रूप में बनाए रखना ज़रूरी है
- condition check और assignment के memory access को स्वतंत्र दिखाने के लिए assignment वाली तरफ
volatile cast लगाया गया
for (int i = 0; i < n_symbols; i++) {
if (j != next_j[i][j]) {
j = *(uint8_t volatile *)&next_j[i][j];
}
encoding[i] = j;
}
- 13 जुलाई की अतिरिक्त पुष्टि में, ibookstein द्वारा खोजे गए तरीके की तरह
[[unlikely]] या __builtin_expect(..., 0) का उपयोग करने पर भी LLVM में वही प्रभाव मिला
volatile वाला तरीका बेहतर code बनाता है और GCC में भी काम करता है
benchmarks में दिखा performance change
- synthetic benchmark में loop का execution time 320µs से 80µs तक घटा, यानी 4 गुना तेजी
- एक बार के execution में समय का अंतर छोटा है, लेकिन compression process में loop कई बार चलता है, इसलिए कुल cost में यह जुड़ जाता है
- अधिक realistic experiment में लगभग 2 गुना performance improvement दर्ज किया गया
- संभव है कि यह LLVM के suboptimal code generation के प्रभाव से जुड़ा हो, फिर भी यह लागू करने लायक सुधार था
bitmask-आधारित representation एक विकल्प के रूप में
- इस algorithm में
next_j[i][j] केवल दो में से एक value ले सकता है
- अधिकतर समय यह वर्तमान
j होता है
- बाकी स्थिति में यह ऐसा value होता है जो
j से स्वतंत्र और सिर्फ i पर निर्भर होता है
- 8-element array
next_j[i] को replacement value और bitmask की pair से बदला जा सकता है
- इस representation में
if अर्थ की दृष्टि से वास्तव में ज़रूरी होता है, इसलिए volatile workaround की आवश्यकता नहीं रहती
- लेकिन x86 पर variable position वाले bit की जाँच करने वाला operation compare से धीमा हो सकता है, इसलिए कुल performance उल्टा घट भी सकती है
जब j का अनुमान लगाना मुश्किल हो
- value speculation से linked list traversal तेज़ करने वाली पोस्ट में भी इसी तरीके से performance बढ़ाने की चर्चा है
- अगर
j को predict करना कठिन हो, तो vector indexing operation pshufb का उपयोग किया जा सकता है
pshufb की latency 1 cycle है, इसलिए speculative execution के बिना इसे और कम करना कठिन है
- vector operations से हर संभावित शुरुआती
j के path को parallel में compute किया जा सकता है
- काम को कई threads में बाँटकर बाद में results merge करने का तरीका भी संभव है
1 टिप्पणियां
Lobste.rs की टिप्पणियाँ
C++20 का
[[unlikely]]attribute अगर if statement पर लगाया जाए, तो clang को उस code को हटाने से रोकने के लिए यह पर्याप्त लगता है: https://clang.godbolt.org/z/r4xYWfPfeदूसरे compilers में जाँच नहीं की
हालांकि
[[unlikely]]code को fast path से बाहर move कर देता है, इसलिए LLVM नीचे जैसा कुछ कम efficient code generate करता है अगला रूप ज्यादा उचित लगता है साथ ही, memory से value को कई बार पढ़ने से बचाने के लिए optimize करते हुए यह hot loop में एक instruction भी जोड़ देता है। इसलिएvolatileका इस्तेमाल करने वाली technique अब भी उपयोगी है, लेकिन यह जानकर अच्छा लगा कि मूल code भी वैसे ही काम कर सकता हैइस technique को दूसरी जगहों पर value speculation कहा जाता है: https://mazzo.li/posts/value-speculation.html
मुझे उत्सुकता है कि 2–3 साल बाद, जब developers LLM के output को लेकर धीरे-धीरे और उदासीन हो जाएंगे, तब भी क्या वे इस तरह के blog posts में रुचि रखेंगे
फिर भी जिन लोगों की इसमें रुचि है, उनके लिए यह अब भी बहुत उपयोगी और अच्छा लेख है
जो developer details को महत्वपूर्ण नहीं मानता, उसके लिए अच्छा developer बनना मुश्किल है। जैसे एक बेहतरीन civil engineer बड़े project के bolts और nuts तक पर ध्यान देता है, वैसे ही बेहतरीन developer को भी छोटी-छोटी details में उतरना चाहिए। पूरी चीज details से मिलकर बनती है, इसलिए अगर आप अपने बनाए result को महत्व देते हैं, तो इससे बच नहीं सकते
AI द्वारा लिखे code का compiler output देखने वाले लोग और कम हो सकते हैं, लेकिन basic picture शायद नहीं बदलेगी
10 साल पहले अगर कोई Bash script थोड़ी धीमी होती, तो हम सबसे पहले instruction-level parallelism या CPU branch prediction के बारे में नहीं सोचते। किसी compilable और optimizable language में लिखा performance-critical हिस्सा हो सकता है, उसके भीतर detail में देखने लायक hot path हो सकता है, और फिर उसके भीतर एक-दो hot loops हो सकते हैं जहाँ vectorization या modern CPUs के व्यवहार में फर्क देखना meaningful हो
LLM इस्तेमाल करने पर भी performance के लिहाज से सबसे hot points exceptional cases के रूप में manually review करने लायक होते हैं। output की कम quality को जानबूझकर स्वीकार करना, या LLM से 70% बनाकर बाकी 30% हाथ से ठीक करने वाला तरीका 85:15 तक सुधर सकता है, फिर भी यह लेख अब भी top 1% स्तर के detailed area को ही cover करता है