AVX-512 ile uygulanmış tolower() fonksiyonu
(dotat.at)- Bir string’i kopyalarken ASCII büyük harfleri küçük harfe çevirme işini AVX-512-BW ile 64 baytlık bloklar halinde işleyen, küçük string’lerde bile SIMD performansı elde etmeyi deneyen bir çalışma
- Uygulamanın özü, her baytın
'A'ile'Z'arasında olup olmadığını karşılaştırdıktan sonra yalnızca ilgili konumlara'a' - 'A'ekleyen bir maske işlemi yapmak - Kısa string’ler ve uzun string’lerin sonda kalan parçaları masked load/store ile işlenerek, SIMD kodunun sıkça yaşadığı küçük parça işleme maliyeti azaltılıyor
- Clang 16, Debian 11, AMD Ryzen 9 7950X üzerinde yaklaşık 1MiB kopyalama 1 bayt–1KiB arası chunk’larla ölçüldüğünde,
tolower64karşılaştırılanlar arasında sürekli hızlı grupta yer alıyor - Zen 4’te AVX-512-BW’nin string işleme için iyi uyum sağladığı görüldü; ancak ARM SVE ve RISC-V Vector extension doğrudan ayrıntılı olarak doğrulanamadı
AVX-512-BW ile 64 baytlık tolower() yapmak
- Amaç, bir string’i kopyalarken büyük harf ASCII karakterlerini küçük harfe çeviren
tolower()kernel’ini SIMD ile uygulamak - AVX-512-BW, bayt ve word düzeyinde işlemleri destekleyen bir uzantıdır ve yeni AMD Zen işlemcilerde kullanılabilir
- AVX-512 birden çok uzantıya ayrıldığı için destek durumu karmaşıktır
- Intel tarafındaki desteğin özellikle tutarsız olduğu değerlendiriliyor
- ARM SVE de string işleme için uygun bayt düzeyinde masked load/store sağlar
- Amazon Graviton gibi yeni big-ARM Neoverse çekirdeklerinde kullanılabilir
- Apple Silicon’da kullanılamaz
- RISC-V Vector extension da ARM SVE’ye benzer bir stildedir ve çeşitli küçük single-board computer’larda kullanılabilir
tolower64() nasıl çalışıyor
tolower64(), tek seferde 64 bayt işleyen AVX-512 tabanlı bir kernel’dir- Önce 64 bayt içeren vektör register’ına referans değerleri doldurur
'A''Z''a' - 'A'
- Girdi karakter vektörü
c,'A've'Z'ile karşılaştırılarak ayrı ayrı 64 bitlik maskeler oluşturulurc >= 'A'olan konumlarc <= 'Z'olan konumlar
- İki maske
_kand_mask64()ile birleştirilerek yalnızca büyük harf konumlarını işaretleyenis_uppermaskesi oluşturulur - Son olarak
_mm512_mask_add_epi8()uygulanıris_upperfalse olan baytlarda özgünckorunuris_uppertrue olan baytlarda değerc + ('a' - 'A')olur
Uzun ve kısa string’lerin işlenmesi
- Uzun string’lerin büyük kısmı olağan unaligned vektör load/store ile işlenir
_mm512_loadu_epi8()tolower64()_mm512_storeu_epi8()
- Kısa string’ler ve uzun string’lerin sonda kalan son parçaları için masked unaligned load/store kullanılır
- Maske, yalnızca düşük taraftaki
lenbitleri açık olacak şekilde oluşturuluruint64_t len_bits = (~0ULL) >> (64 - len)_cvtu64_mask64(len_bits)ile SIMD maske register’ına alınır
_mm512_maskz_loadu_epi8(), maskenin kapalı olduğu konumlarda hedef register’ı 0 ile doldurur_mm512_mask_storeu_epi8(), yalnızca maskenin açık olduğu konumları kaydeder- Bu yaklaşım, küçük string parçalarını hızlı işlemenin temelidir
Benchmark koşulları ve karşılaştırılanlar
- Benchmark, Clang 16, Debian 11, AMD Ryzen 9 7950X üzerinde çalıştırıldı
- Ölçülen işlem yaklaşık 1MiB kopyalama; chunk uzunluğu 1 bayttan 1KiB’ye kadar değiştirildi
- Kaynak ve hedef string’lerin hizalama farkını yansıtmak için her string arasına birkaç bayt kondu; bu baytlar 1MiB’lik ölçüm miktarına dahil edilmedi
- Ryzen 9 7950X’in L2 cache’i çekirdek başına 1MiB olduğundan, her test çalışmasının L3 cache’e kadar taşacağı varsayıldı
- Her fonksiyon, inlining ve kod taşımanın etkisini önlemek için ayrı derlendi
- Gerçek kodda inlining’i engellemektense teşvik etmek daha olasıdır
Sonuçlar: tolower64 için pürüzsüz performans
- Pembe
tolower64, genel olarak test fonksiyonları arasında sürekli en hızlı gruba yakın- Uzunluk 65 bayt olduğunda ikinci vektöre geçerken hafif bir düşüş var
- Hızlı yükseliyor ve derin performans çukurları yok; bu da masked load/store’un kısa string parçalarını işlemede etkili olduğunu gösteriyor
- Yeşil
copybytes64, AVX-512’yi benzer biçimde kullanan birmemcpysürümüdürtolower64’ten çok daha hızlı değil- Yeni Clang bu fonksiyonun anlamını tanıyıp tamamen yeniden yazdığı için Clang 11 ile derlendi
- Turuncu
copybytes1, bayt düzeyindememcpysürümüdür- Clang 11 ile derlendi
- 256 bayttan küçük string parçalarında Clang 11’in otomatik vektörleştirme heuristics’inin görece iyi olmadığını gösteriyor
- Kırmızı
tolower,<ctype.h>içindeki standarttolower()çağıran baseline’dır ve çok yavaştır - Mor
tolower1, Clang 16 ile derlenmiş bayt düzeyindetolower()’dır- Clang 16’nın otomatik vektörleştirmesi Clang 11’den çok daha iyi hale gelmiş
- Elle yazılan sürümden daha yavaş ve çok daha karmaşık kod üretiyor
- Kısa string parçalarını işleme,
tolower64kadar iyi olmadığından performans grafiği sivri biçimde dalgalanıyor
- Kahverengi
tolower8, önceki yazıdaki SWARtolower()’dır- Clang otomatik vektörleştirme denese de fonksiyon karmaşık olduğu için sonuç iyi değil
- Clang 16 ile derlenmiş olsa da Clang 11 tarzı 256 baytlık performans uçurumu görülüyor
- Mavi
memcpy, glibc’ninmemcpy’sini çağırır- Başta hızlıdır, ancak
copybytes64hızının yaklaşık yarısına düştüğü bir aralık vardır - Nedeni doğrulanamadı
- Başta hızlıdır, ancak
Sonuç ve kod
- AVX-512-BW, string’lerle, özellikle kısa string’lerle çalışmak için çok uygundur
- Zen 4’te çok hızlıdır ve intrinsic fonksiyonların kullanımı da görece kolaydır
- En dikkat çekici özellik pürüzsüz performanstır
- Otomatik vektörleştirmenin küçük string parçalarında skaler koda geçerken yaşadığı performans çukurları neredeyse görünmez
- ARM SVE destekli donanıma veya RISC-V Vector extension donanımına kolay erişim olmadığı için bu iki uzantı ayrıntılı biçimde incelenemedi
- Kod, web sitesindeki git deposunda görülebilir
1 yorum
Hacker News yorumları
“unsafe read beyond of death” hilesi donanımda izinli olsa bile Rust ve LLVM bellek modellerinde tanımsız davranış kabul ediliyor
Diğer tanımsız davranışlarda olduğu gibi derleyici, optimizasyon sırasında “böyle bir şey olmaz” varsayımını yapabildiği için beklenmedik sonuçlar çıkabilir; bunu aşmak için inline assembly kullanmak gerekir
https://github.com/ogxd/gxhash/issues/82
“Ayırma aralığının dışındaki değerleri belirtilmemiş öğeler olarak oku, yalnızca donanım bundan hoşlanmadığında tanımsız davranış olsun” gibi bir load’u desteklemek zor görünmüyor; içeride ilgili assembly çağrısının takma adı olsa bile yeterli olurdu
Daha da ileri gidip
malloc, stack, sabitler vb. tüm ayırmaların ardından en az 64 bayt civarında fault üretmeyen adres garantisi verilse iyi olurdu; ama birçok bileşenin işbirliği gerektiğinden bu çok daha karmaşıkÖzel allocator’larda bu önemsiz bir şey, ancak o durumda özel heap dışındaki verilerde SIMD kodu kullanmak zorlaşır ve çok küçük bir segfault olasılığına bağlı kalırsınız
Sanitizer’lar veya Valgrind de hâlâ işe yarar. Çünkü aralık dışındaki değerler belirsiz değerler olarak izlenebilir ve fiilen kullanıldıklarında hata verebilirler
Eşlenmemiş bir sayfadan ya da korumalı bellekten okursanız ne olacağını merak ediyorum; kodu görmediğim için hizalama garantisinin bunu önleyip önlemediğini bilmiyorum
Tanımsız davranış C standardına ait teknik bir terim, bu yüzden bunu genellemek başlı başına garip; ANSI C böyle bir varsayıma açıkça izin vermez, ISO C ise daha açık olsa da bu varsayımı somut biçimde gerekçelendirmez
“UB = olamaz diye varsayılır” tarzı açıklamaları oldukça dürüst olmayan bir korku tellallığına yakın görüyorum
Yazıdaki temiz ve yüksek performanslı kodu görünce AMD’nin AVX512 uygulaması ile Intel’in planlanan AVX10’unun nasıl rekabet edeceğini merak ediyorum
AVX10’un özü Intel’in P-core/E-core durumunu çözmek gibi görünüyor; AMD ise duruma göre Zen5’in tam genişlikli uygulamasını ya da Zen4 ve Zen5 mobildeki 256 bitlik iki geçişli işleme yöntemini kullanıp API’yi pürüzsüz tutarak daha iyi bir yaklaşım seçmiş gibi
Yazıdaki büyük performans artışlarının hepsi de Zen4 çekirdeklerinden gelen sonuçlar ve AVX512’nin çok avantajı var; bu yüzden Intel’in bunu pazar segmentasyonu için aşırı kısıtlayıp genel istemci kodunda benimsenmesini fiilen engellemesi can sıkıcı
Pazar, CPU’ya göre kod yolu dallandırmasını tekrar tekrar reddetti; pratikte önemli SIMD uygulaması en düşük ortak paydadır
AVX10.1/256 ile AVX512VL’nin ortak bir alt kümesi var; yeterince zaman geçip CPU’ların çoğu bunu destekler hale gelince insanlar o bölümü hedeflemeye başlayacaktır
AMD, AVX512 desteği için güncellenmiş bazı benchmark uygulamalarında kolay galibiyetler almaya devam edecek; ancak Intel AVX10 planını sürdürürse AMD de eninde sonunda AVX512 uyumluluğunu korurken AVX10/256’yı verimli desteklemek için iki geçişli SIMD hattını geniş ölçekte kullanacaktır
Intel son 10 yılda çok kötü seçim yaptı ama komut kümesiyle pazarı bölmesi bunların en kötülerinden biriydi. En yeni yeniliklerin ivmesini ve ilgisini kendi eliyle öldürdü; genişliğin kendisinden çok maske işlemleri gibi özellikler çok daha önemli, bu yüzden tüm ürün gamına AVX10/256 koymasını umuyorum
Bu ifadenin belirli bir anlamı var ve gerçek işleyişle uyuşmuyor
Zen 4, ZMM register işlemlerini birden çok mikroişleme decode edip boş 256 bitlik birimlere zamanlıyor; yalnızca 512 bit tam genişlikli shuffle işlemlerini pahalı emülasyondan kaçınmak için özel donanımla özel olarak ele alıyor
Bu yüzden 4 adet 256 bit SIMD birimine sahip Zen 4 de güçlü bir 2×512 bit çekirdek gibi davranıyor; bu uygulama kesinlikle ucuz bir yöntem değil ve şu ana kadar tüketici donanımındaki en iyi biçim olma ihtimali yüksek
Bunu düzeltmek için zaten yılları vardı; AMD desteklese de pazar payı nedeniyle benimsenmemesi sinir bozucu ve AVX10 ne yazık ki Intel’in dünyayı daha uzun süre yerinde tutmasına yol açacak gibi
Masaüstünde daha iyi çekirdekler, daha çok çekirdek, geniş SIMD, float16, gather/scatter gibi faydalı özellikleri açan iyi standartlaştırılmış bir komut kümesi görmek istiyorum; AMD bunu epey iyi yapıyor
Buna karşılık Intel, iyi çekirdeklerin yanına zayıf çekirdekler koyuyor; zayıf çekirdeklere uydurmak için iyi çekirdekleri kısıtlıyor; birkaç nesildir aynı çekirdek sayısına sahip CPU’lar çıkarıyor; zayıf çekirdeklerle çekirdek sayısı fazla gibi görünmesini sağlıyor; yararlı bir ortak küme oluşmasını zorlaştıracak kadar çok komut varyantı çıkarıyor ve umut vaat ediyor gibi göründükleri komut desteğini de terk ediyor
Masaüstü üreticisi tercihinde 90’larda Intel, 2000’lerin başında AMD, 2000’lerin sonunda ve 2010’larda Intel, şimdi ise yeniden AMD öndeydi. Intel’in rakibine engel olmak dışında yeniden zemin kazanmak için ne yapacağını merak ediyorum; rekabetin sürmesi gerekiyor ki taraflardan biri fazla rehavete kapılmasın
Eğlencesine bakılabilecek iyi bir kaynak: http://www.unicode.org/Public/3.1-Update1/CaseFolding-4.txt
Örneğin
"straße".upper()sonucu'STRASSE'olurAyrıca locale belirtilmezse Türk dillerindeki noktasız i’yi büyük/küçük harfe gidip getirirken
'ı'.upper().lower()sonucu'i'olur ve bozulurASCII’de büyük/küçük harf duyarsız birçok protokol var ve bunlar pek çok sunucunun sıcak yolunda sıkça karşımıza çıkıyor
İlki için genelde 8 bit kodlamada saf ASCII yeterli olur, ama ikincisi karmaşıklaşır
DNS adresleri kolay bir örnek: teknik olarak neredeyse tüm Unicode’u barındırabilirler, fakat gerçek DNS çözümlemesi için çok sınırlı bir ASCII alt kümesine dönüştürülürler ve bu çözümleme süreci büyük/küçük harfe duyarsızdır
Elbette Unicode’un tüm yazı sistemlerini desteklerken tanımlayıcıları büyük/küçük harfe duyarsız olan programlama dilleri gibi şeyler de var. Böyle bir şeyle uğraşıyorsanız başınız sağ olsun
maßeninMASSEye dönüşmesi örneğiyle ilgili olarak, Almancada büyük harf Eszett olan ẞ de varHenüz yaygın dağıtılmış değil ve destekleyen yazı tipi az, ama teorik olarak artık var
Yazıdaki “maske toplaması” açıklaması yanlış değil mi diye düşünüyorum
is_upperfalse olduğunda ekleyip true olduğunda olduğu gibi kopyalamak gerekmiyor mu, emin olamadımto_upperdeğişken adının ters olduğunu, aslındato_lowerdenmesi gerektiğini sonradan fark ettimKafa karıştıran yeri işaret ettiğin için teşekkürler; yazıyı ve kodu düzelttim
tolowerBüyük A
0x40, küçük harf ise0x60olduğundan 0x20 eklemeis_uppertrue olduğunda yapılmalıBu tür SWAR optimizasyonları çoğu zaman yalnızca dizge 8 baytlık adrese hizalıyken işe yarar
Hizalı olmayan dizgelere SWAR algoritması uygulayınca özgün algoritmadan daha yavaş olması sık görülen bir durumdur
Baş kısmı hizalı adrese kadar işlemek, hizalı gövdeyi işlemek ve 8 bayttan küçük kuyruğu işlemek diye 3 aşamaya bölerseniz komut sayısı daha da artar
Go’da
utf8.IsValidin daha hızlı olduğuna dair hatalı iddiaya benzer bir örnek ve benchmark burada: https://github.com/sugawarayuuta/charcoal/pull/1Bellek işlemleri her zaman hizalı ve tam vektör boyutunda yapılır, ama yalnızca geçerli öğelere maske uygulanabilir
Maskeli vektör bellek işlemi hizalı olmasa ve eşlenmemiş ya da korumalı bir sayfanın üzerinden geçse bile, ilgili lane maske ile kapalıysa fault oluşmaz
strlen()gibi uzunluğu önceden bilinmeyen işlemler için, fault verecek ilk öğenin hemen öncesinde vektör uzunluğunu küçülten özel load komutları da vardırMaske toplaması güzel görünüyor. .NET intrinsic’lerinde AVX512’nin maske yazmaçlarını doğrudan işleyebilmek iyi olurdu ama şimdilik “tanınan deyim kalıplarına” güvenmek gerekiyor
GCC’nin ürettiği yazarın çekirdek döngüsünü uiCA(CQA/MAQAO) ile Ice Lake temelinde analiz edince yaklaşık 32B/cycle çıkıyor; 3GHz’e çevrildiğinde, bellek darboğazı olmadığı varsayılırsa neredeyse 96GiB/s ediyor. Elbette bu tür algoritmalarda bellek erişimi her zaman darboğazdır
Yine de optimum kullanıma çok yakın görünmüyor; Clang kullanıldığında daha iyi açılmış unroll sonucu ve daha iyi komut seçimiyle 42.67B/cycle’a kadar çıkıyor. L2 cache’in de böyle bir throughput’u sürdürmesi zor görünüyor, ama orta uzunluktaki dizgelerin büyük/küçük harf dönüşümünün ekran ışığının korneaya ulaşması kadar kısa sürede bitmesi ilginç
Birkaç ay önce C# ile UTF-8 içindeki ASCII büyük/küçük harf dönüşümüne benzer bir şey uygulamıştım: https://github.com/U8String/U8String/blob/main/Sources/U8Str...
Kısa dizgeler çoğu kod tabanına hâkim olduğundan, vektörleştirme uzunluğunun altı için unroll edilmiş dönüşüm önemli;
switchise jump table ve branchless fall-through olarak derleniyorŞu anda yalnızca 256 bit’e kadar kullanıyorum; çünkü Zen 3 veya 4 gibi yalnızca 256×4 SIMD birimleri olan durumlarda zaten doygunluğa ulaşıyor. C sürümüyle yan yana karşılaştırılmış bir örnek burada: https://godbolt.org/z/eTGYhTPan
AVX512’de
vpternlogdile 3 komutluk dönüşüm de mümkün olabilir gibi; AVX512 donanımı kullanabildiğim dönemde .NET bunu 256 bit genişlik + AVX512VL ile öyle optimize ediyordu, ama şimdi tuhaf biçimde 512 bit genişlikte yeniden üretemiyorumswitchdispatch tarafındaki başarısız SWAR denemesini de göreceksiniz; yazının lisansını merak ediyorum. Test paketinden geçerse alıp kullanmak isterimİki derleyicinin yapısı düşünülünce anlaşılır, ama sonuç bazen iyileşme bazen de kayıp olabiliyor
Birkaç yıl önce ikisiyle de derlenmesi gereken, yoğun biçimde vektörleştirilmiş bir projede çalışırken, C referans sürümünün yanında belirli hedefler için inline assembly ve
.Sdosyalarını depoda tutmak zorunda kalmıştımMakefile kirlenmişti ve test paketine benchmark’lar da koymak gerektiği için bakım yükü büyüktü; bu yüzden intrinsic’leri otomatik vektörleştirmeden daha iyi bir düşük seviye araç olarak kullanma konusunda çok dikkatli olmak gerektiği sonucuna vardım
Örnek: https://godbolt.org/z/T4Pjhrz5d adresinde GCC çıktısı beklediğim gibiydi, ama Clang çıktısı şaşırtıcıydı ve gerçekten daha yavaştı. Döngü içinde çalıştırınca uiCA’ya göre GCC’nin 4 cycle’ına karşı 7 cycle; brute-force algoritmasında bu fonksiyonun milyarlarca kez çalıştığı gerçek uygulama benchmark’larında da ortaya çıkmıştı
LLVM kod tabanına baktığımda, Clang 16’nın dahili refactor nedeniyle bazı maskeli AVX-512 komutlarını hiç üretemeyebileceği yönünde bir sorun gördüğümü de hatırlıyorum
Mümkün olan en yüksek performansı hedeflememiştim; başta sadece çalışıp çalışmayacağına bakmak istemiştim, ilk denemenin oldukça iyi çıkması ise bonus oldu
Asıl ilgi alanım vektör yazmacından kısa dizgeler ve throughput grafiğindeki çukurları ortadan kaldırmak
Blog yazısının sonundaki kod bağlantısını izlerseniz lisans bilgisi var; BIND için özgün olarak yazılmış MPL-2.0 kısmı hariç 0BSD veya MIT-0
(x >= 'a' && x <= 'z')ifadesini(x - 'a') < ...biçiminde yeniden yazarak bir komutu azaltıyorGarip opcode kodlaması yüzünden register load’un da azaldığı durumlar oluyor
swar’ın ne olduğunu bilmiyorum
Genellikle tek bir yazmaca birden çok öğeyi packed biçimde koyup, açık SIMD komutları olmadan fiilen SIMD gibi kullanma tekniğini ifade eder
Örneğin 64 bitlik bir yazmaca 31 bitlik ve 32 bitlik sayıları koyup carry için 1 bit bırakırsanız, tek bir 64 bit toplamayla iki toplama yapabilirsiniz
Oyunlarda RGB(A) değerlerini 32 bit tamsayıya packing ederek grafiklerde bu tür numaralar kullanılageldi; ScummVM’de de 32 bitlik değer içindeki iki adet 16 bit RGB pikseli, toplam 6 bileşeni enterpole eden kod var: https://github.com/scummvm/scummvm/blob/master/graphics/scal...
Unicode ortaya çıktıktan sonra büyük harf ve küçük harf kavramı bataklığa dönüştü
Doğru yapmak için çok veri gerekiyor
ASCII
tolowerçalıştırma hızına zamanında bitip bitmeyeceği bağlı bir iş yapıyorsanız, oyunun kurallarını ve önkoşulları değiştirmek daha iyi olurEskiden bir görüntünün etrafına siyah kenarlık ekleyerek SIMD’nin buffer ötesi okuma sorununu tamamen önlemiştim
Çok iyi çalışmıştı ve hız açısından bazı OpenCV uygulamalarını geçebilmiştim; ama girdiyi her zaman bu kadar tamamen kontrol edemezsiniz
Bu şekilde deneyip denemediğini merak ediyorum. Otomatik vektörleştirme sonucu epey temiz görünüyor
https://godbolt.org/z/1c5joKK5n
tolower1ile aynı. Grafiğin altındaki bullet’lara bakın