2 puan yazan GN⁺ 2024-03-11 | 1 yorum | WhatsApp'ta paylaş
  • 1BRC’deki darboğaz, 1 milyar CSV sıcaklık değerini aşırı hızlı ayrıştırmaktı; Quân Anh Mai’nin merykitty SWAR kodu, if kullanmadan sabit ALU işlemleriyle sıcaklıkları tam sayıya dönüştürdüğü için dikkat çekti
  • Bu kod, tek bir long içindeki 8 baytı aynı anda işleyen SWAR (SIMD Within A Register) yaklaşımını kullanıyor; normal CPU yazmaçlarında birden çok karakteri paralelmiş gibi işliyor
  • İşleme akışı eksi işaretini algılama, işareti kaldırma, ondalık noktanın konumunu bulma, XY.Z hizalaması, ASCII rakam dönüşümü, sihirli çarpma ve işaretin uygulanması şeklinde ilerliyor
  • Girdi biçimleri -XX.X, -X.X, X.X, XX.X olmak üzere dört tane; ondalık noktanın konumuna göre baytlar kaydırılarak farklı uzunluklar aynı bit yerleşimine getiriliyor
  • Dallanma ve döngüleri azaltmak yerine ASCII kod özellikleri, ikinin tümleyeni, bit maskeleri ve çarpmanın kaydırma-toplama niteliği yoğun biçimde kullanılarak yüksek performanslı ayrıştırma gerçekleştiriliyor

1BRC’de darboğaz haline gelen sıcaklık ayrıştırma

  • One Billion Row Challenge (1BRC) kapsamında CSV dosyasındaki sıcaklık değerlerini çok hızlı ayrıştırmak temel darboğaz olarak öne çıktı
  • Önceki optimizasyonlarla bile idiomatik paralel Java kodu 71 saniyeden 1,7 saniyeye kadar hızlandı
  • Sıcaklık biçimi basit olsa da 1 milyar değeri 1 saniyenin altında ayrıştırmak için küçük maliyetler bile büyük ölçüde birikir
    • Olası biçimler -XX.X, -X.X, X.X, XX.X
  • İlk katılımcılar Double.parseDouble() kullandı, ancak daha sonra döngüsüz özel ayrıştırıcılar ortaya çıktı
  • Quân Anh Mai’nin @merykitty çözümünün bir bölümü, if olmadan tek dosya okumasıyla çalıştığı için 1BRC’nin üst sıralardaki çözümlerinde standart bir unsur gibi yayıldı
  • Kazanan Thomas Wuerthinger, kendi çözümüne katkıda bulunan ekibin bir parçası olarak Quân Anh’ı özellikle belirtti

merykitty kodu ne yapıyor?

  • Kod, 8 baytlık CSV girdisini içeren bir long alıp gerçek sıcaklığın 10 katı olan tam sayı sıcaklık değerini döndürüyor
  • Girdi, mmap edilmiş CSV dosyasından doğrudan yerel bellek okumasıyla geliyor; bu kısım ayrı bir ilgi alanı olarak ayrılıyor
  • İşlemler sabit sıradaki 18 ALU işleminden oluşuyor
    • Bit kaydırma, AND, NOT, XOR
    • Toplama, çıkarma, çarpma
    • Long.numberOfTrailingZeros()
  • numberOfTrailingZeros(), JDK derleyici intrinsic’i aracılığıyla özel CPU komutunu kullanıyor
  • Genel SIMD’ye özel komutlar yerine normal CPU yazmaçları ve komutlarıyla birden çok bayt işlendiği için bu SWAR yaklaşımına giriyor
  • Örnek kod, okunabilirlik için özgün halinden biraz değiştirilmiş; özgün kod CalculateAverage_merykitty.java içinde bulunuyor

