C’den {n} kat daha hızlı
(owen.cafe)- Küçük bir C döngüsünde bile derleyici çıktısı her zaman en iyisi olmadığından, x86_64 assembly elle ayarlandığında koşullu dallanmayı kaldıran sürüm clang çıktısından 6,73 kat daha hızlı oldu
- Hedef fonksiyon, bir dizgede
's'için +1,'p'için -1,'\0'için sonlandırma uygular; clang 16 çıktısı bu akışı 3 koşullu dallanmaya böler - Dallanma sırasını değiştirme, temel blokları yeniden yerleştirme ve atlamaları aritmetikle değiştirme sonucunda çalışma süresi 3,23 saniyeden 2,87 saniyeye düştü; bu aşamada GCC 12 ile aynı hıza ulaştı
- En hızlı sürüm,
cmoveile her karakter için eklenecek değeri 0, 1, -1 arasından seçip her zamanaddçalıştırarak 0,48 saniye ve 1,94GiB/s işleme hızı kaydetti - Benchmark, AMD Ryzen 5 5625U ve Linux 6.1.33 üzerinde 1 milyon rastgele
'p'/'s'karakterinden oluşan listeyi 1000 kez işledi; birden fazla çalıştırma içindeki en iyi sonuç kullanıldı
Deney konusu fonksiyon ve derleyici çıktısı
- Hedef fonksiyon, dizge işaretçisini teker teker artırırken karaktere göre
restamsayısını günceller's':res += 1'p':res -= 1'\0':resdöndürülür- Diğer karakterler: değişiklik yok
- Fonksiyon küçük olduğu için gcc veya clang’in bunu oldukça iyi, hatta belki de en iyi şekilde optimize edebileceği beklentisiyle yola çıkıldı
- clang’in ürettiği ilk assembly, dört durumu üç koşullu dallanmaya (
je,je,jne) ayırıyorres = 0ile başlıyor- Karakteri okuyup önce
'\0'olup olmadığını kontrol ediyor - Ardından
'p've's'ile karşılaştırıyor
- İlk clang sonucu
- Çalışma süresi: 3,23 saniye
- İşleme hızı: 295,26MiB/s
- GCC biraz daha fazla kod üretti ancak az da olsa daha hızlıydı
Nadir sonlandırma koşulundan önce yaygın karakterleri kontrol etmek
- Döngü yalnızca null sonlandırma karakteri
'\0'ile karşılaştığında biter ve bu fonksiyonda null sonlandırma karakteri en fazla bir kez görünür - clang çıktısı önce
'\0'kontrolü yaptığı için her'p've's'karakterinde önce sonlandırma koşulunu kontrol eden bir yapı oluşur - İlk manuel değişiklik, karşılaştırma sırasını değiştirip önce
'p've's'karakterlerini kontrol etmek oldu - Sonuç
- Çalışma süresi: 3,10 saniye
- Hız artışı: 1,04 kat
- İşleme hızı: 307,64MiB/s
Temel blokları yeniden yerleştirme ve atlamaları azaltma
- Yaygın iki durum olan
'p've's'ikisi de döngü başına geri atladığından, bloklardan biri döngünün üstüne yerleştirilerek dallanma azaltılabilir 's'bloğu döngünün hemen önüne konursa,'s'işlendikten sonra ayrı bir atlama olmadan akış döngüye düşer- Buna karşılık fonksiyon başlangıcında
's'bloğunu atlamak için döngüye bir kez atlamak gerekir- Fonksiyon başlangıcındaki atlama yalnızca bir kez gerçekleşir
's'karakteri birçok kez karşılaşılabileceğinden bu kabul edilebilir bir takas olarak ele alındı
- Sonuç
- Çalışma süresi: 2,98 saniye
- Toplam hız artışı: 1,08 kat
- İşleme hızı: 320,02MiB/s
Aritmetik ile bir koşulsuz atlamayı kaldırmak
p:bloğundan döngüye dönen koşulsuzjmpkomutunu kaldırmak için aritmetik kullanıldı- Bir kez azaltma,
sub eax, 2ardındaninc eaxile aynı etkiyi verebildiğinden,'p'işlendiğinde akışın's'bloğuna düşmesi sağlandı - Bu yöntemle bir dallanma komutu daha kaldırıldı
- Sonuç
- Çalışma süresi: 2,87 saniye
- Toplam hız artışı: 1,12 kat
- İşleme hızı: 332,29MiB/s
- Bu noktadaki performans, GCC 12’nin ürettiği kodla aynıydı
- GCC 12 kodu da 2,87 saniyede çalıştı
- Elle yazılan sürüm 13 komuttan oluşuyordu
- GCC çıktısı 19 komuttu
- GCC kodu döngüyü unroll etmiş ve case bloklarını bir ölçüde yeniden kullanmış gibi görünüyor
Koşullu dallanmayı cmove ile değiştirmek
- Darboğaz koşullu dallanmalarsa, dallanma tahminleyicisine güvenmek yerine koşullu dallanmanın kendisi kaldırılabilir
- En hızlı sürüm
cmove, yani eşitlik koşullu taşıma kullandı - Çalışma kuralı basit
- Varsayılan değer 0
- Geçerli karakter
's'ise 1 - Geçerli karakter
'p'ise -1 - Her yinelemede seçilen değer her zaman
resdeğerine eklenir
- Bu yöntem kontrol akışı grafiğindeki birçok oku kaldırır
- Sonuç
- Çalışma süresi: 0,48 saniye
- Toplam hız artışı: 6,73 kat
- İşleme hızı: 1,94GiB/s
- Elle yazılmış yoğun bir C döngüsü assembly’sinde, derleyicinin otomatikleştirmediği optimizasyonla 6 katın üzerinde hız artışı mümkün oldu
Register tasarrufu denemesi ve başarısız ek deneyler
- x86_64’teki
setekullanılarak 1 baytlık register’ı koşula bağlı olarak 0 veya 1’e ayarlayan bir sürüm de denendi - Bu sürüm
r8dkullanımını kaldırıyor, ancak yalnızcacmovkullanan sürümden daha yavaştı - Sonuç
- Çalışma süresi: 0,51 saniye
- Toplam hız artışı: 6,33 kat
- İşleme hızı: 1,83GiB/s
- Daha az register kullanmak veya 32 bit işlemler yerine 8 bit işlemler kullanmak daha hızlı hale getirmedi
- Ek denemeler de performansı düşürdü
- En iyi sürümde döngü unroll: yavaşladı
- Döngü başlangıcını 16 bayt sınırına hizalama: yavaşladı
- GNU assembler’da etiketin önüne
.align <bytes>koymaknopekleyebilir
Benchmark ortamı ve kod
- Kod listesi GitHub üzerinde bulunuyor
- Benchmark ortamı
- OS: Linux 6.1.33
- CPU: AMD Ryzen 5 5625U with Radeon Graphics
- CPU family 25, 6 çekirdek, çekirdek başına 2 thread, 1 soket
- clang: 16.0.1
- gcc: 12.2.0
- C sürümü
-march=nativeile derlenerek belirli CPU’ya uygun kod üretmesi sağlandı - Benchmark, rastgele
'p've's'karakterlerinden oluşan 1 milyon karakterlik bir listeyi hedef alıyor- Her fonksiyon sürümü bu listeyi 1000 kez işler
- Her sürüm birden fazla kez çalıştırıldı ve en iyi sonuç seçildi
- Devam yazısı olarak part two bağlantılı
1 yorum
Hacker News görüşleri
Doğru sonuç elle yazılmış assembly C'den 6 kat daha hızlıdır değil, daha çok dallanma koşullu aritmetikten çok daha yavaş olabilir olmalı
C'de de
switchkullanmayıp bir ikiifile işlenirse aynı etki kolayca elde edilebilir. C fonksiyonusise artır,pise azalt,\0ise bitir şeklinde değiştirildiğinde 5.5 kat hızlandı; örnek çalıştırmada da 3.58 saniyeden 0.65 saniyeye düştüBaşkalarının da dediği gibi, girdi düzenlendikten sonra algoritma vektörize de edilebilir. Bunu eğitsel bir alıştırma olarak gördüm ve gerçekten iyi bir gerekçe olmadan assembly seviyesine inilmemesini içtenlikle umuyorum
Linus da zamanında öngörülebilir dallanmalarda
cmovun faydalı olmadığını uzun uzun yazmıştı: https://yarchive.net/comp/linux/cmov.htmlgcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0ileloneveltwoikisi de yaklaşık 3.58 saniyeydiswitchi birkaçifile değiştirmenin her zaman daha hızlı olup olmadığını merak ediyorum. Hangi durum sayısından sonraswitchin daha hızlı olduğunu da merak ediyorum ve eğer tutarlıysa bunun derleyici optimizasyonuna girmesi gerekir gibi görünüyorBence özgün kod derleyici dostu yazılmamış.
result += *s == 's'; result -= *s == 'p';gibi yazılırsa derleyici uygun dallanmayansete/cmovkodu üretir ve yazıdaki optimize assembly ile neredeyse aynı hıza ulaşırYine de döngü açma veya vektörizasyon yapmaz. Dize boyutu ayrı verilerek
sizebiliniyorsa derleyici döngü boyutunu bildiği için açma yapar ve mümkünse AVX-512 komutlarını da kullanır. Büyük girdilerde çok daha hızlıdır ama kendim benchmark yapmakla uğraşmak istemiyorum. Dize uzunluğunu takip etmeyen C programcıları canı ne isterse yapsın ama bence gerçekten yapmamalılar: https://godbolt.org/z/rde51zMd8O sürüm 3.88GiB/s değerine ulaşıyor. Bilerek vektörizasyona kadar gitmedim; problemin kapsamını küçük tutup yazıdaki assembly ipuçları ve hilelerini göstermek istedim. Sonradan girdi dizesini padding yapıp algoritmayı vektörize etmeye dair bir yazı için alan var
/* DON’T REFACTOR THIS FOR READABILITY IT WILL SLOW DOWN */{.overflowChecks:off.}açılıyor veinputüzerinde dolaşırken's' == cise artırılıp'p' == cise azaltılıyorApple M1'de yaklaşık 5 kat hızlanma görüldü; taşma kontrolleri açıkken ise temel C sürümüne göre yalnızca yaklaşık 2 kat daha hızlıydı. SIMD optimizasyonunu tetikleyen iyi desenleri bilmek her zaman faydalıdır
Optimizasyon uzmanına yakın bir bakış açısından ben bu problemi tamamen farklı çözerdim. Benim makinemde ilk C sürümü saniyede 389MB idi ve yazıdaki assembly aynı 6.2 kat artışı veriyorsa bu saniyede yaklaşık 2.4GB eder
Uzun tamponlarda bu C++ sürümü benim makinemde saniyede 24GB'ı aşıyor: https://gist.github.com/Const-me/3ade77faad47f0fbb0538965ae7...
Assembly olmadan, AVX2 intrinsic tabanlı olarak özgün sürüme göre 61 kat hızlanıyor
ymmyazmaçlarında tutmak yerinemovemaskvepopcntile prologu vektörize etmek mümkün olabilir gibi görünüyorHenüz test edilmemiş bir kod, yani benchmark gerekir; ama
s,p,\0maskeleri oluşturup dizenin sonuna kadarki bitleritzcntvebzhiile sayma yaklaşımı mümkün görünüyorstd::experimental::simdile de yapılıp yapılamayacağını bilmek isterim: https://en.cppreference.com/w/cpp/experimental/simdBu kod SIMD için gerçekten çok uygun görünüyor. Prototip açık uzunluk alacak şekilde değiştirilebilirse 16 baytlık bloklar halinde okumak ve işlemek kolay olur
Karşılaştırma sonuçları doğrudan toplanıp çıkarılabilir; fonksiyon başında
strlen()çağırıp açık uzunluğu almak bile muhtemelen buna değerHızlıca bir RISC-V vektörize uygulaması yaptım.
rvvile dize okunuyor,\0konumu bulunuyor, ardındansvepsayılarıvcpopile sayılıyorMangopi MQ Pro'da (C906,
rv64gc+rvv 0.7.1, 128 bit vektör uzunluğu)switch0.19 Bytes/Cycle, tablo tabanlı C uygulaması 0.17 Bytes/Cycle,rvvise 1.57 Bytes/Cycle verdi ve yaklaşık 30KiB sonrasında 1.35'e düştü. İşaretçiyi sayfa hizalı yapıpvldeğerini sayfa boyutundan büyük olmayacak şekilde tutarsanız 2/1.7 Bytes/Cycle da mümkün oluyorrvvbunu destekliyor; aksi halde null baytı ayrılmış belleğin sonundan hemen önceyse başarısız olabilirBu, x86 mimarisine özgü bir özellik gibi görünüyor. Dallanma yapmamanın maliyeti o kadar ucuz ki, dallanma görece pahalıymış gibi görünüyor: https://wordsandbuttons.online/challenge_your_performance_in...
Ama diğer işlemcilerde durum böyle olmayabilir: https://wordsandbuttons.online/using_logical_operators_for_l...
Daha büyük soru, genel olarak neden C'ye ihtiyaç duyulduğu. Eğer belirli bir donanımda en iyi çalışacak şekilde elle ayarlama yapacaksanız, C yanlış araçtır; assembly ve iyi bir makro sistemi gerekir. C'nin asıl hedefi, sistem seviyesindeki kodu bir platformdan diğerine taşımayı kolaylaştırmaktı ve bu süreçte verimlilik kaybı beklenen bir şeydi. Bu, Hintçe bir şiiri Urduca'ya çevirmek yerine Esperanto ile yazıp istenen dillere otomatik çeviri yapmak gibi. Ortaya iki harika şiir çıkmaz, ama iki düşük kaliteli çeviriyi hızlıca elde edersiniz; C'nin rolü de budur
FDO/PGO ile derlerseniz, dallanma ve blok yeniden düzenleme kesinlikle yapılabilir. FDO olmadan derleyici her dalın ne kadar sık seçileceğini bilemez. Bazı durumlarda FDO,
cmov'u da etkinleştirebilirYine de
cmov'un sıradantest/jump'tan daha etkili olup olmadığı büyük ölçüde dallanmanın ne kadar öngörülebilir olduğuna bağlıdır ve genelde dallanma çok öngörülemez olduğundacmovdaha iyi çalışır.cmovile 6 kat hızlandıysa, test girdisinin neredeyse tamamensvep'den oluşan rastgele dizeler olduğunu tahmin ediyorum. Bu yanlış değil ama yazı, verinin açıkça belirtilmemiş özelliklerini benchmark'a özel olarak kullanmış olduğu için biraz yanıltıcı olabilirRastgele
's'veya'p'seçiliyor ve karakter olarak's','p've sonlandırıcı null dışında başka bir şey gelemiyor. Bu girdi özelliğini biliyorsanız,result += (1 | *s++) - 'r';gibi aşırı kurnaz bir optimizasyon bile mümkün. Fazla akıllı bir kod ama verinin özelliklerinden yararlanma fikrini mükemmel biçimde gösteriyor'\0', fonksiyon geri döndüğü için en fazla bir kez görülebilir, ama diğer karakterler birden çok kez görülebilir. Bu bilgi, PGO olmadan da derleyicinin erişebileceği bir bilgi gibi görünüyorElbette PGO yardımcı oluyor ve benim bilgisayarımda 2.80 saniye verdi; bu da
Rearranging blocksbölümünün sonundaki koddn daha iyiydi. Girdi,Benchmarking setupbölümünde açıklanmış ve depoda da bulunuyor: https://github.com/414owen/blog-code/blob/master/01-six-time...Yazının sonunda bağlantısı verilen 2. bölümde, C kodunu olabildiğince hızlandırıp bu yazıdaki tüm assembly'leri geçiyor. Assembly yazmanın mutlaka iyi bir fikir olduğunu hiç söylemedim; optimizasyonu ve derleyici çıktısını çözümlemeyi ilginç bir meydan okuma ve iyi bir öğrenme fırsatı olarak görüyorum
Bunu, yazıdan ve devam yazısından daha hızlı yapmış gibi görünüyorum. Ama bunun bedeli, yalnızca
's've'p''den oluşan dizelere özel hale gelmiş olmasıBenchmark da sadece
's've'p''den oluşan dizeleri test ettiği için bunu adil buluyorum. Ana fikir şu: sonraki karaktersolduğundares'i 1 artırmak istiyoruz, amares += c - 'r'ifadesisiçin 1 ikenpiçin -2 oluyor ve başarısız oluyor. Ancak'p' - 'r'işaretsiz tamsayı olarak ele alınırsa underflow oluşur ve carry flag ayarlanır; x64'teadc, iki register'ı ve carry flag'i birlikte toplar. Böylece ikicmp, cmovbirsub, adcile değiştirilebilir. Bu sürüm, devam yazısındaki C sürümünden 1.08 kat, mevcut x64-7'den ise 1.66 kat daha hızlıydı. Elbette SWAR/SIMD ile daha da iyileştirilebilir02-the-same-speed-as-c/loop-5.x64.siçindeki nispeten basit assembly'nin elimdeki en hızlı sürüm olduğunu açıkça belirtmem gerekirdiBenim bilgisayarımda
loop-5.x64.s0.244 saniye, yukarıdaki uygulama ise 0.422 saniye veriyor. Bu farkın nedenini tam bilmiyorum; görünüşte yukarıdaki uygulama daha hızlı olmalı. O yüzden her zaman gerçekten çalıştıracağınız donanım üzerinde benchmark yapmak gerekir'p' * lençıkartabilir ve('s' - 'p')ile bölerekssayısını bulabilirsiniz.psayısı dalen - s_countolurİlk toplama işlemi de kolayca vektörleştirilebilir. Hata yapmadıysam çalışır; tek sorun biriken toplamın overflow ihtimali. Bunu bizzat benchmark etme motivasyonum yok. Düzeltme:
sgörüldüğünde azalan kısmı atlamışım, dolayısıyla nihai sonuçp_count - s_countstrlen()muhtemelen oldukça hızlı uygulanmıştır ve tampon boyutunu biliyorsanız derleyici iç döngüyü otomatik vektörleştirebilirNitekim
len = strlen(buf)sonrasındafordöngüsünde(buf[i] == 's') - (buf[i] == 'p')toplamak otomatik vektörleştiriliyor: https://gcc.godbolt.org/z/qYfadPYoqBir zamanlar SBCL hedefleyerek Common Lisp UTF-8 decoder yazmıştım. Zaten yerleşik decoder vardı; bu daha çok egzersiz içindi
Bariz kolay optimizasyonlar dışında, neredeyse tüm performans artışı derleyicinin dallanma yerine
cmov*komutları üretmesini sağlayacak şekilde kodu yapılandırmaktan geldiUTF-8 decoder'lar genelde tamamen ASCII olan girdilerde çok çalıştırılır. Hangi girdilerle benchmark yaptığınızı merak ediyorum