- Bottleneck 1BRC adalah mem-parsing 1 miliar nilai suhu CSV dengan sangat cepat, dan kode SWAR merykitty dari Quân Anh Mai menarik perhatian karena mengubah suhu menjadi bilangan bulat lewat operasi ALU tetap tanpa
if
- Kode ini menggunakan pendekatan SWAR (SIMD Within A Register), yang menangani 8 byte dalam satu
long sekaligus, memproses beberapa karakter seolah-olah paralel di register CPU biasa
- Alur pemrosesannya meliputi deteksi tanda minus, penghapusan tanda, pencarian posisi titik desimal, penyelarasan
XY.Z, konversi digit ASCII, perkalian ajaib, lalu penerapan tanda
- Format input ada empat:
-XX.X, -X.X, X.X, XX.X; berdasarkan posisi titik desimal, byte digeser agar panjang yang berbeda cocok ke tata letak bit yang sama
- Alih-alih mengurangi percabangan dan perulangan, kode ini memanfaatkan secara rapat karakteristik kode ASCII, komplemen dua, bit mask, serta sifat shift-penjumlahan dari perkalian untuk mewujudkan parsing berkinerja tinggi
Parsing suhu yang menjadi bottleneck di 1BRC
- Dalam One Billion Row Challenge(1BRC), mem-parsing nilai suhu dari file CSV dengan sangat cepat muncul sebagai bottleneck utama
- Dengan optimasi sebelumnya saja, kode Java paralel yang idiomatis sudah dipercepat dari 71 detik menjadi 1,7 detik
- Format suhu memang sederhana, tetapi untuk mem-parsing 1 miliar nilai dalam kurang dari 1 detik, biaya kecil pun akan terakumulasi besar
- Format yang mungkin adalah
-XX.X, -X.X, X.X, XX.X
- Para peserta awal menggunakan
Double.parseDouble(), tetapi kemudian muncul parser kustom tanpa loop
- Sebagian solusi @merykitty dari Quân Anh Mai memproses dengan pembacaan satu file tanpa
if, dan menyebar seperti elemen standar dalam solusi papan atas 1BRC
- Pemenangnya, Thomas Wuerthinger, menyebut Quân Anh sebagai bagian dari tim yang berkontribusi pada solusinya
Apa yang dilakukan kode merykitty
- Kode menerima
long berisi input CSV 8 byte dan mengembalikan nilai suhu bilangan bulat yang merupakan 10 kali suhu sebenarnya
- Input masuk dari file CSV yang di-
mmap melalui pembacaan memori native secara langsung; bagian itu dipisahkan sebagai perhatian tersendiri
- Operasinya terdiri dari 18 operasi ALU dalam urutan tetap
- Bit shift, AND, NOT, XOR
- Penjumlahan, pengurangan, perkalian
Long.numberOfTrailingZeros()
numberOfTrailingZeros() menggunakan instruksi CPU khusus melalui intrinsic compiler JDK
- Karena menangani beberapa byte dengan register dan instruksi CPU biasa, bukan instruksi khusus SIMD umum, pendekatan ini termasuk SWAR
- Kode contoh sedikit dimodifikasi dari aslinya agar lebih mudah dibaca, dan versi asli ada di CalculateAverage_merykitty.java
Tahapan pemrosesan lengkap
- Kode mem-parsing suhu dengan urutan berikut
- Memeriksa apakah karakter pertama adalah
- untuk mendeteksi apakah nilainya negatif
- Jika ada karakter tanda, byte tersebut dibuat menjadi 0
- Menemukan posisi titik desimal
.
- Menggeser bit di dalam
long agar digit cocok dengan template XY.Z
- Mengubah karakter ASCII menjadi nilai angka sebenarnya
- Mengalikan tiap digit dengan bobot
1x, 10x, 100x lalu menjumlahkannya
- Menerapkan tanda di akhir
- Secara permukaan ini terlihat seperti masalah parsing tingkat tinggi, tetapi setiap tahap diimplementasikan hanya dengan operasi ALU
Tahap 1: Deteksi tanda minus
- Deteksi tanda dimulai dengan kode berikut
long negatedInput = ~inputData;
long broadcastSign = (negatedInput << 59) >> 63;
- Jika urutan penjelasannya dibalik, ini dapat dilihat seperti
( ~(inputData << 59) ) >> 63
- Dalam ASCII, minus
- memiliki bit 4 bernilai 0, sedangkan karakter digit memiliki bit tersebut bernilai 1; sifat ini dimanfaatkan
- Menggeser input 59 bit ke kiri memindahkan bit pembeda karakter pertama ke bit paling signifikan
- Setelah bit dibalik dengan NOT, melakukan arithmetic right shift 63 bit menyebarkan bit paling signifikan ke seluruh
long
- Hasilnya,
broadcastSign, menjadi semua bit 1 jika ada minus, dan semua bit 0 jika tidak ada
Tahap 2: Menghapus karakter tanda
- Karena informasi negatif sudah disimpan di
broadcastSign, karakter tanda dihapus dari data input
long maskToRemoveSign = ~(broadcastSign & 0xFF);
long withSignRemoved = inputData & maskToRemoveSign;
- Jika
broadcastSign semuanya 1, maka broadcastSign & 0xFF hanya membuat 8 bit terbawah menjadi 1
- Melakukan NOT terhadapnya menghasilkan mask yang hanya 8 bit terbawahnya 0
- Jika di-AND dengan
inputData, - pada byte terbawah akan dihapus
- Jika tidak ada minus,
broadcastSign adalah 0, sehingga mask menjadi semua bit 1 dan byte digit tetap dipertahankan
Tahap 3: Menemukan posisi titik desimal
- Posisi titik desimal dihitung dengan kode berikut
int dotPos = Long.numberOfTrailingZeros(negatedInput & DOT_DETECTOR);
- Karakter
. juga, seperti minus, memiliki karakteristik bit 4 bernilai 0
- Untuk hanya memeriksa bit 4 pada kemungkinan posisi titik desimal, digunakan mask
DOT_DETECTOR = 0x10101000
- Dalam
negatedInput, yaitu input asli yang dibalik, bit terkait pada posisi titik desimal menjadi 1
Long.numberOfTrailingZeros() mengembalikan posisi bit 1 ini
- Pada contoh
-10.8, titik desimal berada di posisi bit 28, sehingga dotPos = 28
Tahap 4: Menyelaraskan ke template tetap
- Berdasarkan posisi titik desimal, input digeser ke kiri agar selalu cocok dengan template yang sama
long alignedToTemplate = withSignRemoved << (28 - dotPos);
- Template target adalah sebagai berikut
0 0 0 Z . Y X 0
- Di sini
X adalah digit puluhan, Y adalah digit satuan, dan Z adalah digit pertama setelah desimal
0 berarti byte bernilai 0, bukan ASCII "0"
- Setelah tanda dihapus, input dapat berada dalam salah satu dari empat tata letak
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 sudah memiliki dotPos = 28, sehingga jumlah pergeserannya 0
-7.7 memiliki titik desimal pada posisi bit 20, sehingga digeser 8 bit, yakni satu byte ke kiri, dan 0 ditempatkan pada posisi X
Tahap 5: Mengubah digit ASCII menjadi nilai
- Setelah penyelarasan, hanya nilai angka yang disisakan dari karakter ASCII
long digits = alignedToTemplate & ASCII_TO_DIGIT_MASK;
- Digit ASCII
0 sampai 9 adalah 0x30 sampai 0x39 dalam heksadesimal
- Jika hanya 4 bit bawah yang disisakan, kode karakter menjadi nilai angka sebenarnya
- Mask yang memiliki
F hanya pada posisi digit di template diterapkan
0 0 0 Z . Y X 0
000000F000F0F00
- Pada contoh
-10.8, setelah mask diterapkan, yang tersisa hanya nilai yang mewakili Z=8, Y=0, X=1
Tahap 6: Menjumlahkan nilai tempat dengan perkalian ajaib
- Nilai absolut akhir harus dihitung sebagai
100 * X + 10 * Y + Z
- Dengan memanfaatkan sifat bahwa perkalian adalah kombinasi shift dan penjumlahan, perhitungan bobot beberapa digit diproses dengan satu perkalian
- Pertama, jika membayangkan
X + Y + Z, nilai digits yang digeser ke posisi 0, 16, dan 24 bit dapat dijumlahkan untuk mengumpulkan hasil di rentang bit tertentu
- Kombinasi shift-penjumlahan ini dapat direpresentasikan sebagai perkalian berikut
0x1 + 0x10000 + 0x1000000
- Pada praktiknya, bobot tiap digit berbeda, sehingga
MAGIC_MULTIPLIER disusun seperti berikut
MAGIC_MULTIPLIER = 0x1 + 10 * 0x10000 + 100 * 0x1000000;
- Rumus perhitungannya adalah sebagai berikut
absValue = ((digits * MAGIC_MULTIPLIER) >>> 32) & 0x3FF;
0x3FF adalah mask untuk memisahkan hanya hasil selebar 10 bit
100 * X dapat membesar hingga 10 bit dan tumpang tindih dengan bit di sebelahnya, tetapi karena dua bit kanan dari Y * 100 menjadi 0, ruang bit yang diperlukan tetap tersedia
- merykitty meninggalkan komentar
// That was close :) pada bagian ini
Tahap 7: Menerapkan tanda tanpa percabangan
- Pada titik ini terdapat nilai absolut
absValue dan informasi tanda broadcastSign
broadcastSign berperilaku sebagai 0 untuk positif dan -1 untuk negatif
- Dalam komplemen dua, nilai negatif dinyatakan dengan rumus berikut
-n = NOT(n) + 1
- XOR dapat digunakan seperti NOT bersyarat
n XOR -1 adalah NOT(n)
n XOR 0 adalah n
+1 opsional ditangani dengan -broadcastSign
temperature = (absValue ^ broadcastSign) - broadcastSign;
- Hasilnya, tanpa
if, nilai positif dibiarkan apa adanya, sedangkan nilai negatif dikonversi menjadi nilai negatif komplemen dua
Bonus: Menghitung posisi awal baris CSV berikutnya
- Dalam keseluruhan solusi 1BRC, posisi awal baris CSV berikutnya juga harus dihitung dengan murah
- Setelah titik desimal, selalu ada satu digit desimal dan karakter newline, sehingga posisi awal baris berikutnya dihitung berdasarkan posisi titik desimal
- Karena
dotPos adalah posisi dalam bit, pembagian dengan 8 dilakukan menggunakan shift kanan 3 bit
nextLineStart = (dotPos >>> 3) + 3;
+3 adalah nilai untuk menunjuk byte pertama setelah titik desimal, satu digit desimal, dan newline
Kesimpulan
- Kode SWAR merykitty mem-parsing empat format string suhu menjadi seragam hanya dengan operasi bit tetap
- Intinya adalah karakteristik bit kode ASCII, penyelarasan berdasarkan posisi titik desimal, ekstraksi digit melalui mask, penjumlahan nilai tempat menggunakan perkalian, dan penerapan tanda berbasis komplemen dua
- Jika dipecah tahap demi tahap, cara kerjanya dapat diikuti, tetapi fakta bahwa kombinasi ini dirangkai dalam beberapa hari selama tantangan online tetap menjadi bagian yang mengesankan
1 komentar
Komentar di Hacker News
Lebih dari 2 tahun lalu saya mengetahui bahwa byte array view var handle cukup cocok untuk membuat rutin SWAR yang efisien di Java/Scala
Ada banyak contoh penggunaan SWAR di sini juga, seperti parsing string Base16/64,
java.time.*, dan parsing nilai angka langsung dari array byte: https://github.com/plokhotnyuk/jsoniter-scala/blob/master/js...Nilai besar dari parser yang teruji di dunia nyata ada pada pemeriksaan dan pemulihan kesalahan yang efisien
Dan saya juga penasaran seberapa banyak pekerjaan yang diperlukan untuk mendeteksinya agar mengembalikan semacam nilai error sentinel seperti gaya kode saat ini
Tidak cukup menarik untuk saya coba sendiri, sih ;-)
MULadalah metode yang cukup dikenalLihat tulisan Lemire: https://lemire.me/blog/2023/11/28/parsing-8-bit-integers-qui...
Makalah: https://arxiv.org/abs/1902.08318
Github: https://github.com/simdjson/simdjson
Selain itu, 1BRC resmi menyatakan bahwa hasil dievaluasi dari RAM disk agar kecepatan input/output benar-benar dikecualikan: 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)”
Dari pemahaman saya yang terbatas, file teks besar diambil secara berurutan ke L1 dan setiap nilai dibaca sekali. Pada kebanyakan prosesor, pembacaan seperti ini bisa dilakukan dua kali per siklus. Bagian yang lambat adalah membawa data dari RAM ke L1, tetapi pembacaan berurutan cukup cepat
Setelah itu, ada pemrosesan untuk setiap pembacaan. Sekilas, pada versi yang dioptimalkan tampaknya sekitar 4 siklus. Lalu hasilnya harus ditulis ke suatu tempat, dan mungkin sebelum itu diperlukan satu atau dua pembacaan acak. Apakah bagian ini yang Anda anggap sebagai bottleneck input/output?
Saya tidak mengatakan bahwa batasan CPU itu jelas, tetapi tampaknya tidak jelas juga bahwa bukan begitu
Edit: saya tidak mempertimbangkan bahwa yang dimaksud mungkin “input/output disk”. Seperti dikatakan orang lain, di sini itu pada dasarnya bukan faktor
Dengan kata lain, semua data berada di RAM, lebih tepatnya di page cache
Kalau ingatan saya benar, penanganan overflow cukup rumit. Saya sangat menyukai tulisan ini
Masih ada orang yang benar-benar tahu cara memprogram CPU dan memahami apa yang mereka lakukan
Misteri sebenarnya adalah kebanyakan orang yang menyebut diri programmer kurang memiliki pemahaman mendalam, dan bahkan tampaknya tidak menyadari bahwa mereka sangat kekurangan hal itu
Bahwa ini benar-benar bekerja dapat dilihat dari solusi C# yang tampaknya paling cepat di antara 1BRC yang sudah dipublikasikan sejauh ini: https://hotforknowledge.com/2024/01/13/1brc-in-dotnet-among-...
Masalahnya adalah apakah biaya menyusun vektor awal dan mengekstrak hasilnya tidak berlebihan
Namun saya ragu HotSpot akan melakukannya sendiri, dan secara terpisah, kebanyakan submission 1BRC dijalankan dengan Graal untuk mengurangi overhead startup
SSE2 dasar tidak memiliki perkalian 32-bit atau 64-bit, jadi perkalian 32×32→64-bit menjadi masalah, tetapi SSE4.1 menambahkan
pmuldqyang persis dibutuhkan. Namun hasilnya 64-bit, sehingga untuk memproses seluruh vektor integer 32-bit, operasi seperti ini diperlukan dua kaliSelain itu, field suhu panjangnya variabel, jadi meskipun disimpan per kolom, mungkin tidak memberikan keuntungan
Namun SSE berhasil diterapkan untuk mencari delimiter antara nama dan suhu