- SIMD, yalnızca en yüksek performanslı yazılımlar için kullanılan karmaşık bir teknik değil; ardışık verileri aynı anda birden çok değer halinde işleyerek sıradan döngüleri hızlandıran günlük bir optimizasyon aracıdır
- Tipik SIMD kodu 5 aşamalı bir yapıyı izler: sabit yayınlama, vektör genişliği kadar ilerleme, paralel işlem, sonucu indirgeme/kaydetme ve skaler kuyruk işleme
- Ghostty'nin kod noktası arama döngüsü, bir seferde 4, 8 veya 16
u32 karşılaştırır; teoride bu, işlem hacmini ARM NEON'da 4 kata, AVX2'de 8 kata, AVX-512'de 16 kata kadar artırabilir
- AVX2'li bir Intel masaüstünde terminalin uçtan uca işlem hacmi yaklaşık 5 kat arttı; desteklenen bir vektör genişliği yoksa veya giriş artarsa, mevcut skaler döngü tüm girdiyi ya da kalanı işler
- Derleyicinin otomatik vektörleştirmesi, basit döngülerde bile fırsatları kaçırabilir; bu yüzden önce optimize edilmiş çıktıyı kontrol etmek gerekir, ancak kritik sıcak döngülerde açık SIMD kullanmak davranışı ve performansı öngörülebilir tutabilir
SIMD ne yapar?
- SIMD, CPU'nun tek bir komutla birden çok değeri paralel işlemesini sağlar
- Baytları tek tek karşılaştırmak yerine, aynı anda 4, 8 veya daha fazlasını karşılaştırabilirsiniz
for (byte in bytes), for (character in string), for (value in array) gibi döngülerde vektör genişliği kadar işleme geçme fırsatı vardır
- Veri yüzlerce, binlerce ya da milyonlarca baytsa, paralellik genişliğine bağlı olarak yerel olarak 4 kat, 8 kat veya daha fazla hızlanma elde edilebilir
- Veri sadece birkaç ya da birkaç düzine öğeden oluşuyorsa SIMD uygulamaya değmez
- simdutf ve simdjson karmaşık SIMD teknikleri kullanır, ancak günlük SIMD'nin bu kadar karmaşık olması gerekmez
- Örnekler Zig kullanıyor, ancak 5 aşamalı yapı diğer dillere de uygulanabilir; her dil SIMD komutlarını farklı biçimde destekler
Tekrarlanan 5 aşamalı yapı
- Gerekli sabitleri tüm lane'lere yayınlayın ve gerekiyorsa vektör biriktiricilerini başlatın
- Girdiyi her seferinde vektör genişliği kadar ilerleyerek dolaşın
- Tüm lane'lerde karşılaştırma veya aritmetik işlemleri paralel yürütün
- Algoritmaya uygun şekilde vektör sonucunu indirgemeyi veya kaydetmeyi yapın
- Tam bir vektöre sığmayan kalanı mevcut döngü olan skaler kuyruk (scalar tail) ile işleyin
- Bu yapıya alışınca, normal bir döngüyü de aynı 5 aşamaya ayırabilir ve SIMD yazmayı skaler döngü kadar basit hale getirebilirsiniz
- Eğer bir işlem bu yapıyla kolayca ifade edilemiyorsa, şimdilik SIMD uygulamayı atlamak daha uygundur
Ghostty'deki gerçek arama döngüsü
- Ghostty, çözümlenmiş kod noktası dizisinde
0xF veya daha küçük bir değerle karşılaşana kadar veriyi tüketir
- Terminal verisinin çoğu yazdırılacak normal karakterler olduğu için bunları toplu işler
- Döngü, bir sonraki yazdırılabilir bölümün sonunu mümkün olduğunca hızlı bulur
- Orijinal skaler uygulama kod noktalarını tek tek kontrol eder
while (end < cps.len and cps[end] > 0xF) end += 1;
- Vektör uygulaması CPU'ya özel intrinsic'ler yerine genel vektörleri kullanır ve skaler sürümden sadece 12 satır daha uzundur
- Beklenen işlem hacmi artışı vektör lane sayısıyla orantılıdır
- ARM NEON ve Apple Silicon: en fazla 4 kat
- Modern x86 CPU'ların çoğunun desteklediği AVX2: en fazla 8 kat
- Bazı Intel CPU'ları ve AMD Zen 4+ tarafından desteklenen AVX-512: en fazla 16 kat
- AVX2'li bir Intel masaüstünde, terminal programı girdisinden son terminal durumuna kadar ölçülen toplam işlem hacmi yaklaşık 5 kat hızlandı
- SIMD çevresindeki ek işler yüzünden teorik hızlanmanın tamamı elde edilemez
- C0 kontrol karakterleri
0xF sonrasında da vardır, ancak bu Ghostty kod yolunda eşik olarak 0xF kullanılır
- ESC ve diğer kontrol dizileri ayrı bir yolda işlenir
1. aşama: sabit yayınlama
if (simd.lanes(u32)) |lanes| {
const V = @Vector(lanes, u32);
const threshold: V = @splat(0xF);
- Ghostty'deki
simd.lanes(u32), hedef CPU'nun aynı anda işleyebildiği u32 sayısını döndürür
- Bu değerlerin her birine lane denir
- ARM için 4, AVX2 için 8, AVX-512 için 16 döner
- Kullanılabilir bir vektör boyutu yoksa
null döndürür ve SIMD kodu atlanır
@Vector(lanes, u32), bu lane sayısına sahip bir vektör tipi oluşturur
lanes 8 ise bir V içinde paralel işlenebilecek 8 adet u32 bulunur
- Vektör karşılaştırmasında her iki tarafın da vektör olması gerektiği için
@splat(0xF), 0xF değerini tüm lane'lere kopyalar
{ 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
- Bu algoritmada vektör biriktiriciye gerek yoktur, ancak başka algoritmalar bu aşamada biriktirici başlatabilir
2. aşama: vektör vektör ilerleme
while (end + lanes <= cps.len) : (end += lanes) {
const values: V = cps[end..][0..lanes].*;
lanes 8 ise, yalnızca en az 8 değer kaldığında döngüye girilir ve 8 değer values içine yüklenir
- Her yinelemenin sonunda
end, 1 değil lane sayısı kadar artırılır
- Tam bir vektör yüklenebilmesi gerektiğinden, sadece 5 değer kalmışsa 8 lane'li vektör okunmaz
- Vektöre sığmayan değerler 5. aşamadaki skaler kuyruk tarafından işlenir
3. aşama: tüm lane'lerde paralel karşılaştırma
const greater_than_threshold = values > threshold;
values ve threshold ikisi de vektör olduğu için >, karşılık gelen tüm lane'leri tek bir vektör işlemi ile karşılaştırır
- 8 lane varsa,
cps[end] > 0xF karşılaştırmasına denk gelen 8 işlem paralel yapılır
values: { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
threshold: { 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
greater_than_threshold: { true, true, true, false, true, true, true, true }
- Açık bir iç döngü yoktur; sonuç, lane başına boolean içeren bir vektördür
- Aynı yapı sadece karşılaştırma için değil, toplama, çarpma, minimum, maksimum gibi vektör tipinin desteklediği işlemler için de geçerlidir
- Karşılaştırmanın kendisi tek bir vektör işlemi olsa da vektör yükleme, sonuç indirgeme ve başarısız lane'i bulma için ek komutlar gerekir
4. aşama: vektör sonucunu indirgeme
if (@reduce(.And, greater_than_threshold)) continue;
@reduce(.And, ...), tüm boolean değerlerini and ile birleştirip tek bir boolean üretir
- Tüm lane'ler
true ise bir sonraki vektöre geçilir; en az biri false ise başarısız olan tam konum bulunur
const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;
@bitCast, boolean vektörünü lane başına 1 bitlik bir tamsayı maskesine dönüştürür
1, değerin 0xF'ten büyük olduğunu gösterir
0, karşılaştırmanın başarısız olduğunu gösterir
- Maske ters çevrilince başarısız karşılaştırmalar
1 olur ve @ctz, ilk 1 bitinden önceki 0 bitlerinin sayısını verir
values: { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
greater_than_threshold: { true, true, true, false, true, true, true, true }
mask: { 1, 1, 1, 0, 1, 1, 1, 1 }
~mask: { 0, 0, 0, 1, 0, 0, 0, 0 }
- Bu örnekte
@ctz(~mask) değeri 3 döndürür ve end değişkenini ilk kontrol karakteri olan 0x0A'nın bulunduğu 3. lane'e taşır
- Sonuç indirgeme, 5 aşama içinde algoritmadan algoritmaya en çok değişen kısımdır
- Toplam alma işlemleri vektör biriktiriciyi tek bir sayıya indirebilir
- Dönüşümler tüm vektörü çıktı tamponuna yazabilir
- Bu arama, bit maskesi üretip belirli bir lane'in konumunu bulur
5. aşama: skaler kuyruk işleme
while (end < cps.len and cps[end] > 0xF) end += 1;
- Girdi uzunluğu vektör genişliğinin tam katı değilse, orijinal skaler döngü kalanı işler
- 8 lane'li vektör döngüsünden sonra 0 ile 7 arasında değer kalabilir
simd.lanes(u32) değeri null olan CPU'larda SIMD bölümü atlanır ve tüm girdiyi skaler döngü işler
- Orijinal uygulama aynı anda hem kalanı işleme hem de uyumluluk fallback'i görevini görür
- Genel vektörler CPU'ya özgü sözdizimini ortadan kaldırır, ancak CPU'ya özgü kod üretimini ortadan kaldırmaz
- Zig, vektör işlemlerini hedefte etkin olan komut kümesine dönüştürür
Otomatik vektörleştirmenin kaçırdıkları
- Derleyiciler, karmaşık kontrol akışı içermeyen düzenli aritmetik döngüler gibi basit kodları otomatik vektörleştirebilir
- Elle SIMD yazmadan önce skaler sürümü optimizasyon seçenekleriyle derleyip üretilen kodu incelemelisiniz
- Üretim derleyicileri sık sık vektörleştirme fırsatlarını kaçırır; otomatik vektörleştirme onlarca yıldır araştırılıyor ve yakın tarihli çalışmalar da bu sorundan yola çıkıyor
- Eğer bir döngü için 5 kat hızlanma önemliyse, davranışı öngörülebilir tutmak adına vektörleştirmeyi açıkça yazabilirsiniz
- Böylece alakasız kod değişiklikleri veya derleyici güncellemelerinin vektör döngüsünü sessizce yeniden skaler döngüye çevirmesini önleyebilirsiniz
Geliştiricilerin bilmesi gereken SIMD düzeyi
- Büyük miktarda ardışık veriyi arayan, karşılaştıran, sayan veya dönüştüren sıcak döngüler gördüğünüzde, vektör genişliği kadar işleme yaklaşımını düşünebilmelisiniz
- Günlük SIMD, sabit hazırlama, vektör yükleme, paralel işlem, sonuç indirgeme ve skaler kuyruk şeklinde düzenli bir yapı izler
- Dil SIMD'yi iyi destekliyorsa, assembly ya da CPU'ya özgü ayrıntıları doğrudan bilmeden de performansı artırabilirsiniz
- Her geliştiricinin ihtiyaç duyduğu seviye,
simdutf veya simdjson tarzı karmaşık teknikler değil; SIMD fırsatlarını fark edip ortak yapıyı kullanabilme düzeyidir
1 yorum
Hacker News yorumları
İyi bir yazı; ancak SIMD’nin anlaşılmasının kolay ve for döngüsü yazmak kadar kolay olduğunu söyleyerek başlayıp daha ilk örnekte tek satırlık skaler kodu 12 satıra çevirmek pek ikna edici değil.
Bunun yerine SIMD’nin zor olduğunu, ama sonucun buna değdiğini dürüstçe söylemek daha iyi olur. Hedef kitle yeni başlayanlarsa, 1. adımdan itibaren
broadcastgibi SIMD’ye özgü terimler açıklamasız kullanılmamalı; skaler kuyruk işlemeyi açıklayan 5. adım ise iyi kurgulanmışDonanımın tek seferde kaç öğe işleyebildiğini anlamak, işi o boyutta gruplamak, sonuçları tekrar açmak, kalan öğeleri ayrı işlemek ve sabitleri de çoğaltılmış vektörler hâline getirmek gerekiyor. Bunların her biri zor değil, ama iş miktarını artırıp süreci zahmetli kılıyor
En sevdiğim özellik, bazı sınır koşulları altında derleyicinin for döngülerini otomatik paralelleştirmesini sağlayan
par(; ; )idiKonu gerçekten karmaşıksa onu daha küçük ve basit parçalara bölmek, ardından dik öğrenme eğrisini tırmanmayı mümkün kılacak şekilde sırayı iyi kurmak ve buna değdiğine ikna etmek gerekir
Daha iyi tavsiye, herkesin dizi programlamayı bilmesi gerektiğidir. SIMD optimizasyonu genellikle bu düşünme biçimini gerektirir; yalnızca paketlenmiş SIMD’ye özgü teknikler ise şaşırtıcı derecede nadirdir
Dizi programlama, derleyicinin otomatik vektörleştirme yapmasını kolaylaştırdığı için, SIMD’yi doğrudan kullanmasanız bile çoğu zaman performanslı kod üretir
Çok deneyimim yok ama Julia, benzer vektörleştirme yeteneklerine sahip daha modern ve ifade gücü yüksek bir dile en yakın seçenek gibi görünüyor
Son birkaç gündür bir biyoinformatik projesindeki matris işlemlerini AVX-512 ile optimize ettim ve sonuçtan çok memnunum
Çoğu uygulamada darboğaz, büyük veri kümelerini bellekten okuma sürecidir; bu yüzden birden çok işlem için tekrar tekrar okumak yerine AVX register’ları ve birleştirilmiş kernel ile hepsini tek seferde işleyebilirsiniz. 5 kat hızlanma da yaygın; ben doğrudan intrinsic kullandım ama
widecrate’i ile yaygın işlemler oldukça basitleşiyor: https://docs.rs/wide/latest/wide/Geliştiricilerin ezici çoğunluğunun SIMD öğrenmesine hiç gerek yok. Neden tüm geliştiricilerin bunu bilmesi gerekiyormuş da ancak o zaman gerçek geliştirici sayılırlarmış gibi bir yanlış izlenim yaratılıyor, anlamıyorum
Başlığı “Herkes SIMD’nin uygulanmadığı anı bilmeli” diye değiştirmek daha iyi olur
Modern derleyiciler vektörleştirmede çok iyidir, ama tek bir varsayım ya da veriye bağımlı bir dallanma yüzünden aniden skaler koda geri düşebilirler. SIMD yazmayı öğrenmektense derleyicinin optimizasyon raporunu nasıl kontrol edeceğini öğrenmek daha değerli olabilir
Yalnızca sorunu tespit edebiliyorsanız sonuçta “yazık olmuş” demekle kalırsınız
Geçen yıl bir ses sentezleyici geliştirirken x86 ve ARM SIMD öğrenmeye başladım: https://github.com/seclorum/SIMDSynth
Çoklu timbre ve polifonik sentezleyici yapısı, birden çok veri akışına aynı işlemi uyguladığı için SIMD ilkelerini öğrenmeye çok uygundu. Ancak hata ayıklama oldukça zordu; her işleme hattının durumunu anlayabileceğim bir simülatöre ciddi ihtiyaç duydum ve SIMD araçlarını araştırmak da ayrıca büyük bir yatırım gerektirecek gibi görünüyor
Yazı iyi ve daha fazla dilin SIMD’yi desteklemesi güzel olurdu; ancak en popüler iki dilin SIMD’yi yerel olarak desteklemediği bir durumda “tüm programcılar bilmeli” ifadesi biraz tuhaf
Doğrudan SIMD yazmayacak ya da işi yapay zekaya bırakmayı planlayacak olsanız bile, hangi işlerin hangi donanımda SIMD ile hızlanabileceğini bilmelisiniz. Böylece algoritma ve kod yapısını SIMD uygulanabilecek şekilde tasarlayabilirsiniz
Veri bağımlılığının etkisi, vektör öğe genişliğini artırmanın maliyeti ve bundan kaçınma yolları, koşul ve dallanmaları maskelere dönüştürme, “bölme komutu yoktur” gibi özellikler, SIMD’yi az da olsa doğrudan kullanınca çok daha kolay içselleştirilir
Büyük ve ardışık verileri tek seferde denetlerken ya da dönüştürürken iyi çalışır; ancak girdinin her birkaç baytında bir karar vermeniz gerekiyorsa skaler yöntemle aynı hızda, hatta daha yavaş olabilir. SIMD sihirli bir hızlandırma düğmesi değildir
The Witness geliştirme ekibinin gerçek bir performans sorununu SIMD ile çözme sürecini Casey Muratori’nin anlattığı faydalı bir video: https://www.youtube.com/watch?v=Ge3aKEmZcqY
Performans için dikey entegrasyonun iyi bir örneği: genel soyutlamaların neden var olduğunu ve neden genel amaçlı olmaları gerektiğini anladıktan sonra, belirli bir kullanım senaryosunda problem tanımından SIMD’ye kadar dikey olarak entegre olup büyük kazanç elde etme sürecini gösteriyor
SIMD gibi mikro optimizasyonlara girmeden önce veri yapıları ve erişim desenleri ciddi biçimde gözden geçirilmeli
Eskiden Zig koduna SIMD uygulamıştım, ama veri yapısı modeli optimizasyonun tam tersine olduğu için, bozuk motorlu bir hurda arabaya yüksek performanslı yarış lastikleri takmak gibiydi. Performansı ölçmeden ve bellek ayırmanın nerede yapıldığını dikkate almadan yapılan tipik bir acele optimizasyondu
Artık veriye SQL tablosu gibi bakıyor, yapıyı potansiyel birincil anahtarlar ve erişim desenleri etrafında tasarlıyorum. Eskiden heap’teki başka struct’ları işaret eden bir ağaç kullanıyordum; bu da bağlı listelerin dezavantajlarını, çok sayıda heap vektörünün parçalanmasını ve yavaş oluşturma/serbest bırakma maliyetlerini birlikte getiriyordu; yalnızca
Dropbile çalışma süresinin kayda değer bir kısmını tüketiyorduAğaçlar her zaman lineerleştirilebildiğinden erişim/ekleme desenlerini, yapının gerçekten ağaç mı yoksa başka bir graph mı olduğunu,
Vecya da birden fazlaVec’ten oluşan bir struct olarak mı saklanacağını değerlendiriyorum. Sonuçta kod daha hızlı ve daha basit hale geldi; veriler homojen dizilerde toplandığı için derleyicinin SIMD optimizasyonlarından ve L1 cache’ten yararlanmak kolaylaşıyor, gerektiğinde doğrudan dallanmasız SIMD kodu da yazılabiliyorİlgili kaynak: https://hn.algolia.com/?dateRange=all&page=0&prefix=true&que...
Gerçek darboğaz bölgelerinde bellek ayırmadan, sanal fonksiyon tablosu aramalarından ve aşırı dolaylı erişimden kaçınmak gerekir. C++’ın
vector’ü de beklenmedik ayırmalar çağırabiliyorsa her zaman en iyi seçenek değildirBuna karşılık veri odaklı kod neredeyse her zaman threading ve SIMD desteğini kolaylaştırır
İlginçtir ki CPU kodu da eninde sonunda GPU tarzında yazılır; nesne dizileri yerine Parquet tarzı dizilerin struct’ı kullanmak bunun bir yoludur