1 puan yazan GN⁺ 2024-07-30 | 1 yorum | WhatsApp'ta paylaş
  • 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, tolower64 karşı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şturulur
    • c >= 'A' olan konumlar
    • c <= 'Z' olan konumlar
  • İki maske _kand_mask64() ile birleştirilerek yalnızca büyük harf konumlarını işaretleyen is_upper maskesi oluşturulur
  • Son olarak _mm512_mask_add_epi8() uygulanır
    • is_upper false olan baytlarda özgün c korunur
    • is_upper true olan baytlarda değer c + ('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 len bitleri açık olacak şekilde oluşturulur
    • uint64_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 bir memcpy sürümüdür
    • tolower64’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üzeyinde memcpy sü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 standart tolower() çağıran baseline’dır ve çok yavaştır
  • Mor tolower1, Clang 16 ile derlenmiş bayt düzeyinde tolower()’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, tolower64 kadar iyi olmadığından performans grafiği sivri biçimde dalgalanıyor
  • Kahverengi tolower8, önceki yazıdaki SWAR tolower()’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’nin memcpy’sini çağırır
    • Başta hızlıdır, ancak copybytes64 hızının yaklaşık yarısına düştüğü bir aralık vardır
    • Nedeni doğrulanamadı

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

 
GN⁺ 2024-07-30
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

    • Böyle durumlarda assembly olmayan bir seçenek olsaydı iyi olurdu
      “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
    • Donanım düzeyinde bile bunun gerçekten doğru olup olmadığından şüpheliyim
      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ışsa derleyici böyle bir şeyin olmadığını varsayabilir” açıklaması yanlış
      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ı

    • Intel gelecekte çıkardığı tüm CPU’lara AVX10/256’yı gerçekten koyarsa, sonunda yaygınlık nedeniyle kazanacaktır
      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
    • Zen 4’ün AVX512 uygulaması double-pumped değil; teknoloji gazetecileri buna böyle demeyi bırakmalı
      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
    • Intel’in E-core’lara iki geçişli AVX512 koyup bu sorunu neden çözmediğini anlamıyorum. Ya da masaüstü için zaten olması gerektiği gibi yalnızca P-core içeren CPU’lar üretebilir
      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

    • Böyle şeyler de var: Almancadaki ß büyük harfe çevrilince dizgenin uzunluğu değişir
      Örneğin "straße".upper() sonucu 'STRASSE' olur
      Ayrıca locale belirtilmezse Türk dillerindeki noktasız i’yi büyük/küçük harfe gidip getirirken 'ı'.upper().lower() sonucu 'i' olur ve bozulur
    • Neyse ki bu kod DNS işlerinden çıktığı için yalnızca ASCII ve bu karmaşıklıklarla uğraşması gerekmiyor
      ASCII’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
    • İçeride ID gibi kullanılan dizgeler ile insanın girdiği metin farklıdır
      İ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
    • Almanca maßenin MASSEye dönüşmesi örneğiyle ilgili olarak, Almancada büyük harf Eszett olan de var
      Henü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_upper false olduğunda ekleyip true olduğunda olduğu gibi kopyalamak gerekmiyor mu, emin olamadım

    • Ah, to_upper değişken adının ters olduğunu, aslında to_lower denmesi gerektiğini sonradan fark ettim
      Kafa karıştıran yeri işaret ettiğin için teşekkürler; yazıyı ve kodu düzelttim
    • Bu işlem tolower
      Büyük A 0x40, küçük harf ise 0x60 olduğundan 0x20 ekleme is_upper true 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/1

    • AVX-512 ve ARM SVE’deki maskeli SIMD işlemleri tam da bu sorunu çözmek için ortaya çıktı
      Bellek 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ır
  • Maske 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; switch ise 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 vpternlogd ile 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 üretemiyorum
    switch dispatch 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

    • Clang ve GCC’nin intrinsic işleme biçimleri farklı; özellikle AVX-512 komutlarında Clang’in Intel kılavuzundaki belirtilen opcode ve algoritmadan sapma olasılığı GCC’den daha yüksek
      İ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 .S dosyalarını depoda tutmak zorunda kalmıştım
      Makefile 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
    • Analiz çok faydalı
      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
    • Büyük assembly yığınlarına bakınca pek seçilmiyor, ama Clang (x >= 'a' && x <= 'z') ifadesini (x - 'a') < ... biçiminde yeniden yazarak bir komutu azaltıyor
      Garip opcode kodlaması yüzünden register load’un da azaldığı durumlar oluyor
  • swar’ın ne olduğunu bilmiyorum

    • SIMD Within A Register” kısaltması
      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...
    • Yazmaç içindeki SIMD demek
  • 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 olur

  • Eskiden 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

    • Bu temelde tolower1 ile aynı. Grafiğin altındaki bullet’lara bakın