- AArch64 assembly में loop की एक branch कम करने की कोशिश वाली optimization उल्टा 4 गुना धीमी हो गई, और वजह यह थी कि
bl/ret call-return pair को असममित तरीके से इस्तेमाल करने से branch predictor भ्रमित हो गया
- मूल code में हर loop पर
bl foo और b loop दो branch होती हैं, लेकिन बदले हुए code में एक बार bl loop से x30 को स्थिर कर दिया जाता है और कई ret को उसी address पर लौटने दिया जाता है, ताकि branch की संख्या घटाई जा सके
- M1 Max पर 1024
float array को जोड़ने के परिणाम में, सामान्य bl/ret code 969ns था, लेकिन एक bl और कई ret वाला ढांचा 3.85µs तक धीमा हो गया; ret को br x30 में बदलने पर यह 913ns तक लौट आया
- Instruments performance counters ने दिखाया कि 1 अरब elements के sum में असममित
bl/ret संरचना में return branch prediction failure लगभग 93% तक पहुँची, और FETCH_RESTART, MAP_DISPATCH_BUBBLE, MAP_REWIND भी बहुत बढ़ गए
- इससे भी तेज implementation
foo inline करने, Rust iter().sum(), NEON SIMD और manual loop unrolling तक पहुँचा, और अंतिम SIMD version 94ns तक घट गया, लेकिन floating-point addition का क्रम बदलने से result अलग हो सकता है
AArch64 loop में उल्टा असर
- उदाहरण function
float array को traverse करता है, हर value को foo में देता है, और foo accumulated value g को update करता है
- सरल AArch64 रूपांतरण में यह flow होता है
- loop की शुरुआत में
n == 0 है या नहीं, यह जाँचा जाता है
ldr s1, [x0], #4 से value पढ़ी जाती है
bl foo से subroutine को call किया जाता है
foo का ret, bl के बाद वाली अगली instruction पर लौटता है
b loop से फिर loop की शुरुआत पर जाया जाता है
foo parent function के साथ वही stack frame और registers इस्तेमाल करने वाले naked function जैसे रूप के करीब है, और s1 पढ़कर s0 में accumulation करता है
bl और ret की भूमिका
bl एक branch and link instruction है, जो दिए गए label पर jump करते हुए अगली instruction का address link register lr या x30 में store करता है
ret link register में मौजूद address पर jump करता है
- मूल संरचना में
bl foo और ret एक-दूसरे के अनुरूप हैं, और ret हमेशा उसी bl के बाद वाली instruction पर लौटता है
असफल “एक branch कम करो” optimization
- बदली हुई संरचना
foo को बदले बिना loop के अंदर एक branch कम करना चाहती है
- function की शुरुआत में
bl loop call करके x30 में loop की शुरुआत का address डाला जाता है
- loop exit condition जाँचने के बाद अलग branch के बिना
foo code में गिरा जाता है
foo का ret, x30 में मौजूद loop पर लौटता है
- इस संरचना में loop body के भीतर
x30 बदलता नहीं, इसलिए बार-बार होने वाले ret हमेशा उसी address पर लौटते हैं
- सरल
foo इस तरह एक पंक्ति का code है, जो float जोड़ता है
foo:
fadd s0, s0, s1
ret
- इस स्थिति में पूरा function input
float array का sum निकालता है
benchmark परिणाम और branch prediction समस्या
criterion से M1 Max CPU पर 1024 elements वाले array का benchmark किया गया
- सामान्य
bl/ret: 969ns
- एक
bl, कई ret: 3.85µs
- एक branch कम करने वाला code, दो branch इस्तेमाल करने वाले मूल code से लगभग 4 गुना धीमा निकला
- Cliff और Dan का मानना था कि
bl/ret pair के मेल न खाने से branch predictor भ्रमित हो गया
- ARM documentation के अनुसार
RET processor को function return पहचानने देता है, जिससे branch prediction अधिक सटीक हो सकती है
BR LR भी functional रूप से वही काम कर सकता है
- लेकिन
RET एक अलग instruction है, जिसे processor function return के रूप में पहचान सकता है
- यदि branch prediction सही हो, तो pipeline में सही instructions पहुँचती हैं और memory से instructions का इंतज़ार करने का समय बचता है
return address stack और br x30 प्रयोग
- branch predictor आंतरिक रूप से function return address stack बनाए रखता होगा
bl चलने पर return address stack में push होता है
ret दिखने पर यह मान लिया जाता है कि control सबसे हाल के bl के return address पर जाएगा
- उसी address के आधार पर prefetch और speculative execution शुरू होती है, फिर stack से pop किया जाता है
- यह तरीका तब अच्छा काम करता है जब
bl/ret सही जोड़ी में हों
- जब कई
ret बार-बार एक ही address का उपयोग करते हैं, तो prediction fail हो सकती है, बेकार prefetch हो सकती है, गलत speculative execution हो सकती है, और pipeline stall या flush हो सकता है
- Dan के सुझाव पर
ret को br x30 में बदलने से performance गिरावट गायब हो गई
- सामान्य
bl/ret: 969ns
- एक
bl, कई ret: 3.85µs
- एक
bl, कई br x30: 913ns
br x30 version हर loop में सिर्फ एक branch करता है, इसलिए यह मूल code से थोड़ा तेज है
Instruments performance counters
- Instruments से पहले दो programs के performance counters देखे गए
- माप 1 अरब elements वाले array का sum निकालते समय किया गया
- असममित
bl/ret में return branch prediction failure लगभग 93% हुई
| काउंटर |
सामान्य bl/ret |
एक bl, कई ret |
BRANCH_RET_INDIR_MISPRED_NONSPECIFIC |
92 |
928,644,975 |
FETCH_RESTART |
61,121 |
987,765,276 |
MAP_DISPATCH_BUBBLE |
1,155,632 |
7,350,085,139 |
MAP_REWIND |
6,412,734 |
2,789,499,545 |
- Apple ने इन counters को पूरी तरह document नहीं किया है
- दूसरे counters को खराब branch prediction के downstream effects के रूप में माना गया
FETCH_RESTART: संभवतः गलत prefetch
MAP_DISPATCH_BUBBLE: संभवतः pipeline stall से संबंधित
MAP_REWIND: संभवतः गलत speculative execution जिसे rewind करना पड़ा
और तेज कैसे बनाया जाए
- उदाहरण एक educational code है, और
foo के subroutine होने की वजह भी “अधिकतम तेज code” के बजाय समझाने वाली संरचना के करीब है
- अगर
foo की content build time पर पता हो और jump distance की सीमा के भीतर हो, तो bl और ret को पूरी तरह हटाकर inline किया जा सकता है
- 969ns से 911ns तक, लगभग 6% तेज
- Rust में सिर्फ
f.iter().sum() लिखने से यह 833ns तक आ जाता है
pub fn sum_slice(f: &[f32]) -> f32 {
f.iter().sum()
}
SIMD और manual loop unrolling
- manual AArch64 SIMD implementation तीन loop से बनी है
loop: जब तक बाकी values की संख्या 4 के multiple न हो जाए, अलग-अलग values को s0 में जोड़ा जाता है
simd: एक बार में 4 values को vector register v1 में जोड़ा जाता है, और यह तब तक दोहरता है जब तक बाकी values की संख्या 8 के multiple न हो जाए
simd2: simd को 2 गुना unroll करके हर iteration में 8 values process की जाती हैं और v1 तथा v2 में accumulate किया जाता है
- function के अंत में
v1 और v2 की values को s0 में जोड़कर return किया जाता है
- type punning में
float* वाले x0 को double* की तरह लेकर 128-bit, यानी 4 float, को d3 और d4 में पढ़ा जाता है
mov v3.d[1], v4.d[0] से d4 के 64-bit को v3 के ऊपरी 64-bit में ले जाया जाता है
fadd v1.4s, v1.4s, v3.4s में .4s suffix के कारण इसे चार float की तरह treat किया जाता है
- यह SIMD implementation 94ns में चलती है, जो पिछले सर्वश्रेष्ठ Rust version 833ns से लगभग 8.8 गुना तेज है
कुल performance सारांश और सावधानियाँ
| implementation |
समय |
सामान्य bl/ret |
969ns |
एक bl, कई ret |
3.85µs |
एक bl, कई br x30 |
913ns |
b इस्तेमाल करने वाला साधारण loop |
911ns |
| Rust में rewrite |
833ns |
| SIMD + manual loop unrolling |
94ns |
- SIMD code floating-point addition का क्रम बदल देता है
- floating-point addition associative नहीं होती, इसलिए SIMD version straight-line code जैसा ठीक वही result नहीं दे सकता
- संभव है कि compiler ने sum के लिए SIMD instructions इसलिए generate नहीं की हों
- सारा code GitHub पर सार्वजनिक है
- ARM64 machine पर
cargo bench चलाकर benchmark को reproduce किया जा सकता है
1 टिप्पणियां
Hacker News की रायें
आखिरी optimized code 32-bit floating-point के 1024 elements वाले array का sum 94ns में पूरा कर देता है
उन 94ns के दौरान पुराना दोस्त 1MHz 6502 तो शायद program के पहले instruction का पहला byte fetch करने के लिए memory chip को signal भेजे या नहीं, यही सोचना शुरू कर रहा होता
हालांकि यह code पूरी तरह इस premise पर निर्भर है कि वह cache के अंदर execute हो रहा है। नहीं तो लेख में आया ताकतवर M1 Max भी पहले memory fetch का इंतजार करते हुए रुका होता। DRAM धीमा है
Raymond Chen ने करीब 20 साल पहले यही बात कवर की थी: https://devblogs.microsoft.com/oldnewthing/20041216-00/?p=36...
silicon की semiconductor प्रकृति और doping की बुनियादी समझ को छोड़ दें, तो इसमें intuitive बहुत कम है। अगर आपने die circuit diagram और wiring, paths नहीं देखे हैं, तो जब तक engineers और datasheets स्पष्ट रूप से न कहें, A के B से तेज होने की उम्मीद करने की बहुत कम वजह है। खासकर ARM में तो मुझे ऐसा और भी लगता है
इस बार के लेख ने जो जोड़ा है, वह
retको एक औरbrinstruction से बदलने वाला साधारण correction है। इसलिए pair फिर से “symmetric” हो जाता है, और branch predictor को बिगाड़े बिना थोड़ा तेज code मिल सकता हैबेशक कुछ भी संभव है, और array sum निकालने वाला सामान्य loop सच में computer को elements एक-एक करके accumulate करने का निर्देश देने के रूप में होता है
लेकिन, उदाहरण के लिए, SIMD से चार accumulators parallel में बनाकर आखिर में जोड़ना, elements को एक-एक करके जोड़ने से ज्यादा गलत है—ऐसा कहना कठिन है
floating-point summation को मूल रूप से error bounds वाला मानना चाहिए, और उस range के भीतर का answer valid होना चाहिए। अगर input floating-point के बारे में कुछ खास पता है, तो language को वह intention explicitly express करने का तरीका देना चाहिए। सबसे basic loop ही default है, इसलिए default तौर पर best performance देना बेहतर लगता है
loop में एक-एक करके जोड़ने का naive तरीका obvious है, लेकिन accumulated error के total पर बेहतर bounds देने वाली ज्यादा sophisticated methods भी हैं, और Kahan summation एक जाना-पहचाना example है: https://en.wikipedia.org/wiki/Kahan_summation_algorithm
streaming data हो तो शायद एक-एक करके जोड़ने के अलावा कुछ न कर सकें, लेकिन fixed-size buffer के N slots इस्तेमाल कर सकते हों, तो नए number के आने पर किस subset को partial sum बनाया जाए, उसे accumulated sum में कैसे जोड़ा जाए, और क्या उस choice से provable error improvement मिलता है—ऐसे सवाल उठते हैं
उदाहरण के लिए
[1e50, -1e50, 1e3, 1e3]को(((1e50 + -1e50) + 1e3) + 1e3)की तरह calculate करें तो2e3आता है, लेकिन((1e50 + 1e3) + (-1e50 + 1e3))की तरह calculate करें तो0आता हैछोटे values को किसी एक बहुत बड़े value में बहुत बार जोड़ने पर भी ऐसा ही है, इसलिए
(((1e3 + 1e3) + 1e3) ... + 1e50)और(((1e50 + 1e3) + 1e3) ... + 1e3)काफी अलग हैंअगर builds के बीच arithmetic results stable न हों, तो यह पूरा nightmare बन जाता है। same input के साथ software को फिर से build करके run करने पर अलग result नहीं आना चाहिए
बहुत पहले Intel-specific case भी झेला था: FPU internally 80-bit registers इस्तेमाल करता था और memory में 64-bit, इसलिए registers fill/empty होने का timing बदलता तो rounding का timing भी बदलता और result भी बदल जाता। program start पर global FPU flag set करके हर operation पर rounding force की जा सकती है
हमेशा सही answer मौजूद होता है, और compiler को कम से कम default में गलत change नहीं करना चाहिए, ऐसा मैं सोचता हूं। हालांकि programmer को अपना intention और साफ तौर पर express करने का तरीका मिलना हमेशा welcome है
floating-point loop पर SIMD लगाना default बन सकता था, लेकिन यह बहुत सारे existing code को तोड़ देता और output अक्सर non-deterministically बदल जाता, इसलिए यह ऐसी feature बन गई जिसे programmer को explicitly चुनना पड़ता है
ऊपर से कई programmers को यह बात पता नहीं हो सकती, इसलिए अगर
float Sum(float[] values)अलग value return करना शुरू करे, तो उन्हें यह जानने का तरीका नहीं होगा कि वजह vectorization है। इसलिए, उदाहरण के लिए, .NET standard libraryintegers.Sum()में SIMD इस्तेमाल करती है, लेकिनfloats.Sum()में नहींसिर्फ “लूप खत्म होने की जांच करने के बाद, बिना किसी branch के सीधे
foofunction के अंदर गिर जाता है” वाली लाइन पढ़कर ही लगा, “अच्छा, यही समस्या है।”लगा था कि यह चमकदार branch predictor heuristics पर कोई गहरी चर्चा होगी, लेकिन आखिर में यह basic heuristic का उल्लंघन ही निकला।
यह नहीं सोचना चाहिए कि mismatched
call/retinstructions इस्तेमाल करके जबरदस्त speedup मिल सकता है। branch predictor द्वारा return address का shadow stack बनाए रखना दशकों से चली आ रही तकनीक हैइसलिए ऐसे risks document किए जाने चाहिए, लेकिन designer यह मानकर चल सकते हैं कि हाथ से assembly लिखने वाला व्यक्ति documentation पढ़ चुका है
दूसरी तरफ, Sophie Wilson ने मूल रूप से ARM के लिए BBC BASIC implementation लिखा था, लेकिन उस समय branch predictor नहीं था। भले ही 32-bit होने से rules अलग हों, यह जानना दिलचस्प है कि architectural assumptions बदलने पर AArch64 code को कैसे धीमा बनाता है
यह classic SNL reference “Do not taunt happy fun ball” है: https://www.youtube.com/watch?v=GmqeZl8OI2M
यह नहीं भूलना चाहिए कि यह 2023 का लेख है। अब यह थोड़ा पुराना हो चुका है, और Rust 1.78 से compiler ज्यादा aggressive loop unrolling और थोड़ा SIMD इस्तेमाल करता है: https://godbolt.org/z/zhbobW7rr
मूल लेख में कहा गया था “assembly देखने पर loop unrolling हो रही है” और https://godbolt.org/z/Kv77abW6c link किया था, लेकिन वह लगातार बदलने वाला “Rust Nightly” इस्तेमाल कर रहा था। अब loop unrolling और ज्यादा हो गई है
loop unrolling Rust 1.59 से शुरू हुई थी: https://godbolt.org/z/5PTnWrWf7
GitHub code के हिसाब से Rust
1.67.0-nightly, 2022-11-27 version इस्तेमाल किया जा रहा थाउसी hardware पर latest nightly Rust 1.81 की aggressive loop unrolling के साथ खुद benchmark चलाया, लेकिन कोई फर्क नहीं था; speed 1.5 साल पहले जैसी ही थी
यह 2023 का लेख है। उस समय की discussion: https://news.ycombinator.com/item?id=34520498
करीब 1 साल बाद repost होना ठीक है, और पुरानी thread का link उन readers के लिए है जो और जानना चाहते हैं
ARM/ARM64 assembly से बहुत परिचित नहीं था, इसलिए
x0कैसे increment होता है, इस पर confusion हुआconst float f = *data++;ldr s1, [x0], #4बनता है, और यह instruction value पढ़ते हुए साथ-साथx0को 4 से increment करता दिखता हैnegative value भी इस्तेमाल की जा सकती है, इसलिए reverse traversal भी संभव लगता है। काफी अच्छा है। x86_64 में read और increment को एक साथ करने वाला single instruction शायद नहीं है
lodsऔरstosक्रमशःrsiयाrdiपर read/write और increment को साथ करते हैं। दो memory addresses के बीच copy करते हुए increment करने वालाmovsभी हैआम तौर पर इसे
repके साथ इस्तेमाल किया जाता है, जोrcxबार repeat करता है। उदाहरण के लिए 10-bytememsetmov rcx, 10,mov rdi, dest,mov rax, 0,rep stosbके रूप में किया जा सकता हैw,d,qsuffix लगाने पर यह क्रमशः 2, 4, 8 bytes आगे बढ़ता हैलेख अच्छा था, लेकिन tables देखते समय comparison मुश्किल हो गया क्योंकि µs और ns units के बीच बार-बार switch किया गया
code optimize करने से पहले कम smart तरीका न आजमाया जाना हैरान करता है
assembly को दोबारा लिखने पर loop के bottom में केवल एक branch की जरूरत होगी, और
X1के लिए भी comparison subtraction और decrement अलग-अलग करने के बजाय एक ही ALU operation से निपटाया जा सकता हैआगे बढ़ें तो
fooको बस inline किया जा सकता है, और mismatchedBL/RETtrick के बिनाRETinstruction भी हटाया जा सकता है। खुद benchmark नहीं किया है, इसलिए असल में कितना तेज होगा, पता नहींcbnzलिखा है, वहcbzहोना चाहिए। CBZ register 0 होने पर label पर branch करता है, और CBNZ 0 न होने पर branch करता है