Tüm işleme adımları

  • Kod sıcaklığı şu sırayla ayrıştırıyor
    • İlk karakterin - olup olmadığını kontrol ederek negatiflik durumunu algılıyor
    • İşaret karakteri varsa ilgili baytı 0 yapıyor
    • Ondalık nokta . konumunu buluyor
    • Rakamlar XY.Z şablonuna uyacak şekilde long içindeki bitleri kaydırıyor
    • ASCII karakterleri gerçek rakam değerlerine çeviriyor
    • Her basamağı 1x, 10x, 100x ağırlıklarıyla çarpıp topluyor
    • Son olarak işareti uyguluyor
  • Dışarıdan bakınca üst düzey bir ayrıştırma problemi gibi görünse de her adım yalnızca ALU işlemleriyle uygulanıyor

1. adım: eksi işaretini algılama

  • İşaret algılama şu kodla başlıyor
long negatedInput = ~inputData;
long broadcastSign = (negatedInput << 59) >> 63;
  • Açıklama amacıyla sıra değiştirilirse ( ~(inputData << 59) ) >> 63 gibi düşünülebilir
  • ASCII’de eksi - karakterinin 4. biti 0, rakam karakterlerinde ise bu bitin 1 olması özelliğinden yararlanılıyor
  • Girdi 59 bit sola kaydırıldığında ilk karakterin ayırt edici biti en yüksek anlamlı bite taşınıyor
  • NOT ile bitler ters çevrildikten sonra 63 bitlik aritmetik sağa kaydırma yapılınca en yüksek anlamlı bit tüm long boyunca yayılıyor
  • Sonuç olan broadcastSign, eksi varsa tüm bitleri 1, yoksa tüm bitleri 0 oluyor

2. adım: işaret karakterini kaldırma

  • Negatiflik durumu broadcastSign içinde saklandığı için işaret karakteri girdi verisinden kaldırılıyor
long maskToRemoveSign = ~(broadcastSign & 0xFF);
long withSignRemoved = inputData & maskToRemoveSign;
  • broadcastSign tamamen 1 ise broadcastSign & 0xFF yalnızca en düşük 8 biti 1 yapar
  • Bunun NOT’u alındığında yalnızca en düşük 8 biti 0 olan bir maske oluşturulur
  • inputData ile AND yapılınca en düşük bayttaki - kaldırılır
  • Eksi yoksa broadcastSign 0 olduğundan maske tüm bitleri 1 olur ve rakam baytları korunur

3. adım: ondalık noktanın konumunu bulma

  • Ondalık noktanın konumu şu kodla hesaplanıyor
int dotPos = Long.numberOfTrailingZeros(negatedInput & DOT_DETECTOR);
  • . karakteri de eksi gibi 4. bitinin 0 olması özelliğine sahip
  • Olası ondalık nokta konumlarında yalnızca 4. biti kontrol etmek için DOT_DETECTOR = 0x10101000 maskesi kullanılıyor
  • Ters çevrilmiş özgün girdi olan negatedInput içinde ondalık nokta konumundaki ilgili bit 1 oluyor
  • Long.numberOfTrailingZeros() bu 1 bitinin konumunu döndürüyor
  • -10.8 örneğinde ondalık nokta bit konumu 28’de olduğundan dotPos = 28 oluyor

4. adım: sabit şablona hizalama

  • Ondalık noktanın konumuna göre girdi sola kaydırılarak her zaman aynı şablona uyduruluyor
long alignedToTemplate = withSignRemoved << (28 - dotPos);
  • Hedef şablon şöyle
0 0 0 Z . Y X 0
  • Burada X onlar basamağı, Y birler basamağı, Z ise onda birler basamağıdır
  • 0, ASCII "0" değil, değeri 0 olan bayt anlamına gelir
  • İşaret kaldırıldıktan sonra girdi dört yerleşimden biri olabilir
    • 0 0 0 Z . Y X 0
    • 0 0 0 0 Z . Y 0
    • 0 0 0 0 Z . Y X
    • 0 0 0 0 0 Z . Y
  • -10.8 için dotPos = 28 olduğundan kaydırma miktarı 0’dır
  • -7.7 için ondalık nokta konumu bit 20’dedir; bu nedenle 8 bit, yani bir bayt sola kaydırılarak X yerine 0 konur

