Elle yazılan SIMD, derleyiciyi ne zaman geçer?
CPU dot product çalışmamın Türkçe açıklaması. AVX2, toplama sırası ve iki derleyicinin farklı sonuçları üzerinden adil bir karşılaştırma.
Bir C++ döngüsünü elle SIMD komutlarıyla yazmak gerçekten gerekli mi? Derleyici aynı işi zaten yapabiliyorsa, mimariye özel kodun bakım maliyetini neden üstlenelim?
Bu yazı, When Does Manual SIMD Beat Compiler Auto-Vectorization? A Kernel-Level Dot-Product Case Study for CPU Inference başlıklı çalışmamın Türkçe açıklaması. Sayısal sonuçları kaynak PDF’deki deney kaydından aktarıyorum; burada yeni bir benchmark sonucu sunmuyorum.
Çalışmanın kapsamı CPU üzerinde tek bir dot product çekirdeği. Tam model gecikmesi ve model doğruluğu bu deneyde ölçülmedi.
Küçük bir döngü, önemli bir bağımlılık
Dot product, iki dizinin karşılıklı elemanlarını çarpıp sonuçları toplar:
#include <cstddef>
float dot(const float* a, const float* b, std::size_t n) {
float sum = 0.0f;
for (std::size_t i = 0; i < n; ++i) {
sum += a[i] * b[i];
}
return sum;
}
Bu sade döngü, incelenen hesabı gösteriyor. Matris-vektör çarpımında da benzer iç toplamlar bulunur; ama tek bir iç toplamın hızını bilmek, çevresindeki bütün operatörün hızını belirlemez.
Döngüdeki sum her adımda önceki toplamı kullanır. İşlemci bir sonraki toplamayı hazırlarken bu bağımlılığı taşır. SIMD (single instruction, multiple data), tek komutla birden fazla veri elemanında işlem yapabilme olanağı sağlar; toplamanın bağımlılık yapısı ise bu olanağın nasıl kullanılabileceğini sınırlar.
Neden yalnızca -O3 demek yetmiyor?
Gerçek sayılarla toplama yaparken parantezleri değiştirmek sonucu değiştirmez. Kayan noktalı sayılarda ara sonuçlar yuvarlandığı için bu özellik genel olarak geçerli değildir.
Bir derleyicinin sıralı toplamayı birkaç bağımsız toplama zincirine bölmesi, işlemlerin gruplanmasını değiştirebilir. Reassociation, burada bu yeniden gruplamaya izin vermek demek. İzin verilen dönüşümler, bir performans seçeneği olduğu kadar sayısal davranış kararıdır.
LLVM’nin vektörleştirme belgeleri bu ayrımı açıklıyor: çoğu hedefte kayan noktalı reduction için bazı kuralların gevşetilmesi gerekir. Bazı hedeflerde sonucu koruyan sıralı reduction desteği de bulunduğu için bunu bütün mimariler için mutlak bir yasak olarak okumamak gerekir.
Dört sürüm neyi karşılaştırıyor?
Çalışmada aynı hesabın dört sürümü var. Tablodaki FP, floating-point, yani kayan nokta anlamına geliyor:
| Sürüm | Değişen ne? |
|---|---|
| Scalar | Otomatik vektörleştirme ve FP contraction kapalı referans döngü. |
| Compiler-optimized | Derleyicinin vektörleştirmesine izin var; toplama sırasını gevşetmek ayrıca istenmiyor. |
| Compiler-reassociated | Aynı döngü, kayan noktalı işlemleri yeniden gruplamaya izin veren bayraklarla derleniyor. |
| Manual AVX2 | Sekiz float elemanını işleyen vektörler, dört bağımsız accumulator ve kalan elemanlar için kuyruk kodu kullanılıyor. |
Accumulator, ara toplamı tutan değişken ya da register. Elle yazılmış sürüm, ana döngünün her adımında dört ayrı sekizli grubu, yani 32 elemanı işliyor. Ayrı toplamlar sonunda birleştiriliyor; yatay toplama ve kalan elemanların işlenmesi de süreye dahil.
Dört accumulator kullanmak, bütün işi tek bir toplamın peşine dizmek yerine bağımsız zincirler oluşturuyor. Bunun karşılığında ürünlerin toplanma sırası değişiyor. Sıralı döngüyle bit düzeyinde aynı sonuç isteniyorsa, bu elle yazılmış kernel doğrudan eşdeğer bir alternatif sayılmaz.
FMA, çarpma ve toplamayı tek bir işlemde birleştirir ve yuvarlamayı etkiler. Çalışma bu etkiyi ayırmak için FP contraction’ı kapatıyor; manual sürümde FMA da kullanılmıyor.
Bayraklar iki derleyicide birebir aynı politika değil. GCC sürümünde -fassociative-math -fno-signed-zeros -fno-trapping-math, Clang sürümünde daha geniş varsayımlar getiren -ffast-math kullanılıyor. Clang’de ardından -ffp-contract=off uygulanıyor. Bu son bayrak, fast-math içindeki diğer gevşetmeleri geri almıyor. Ayrıntılar GCC ve Clang belgelerinde mevcut.
Deneyin sınırı nasıl çizildi?
Ana sonuçlar, 8 Eylül 2026 tarihli AMD EPYC 8024P fiziksel sunucu oturumuna ait. Boost kapalı, governor performance, işlem tek bir çekirdeğe sabitlenmiş. Derleyiciler GCC 15.2 ve Clang 21.
İki farklı derleme koşulu ayrı tutuluyor:
- Native: Derleyici sürümleri
-march=nativekullanabiliyor. Bu sunucuda AVX-512 de kullanılabilir; manual sürüm AVX2 olarak kalıyor. - AVX2 koşulu: Derleyici sürümleri
-mavx2ile derleniyor. Böylece karşılaştırılan vektör komut kümesi AVX2 ile sınırlandırılıyor.
Native koşulu yalnızca vektör genişliğini değiştirmiyor; hedefe özgü scheduling gibi seçimleri de etkileyebilir. Dolayısıyla bu iki derlemeyi saf bir “256 bit ile 512 bit” deneyi gibi yorumlamıyorum.
Her boyutta beş ısınma çağrısı var. Zamanlanan blok en az 10 ms’ye ulaşana kadar tekrar sayısı artırılıyor; bir üst sınır da uygulanıyor. İki koşunun her birinden yedi örnek alınıp toplam 14 örneğin medyanı kullanılıyor. Bu, 14 bağımsız makine deneyi demek değil.
Girdi üretimi, bellek ayırma ve CSV yazımı zamanlanan bölgenin dışında. Aynı ayrılmış tamponlar tekrar kullanılıyor. Ölçülen şey, bu koşullardaki kernel çağrısı süresi.
Sonuç: derleyici ve hedef değişince cevap da değişiyor
Kaynak metnin Tablo II’sindeki GCC native kaydından iki örnek:
| Eleman sayısı | Scalar | Compiler-optimized | Compiler-reassociated | Manual AVX2 |
|---|---|---|---|---|
| 4.096 | 5.117 ns | 5.081 ns | 321 ns | 300 ns |
| 1.048.576 | 1.322.439 ns | 1.322.369 ns | 134.203 ns | 140.285 ns |
Değerler çağrı başına medyan nanosaniyedir; nokta binlik ayırıcıdır. İlk satırda scalar/manual oranı yaklaşık 17,1. Aynı satırda yeniden gruplamaya izin verilen derleyici sürümü ile manual kernel arasındaki fark çok daha küçük: 321 ns ile 300 ns. Büyük girdide ise derleyici sürümü daha kısa sürede bitiyor.
Bu nedenle yalnızca scalar’a karşı hızlanmayı başlığa taşımak, asıl karşılaştırmayı saklar. Derleyiciye hangi dönüşümlerin izni verildiği de sonuçla birlikte okunmalı.
İki derleyici ve iki koşul için ana karşılaştırma şöyle:
| Koşul | GCC: reassociated/manual | Clang: reassociated/manual |
|---|---|---|
| Native | 0,96-1,07 | 0,89-1,05 |
| AVX2 | 1,15-2,01 | 0,97-1,02 |
Bu oranlar, ölçülen boyutlar arasındaki aralığı gösteriyor. 1’in üstü manual sürümün daha kısa sürdüğünü, altı derleyici sürümünün daha kısa sürdüğünü ifade ediyor.
Clang, AVX2 koşulunda da manual sürüme yakın kalıyor. GCC’de ise AVX2 koşulunun orta boyutlarında manual sürüm belirgin biçimde önde; n = 4096 için oran 2,01. Native koşulunda aynı büyük fark görülmüyor. Yakın süreler, istatistiksel eşdeğerlik kanıtı olarak sunulmuyor.
Assembly neyi açıklıyor, neyi kanıtlamıyor?
Kaynakta incelenen assembly kayıtları, varsayılan sıralamayı koruyan x86 birikiminin seri bir toplama zinciri olarak kaldığını gösteriyor. AVX2 koşulunda GCC’nin yeniden gruplamaya izin verilen sürümünde tek vektör accumulator görülürken, manual kernel ve Clang sürümü dört bağımsız accumulator kullanıyor.
Bu yapı, GCC’deki fark için makul bir açıklama sunuyor. Ancak donanım performans sayaçlarıyla doğrulanmış bir nedensellik ölçümü değil. Kayıtlar aynı kaynak ve araç zinciriyle üretilmiş destekleyici assembly/disassembly kanıtları; doğrudan zamanlanan binary’nin disassembly’si olarak sunulmuyor.
Benzer biçimde, büyük girdide hızlanmanın azalması tek başına “memory-bound olduk” demeye yetmez. Çalışmada cache ve bellek bant genişliği sayaçları ölçülmedi.
Hız kadar doğruluk kontrolünün anlamı da önemli
Sonuçlar aynı girdi üzerinde double referansla karşılaştırılıyor. Kullanılan ilk kabul eşiği şu biçimde:
abs(result - reference) < 1e-5 * max(1, sum(abs(a[i] * b[i])))
Bu, veri kümesine özel bir kabul politikası; her girdi için türetilmiş bir hata garantisi değil.
Önemli bir sınırı var: pozitif ve negatif terimler birbirini büyük ölçüde götürdüğünde, mutlak ürünlerin toplamı son sonuçtan çok büyük olabilir. Kaynaktaki en büyük boyutta eşik yaklaşık 2,25, referans sonuç ise yaklaşık -0,126. Bu eşik tek başına kullanılsaydı sıfır döndüren hatalı bir uygulama bile kabul edilebilirdi.
Bu yüzden kontrol yalnızca bu eşikten oluşmuyor. Sonlu sonuç kontrolü, tam boyutlu tamponlar, n = 0..65 ve 0/1 ofsetlerinde ASan/UBSan kontrolleri ile bütün ürünleri pozitif olan ek bir veri örneği de var. Pozitif örnekte kullanılan yüzde 0,01’lik göreli eşik, sıfır sonucu reddediyor. Bunlar test kapsamını güçlendiriyor; rastgele model girdilerinin doğruluğunu garanti etmiyor.
Bulguyu nereye kadar taşıyabiliriz?
Performans girdileri sentetik ve periyodik; gerçek model ağırlıklarının ya da aktivasyonlarının örneklemi değil. Altı zamanlama boyutunun tamamı 32’nin katı. Kalan elemanların işlendiği kuyruk yolları doğruluk açısından kontrol edilmiş olsa da düzensiz boyutların performansı burada karakterize edilmiyor.
Sürümler sabit sırayla ölçülüyor; sıra ve ısınma yanlılığı olasılığı var. ARM kaydı ise başka bir makinede yalnızca scalar ve compiler-optimized sürümlerini içeriyor. Bu kayıttan ARM ile x86 arasında genel bir hız sıralaması çıkarılamaz.
Kernel düzeyindeki kazanç, uçtan uca inference kazancı değildir. Veri yerleşimi, diğer operatörler, operatör birleştirme ve modelin hata toleransı sonucu değiştirebilir. GEMV tabanlı bir katmana ve ardından küçük bir modele geçiş, kaynak metinde gelecek çalışma olarak yer alıyor.
Bu çalışmadan çıkardığım karar sırası
Önce hangi sayısal davranışın kabul edildiğini belirlemek gerekiyor. Sonra aynı politika altında derleyici sürümlerini karşılaştırıp üretilen kodu incelemek anlamlı. Mimariye özel intrinsics, geriye kalan fark bakım maliyetini haklı çıkarıyorsa değerlendirilir.
Bu örneğin cevabı koşullu: Clang’in derleyici çıktısı iki koşulda da manual kernele yakın; GCC’de AVX2 koşulunda elle yazılmış sürümün avantajı var. Tek bir scalar karşılaştırması, bu ayrımı göstermiyor.
Kaynaklar
- Omer Isildak, When Does Manual SIMD Beat Compiler Auto-Vectorization? A Kernel-Level Dot-Product Case Study for CPU Inference, yazar nüshası. Bu yazıdaki deney düzeni ve sayılar PDF’nin III-VII. bölümlerinden, özellikle Tablo II ve IV’ten aktarılmıştır.
- LLVM: reductions ve otomatik vektörleştirme.
- GCC: optimizasyon seçenekleri.
- Clang: kayan noktalı davranışın denetlenmesi.
Small experiments. Honest notes.