- AArch64 assembly’de bir döngüdeki dallanmalardan birini azaltmaya yönelik optimizasyon, aksine 4 kat yavaşladı; nedenin
bl/ret çağrı-dönüş çiftinin asimetrik kullanılarak dal tahmincisinin şaşırtılması olduğu ortaya çıktı
- Orijinal kod her döngüde
bl foo ve b loop ile iki kez dallanırken, değiştirilmiş kod tek bir bl loop ile x30’u sabitleyip birden çok ret’in aynı adrese dönmesini sağlayarak dallanma sayısını azaltmaya çalıştı
- M1 Max’te 1024 elemanlı bir
float dizisini toplama sonucunda, normal bl/ret kodu 969ns sürerken tek bir bl ve birden çok ret yapısı 3.85µs’ye yavaşladı; ret, br x30 ile değiştirildiğinde süre 913ns’ye toparlandı
- Instruments performans sayaçları, 1 milyar elemanın toplanmasında asimetrik
bl/ret yapısının dönüş dallanması tahmin hatasının yaklaşık %93 olduğunu gösterdi; FETCH_RESTART, MAP_DISPATCH_BUBBLE, MAP_REWIND de büyük ölçüde arttı
- Daha hızlı uygulamalar
foo’yu inline etmeye, Rust iter().sum() kullanımına, NEON SIMD ve manuel döngü unrolling’e doğru ilerliyor; nihai SIMD sürümü 94ns’ye kadar inse de kayan nokta toplama sırası değiştiği için sonuç farklı olabilir
AArch64 döngüsünde ortaya çıkan ters etki
- Örnek fonksiyon, bir
float dizisi üzerinde dolaşıp her değeri foo’ya ileten ve foo’nun birikimli değer g’yi güncellediği bir yapıya sahip
- Basit AArch64 dönüşümü şu akışa sahiptir
- Döngü başında
n == 0 olup olmadığını kontrol eder
ldr s1, [x0], #4 ile değeri okur
bl foo ile alt rutini çağırır
foo içindeki ret, bl sonrasındaki komuta döner
b loop ile tekrar döngü başlangıcına gider
foo, üst fonksiyonla aynı stack frame’i ve register’ları kullanan, naked function’a yakın bir biçimde s1’i okuyup s0’da biriktirir
bl ve ret’in rolleri
bl, branch and link komutudur; belirtilen etikete atlarken bir sonraki komutun adresini link register lr ya da x30’a kaydeder
ret, link register’da bulunan adrese atlar
- Orijinal yapıda
bl foo ile ret birbiriyle eşleşir ve ret her zaman ilgili bl’den sonraki komuta döner
Başarısız “bir dallanma azaltma” optimizasyonu
- Değiştirilmiş yapı,
foo’yu değiştirmeden döngü içindeki bir dallanmayı azaltmaya çalışır
- Fonksiyon başında
bl loop çağrılarak x30’a loop başlangıç adresi konur
- Döngü bitiş koşulu kontrol edildikten sonra ayrı bir dallanma olmadan
foo koduna düşülür
foo içindeki ret, x30’da bulunan loop’a döner
- Bu yapıda döngü gövdesinde
x30 değişmediğinden, tekrarlanan ret her zaman aynı adrese döner
- Basit
foo, aşağıdaki gibi float toplayan tek satırlık bir koddur
foo:
fadd s0, s0, s1
ret
- Bu durumda tüm fonksiyon, girdi
float dizisinin toplamını hesaplar
Benchmark sonuçları ve dal tahmini sorunu
criterion ile M1 Max CPU üzerinde 1024 elemanlı dizi benchmark edildi
- Normal
bl/ret: 969ns
- Tek bir
bl, birden çok ret: 3.85µs
- Bir dallanmayı azaltan kod, iki dallanma kullanan orijinal koda göre yaklaşık 4 kat daha yavaş
- Cliff ve Dan,
bl/ret çiftinin eşleşmemesi nedeniyle dal tahmincisinin karıştığını düşündü
- ARM belgelerine göre
RET, işlemcinin fonksiyon dönüşünü tanımasını sağlayarak daha doğru dal tahminini mümkün kılar
BR LR da işlevsel olarak aynı işi yapabilir
- Ancak
RET, işlemcinin fonksiyon dönüşü olarak tanıyabileceği ayrı bir komuttur
- Dal tahmini doğruysa pipeline’a doğru komutlar girer ve bellekten komut bekleme süresi önlenebilir
Dönüş adresi stack’i ve br x30 deneyi
- Dal tahmincisinin içeride fonksiyon dönüş adresi stack’i tutuyor olması muhtemeldir
bl çalıştırıldığında dönüş adresini stack’e push eder
ret gördüğünde en son bl’in dönüş adresine dönüleceğini varsayar
- Bu adrese göre önceden getirme ve spekülatif yürütmeyi başlatıp ardından stack’ten pop eder
- Bu yöntem,
bl/ret eşleşen çiftler olduğunda iyi çalışır
- Aynı adres birden çok
ret tarafından tekrar tekrar kullanılırsa tahmin başarısız olur; işe yaramaz önceden getirme, hatalı spekülatif yürütme, pipeline durması veya flush oluşabilir
- Dan’in önerisiyle
ret, br x30 olarak değiştirildiğinde performans düşüşü ortadan kalktı
- Normal
bl/ret: 969ns
- Tek bir
bl, birden çok ret: 3.85µs
- Tek bir
bl, birden çok br x30: 913ns
br x30 sürümü her döngüde yalnızca bir dallanma yaptığı için orijinal koddan biraz daha hızlıdır
Instruments performans sayaçları
- İlk iki programın performans sayaçları Instruments ile kontrol edildi
- Ölçüm, 1 milyar elemanlı dizinin toplanması sırasında yapıldı
- Asimetrik
bl/ret’te dönüş dallanması tahmin hatası yaklaşık %93 oranında gerçekleşti
| Sayaç |
Normal bl/ret |
Tek bir bl, birden çok 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 bu sayaçları tamamen belgelemiyor
- Diğer sayaçların kötü dal tahmininin downstream etkileri olduğu tahmin ediliyor
FETCH_RESTART: muhtemelen hatalı önceden getirme
MAP_DISPATCH_BUBBLE: pipeline durması ile ilişkili olabilir
MAP_REWIND: geri sarılması gereken hatalı spekülatif yürütme olabilir
Daha hızlı hale getirmenin yolları
- Örnek eğitim amaçlı koddur;
foo’nun alt rutin olmasının nedeni de “mümkün olan en hızlı kod” değil, daha çok açıklama amaçlı yapıdır
foo içeriği derleme zamanında biliniyorsa ve maksimum atlama mesafesinden kısaysa bl ve ret tamamen kaldırılıp inline edilebilir
- 969ns’den 911ns’ye, yaklaşık %6 hızlanır
- Rust’ta basitçe
f.iter().sum() kullanılırsa 833ns’ye kadar iner
pub fn sum_slice(f: &[f32]) -> f32 {
f.iter().sum()
}
SIMD ve manuel döngü unrolling
- Manuel AArch64 SIMD uygulaması üç döngüden oluşur
loop: Kalan değer sayısı 4’ün katı olana kadar tekil değerleri s0’a ekler
simd: 4 değeri tek seferde v1 vektör register’ına ekler ve kalan değer sayısı 8’in katı olana kadar tekrar eder
simd2: simd’i 2 kat unroll ederek her iterasyonda 8 değeri işler ve v1 ile v2’de toplar
- Fonksiyon sonunda
v1 ve v2 değerleri s0’a eklenip döndürülür
- Type punning,
float* olan x0’ı double* gibi ele alarak 128 bit, yani 4 adet float’ı d3 ve d4 olarak okuma yöntemidir
mov v3.d[1], v4.d[0] ile d4’ün 64 bit’i v3’ün üst 64 bit’ine taşınır
fadd v1.4s, v1.4s, v3.4s içinde .4s sonekiyle dört adet float gibi işlenir
- Bu SIMD uygulaması 94ns’de çalışır ve önceki en iyi Rust sürümü olan 833ns’den yaklaşık 8,8 kat daha hızlıdır
Genel performans özeti ve dikkat edilmesi gerekenler
| Uygulama |
Süre |
Normal bl/ret |
969ns |
Tek bir bl, birden çok ret |
3.85µs |
Tek bir bl, birden çok br x30 |
913ns |
b kullanan sıradan döngü |
911ns |
| Rust ile yeniden yazım |
833ns |
| SIMD + manuel döngü unrolling |
94ns |
- SIMD kodu kayan nokta toplama sırasını değiştirir
- Kayan nokta toplamada birleşme özelliği geçerli olmadığından SIMD sürümü düz kodla aynı sonucu üretmeyebilir
- Derleyicinin toplam için SIMD komutları üretmemesinin nedeni de muhtemelen budur
- Tüm kod GitHub’da açık olarak yayımlanmıştır
- ARM64 makinede
cargo bench çalıştırılarak benchmark yeniden üretilebilir
1 yorum
Hacker News görüşleri
Son optimize edilmiş kod, 1024 elemanlı 32 bit kayan noktalı sayı dizisinin toplamını 94 ns’de bitiriyor.
O 94 ns içinde eski dostumuz 1 MHz 6502, programın ilk komutunun ilk baytını getirmesi için bellek çipine sinyal gönderip göndermemeyi daha yeni düşünmeye başlamış olurdu.
Ancak bu kod tamamen önbellek içinde çalıştığı varsayımına dayanıyor. Aksi halde yazıda geçen güçlü M1 Max bile ilk bellek getirmesini beklerken durmuş olurdu. DRAM yavaş.
Raymond Chen neredeyse 20 yıl önce aynı konuyu ele almıştı: https://devblogs.microsoft.com/oldnewthing/20041216-00/?p=36...
Silisyumun yarı iletken özellikleri ve katkılama konusunda temel bir anlayış dışında, burada sezgisel olan pek bir şey yok. Kalıp devre şemasını, kablolamayı ve yolları görmediyseniz, mühendisler ve veri sayfaları açıkça söylemedikçe A’nın B’den hızlı olmasını beklemek için pek neden yok. Özellikle ARM için bunun daha da geçerli olduğunu düşünüyorum.
Bu yazının eklediği şey,
reti başka birbrkomutuyla değiştiren basit bir düzeltme. Böylece çift yeniden “simetrik” hâle geliyor ve dal tahmincisini bozmadan biraz daha hızlı kod elde edilebiliyor.Elbette her şey mümkün ve bir dizinin toplamını alan sıradan bir döngü, gerçekten de bilgisayara elemanları tek tek biriktirmesini söyleyen bir biçimde yazılır.
Ama örneğin SIMD ile dört birikim değerini paralel oluşturup sonunda toplamanın, elemanları tek tek toplamaktan daha yanlış olduğunu söylemek zor.
Kayan noktalı toplama işlemlerinde temelde bir hata aralığı olduğu varsayılmalı ve bu aralıktaki cevaplar geçerli olmalı. Girdi kayan noktalı sayıları hakkında özel bir bilginiz varsa, dil bu niyeti açıkça ifade edecek bir araç sunmalı. En temel döngü varsayılan olduğuna göre, varsayılan olarak en iyi performansı vermesi gerektiğini düşünüyorum.
Döngüde tek tek toplamak bariz yöntem, ancak birikimli hatanın toplamı için daha iyi sınırlar veren daha sofistike yöntemler var; Kahan toplamı iyi bilinen bir örnek: https://en.wikipedia.org/wiki/Kahan_summation_algorithm
Akış verisiyse tek tek toplamaktan başka seçeneğiniz olmayabilir; ama sabit boyutlu N adet arabellek kullanabiliyorsanız, yeni bir sayı geldiğinde hangi alt kümeyi kısmi toplam yapacağınız, bunu birikimli toplama nasıl ekleyeceğiniz ve bu seçimin kanıtlanabilir bir hata iyileştirmesi olup olmadığı gibi sorular ortaya çıkar.
Örneğin
[1e50, -1e50, 1e3, 1e3]değerlerini(((1e50 + -1e50) + 1e3) + 1e3)şeklinde hesaplarsanız2e3çıkar;((1e50 + 1e3) + (-1e50 + 1e3))şeklinde hesaplarsanız0olur.Küçük değerlerin çoğunu tek bir büyük değere eklerken de benzer bir durum vardır;
(((1e3 + 1e3) + 1e3) ... + 1e50)ile(((1e50 + 1e3) + 1e3) ... + 1e3)oldukça farklıdır.Derlemeler arasında aritmetik sonuçlar kararlı değilse bu tam bir kâbusa dönüşür. Aynı girdilerle yazılımı yeniden derleyip çalıştırdığınızda farklı sonuçlar çıkmamalı.
Eskiden Intel’e özgü bir örnek de yaşamıştım: FPU içeride 80 bit kayıtlar, bellekte ise 64 bit kullanıyordu; bu yüzden kayıtların doldurulup boşaltılma zamanı değiştiğinde yuvarlama zamanı da değişiyor, sonuç da farklılaşıyordu. Program başlangıcında genel FPU bayrağı ayarlanarak her işlemde yuvarlama zorlanabiliyor.
Her zaman doğru bir cevap vardır ve derleyicinin en azından varsayılan olarak yanlış bir değişiklik yapmaması gerektiğini düşünüyorum. Bununla birlikte programcının niyetini daha açık ifade edebilmesinin yolları her zaman memnuniyet verici.
Kayan noktalı döngülere SIMD uygulamak varsayılan olabilirdi, ancak mevcut birçok kodu bozacağı ve çıktıyı sık sık deterministik olmayan biçimde değiştireceği için programcının açıkça seçmesi gereken bir özellik hâline geldi.
Üstelik birçok programcı bunu bilmiyor olabilir; bu yüzden
float Sum(float[] values)farklı değer döndürmeye başlasa bile nedeninin vektörleştirme olduğunu anlamanın bir yolu olmayabilir. Bu nedenle örneğin .NET standart kütüphanesiintegers.Sum()için SIMD kullanır, amafloats.Sum()için kullanmaz.Yalnızca “döngü çıkışını kontrol ettikten sonra, dallanma olmadan doğrudan
foofonksiyonunun içine düşüyor” satırını okuyunca “ha, sorun buymuş” diye düşündüm.Gösterişli dal tahmincisi sezgiselleri hakkında derin bir konu olacağını sanmıştım ama sonuçta temel sezgiselin ihlaliymiş.
Eşleşmeyen
call/retkomutları kullanarak muazzam hız artışı elde edebileceğinizi düşünmemek gerekir. Dal tahmincisinin dönüş adreslerinin gölge yığınını tutması onlarca yıldır var olan bir yöntem.Bu yüzden bu tür riskler belgelenmeli, ancak tasarımcılar assembly’yi doğrudan yazan kişinin belgeleri okuduğunu varsayabilmelidir.
Öte yandan Sophie Wilson başlangıçta ARM için BBC BASIC uygulamasını yazmıştı, ama o zaman dal tahmincisi yoktu. 32 bit olduğu için kurallar farklı olsa da, mimari varsayımlar değiştiğinde AArch64’ün kodu nasıl yavaşlattığını merak ediyorum.
Klasik SNL göndermesi olan “Do not taunt happy fun ball”: https://www.youtube.com/watch?v=GmqeZl8OI2M
Bunun 2023 tarihli bir yazı olduğu gözden kaçmamalı. Şu an biraz eskidi bile; Rust 1.78’den itibaren derleyici daha agresif döngü açma ve bir miktar SIMD kullanıyor: https://godbolt.org/z/zhbobW7rr
Orijinal yazı “assembly’ye bakınca döngü açma yaptığını görüyorum” deyip https://godbolt.org/z/Kv77abW6c bağlantısını vermişti; bu ise sürekli değişen “Rust Nightly” kullanıyordu. Şimdi döngü açma daha fazla.
Döngü açma Rust 1.59’dan itibaren başlamıştı: https://godbolt.org/z/5PTnWrWf7
GitHub koduna göre Rust
1.67.0-nightly, 2022-11-27 sürümünü kullanıyordu.Aynı donanımda güncel nightly Rust 1.81’in agresif döngü açmasıyla benchmark’ı kendim çalıştırdım, ama fark yoktu; hız 1,5 yıl öncekiyle aynıydı.
2023 tarihli bir yazı. O zamanki tartışma: https://news.ycombinator.com/item?id=34520498
Yaklaşık 1 yıl sonra yeniden paylaşılması sorun değil; geçmiş konu bağlantısı daha fazlasını merak eden okurlar için.
ARM/ARM64 assembly’ye çok aşina olmadığım için
x0’ın nasıl artırıldığını karıştırdım.const float f = *data++;ifadesildr s1, [x0], #4oluyor; bu komut değeri okurken aynı andax0’ı 4 artırıyor gibi görünüyor.Negatif değer de kullanılabildiğinden geriye doğru gezinmek de mümkün olmalı. Oldukça hoş. x86_64’te okuma ve artırmayı tek seferde yapan tek bir komut yok gibi.
lodsvestossırasıylarsiveyardiüzerinde okuma/yazma ile artırmayı birlikte yapar. İki bellek adresi arasında kopyalarken artıranmovsda var.Genellikle
rcxkez yineleyenrepile birlikte kullanılır. Örneğin 10 baytlık birmemset,mov rcx, 10,mov rdi, dest,mov rax, 0,rep stosbbiçiminde yapılabilir.w,d,qsoneklerini kullanırsanız sırasıyla 2, 4, 8 bayt ilerler.Yazı iyiydi, ama tablolara göz atıp karşılaştırmayı zorlaştıracak şekilde µs ve ns birimleri arasında sürekli gidip gelmesi hoş değildi.
Kodu optimize etmeden önce daha az akıllıca bir yöntemin denenmemiş olması şaşırtıcı.
Assembly yeniden yazılırsa döngünün sonunda yalnızca bir dal gerekir; ayrıca
X1için karşılaştırma amaçlı çıkarma ve azaltmayı ayrı ayrı yapmak yerine tek bir ALU işlemi ile halledilebilir.Daha da ileri gidilirse
foodoğrudan inline edilebilir ve eşleşmeyenBL/REThilesi olmadanRETkomutu da atlanabilir. Kendim benchmark yapmadığım için gerçekte ne kadar hızlanacağını bilmiyorum.cbnzyazan satırcbzolmalı. CBZ, kayıt 0 ise etikete dallanır; CBNZ ise 0 değilse dallanır.