5. adım: ASCII rakamlarını değerlere dönüştürme

  • Hizalamadan sonra ASCII karakterlerinden yalnızca rakam değerleri bırakılır
long digits = alignedToTemplate & ASCII_TO_DIGIT_MASK;
  • ASCII rakamları 0dan 9a kadar onaltılık olarak 0x30 ile 0x39 arasındadır
  • Yalnızca düşük 4 bit bırakıldığında karakter kodu gerçek rakam değerine dönüşür
  • Şablondaki rakam konumlarında yalnızca F bulunan bir maske uygulanır
0 0 0 Z . Y X 0
000000F000F0F00
  • -10.8 örneğinde maske uygulandıktan sonra yalnızca Z=8, Y=0, X=1 değerlerini temsil eden değerler kalır

6. adım: sihirli çarpmayla basamak değerlerini toplama

  • Nihai mutlak değer 100 * X + 10 * Y + Z olarak hesaplanmalıdır
  • Çarpmanın kaydırma ve toplamanın bir birleşimi olması özelliğinden yararlanılarak birden çok basamağın ağırlık hesaplaması tek bir çarpmayla yapılır
  • Önce X + Y + Z düşünüldüğünde, digits değerini 0, 16 ve 24 bit konumlarına kaydırılmış halleriyle toplamak, toplamı belirli bir bit aralığında toplayabilir
  • Bu kaydırma-toplama birleşimi şu çarpma olarak ifade edilir
0x1 + 0x10000 + 0x1000000
  • Gerçekte her basamağın ağırlığı farklı olduğundan MAGIC_MULTIPLIER şöyle oluşturulur
MAGIC_MULTIPLIER = 0x1 + 10 * 0x10000 + 100 * 0x1000000;
  • Hesaplama ifadesi şöyledir
absValue = ((digits * MAGIC_MULTIPLIER) >>> 32) & 0x3FF;
  • 0x3FF, yalnızca 10 bit genişliğindeki sonucu ayıran maskedir
  • 100 * X 10 bite kadar büyüyüp komşu bitlerle çakışabilir; ancak Y * 100 değerinin sağdaki iki bitinin 0 olması özelliği sayesinde gerekli bit alanı sağlanır
  • merykitty bu bölüme // That was close :) yorumunu bırakmıştır

7. adım: dallanma olmadan işareti uygulama

  • Bu noktada mutlak değer absValue ve işaret bilgisi broadcastSign vardır
  • broadcastSign pozitif için 0, negatif için -1 gibi davranır
  • İkinin tümleyeninde negatif sayı şu ifadeyle gösterilir
-n = NOT(n) + 1
  • XOR koşullu NOT gibi kullanılabilir
    • n XOR -1, NOT(n) olur
    • n XOR 0, n olur
  • Seçimli +1, -broadcastSign ile işlenir
temperature = (absValue ^ broadcastSign) - broadcastSign;
  • Sonuç olarak if olmadan pozitif değerler olduğu gibi kalır, negatif değerler ikinin tümleyeni negatif değerlere dönüştürülür

Bonus: bir sonraki CSV satırının başlangıç konumunu hesaplama

  • Tüm 1BRC çözümünde bir sonraki CSV satırının başlangıç konumunu da düşük maliyetle hesaplamak gerekir
  • Ondalık noktadan sonra her zaman bir ondalık basamak ve satır sonu karakteri geldiğinden, bir sonraki satır başlangıcı ondalık nokta konumuna göre bulunur
  • dotPos bit düzeyinde bir konum olduğundan 8’e bölmek için 3 bit sağa kaydırma kullanılır
nextLineStart = (dotPos >>> 3) + 3;
  • +3, ondalık nokta, bir ondalık basamak ve satır sonundan sonraki ilk baytı göstermek içindir

