- स्ट्रिंग को कॉपी करते हुए ASCII uppercase अक्षरों को lowercase में बदलने का काम AVX-512-BW से 64-बाइट इकाइयों में प्रोसेस करने का एक प्रयोग है, ताकि छोटी स्ट्रिंग्स में भी SIMD performance निकाली जा सके
- इम्प्लीमेंटेशन का मुख्य बिंदु यह है कि हर byte के लिए पहले जाँचा जाता है कि वह
'A'से बड़ा या बराबर और'Z'से छोटा या बराबर है या नहीं, फिर केवल उन्हीं जगहों पर'a' - 'A'जोड़ने वाला masked operation किया जाता है - छोटी स्ट्रिंग्स और लंबी स्ट्रिंग्स के आख़िरी बचे हिस्से को masked load/store से संभाला जाता है, जिससे SIMD code में अक्सर आने वाली छोटे टुकड़ों की processing cost कम होती है
- Clang 16, Debian 11, AMD Ryzen 9 7950X पर लगभग 1MiB copy को 1 byte से 1KiB chunk तक मापने पर
tolower64तुलना में शामिल विकल्पों में लगातार तेज़ समूह में रहा - Zen 4 पर AVX-512-BW ने string processing के लिए अच्छा फिट दिखाया, लेकिन ARM SVE और RISC-V Vector extension को सीधे विस्तार से जाँचा नहीं जा सका
AVX-512-BW से 64-बाइट tolower() बनाना
- लक्ष्य यह है कि स्ट्रिंग को कॉपी करते समय uppercase ASCII अक्षरों को lowercase में बदलने वाला
tolower()kernel SIMD से इम्प्लीमेंट किया जाए - AVX-512-BW एक ऐसा extension है जो byte और word स्तर के operations को support करता है, और हाल के AMD Zen processors पर उपलब्ध है
- AVX-512 कई extensions में बँटा हुआ है, इसलिए support की स्थिति जटिल है
- खासकर Intel की तरफ़ support को असंगत माना गया है
- ARM SVE भी string processing के लिए उपयुक्त byte-level masked load/store देता है
- यह हाल के big-ARM Neoverse cores, जैसे Amazon Graviton, पर उपलब्ध है
- Apple Silicon पर इसका उपयोग नहीं किया जा सकता
- RISC-V Vector extension भी ARM SVE जैसी शैली का है, और कई छोटे single-board computers पर उपलब्ध है
tolower64() कैसे काम करता है
tolower64()एक AVX-512 आधारित kernel है जो एक बार में 64 bytes प्रोसेस करता है- पहले 64 bytes वाले vector register में base values भरी जाती हैं
'A''Z''a' - 'A'
- input character vector
cको'A'और'Z'से compare करके अलग-अलग 64-bit masks बनाए जाते हैं- जहाँ
c >= 'A' - जहाँ
c <= 'Z'
- जहाँ
- दोनों masks को
_kand_mask64()से जोड़करis_uppermask बनाया जाता है, जो सिर्फ़ uppercase positions को दिखाता है - अंत में
_mm512_mask_add_epi8()लागू किया जाता है- जहाँ
is_upperfalse है, वहाँ मूलcवैसा ही रहता है - जहाँ
is_uppertrue है, वहाँc + ('a' - 'A')बन जाता है
- जहाँ
लंबी और छोटी स्ट्रिंग्स की प्रोसेसिंग
- लंबी स्ट्रिंग के ज़्यादातर हिस्से को सामान्य unaligned vector load/store से प्रोसेस किया जाता है
_mm512_loadu_epi8()tolower64()_mm512_storeu_epi8()
- छोटी स्ट्रिंग्स और लंबी स्ट्रिंग्स के आख़िरी बचे हिस्से के लिए masked unaligned load/store का उपयोग होता है
- mask को इस तरह बनाया जाता है कि नीचे की ओर के सिर्फ़
lenbits on होंuint64_t len_bits = (~0ULL) >> (64 - len)_cvtu64_mask64(len_bits)से इसे SIMD mask register में डाला जाता है
_mm512_maskz_loadu_epi8()mask के off रहने वाले positions पर destination register को 0 से भर देता है_mm512_mask_storeu_epi8()सिर्फ़ उन्हीं positions को store करता है जहाँ mask on है- यही तरीका छोटे string fragments को तेज़ी से संभालने की कुंजी है
benchmark की शर्तें और तुलना के लक्ष्य
- benchmark को Clang 16, Debian 11, AMD Ryzen 9 7950X पर चलाया गया
- माप का लक्ष्य लगभग 1MiB copy था, और chunk length को 1 byte से 1KiB तक बदला गया
- source और destination strings के alignment अंतर को दिखाने के लिए उनके बीच कुछ bytes का gap रखा गया, और ये bytes 1MiB measurement में शामिल नहीं थे
- Ryzen 9 7950X का L2 cache प्रति core 1MiB है, इसलिए उम्मीद थी कि हर test run L3 cache तक जाएगा
- हर function को inlining और code motion के हस्तक्षेप से बचाने के लिए अलग से compile किया गया
- वास्तविक code में inlining को रोकने की बजाय प्रोत्साहित करना अधिक संभावित है
नतीजे: tolower64 का स्मूथ performance
- गुलाबी
tolower64कुल मिलाकर test functions में सबसे तेज़ समूह के लगातार काफ़ी करीब रहा- लंबाई 65 bytes होने पर दूसरे vector में जाने से थोड़ा गिरता है
- यह जल्दी ऊपर जाता है और इसमें गहरी performance valleys नहीं हैं, जो दिखाता है कि masked load/store छोटे string fragments पर प्रभावी है
- हरा
copybytes64memcpyका वह संस्करण है जो AVX-512 को मिलते-जुलते तरीके से इस्तेमाल करता है- यह
tolower64से बहुत ज़्यादा तेज़ नहीं है - नया Clang इस function के अर्थ को पहचानकर इसे पूरी तरह rewrite कर देता है, इसलिए इसे Clang 11 से compile किया गया
- यह
- नारंगी
copybytes1byte-levelmemcpyversion है- इसे Clang 11 से compile किया गया
- यह दिखाता है कि 256 bytes से छोटी string fragments पर Clang 11 की auto-vectorization heuristics अपेक्षाकृत अच्छी नहीं हैं
- लाल
tolower<ctype.h>के standardtolower()को call करने वाला baseline है और बहुत धीमा है - बैंगनी
tolower1Clang 16 से compile किया गया byte-leveltolower()है- Clang 16 की auto-vectorization, Clang 11 की तुलना में काफ़ी बेहतर हो गई है
- फिर भी यह हाथ से लिखे गए version से धीमा है और काफ़ी अधिक जटिल code बनाता है
- छोटी string fragments की processing
tolower64जितनी अच्छी नहीं है, इसलिए performance graph नुकीले उतार-चढ़ाव दिखाता है
- भूरा
tolower8पिछली पोस्ट का SWARtolower()है- Clang auto-vectorization की कोशिश करता है, लेकिन function जटिल होने से नतीजा अच्छा नहीं आता
- इसे Clang 16 से compile किया गया, फिर भी Clang 11 शैली की 256-byte performance cliff दिखाई देती है
- नीला
memcpyglibc केmemcpyको call करता है- शुरुआत में यह तेज़ है, लेकिन कुछ हिस्सों में
copybytes64की लगभग आधी speed तक गिर जाता है - इसका कारण पता नहीं चल सका
- शुरुआत में यह तेज़ है, लेकिन कुछ हिस्सों में
निष्कर्ष और code
- AVX-512-BW strings, खासकर छोटी strings, को संभालने के लिए बहुत उपयुक्त है
- Zen 4 पर यह बहुत तेज़ है, और intrinsic functions का उपयोग भी अपेक्षाकृत आसान है
- इसकी सबसे ध्यान खींचने वाली विशेषता स्मूथ performance है
- छोटी string fragments पर auto-vectorization जब scalar code पर स्विच करती है, तो जो performance valleys दिखती हैं वे यहाँ लगभग नहीं दिखतीं
- ARM SVE support वाले hardware या RISC-V Vector extension वाले hardware तक सुविधाजनक पहुँच न होने के कारण इन दोनों extensions का विस्तार से अध्ययन नहीं किया जा सका
- code को वेबसाइट के git repository में देखा जा सकता है
1 टिप्पणियां
Hacker News की राय
“unsafe read beyond of death” ट्रिक, hardware में अनुमति होने पर भी Rust और LLVM memory model में undefined behavior मानी जाती है
बाकी undefined behavior की तरह compiler optimization के दौरान यह मान सकता है कि “ऐसा होता ही नहीं”, जिससे अप्रत्याशित नतीजे आ सकते हैं, और इसे bypass करने के लिए inline assembly इस्तेमाल करनी पड़ती है
https://github.com/ogxd/gxhash/issues/82
“allocation range से बाहर की values को unspecified elements की तरह पढ़ो, और hardware को दिक्कत हो तभी undefined behavior” जैसे load को support करना कठिन नहीं लगता, और अंदर से वह उसी assembly call का alias भी हो तो पर्याप्त होगा
इससे आगे,
malloc, stack, constants वगैरह हर allocation के बाद कम से कम करीब 64 bytes का fault-free address guarantee हो तो अच्छा होगा, लेकिन इसके लिए कई components का सहयोग चाहिए, इसलिए यह कहीं अधिक जटिल हैcustom allocator में यह मामूली बात है, लेकिन उस स्थिति में custom heap के बाहर के data पर SIMD code इस्तेमाल करना मुश्किल हो जाता है और बहुत छोटी segfault संभावना से बंधना पड़ता है
sanitizer या Valgrind भी फिर भी उपयोगी हैं। क्योंकि range से बाहर की values को undefined values के रूप में track किया जा सकता है, और actual use पर error दिया जा सकता है
unmapped page या protected memory से read करने पर क्या होता है, यह जानना चाहूंगा, और code नहीं देखा इसलिए पता नहीं कि alignment guarantee इससे बचाती है या नहीं
undefined behavior C standard का technical term है, इसलिए इसे generalize करना ही अजीब है, और ANSI C ऐसी assumption को explicit रूप से allow नहीं करता; ISO C थोड़ा अधिक खुला जरूर है, लेकिन उस assumption को specific रूप से justify नहीं करता
“UB = यह मान लेना कि हो ही नहीं सकता” जैसी व्याख्या काफी हद तक बेईमान किस्म की डर फैलाने वाली बात लगती है
लेख का साफ-सुथरा और performant code देखकर उत्सुकता होती है कि AMD का AVX512 implementation और Intel का प्रस्तावित AVX10 कैसे compete करेंगे
AVX10 का मुख्य उद्देश्य Intel की P-core/E-core स्थिति को सुलझाना लगता है, जबकि AMD ने स्थिति के हिसाब से Zen5 का full-width implementation या Zen4·Zen5 mobile का 256-bit 2-pass processing तरीका इस्तेमाल करते हुए API को seamless बनाए रखने वाला बेहतर approach चुना लगता है
लेख में बड़ी performance gains भी सभी Zen4 core से आए results हैं, और AVX512 के कई फायदे हैं, इसलिए Intel ने इसे market segmentation के कारण बहुत ज्यादा restrict करके general-purpose client code में adoption को practically रोक दिया, यह निराशाजनक है
market ने CPU-specific code path branching को बार-बार reject किया है, और असल में महत्वपूर्ण SIMD implementation lowest common denominator ही होता है
AVX10.1/256 और AVX512VL में common subset है, इसलिए पर्याप्त समय बीतने पर जब अधिकांश CPU इसे support करने लगेंगे, लोग उसी हिस्से को target करेंगे
AMD AVX512 support के लिए update किए गए कुछ benchmark apps में आसान जीत हासिल करता रहेगा, लेकिन अगर Intel AVX10 plan बनाए रखता है, तो AMD भी आखिरकार AVX512 compatibility बनाए रखते हुए AVX10/256 को efficiently support करने के लिए 2-pass SIMD pipe को व्यापक रूप से इस्तेमाल कर सकता है
Intel ने पिछले 10 सालों में कई खराब choices की हैं, लेकिन instruction set से market को बांटना सबसे बुरे फैसलों में से एक था। इसने modern innovations की momentum और interest खुद ही खत्म कर दी, और width से ज्यादा mask operations जैसी capabilities कहीं अधिक महत्वपूर्ण हैं, इसलिए उम्मीद है कि पूरी lineup में AVX10/256 डाला जाएगा
उस शब्द का एक खास मतलब है, और यह actual behavior से मेल नहीं खाता
Zen 4 ZMM register operations को कई micro-operations में decode करके खाली 256-bit units पर schedule करता है, और 512-bit full-width shuffle को महंगी emulation से बचाने के लिए dedicated hardware से special-case करता है
इसलिए 4 256-bit SIMD units वाला Zen 4 भी powerful 2×512-bit core की तरह behave करता है, और यह implementation बिल्कुल भी सस्ता तरीका नहीं है; संभव है कि consumer hardware में अब तक का सबसे अच्छा रूप यही हो
इसे ठीक करने के लिए कई साल मिल चुके हैं, और AMD support करे तब भी market share की वजह से adoption नहीं होता, यह खीझ पैदा करता है; और AVX10 दुर्भाग्य से शायद Intel को दुनिया को और लंबे समय तक रोके रखने देगा
desktop में मैं बेहतर cores, ज्यादा cores, और wide SIMD·float16·gather/scatter जैसी उपयोगी features खोलने वाला अच्छी तरह standardized instruction set देखना चाहता हूं, और AMD यह काफी अच्छा कर रहा है
दूसरी तरफ Intel ठीक-ठाक cores के साथ कमजोर cores जोड़ता है, कमजोर cores के हिसाब से अच्छे cores को restrict करता है, कई generations तक समान core count वाले CPU निकालता है, कमजोर cores से ऐसा दिखाता है कि core count ज्यादा है, इतने ज्यादा instruction variants लाता है कि useful common set बनना मुश्किल हो जाता है, और जिन instruction supports का उसने promising संकेत दिया था उन्हें भी छोड़ देता है
desktop manufacturer preference 90s में Intel, 2000s की शुरुआत में AMD, 2000s के अंत और 2010s में Intel, और अब फिर AMD रहा है। Intel अगर rival को रोकने के अलावा फिर से footing हासिल करना चाहता है तो क्या करेगा, यह देखना दिलचस्प होगा; और competition जारी रहना चाहिए ताकि कोई एक पक्ष बहुत complacent न हो
मज़े के लिए देखने लायक सामग्री: http://www.unicode.org/Public/3.1-Update1/CaseFolding-4.txt
उदाहरण के लिए
"straße".upper()से'STRASSE'बनता हैऔर अगर locale निर्दिष्ट न किया जाए, तो तुर्किक भाषाओं के dotless i को uppercase/lowercase में round-trip convert करते समय
'ı'.upper().lower()से'i'बन जाता है और गड़बड़ हो जाती हैASCII case-insensitive protocols कई हैं, और कई servers के hot path में अक्सर दिखाई देते हैं
पहले वाले के लिए आम तौर पर 8-bit encoding वाला pure ASCII इस्तेमाल करना ठीक रहता है, लेकिन बाद वाला जटिल हो जाता है
DNS address इसका आसान उदाहरण है: technically इसमें लगभग पूरा Unicode रखा जा सकता है, लेकिन असली DNS resolution के लिए यह बहुत सीमित ASCII subset में convert होता है, और वह resolution process case-sensitive नहीं होता
बेशक, ऐसी programming languages भी हैं जो Unicode के सभी writing systems को support करते हुए identifiers को case-insensitive रखती हैं। अगर आप ऐसी चीज़ संभाल रहे हैं, तो मेरी संवेदना
maßeकेMASSEमें बदलने वाले उदाहरण के संदर्भ में, जर्मन में uppercase Eszett ẞ भी हैयह अभी व्यापक रूप से deploy नहीं हुआ है और इसे support करने वाले fonts भी कम हैं, लेकिन theory में अब यह मौजूद है
लगता है लेख में “mask addition” की व्याख्या गलत नहीं है क्या
सवाल है कि जब
is_upperfalse हो तब जोड़ना और true हो तब वैसे ही copy करना नहीं होना चाहिए?to_uppervariable name उल्टा है और इसेto_lowerकहना चाहिए थाconfusing हिस्से की ओर इशारा करने के लिए धन्यवाद, मैंने लेख और code ठीक कर दिया
tolowerहैuppercase A
0x40है और lowercase0x60, इसलिए 0x20 जोड़ना तब होना चाहिए जबis_uppertrue होऐसी SWAR optimization अक्सर तभी उपयोगी होती है जब string 8-byte address पर aligned हो
unaligned string पर SWAR algorithm लगाने से अक्सर यह original algorithm से धीमा हो जाता है
अगर इसे 3 stages में बाँटा जाए—शुरुआत को aligned address तक process करना, aligned body को process करना, और 8 bytes से कम tail को process करना—तो instructions और बढ़ जाते हैं
Go में
utf8.IsValidके तेज़ होने वाले गलत दावे जैसा एक case और benchmark यहाँ है: https://github.com/sugawarayuuta/charcoal/pull/1memory operations हमेशा aligned और full vector size में किए जाते हैं, लेकिन mask केवल valid elements पर लगाया जा सकता है
अगर masked vector memory operation unaligned हो और unmapped या protected page को cross करे, तब भी अगर उस lane को mask से off किया गया है तो fault नहीं आता
strlen()जैसे operations के लिए, जहाँ length पहले से पता नहीं होती, ऐसे special load instructions भी हैं जो fault आने वाले पहले element से ठीक पहले vector length को घटा देते हैंमास्क जोड़ अच्छा दिखता है। .NET intrinsic में AVX512 के mask registers को सीधे manipulate कर पाना अच्छा होता, लेकिन फिलहाल “पहचाने जाने वाले idioms” पर निर्भर रहना पड़ता है
GCC द्वारा बनाए गए लेखक के core loop को uiCA(CQA/MAQAO) से Ice Lake के हिसाब से analyze करें तो करीब 32B/cycle मिलता है, और 3GHz में बदलें तो, memory bottleneck नहीं है मानकर, लगभग 96GiB/s हो जाता है। बेशक ऐसे algorithms में memory access हमेशा bottleneck होता है
हालांकि यह optimal utilization के बहुत करीब नहीं लगता, और Clang इस्तेमाल करने पर बेहतर खुले हुए unroll result और बेहतर instruction selection के साथ 42.67B/cycle तक आता है। L2 cache भी शायद ऐसा throughput बनाए रखना मुश्किल पाएगा, लेकिन medium-length strings का case conversion स्क्रीन की रोशनी के cornea तक पहुंचने जितने समय में खत्म हो जाता है, यह दिलचस्प है
कुछ महीने पहले C# में UTF-8 के अंदर ASCII uppercase/lowercase conversion जैसा कुछ implement किया था: https://github.com/U8String/U8String/blob/main/Sources/U8Str...
छोटे strings ही ज्यादातर codebases में dominate करते हैं, इसलिए vectorization length से कम के लिए unrolled conversion महत्वपूर्ण है, और
switchjump table और branchless fall-through में compile होता हैअभी सिर्फ 256-bit तक इस्तेमाल कर रहा हूं, क्योंकि Zen 3 या 4 जैसे cases में, जहां केवल 256×4 SIMD units होते हैं, यह पहले ही saturate हो जाता है। C version के साथ side-by-side comparison का example यहां है: https://godbolt.org/z/eTGYhTPan
AVX512 में
vpternlogdसे 3-instruction conversion भी संभव लगता है, और जब AVX512 hardware इस्तेमाल कर पा रहा था, तब .NET ने 256-bit width + AVX512VL में ऐसा optimize किया था, लेकिन अब अजीब तरह से 512-bit width में reproduce नहीं हो रहाswitchdispatch वाले failed SWAR attempts भी दिखेंगे, लेकिन article की license के बारे में जानना है। अगर test suite pass करता है तो इसे इस्तेमाल करना चाहूंगादोनों compilers की structure सोचें तो यह समझ आता है, लेकिन नतीजा कभी improvement होता है और कभी नुकसान
कुछ साल पहले एक highly vectorized project पर काम किया था जिसे दोनों से compile होना था, और अंत में C reference version के साथ specific targets के लिए inline assembly और
.Sfiles repository में maintain करनी पड़ींMakefile गंदा हो गया और test suite में benchmarks तक डालने पड़े, इसलिए maintenance burden बड़ा था; इसी वजह से निष्कर्ष निकला कि intrinsics को auto-vectorization से बेहतर low-level साधन की तरह इस्तेमाल करना बहुत सावधानी से करना चाहिए
उदाहरण: https://godbolt.org/z/T4Pjhrz5d में GCC output अपेक्षा के मुताबिक था, लेकिन Clang output चौंकाने वाला था और वास्तव में धीमा था। loop में चलाने पर uiCA के हिसाब से GCC के 4 cycles के मुकाबले 7 cycles, और brute-force algorithm में यह function billions बार चलने वाले real app benchmark में भी दिखा
LLVM codebase में देखने पर याद है कि Clang 16 internal refactoring की वजह से कुछ masked AVX-512 instructions बिल्कुल emit नहीं कर पाने वाली समस्या भी दिखी थी
लक्ष्य maximum possible performance नहीं था; शुरू में बस देखना था कि चलता है या नहीं, लेकिन पहला attempt काफी अच्छा निकला, यह bonus था
मुख्य रुचि vector register से छोटी strings और throughput graph की घाटियों को हटाने में है
blog post के अंत में code link follow करें तो license information है, और BIND के लिए मूल रूप से लिखे गए MPL-2.0 हिस्से को छोड़कर यह 0BSD या MIT-0 है
(x >= 'a' && x <= 'z')को(x - 'a') < ...form में rewrite करके एक instruction कम कर देता हैअजीब opcode encoding की वजह से कभी-कभी register load भी कम हो जाता है
swar क्या है, पता नहीं
आम तौर पर इसका मतलब एक register में कई items को packed करके explicit SIMD instructions के बिना भी effectively SIMD की तरह इस्तेमाल करने की technique है
उदाहरण के लिए, 64-bit register में 31-bit और 32-bit numbers डालकर carry के लिए 1 bit छोड़ दें, तो एक 64-bit addition से दो additions किए जा सकते हैं
games में RGB(A) values को 32-bit integer में packing करके graphics में ऐसे tricks इस्तेमाल होते रहे हैं, और ScummVM में भी 32-bit value के अंदर 16-bit RGB pixels के 2, यानी कुल 6 components को interpolate करने वाला code है: https://github.com/scummvm/scummvm/blob/master/graphics/scal...
Unicode आने के बाद uppercase और lowercase की concept दलदल बन गई है
इसे ठीक से करने के लिए बहुत data चाहिए
अगर आप ऐसा काम कर रहे हैं जिसमें ASCII
tolowerकी execution speed पर समय पर पूरा होना निर्भर है, तो बेहतर है कि कुछ game बदलें और preconditions बदलेंपहले एक बार image के आसपास black border डालकर SIMD की buffer से आगे पढ़ने की समस्या पूरी तरह avoid की थी
यह बहुत अच्छी तरह काम करता था और speed के मामले में कुछ OpenCV implementations को हरा सकता था, लेकिन input को हमेशा इस तरह पूरी तरह control कर पाना संभव नहीं होता
उत्सुक हूं कि क्या इसे इस तरह try किया गया है। auto-vectorization का result काफी साफ दिखता है
https://godbolt.org/z/1c5joKK5n
tolower1जैसा ही है। graph के नीचे वाले bullets देखें