İkili aramadan daha hızlı yapmak mümkün
(lemire.me)- Sıralı bir dizide değer ararken yaygın olarak kullanılan ikili arama, bir seferde yalnızca tek bir değeri karşılaştırırken, modern CPU'lar tek bir komutla birden fazla değeri aynı anda karşılaştırabilir; bu yetenek kullanılırsa arama hızı büyük ölçüde artırılabilir
- SIMD Quad, diziyi 16'şarlı bloklara ayırdıktan sonra blok konumunu 4'lü enterpolasyon araması ile hızla daraltan ve blok içinde SIMD komutlarıyla 16 öğeyi tek seferde karşılaştıran hiyerarşik bir arama algoritmasıdır
- Benchmark sonuçlarında Intel warm cache koşullarında ikili aramaya kıyasla 2 kattan daha hızlı performans gösterdi; Apple cold cache koşullarında da 2 kattan daha hızlıydı ve tüm ölçüm koşullarında
std::binary_search'ten üstündü - İkili aramada olduğu gibi ikiye bölmek yerine 4'e bölmek, komut sayısını biraz artırsa da Intel'de büyük diziler için bellek düzeyi paralelliğini daha iyi kullanarak cold cache performansını iyileştiriyor
- Ders kitabı algoritmaları günümüz CPU'larının veri paralelliği ve bellek paralelliğini varsaymadan tasarlandığı için, donanım özelliklerini yansıtan yeniden tasarımla pratikte anlamlı performans artışı elde etmek mümkün
Temel fikir
- İkili arama aynı anda tek bir değeri karşılaştıran bir yapıya sahiptir; ancak modern 64 bit ARM ve x64 işlemciler tek komutla 8 adet 16 bit tamsayıyı eşzamanlı karşılaştırabilir
- Bu donanım yeteneğinden yararlanılırsa arama birimi tek tek öğeler yerine blok düzeyine taşınabilir ve karşılaştırma sayısı büyük ölçüde azaltılabilir
- Diziyi ikiye bölmek yerine 4'e bölmek (4'lü arama), komut sayısını biraz artırsa da darboğaz büyük olasılıkla komut sayısı değildir; ayrıca bellek düzeyi paralelliği de daha iyi kullanılabilir
Sıralı dizide üyelik kontrolünün temel yöntemi
- Sıralı bir dizide bir değerin bulunup bulunmadığını kontrol etmenin en basit yolu, değerleri tek tek tarayan doğrusal aramadır; C++'ta aynı etki
std::findile elde edilebilir - Büyük dizilerde ikili arama daha hızlıdır ve arama aralığının ortasındaki değeri temel alarak üst ya da alt yarıyı eleme işlemini tekrarlar
- C++'taki
std::binary_search, değerin var olup olmadığını boolean olarak döndürür - Roaring Bitmap biçimi, boyutu 1 ila 4096 arasında değişen 16 bit tamsayı dizileri kullanır ve değer varlığını kontrol etmek için ikili arama kullanır
SIMD Quad algoritmasının yapısı
- 16 bit işaretsiz tamsayılardan oluşan sıralı dizi, 16 öğelik sabit boyutlu bloklara bölünür
- Her bloğun son öğesi enterpolasyon anahtarı olarak kullanılır; hedef değerin bulunma olasılığı yüksek olan tek bir bloğa aralık daraltılır, ardından bu bloğun 16 öğesi SIMD ile eşzamanlı incelenir
- Çalışma adımları:
- Öğe sayısı 16'dan azsa tüm dizi üzerinde basit doğrusal arama yapılır
- Dizi, ardışık 16 öğelik bloklara ayrılır ve toplam blok sayısı
num_blocks = cardinality / 16olur - Blokların son öğeleri anahtar olarak kullanılarak mevcut arama aralığındaki 1/4 noktaları hedef değerle karşılaştırılır ve
baseayarlanır - Geçerli blok bulunduğunda ARM'de NEON, x64'te SSE2 ile 16 öğe yüklenir ve paralel eşitlik karşılaştırması yapılır
- Tam bloklara sığmayan kalan öğeler doğrusal aramayla kontrol edilir
Benchmark yöntemi
- 2 ila 4096 arasındaki her dizi boyutu için 16 bit işaretsiz tamsayılardan oluşan sıralı 100.000 dizi üretildi
- Her boyut için iki modda 10 milyon üyelik sorgusu çalıştırıldı
- cold modu: Her sorgu farklı bir dizide arama yaparak cache miss durumunu simüle eder
- warm modu: Aynı dizi art arda 100 kez aranarak cache hit durumu simüle edilir
- Ölçülen metrik ortalama sorgu süresidir; karşılaştırılan yöntemler doğrusal arama (
std::find), ikili arama (std::binary_search) ve SIMD Quad'dır - Ölçüm sistemleri Apple M4 (Apple LLVM) ve Intel Emerald Rapids (GCC) idi
Ölçüm sonuçları
- Dizi büyüdükçe ikili arama, doğrusal aramayı belirgin biçimde geçer; cold cache durumunda daha fazla veri erişimi gerektiği için doğrusal arama daha da dezavantajlıdır
- Intel platformu: warm cache'te SIMD Quad, ikili aramadan 2 kattan fazla daha hızlıdır; cold cache'te kazanç daha küçüktür
- Apple platformu: cold cache'te SIMD Quad 2 kattan fazla daha hızlıdır; warm cache'te kazanç daha sınırlıdır
- Tüm durumlarda SIMD Quad,
std::binary_search'ten daha hızlıydı - SIMD bölümü, özel komutlarla işi azaltır; daha az komut ve dallanma içerdiği için hız artışının nedeni açıktır
4'lü aramanın etkisi
- SIMD optimizasyonu korunup 4'lü enterpolasyon araması ikili aramayla değiştirilen SIMD binary sürümü de karşılaştırıldı
- Apple platformunda 4'lü yaklaşımın etkisi küçüktü
- Intel platformunda büyük dizilerde cold cache koşullarında 4'lü yaklaşım anlamlı bir optimizasyon sağladı
- Intel sunucularında 4'lü arama bellek düzeyi paralelliğini daha iyi kullanıyor
Uygulamanın temel noktaları
simd_quadfonksiyonuuint16_tdizisini, öğe sayısıcardinality'yi ve aranacak değerpos'u alıp boolean döndürürgap16'ya sabitlenmiştir;cardinality < gapise basit bir döngüyle arama yapılır- Blok arama döngüsü,
n > 3olduğu sürece 1/4, 2/4, 3/4 noktalarındaki blok sonu değerlerini okuyup karşılaştırır ve üç karşılaştırmanın toplamına görebase'i kaydırır - Seçilen blokta ARM NEON'un
vceqq_u16ya da x64 SSE2'nin_mm_cmpeq_epi16komutu ile 16 öğe iki grup halinde paralel karşılaştırılır - Kalan öğe aralığında
v >= posolan noktadav == posolup olmadığı döndürülür
Sonuç
- Ders kitabı tarzı ikili arama iyi bir algoritmadır, ancak pratikte anlamlı olacak şekilde daha da hızlandırılabilir
- Standart algoritmalar çoğu zaman günümüz bilgisayarlarının yüksek paralellik düzeyi varsayımıyla tasarlanmış değildir
- SIMD Quad, hem bellek düzeyi paralelliğini hem de veri paralelliğini kullanmayı amaçlayan bir yaklaşımdır
- Daha iyi algoritmalar da mümkün olabilir; daha yaratıcı yaklaşımlara ihtiyaç var
- Kaynak kodu
- Faster intersections between sorted arrays with shotgun
1 yorum
Hacker News görüşleri
Daniel Lemire’in sözünü ettiği düşük seviye donanım optimizasyonundan ayrı olarak, ikili arama ya da onun uygulama varyantları ancak veri hakkında sıralı/monoton olması dışında hiçbir şey bilinmediğinde en iyi seçimdir
Veri dağılımı hakkında önceden bilgi varsa, bu bilgi kullanılarak çok daha hızlı algoritmalar yapılabilir. Kâğıt sözlükte bir insanın saf ikili aramadan daha hızlı biçimde uygun sayfa grubuna gitmesi buna örnektir
Matematiksel olarak sıralı listede arama, kapalı çevrim bir kontrol algoritmasıyla monoton bir fonksiyonun tersini bulmaya yakındır; bir maliyet fonksiyonu kurup gradyan inişi ya da hızlandırılmış türevleri de kullanılabilir. Daha genel olarak, aşırı soyutlanmış bir ifadenin çözümünü almak yerine çözmeye çalışılan belirli problem hakkında daha fazla bilgi kullanmak her zaman daha verimlidir ve donanımı daha iyi kullanarak elde edilen sabit katsayılı iyileştirmelerden çok daha büyük, ölçeklenebilir hız artışları sağlayabilir
Sabit yineleme sayısı dal tahmincisi için iyidir ve çekirdek döngü de hedef donanıma uygun biçimde çarpmadan kaçınarak oldukça sıkı optimize edilmiştir. Daha akıllı yapma girişimleri döngüyü kötüleştirdi ve kazanç sağlamadı. Struct dizisi biçiminde boyut 12 ve genelde N küçük olduğu için iş daha da zor
Yer imlerine eklemediğim için yılda iki kez yeniden arıyorum. Efsaneye göre hâlâ arıyorum
Bu yüzden veritabanlarında B-tree standarttır. Veri her şey olabilir ve büyük miktarda yeni satır gelirse özellikleri her an ciddi biçimde değişebilir
Gradyan inişi benzeri yaklaşımlarla sorguları hızlandıran algoritmalar yapılabilir ama B-tree zaten çok hızlıdır ve en kötü durum performansı, I/O gereksinimlerinin öngörülebilirliği, aralık taraması, sıralı dolaşım, önek koşulu desteği gibi pek çok avantaja da sahiptir
Belirli veri dağılımları için daha verimli sorgu algoritmaları yapılabilir ama bunlar çoğu zaman önemli özellikleri kaybeder. Başka bir algoritma daha yakın bir başlangıç tahmini verse bile son öğeyi bulmakta yavaş kalabilir ya da ortalamada hızlı olsa bile en kötü durum performansı kullanılamayacak kadar kötü olabilir
Kâğıt sözlükte de ilk tahminden sonra neredeyse ikili arama kullanıyorduk ve o ilk tahminin azalttığı adım sayısı sadece birkaç taneydi. Uygun sayfa grubuna geldikten sonra gerekenden biraz daha fazla lineer göz gezdiriyoruz; katı ikili arama belki daha hızlı olurdu ama sayfa çevirme süresiyle de denge kurmak gerekiyor
Ed Kmett bu fikri Haskell için discrimination[2] kütüphanesinde rafine etti ve ilgili teknik sunum[3] da çok ilginç
[1]: https://dl.acm.org/doi/epdf/10.1145/1411203.1411220
[2]: https://hackage.haskell.org/package/discrimination
[3]: https://www.youtube.com/watch?v=cB8DapKQz-I
“Dörtlü arama” sonuçta ikili arama döngüsünün bir adım açılmış hâli değil mi? Öğenin bulunduğu aralığı bulmak için karşılaştırma sayısı kabaca benzer görünüyor; sadece bir seferde 2 yerine 4 işleniyormuş gibi. Döngü açma ile de aynı etki alınabilir gibi
Pratikte işlemci açısından bakınca tüm döngüler zaten bir ölçüde açılmış gibidir; geriye sadece döngünün kendi küçük ek yükü kalır. İkili aramada asıl maliyet veriyi bellekten ya da önbellekten getirmektir; dolayısıyla esas mücadele, ileride lazım olacak veriyi mümkün olduğunca erken istemeyi sağlayıp gelmesini daha az beklemektir
Dörtlü arama gibi algoritmaların farkı, her dalın yalnızca bir tarafını derinlemesine önceden getirmek yerine olası tüm durumları sığ biçimde prefetch etmeleridir. Sonuçta eninde sonunda lazım olacak prefetch mutlaka gönderilir ve gerçek yürütme yolunda kullanılmayacak veriye biraz daha az bant genişliği harcanır
Arama algoritmalarının gerçek performansını tahmin etmek için “karşılaştırma sayısı” neredeyse işe yaramaz bir metriktir. Darboğaz, ne kadar çok karşılaştırma yapılabildiği değil, bellek ve önbellek bant genişliğinin ne kadar iyi kullanılabildiğidir. Modern işlemcinin dal davranışını iç işleyişine kadar hesaba katarsan buna döngü açma da denebilir
Sonuçta her iki arama da sabit katsayıları farklı O(log N). Algoritma derslerinde sabitler çok önemli görünmez ama gerçek hayatta oldukça etkili olabilirler
Yakın zamanda üstel arama[2] hakkında da bir yazı[1] yazdım. Aranan öğelerin kendileri de sıralıyken bir dizi içinde tekrar tekrar ikili arama yapmak gerektiğinde kullanılabilecek bir algoritma ve bizim iş yükümüzde 8 kat hız artışı sağladı
[1] https://lalitm.com/post/exponential-search/
[2] https://en.wikipedia.org/wiki/Exponential_search
HEAD /users/1 -> 200 OK
HEAD /users/2 -> 200 OK
HEAD /users/4 -> 200 OK
...
HEAD /users/2048 -> 200 OK
HEAD /users/4096 -> 404 Not Found
Sonra 2048 ile 4096 arasında ikili arama yaparak en son kullanıcıyı ve yan ürün olarak kullanıcı sayısını bulabilirsin. Rakip SaaS şirketlerini araştırırken bilinmesi faydalı bir bilgi
CPU her zaman tüm cache line’ı, genelde 64 baytı bir kerede eriştiği için bütün cache line’ı aramak daha iyi olur gibi. Veri CPU’ya geldikten sonra neredeyse bedava sayılır
Bu yüzden “ikili” aramada orta cache line içindeki tüm değerleri kontrol edip eşleşme yoksa sola/sağa gitmeyi denemek isterdim. Cache line araması tek bir 512 bit SIMD komutuyla yapılabilir. 64 baytlık bir cache line, 32 adet 16 bit tamsayı eder; dolayısıyla basit ikili aramadan neredeyse 32 kat hızlı olabilir ya da en azından bellek erişimini 32 kat azaltır ki çoğu gerçek programda baskın olan da budur
4 bayt anahtar ve 4 bayt çocuk işaretçisi ya da dizi indeksi kullanırsan iç düğüm, 7 anahtar + 8 çocuk işaretçisi + 1 sonraki işaretçi ile 64 baytlık cache line’ı tam doldurabilir. 1 milyon öğede ağaç derinliği yaklaşık 20’den yaklaşık 7’ye iner ve üst seviyelerdeki birkaç adımın cache’te kalma olasılığı yüksektir
Biraz uğraşırsan SIMD ile B-tree düğümü içindeki aramayı hızlandırabilirsin ama veri bağımlılığı çok yüksektir
Daha küçük dizilerde sona sentinel değeri koyulmuş lineer aramayı yenmek zaten zordur. Ancak bu iddianın muğlak olmasının nedeni “küçük”ün çok belirsiz bir koşul olması ve bu yüzden sezgisel gelmemesidir
Genel olarak bu yazıyı beğendim. Uzun süredir merak ettiğim bir şeyi, faydalı eleme deneyleriyle birlikte eksiksiz biçimde incelemiş
Küçük dizilerde, 16–32 öğe civarında biraz arama deneyi yaptım ve ikili arama çok fazla dal içerdiği için en kötü yöntemlerden biriydi. Küçük dizilerde en hızlı yöntem dalsız lineer arama oldu
Döngüyü ortada kesmeden tüm öğeleri dolaşan yaklaşım. Örneğin dizide belirli bir sayı var mı diye bakacaksan, tüm öğeler üzerindeki test sonuçlarını mantıksal OR ile birleştiriyorsun. SIMD kullanmadım ama küçük dizilerde dallar çok pahalı, bu yüzden dal olmadan tüm öğeleri doğrudan kontrol etmek daha hızlı
Algoritmanın açıklaması biraz kafa karıştırıcıydı
SIMD kısmı yalnızca son aşamada, yani son 16 öğeyi ararken kullanılıyor
Dörtlü arama kısmı 3 noktayı kontrol edip 4 yol oluşturuyor ama basitçe doğru anahtarı değil, doğru bloğu buluyor
Ayrıntı ilginç: yazar, her bloğun son öğesini dörtlü aramada kullanıyor. İlk öğe ya da rastgele bir öğe kullanılsa algoritmanın nasıl değişeceğini merak ediyorum
Buradaki temel sezgi olan SIMD çoklu karşılaştırma, son aşamadan daha büyük ölçeklerde de kullanılabilir gibi görünüyor
Taslak şu: a) gather ile eşit aralıklı 16 noktadan birden fazla öğe al b) SIMD komutuyla paralel karşılaştır c) doğru bloğa odaklan d) blok küçükse lineer aramaya geç, değilse gather/karşılaştırma döngüsünü tekrarla
gather komutu ardışık olmayan bellekten okur ve ikili aramaya göre gerekmeyecek kadar fazla veri okuyabilir. Yine de çok yönlü karşılaştırma mümkün olur ve ikili aramanın 4 adımını tek hamlede toplarsa büyük tablolarda kazanabilir gibi
Her veri türünde tam 16 yönlü karşılaştırma da mümkün değil. float64 aramada AVX-512’de bile 8 yönlü karşılaştırma ile sınırlısın ama int32 ve float32 için 16 yönlü karşılaştırma yapılabiliyor
gather tabanlı bir yöntemden ikili aramanın gerçekte daha hızlı olma ihtimali daha yüksek bence
Klasik bilgisayar bilimi algoritmaları fiilen paralelliği olmayan CPU’lar için “tasarlanmış” sayılır. Birden fazla çekirdek, Hyper-threading, SIMD komutları gibi paralellik yoktur; tüm bellek erişim süreleri aynıdır, yani L1/L2/L3 cache gecikmesi gibi kavramlar yoktur; ayrıca genel/rastgele veri varsayılır
Bu varsayımlardan herhangi birinden çıktığında performansı artırmak için yapılabilecek çok sayıda ayar ortaya çıkar
Klasik algoritmalar, belirli veri biçimleri veya belirli CPU özellikleri/yetenekleri hakkında daha fazla şey öğrendikten sonra daha optimize çözümler geliştirmek için çok iyi bir başlangıç noktası sağlar
Optimizasyonun sivri ucuna kadar gittiğinde genelde verinin bellekte nasıl tutulduğuna ve erişildiğine, ayrıca bunu iyileştirmek için yapılan değişikliklerin sonraki aşamalara zarar verip vermediğine bakarsın. Yıllar önce çalıştığım yerde biri kodun belirli bir kısmını fazlasıyla optimize etmişti ama o optimizasyon yüzünden sonradan gereken birçok bilgi cache’ten atıldı ve tüm uygulama daha yavaş oldu
Bu muhtemelen Rob Pike’ın programlama için 5. kuralına, daha doğrusu The Mythical Man Month’ta Fred Brooks’un söylediğinin yeniden ifadesine yakın. Başvuru: https://www.cs.unc.edu/~stotts/COMP590-059-f24/robsrules.htm...
Ergenlik çağımda bir hafta sonunu, ikili arama her adımda arama uzayını yarıya indirdiği için iyiyse üçlü arama her seferinde üçte bire indiriyor, o zaman daha da iyi olmalı diye düşünerek geçirmiştim
Ortadaki tek değeri karşılaştırmak yerine 1/3 noktasındaki değeri karşılaştırıp, çok düşükse 2/3 noktasındaki değeri karşılaştırmak gibi
Ama her adımda ikili aramadaki 1/2 yerine 1/3’e indirirken ortalamada 3/2 kat daha fazla karşılaştırma yaptığını düşündüm; bu yüzden sonunda aynı kapıya çıktığını varsaydım
Düzeltme: zamadatix’in yanıtını görünce aslında durumların 2/3’ünde 2 karşılaştırma gerektiği için ortalamanın 5/3 kat olduğunu fark ettim
İlk 1/3: 1 karşılaştırma
İkinci 1/3: 2 karşılaştırma
Üçüncü 1/3: 2 karşılaştırma
(1+2+2)/3 = ortalama 5/3 karşılaştırma eder. “1 ya da 2 karşılaştırma yapılıyor” hissi yüzünden %50 sanmak kolay ama gerçekte ilk karşılaştırmada sonuca gitme olasılığı 1/3, 2 karşılaştırma yapma olasılığı 2/3
Dolayısıyla toplam ortalama karşılaştırma sayısında üçlü aramanın çok az daha kötü olduğu gösterilebilir: 5/3*Log_3[n] = 1.052... * Log_2[n]
Yani adım sayısı azalır ama sona ulaşmak için ortalamada daha fazla karşılaştırma yaparsın. Bu tür tüm aramalarda, yani bölme sayısının 2’den büyük olduğu durumlarda genel olarak böyledir. Elbette aranan değerin eşit dağıldığı ve işlem maliyetinin idealize edildiği gibi bazı varsayımlar var; metindeki tartışma tam da bu noktada devreye giriyor
İkili arama algoritmasının üçlü bir sürümü olarak değil. Çünkü bu gerçek bir üçlü arama değil, daha çok dengesiz bir ikili aramaya benziyor. Karşılaştırma özünde ikilidir; bu yüzden karşılaştırmaya dayalı tüm arama algoritmaları bir anlamda ikili aramadır ve ortadaki öğe dışındaki seçimler algoritmik karmaşıklık açısından daha az verimlidir. Yine de gerçek donanımda bazı koşullarda daha iyi olabilir. Gerçek üçlü arama için temel işlem olarak 3 yönlü karşılaştırma gerekir
İşin ilginçleştiği nokta “radix verimliliği”[1] hesaba katıldığında ortaya çıkıyor. En iyi seçim, e’ye en yakın doğal sayı olan 3’tür. Ağaç aramayla da ilişkili; bu yüzden üçlü ağaçlar ikili ağaçlardan daha iyi olabilir
[1] https://en.wikipedia.org/wiki/Optimal_radix_choice
Bu yüzden o fikir o zamanki CPU’larda pratik olmayabilirdi ama bugünün CPU’larında daha olası hâle gelmiş olabilir