Sonuç

  • merykitty’nin SWAR kodu, sabit bit işlemleriyle dört sıcaklık dizgesi biçimini birleştirip ayrıştırıyor
  • Temel nokta; ASCII kodunun bit özellikleri, ondalık nokta konumuna dayalı hizalama, maskeyle rakam çıkarma, çarpmayla basamak değerlerini toplama ve ikinin tümleyenine dayalı işaret uygulama
  • Adımlara ayrıldığında işleyişi takip etmek mümkün; ancak bunu çevrimiçi bir meydan okumanın birkaç günü içinde bir araya getirmiş olması etkileyici kalan nokta

1 yorum

 
GN⁺ 2024-03-11
Hacker News yorumları
  • Adım adım açıklama gerçekten harika
    İki yılı aşkın süre önce byte array view var handle’ın Java/Scala’da verimli SWAR rutinleri oluşturmak için oldukça uygun olduğunu fark etmiştim
    Base16/64 string ayrıştırma, java.time.*, sayısal değerleri doğrudan bayt dizilerinden ayrıştırma gibi SWAR kullanım örnekleri burada da bolca var: https://github.com/plokhotnyuk/jsoniter-scala/blob/master/js...
  • Yazı da iyi, kodun bağlamı içinde çözüm de mükemmel; ancak bu yöntem verinin doğru biçimde olduğunu varsayıyor
    Pratikte kendini kanıtlamış bir parser’ın büyük değeri, verimli hata denetimi ve toparlanma yeteneğinde
    • Hatalı girdinin çıktıyı ne şekilde etkileyebileceğini parçalara ayırıp görmek ilginç olurdu
      Ayrıca mevcut kod stilindeki gibi bir sentinel hata değeri döndürecek şekilde algılama yapmak için ne kadar iş gerekeceğini de merak ediyorum
      Ama bizzat deneyecek kadar ilgimi çekmiyor ;-)
  • Sayı bit alanındaki her basamağı 10’un uygun kuvvetleriyle çarpıp MUL ile shift/toplama yapma tekniği epey bilinen bir yöntem
    Lemire’in yazısına bakın: https://lemire.me/blog/2023/11/28/parsing-8-bit-integers-qui...
  • Yazıya göre SWAR, SIMD Within A Register anlamına geliyor
  • Bu tür şeyleri seviyorsanız simdjson makalesi de benzer teknikler kullanıyor; yazımı çok iyi ve örnekleri de güzel
    Makale: https://arxiv.org/abs/1902.08318
    Github: https://github.com/simdjson/simdjson
    • Bu SWAR değil, ama neden ilginç olacağını anlıyorum
  • BRC’nin neden girdi/çıktı darboğazına takılmadığını açıklayabilir misiniz? CPU’nun darboğaz olmasını anlamıyorum
    • Modern sistemlerde yerel disk G/Ç artık darboğaz değil: https://benhoyt.com/writings/io-is-no-longer-the-bottleneck/
      Üstelik resmi 1BRC, G/Ç hızını tamamen dışarıda bırakmak için sonuçların RAM disk üzerinden değerlendirildiğini açıkça belirtiyordu: https://github.com/gunnarmorling/1brc?tab=readme-ov-file#eva...
      “Programs are run from a RAM disk (i.o. the IO overhead for loading the file from disk is not relevant)”
    • Arka plan olarak Daniel Lemire ile yapılmış bir röportaj var. Kendisi kariyerini G/Ç her zaman darboğaz değildir gözlemi üzerine kurmuş biri: https://corecursive.com/frontiers-of-performance-with-daniel...
    • Bu probleme ayrıntılı bakmadım, ama tersinden başlayabiliriz. Neden bellek G/Ç’sinin darboğaz olduğunu düşünüyorsunuz?
      Sınırlı anladığım kadarıyla büyük bir metin dosyası sıralı biçimde L1’e getiriliyor ve her değer bir kez okunuyor. Çoğu işlemcide bu tür okumalar döngü başına iki kez yapılabiliyor. Yavaş kısım RAM’den L1’e getirmek olurdu, ama sıralı okuma oldukça hızlı
      Ardından her okuma için işlem yapılıyor. İlk bakışta optimize edilmiş sürümde bunun yaklaşık 4 döngü civarında olacağını düşünüyorum. Sonra sonucu bir yere yazmak gerekiyor ve muhtemelen ondan önce bir ya da iki rastgele okuma gerekiyor. Bunu mu G/Ç darboğazı olarak görüyorsunuz?
      CPU ile sınırlı olduğu bariz demek istemiyorum, ama aksi de bariz görünmüyor
      Düzenleme: “disk G/Ç” demek istemiş olabileceğinizi hesaba katmamışım. Başkalarının söylediği gibi burada fiilen bir etken değil
    • Testler memfs ile çalıştırılıyor. Dosya ve her şey en başından itibaren RAM’de
    • Veri kümesi Linux çekirdeğinin sayfa önbelleğine sığacak kadar küçük ve benchmark arka arkaya 5 kez tekrarlandığı için ilk tekrar disk G/Ç darboğazına girebilir, ama kalan 4 tekrar girmez
      Yani tüm veri RAM’de, daha doğrusu sayfa önbelleğinde olur
  • 68000’de SWAR’ı epey etkili kullanırdık. Tek bir komutla 4 baytı paralel işlerdik
    Doğru hatırlıyorsam taşma yönetimi zordu. Bu yazıyı gerçekten sevdim
  • “Tek başına çalışan birinin, ödülü tişört ve kahve kupası olan çevrimiçi bir challenge’ı birkaç gün hafifçe kurcalarken tüm bunları ortaya çıkarması asıl gizem” denmiş; bu neden gizem olsun?
    Hâlâ CPU’yu gerçekten programlamayı bilen ve ne yaptığını anlayan insanlar var
    Asıl gizem, kendine programcı diyenlerin büyük çoğunluğunda derin anlayışın eksik olması ve hatta ciddi biçimde eksik olduklarının farkında bile değilmiş gibi görünmeleri
  • C#’ta bu tür SWAR numaralarına gerek yok. Bunun yerine birinci sınıf çapraz platform SIMD API sunuyor
    Gerçekten iyi çalıştığı da şimdiye kadar yayımlanan 1BRC çözümleri arasında en hızlısı gibi görünen C# çözümünde görülebiliyor: https://hotforknowledge.com/2024/01/13/1brc-in-dotnet-among-...
  • Bunu SSE ile vektörleştirmek mümkün mü? Çekirdek işlemlerin çoğu 4 adet 32 bit tamsayıdan oluşan vektörlerle mümkün görünüyor
    Sorun, başlangıç vektörünü oluşturma ve sonucu çıkarma maliyetinin aşırı olup olmadığı
    • Mümkün ve başka birçok 1BRC implementasyonu da bunu yaptı
      Ancak HotSpot’ın bunu kendi başına yapacağından şüpheliyim; ayrıca çoğu 1BRC gönderiminin başlangıç overhead’ini azaltmak için Graal ile çalıştırıldığı da ayrı bir nokta
      Temel SSE2’de 32 bit veya 64 bit çarpma olmadığı için 32×32→64 bit çarpma sorun olur, ama SSE4.1’e tam gereken pmuldq eklendi. Yalnız sonuç 64 bit olduğundan, 32 bit tamsayılardan oluşan tüm vektörü işlemek için bu işlemden iki kez gerekir
    • Sıcaklık alanı ad alanıyla karışık olduğundan SSE ile ek kazanç elde etmek zor görünüyor
      Ayrıca sıcaklık alanının uzunluğu değişken; sütun bazında saklanıyor olsa bile kazanç sağlamama ihtimali yüksek
      Yine de ad ile sıcaklık arasındaki ayırıcıyı bulmak için SSE başarıyla uygulanmış
    • Böyle kodlar ister en baştan, ister HotSpot sıcak noktayı tespit ettikten sonra otomatik vektörleştirmeye girecek gibi görünüyor