GCC -ffast-math ve IEEE-754 Semantiği
-ffast-math yalnız daha agresif floating-point optimizasyonu değildir; NaN, infinity, signed zero, reassociation ve errno gibi davranışlarda programın sayısal sözleşmesini değiştirebilir.
Floating-point için cebirde doğru olan her dönüşüm program açısından doğru değildir.
(a + b) + c ile a + (b + c) reel sayılarda eşdeğerdir. IEEE-754 floating-point altında yuvarlama nedeniyle farklı sonuç üretebilir. NaN, infinity ve signed zero eklendiğinde optimizer'ın yapabileceği dönüşümlerin sınırı daha da önem kazanır.
C/C++ performans optimizasyonunda -ffast-math bu sınırların bir kısmını bilinçli olarak gevşetir. Benchmark birkaç yüzde hızlanınca cazip görünür; fakat aslında yalnız performans flag'i değil, sayısal semantik kararıdır.
Tek bir optimizasyon değildir
GCC dokümantasyonunda -ffast-math bir dizi alt seçeneği etkinleştirir.
Bunlar floating-point işlemlerinde standart tarafından beklenen bazı davranışların korunmayabileceği varsayımını compiler'a verir.
Optimizer ifadeleri yeniden ilişkilendirebilir, NaN/Infinity oluşmayacağını varsayabilir, signed zero farkını önemsemeyebilir, errno/trapping davranışında daha gevşek olabilir ve reciprocal approximation kullanabilir.
Bu yüzden code review'da yalnız flag adını görmek yeterli değildir. Hangi varsayımların algoritma için güvenli olduğu incelenmelidir.
Reassociation sonucu değiştirir
Şu toplamı düşünelim:
x = 1e20
y = -1e20
z = 3.14(x + y) + z yaklaşık 3.14 üretir.
x + (y + z) ise y + z aşamasında 3.14 bilgisini precision nedeniyle kaybedebilir ve sonuç 0 olabilir.
Optimizer büyük reduction loop'larında daha iyi SIMD kullanmak için toplama sırasını değiştirmek isteyebilir.
Scientific computing veya DSP tarafında bu değişiklik kabul edilebilir de olabilir, olmayabilir de. Cevap algoritmanın error budget'ına bağlıdır.
NaN yalnız hata değildir
NaN bazen veri akışındaki geçersiz örneği bilinçli olarak taşımak için kullanılır.
Kod if (x != x) ile NaN kontrolü yapıyor olabilir. Compiler NaN oluşmayacağı varsayımına izin veren flag altında bu tür kontroller beklenmedik şekilde optimize edilebilir.
Ben sensör, signal processing veya model inference öncesi numeric pipeline'da NaN politikasının açık olmasını tercih ediyorum. "Normalde gelmez" üretim sözleşmesi değildir.
NaN kabul edilmiyorsa girişte validate edilmelidir. Kabul ediliyorsa compiler flag bu semantiği korumalıdır.
Signed zero neden önemli olabilir?
IEEE-754 +0.0 ve -0.0 değerlerini ayırır.
Çoğu business uygulamasında fark görünmez. Fakat 1/+0.0 ve 1/-0.0 farklı infinity işaretleri üretir. Complex arithmetic ve bazı branch-cut hesaplarında işaret anlam taşır.
-fno-signed-zeros benzeri varsayımlar optimizer'a bu farkı yok sayma izni verir.
Bu karar uygulama domain'i bilinmeden güvenli kabul edilemez.
FMA sonucu neden farklıdır?
Fused multiply-add a * b + c işlemini tek rounding ile gerçekleştirebilir. Ayrı multiply ve add ise iki rounding yapar.
FMA genellikle hem hızlı hem daha doğru olabilir; buna rağmen bit seviyesinde eski sonuçla aynı değildir.
Regression test'i floating-point sonucu exact byte equality ile karşılaştırıyorsa compiler veya CPU değişiminde test kırılabilir.
Burada iki soru ayrılmalıdır: matematiksel hata daha mı küçük ve bitwise determinism korunuyor mu?
Kritik sistemde hangisinin gerektiği önceden tanımlanmalıdır.
SIMD için cazibesi
-ffast-math reduction ve vectorization için compiler'a daha geniş hareket alanı sağlar.
Büyük array toplamı, normalize etme, dot product veya DSP kernel'larında önemli hızlanma görülebilir.
Fakat benim tercih ettiğim yöntem global flag açmaktan önce hotspot'u izole etmektir.
Bir uygulamanın yüzde 2'si yoğun floating-point hesap yapıyorsa bütün binary'nin semantiğini gevşetmek yerine ilgili translation unit veya function için kontrollü optimizasyon daha güvenlidir.
Benchmark sonucu da end-to-end ölçülmelidir. 2x hızlanan kernel toplam latency'nin yüzde 5'iyse sistem yalnız yaklaşık yüzde 5 kazanır.
Deterministik sistemlerde ayrı risk
Aynı kaynak kod farklı compiler sürümleri, optimization pass'leri ve CPU ISA'larında farklı işlem sırası üretebilir.
-ffast-math bu serbestliği artırır.
Bir model pre/post-processing hattında küçük numeric fark tolerans içindeyse sorun olmayabilir. Fakat checksum benzeri bitwise reproducibility, scientific regression veya distributed consensus içinde floating-point sonucu kullanılıyorsa ciddi problem oluşabilir.
Floating-point'ı consensus key yapmak zaten çoğu zaman kötü fikirdir; aggressive math bunu daha da kırılgan hale getirir.
Test nasıl yapılmalı?
Flag'i açmadan önce referans implementation tutulmalıdır.
Aday optimize sürüm için absolute error, relative error, ULP farkı, NaN/Infinity davranışı, subnormal değerler, signed zero ve boundary inputs ölçülmelidir.
Performance test ise aynı CPU frequency politikası, affinity ve workload ile yapılmalıdır.
Yalnız "unit test geçti" sayısal eşdeğerliği kanıtlamaz. Test set'i problem uzayını kapsamayabilir.
Global -Ofast kullanımına dikkat
GCC'de -Ofast, -O3 optimizasyonlarına ek olarak -ffast-math davranışlarını da açar.
Bu nedenle build config'te -O3 -> -Ofast değişikliği yalnız "bir seviye daha yüksek optimizasyon" değildir.
Sayısal sözleşme değişir.
Release pipeline'da compiler flag'lerinin artifact metadata'sına yazılması bu nedenle değerlidir. Aynı commit'in farklı flag ile üretilmiş iki binary'si aynı yazılım sürümü sayılmamalıdır.
Performans kazancı kanıt gerektirir
-ffast-math bazı workload'larda büyük kazanç sağlayabilir, bazılarında neredeyse hiç fark yaratmaz.
Flag'i dogmatik olarak yasaklamak da, varsayılan olarak açmak da doğru değil.
Doğru süreç hotspot'u ölçmek, hangi IEEE davranışlarının gerekli olduğunu yazmak, kontrollü optimize build üretmek, numeric error'ı karşılaştırmak ve ancak kazanç anlamlıysa kullanmaktır.
Böylece compiler'a verdiğimiz özgürlüğün karşılığında ne aldığımız bilinir.
Floating-point optimizasyonunda en pahalı hata birkaç cycle kaybetmek değildir. Algoritmanın kabul ettiği matematiksel sınırı fark etmeden değiştirmektir.
Kaynakça