- Pemeriksaan keanggotaan pada array terurut bisa dibuat lebih cepat daripada binary search yang diajarkan di buku teks, dan SIMD Quad lebih cepat daripada
std::binary_search dalam semua kondisi pengukuran
- SIMD Quad membagi array terurut bilangan bulat tak bertanda 16-bit menjadi blok berisi 16 elemen, lalu melakukan pencarian interpolasi kuarterner di batas blok dan perbandingan paralel SIMD di dalam blok
- ARM 64-bit modern dan x64 (Intel/AMD) dapat membandingkan 8 bilangan bulat 16-bit dengan satu instruksi, sehingga kebutuhan untuk tetap mengikuti struktur binary search yang memeriksa satu nilai setiap kali menjadi berkurang
- Benchmark dijalankan dengan 100.000 array terurut berukuran 2–4096 dan 10 juta kueri keanggotaan; mode cold mensimulasikan cache miss, sedangkan mode warm mensimulasikan cache hit
- Pada Intel, SIMD Quad lebih dari 2x lebih cepat daripada binary search pada cache warm; pada Apple, lebih dari 2x lebih cepat pada cache cold; dan pada array besar Intel dengan cache cold, pendekatan kuarterner memanfaatkan paralelisme tingkat memori dengan lebih baik
Bottleneck pada pemeriksaan keanggotaan array terurut
- Cara paling sederhana untuk memeriksa apakah suatu nilai ada di dalam array terurut adalah linear search yang menelusuri nilai satu per satu, dan di C++ efek yang sama bisa diperoleh dengan
std::find
- Untuk array besar, binary search lebih cepat; algoritme ini membuang separuh atas atau bawah berdasarkan nilai tengah rentang pencarian, lalu mengulanginya sampai nilai ditemukan atau rentangnya kosong
std::binary_search di C++ mengembalikan nilai boolean yang menyatakan apakah nilai tersebut ada
- Format Roaring Bitmap menggunakan array bilangan bulat 16-bit berukuran 1–4096 dan memakai binary search untuk memeriksa keberadaan nilai
Titik awal SIMD Quad
- Sebagian besar prosesor modern memiliki instruksi paralel data SIMD, dan ARM 64-bit serta x64 (Intel/AMD) dapat membandingkan 8 bilangan bulat 16-bit dengan nilai target dalam satu instruksi
- Karena sifat ini, binary search tidak perlu terus turun hingga blok yang lebih kecil dari 8 elemen, dan bahkan 16 elemen atau lebih bisa dibandingkan dengan murah
- Binary search memeriksa satu nilai setiap kali, tetapi prosesor modern dapat memuat dan memeriksa beberapa nilai sekaligus serta memiliki paralelisme tingkat memori yang baik
- Alih-alih membagi array menjadi dua, kita bisa mencoba pencarian kuarterner yang membaginya menjadi empat; meskipun jumlah instruksi sedikit bertambah, kemungkinan besar bottleneck-nya bukan pada jumlah instruksi
Struktur algoritme SIMD Quad
- SIMD Quad adalah algoritme pencarian untuk array terurut bilangan bulat tak bertanda 16-bit yang menggabungkan pencarian interpolasi kuarterner dan SIMD
- Array dibagi menjadi blok berukuran tetap 16 elemen, meski blok terakhir bisa menjadi pengecualian
- Elemen terakhir dari setiap blok digunakan sebagai kunci interpolasi untuk mempersempit rentang ke satu blok yang paling mungkin berisi nilai target, lalu 16 elemen dalam blok itu diperiksa sekaligus dengan SIMD
- Alur intinya adalah pencarian bertingkat: mengurangi jumlah perbandingan dengan pencarian interpolasi di batas blok, lalu melakukan perbandingan paralel dengan SIMD di dalam blok
- Langkah kerjanya sebagai berikut
- Jika jumlah elemen kurang dari 16, seluruh array diperiksa dengan linear search sederhana
- Array berukuran
cardinality dibagi menjadi blok elemen berurutan berukuran 16, dan jumlah total blok adalah num_blocks = cardinality / 16
- Elemen terakhir blok pada posisi seperti
16-1, 32-1, dan seterusnya digunakan sebagai kunci untuk dibandingkan dengan target pos pada titik 1/4 dari rentang pencarian saat ini, lalu base disesuaikan
- Berdasarkan hasil interpolasi, indeks blok yang sesuai
lo dipilih
- Jika blok tersebut valid, pada ARM 16 elemen dimuat dengan NEON dan pada x64 dengan SSE2 untuk melakukan perbandingan kesetaraan paralel dengan nilai target; jika ada kecocokan, fungsi mengembalikan
true
- Elemen sisa yang tidak termasuk dalam blok 16 elemen penuh diperiksa dengan linear search
Metode benchmark
- Benchmark membuat 100.000 array terurut bilangan bulat tak bertanda 16-bit untuk setiap ukuran array dari 2 hingga 4096
- Untuk setiap ukuran, dilakukan 10 juta kueri keanggotaan dalam dua mode
- Mode cold: setiap kueri mencari pada array yang berbeda untuk mensimulasikan cache miss
- Mode warm: kueri dikelompokkan per array dan array yang sama dicari 100 kali berturut-turut untuk mensimulasikan cache hit
- Yang diukur adalah waktu kueri rata-rata, dan algoritme pembandingnya adalah linear search (
std::find), binary search (std::binary_search), dan SIMD Quad
- Sistem pengukuran menggunakan Apple M4 dengan Apple LLVM, serta prosesor Intel Emerald Rapids dengan GCC
Hasil pengukuran
- Dalam perbandingan linear search dan binary search, binary search mengungguli linear search ketika ukuran array membesar
- Pada cache cold, linear search relatif lebih dirugikan karena lebih banyak akses data meningkatkan kegagalan cache
- Setelah binary search secara umum unggul atas linear search, ia lalu dibandingkan dengan SIMD Quad
- Hasil pada platform Intel dan Apple terlihat sangat berbeda
- Pada platform Intel, SIMD Quad lebih dari 2x lebih cepat daripada binary search pada cache warm
- Pada cache cold di platform Intel, keuntungannya lebih kecil
- Sebaliknya, pada platform Apple, SIMD Quad lebih dari 2x lebih cepat pada cache cold, sedangkan pada cache warm keuntungannya lebih terbatas
- Dalam semua kasus, SIMD Quad lebih cepat daripada binary search
- Bagian SIMD mengurangi pekerjaan dengan instruksi khusus, dan karena jumlah instruksi serta percabangan lebih sedikit, peningkatan kecepatannya mudah dipahami
Efek pencarian kuarterner
- Versi SIMD binary, yang tetap mempertahankan optimisasi SIMD tetapi mengganti pencarian interpolasi kuarterner dengan binary search standar, juga ikut dibandingkan
- Pada platform Apple, efek pendekatan kuarterner kecil
- Pada platform Intel, pendekatan kuarterner merupakan optimisasi yang berarti pada cache cold dengan array besar
- Pada server Intel, pencarian kuarterner memanfaatkan paralelisme tingkat memori dengan lebih baik
Inti implementasi
- Fungsi
simd_quad menerima array uint16_t, jumlah elemen cardinality, dan nilai yang dicari pos, lalu mengembalikan boolean
gap ditetapkan tetap ke 16, dan jika cardinality < gap, pencarian nilai dilakukan dengan loop sederhana
- Loop pencarian blok selama
n > 3 membaca elemen terakhir blok pada titik 1/4, 2/4, dan 3/4, membandingkannya dengan pos, lalu memindahkan base berdasarkan jumlah dari tiga hasil perbandingan itu
- Setelah itu, selama
n > 1, dilakukan perbandingan berbasis setengah untuk memperkecil rentang yang tersisa, dan akhirnya blok lo dipilih
- Blok yang dipilih dibandingkan sebagai dua kelompok berisi 16 elemen menggunakan
vceqq_u16 pada ARM NEON atau _mm_cmpeq_epi16 pada x64 SSE2
- Rentang elemen sisanya mengembalikan hasil
v == pos pada titik saat v >= pos, dan jika tidak ditemukan sampai akhir maka mengembalikan false
Kesimpulan dan materi
- Binary search ala buku teks adalah algoritme yang baik, tetapi dalam praktiknya masih bisa dibuat lebih cepat dengan cara yang bermakna terhadap performa
- Algoritme standar sering kali tidak dirancang dengan asumsi paralelisme tinggi pada komputer masa kini
- SIMD Quad adalah pendekatan yang mencoba memanfaatkan baik paralelisme tingkat memori maupun paralelisme data
- Algoritme yang lebih baik masih mungkin ada, dan dibutuhkan pendekatan yang lebih kreatif
- Kode sumber
- Faster intersections between sorted arrays with shotgun
1 komentar
Komentar Hacker News
Jika ada pengetahuan awal tentang distribusi data, informasi itu bisa dipakai untuk merancang algoritma yang jauh lebih cepat. Misalnya, pada kamus kertas manusia bisa melompat ke kumpulan halaman yang kira-kira tepat lebih cepat daripada binary search ideal murni
Secara matematis, pencarian pada daftar terurut lebih mirip membalik fungsi monoton dengan algoritma kontrol loop tertutup, dan dengan fungsi biaya yang tepat kita bahkan bisa memakai gradient descent atau variasi percepatannya
Secara lebih umum, untuk menyelesaikan masalah dengan lebih efisien, biasanya lebih baik memakai lebih banyak informasi tentang masalah spesifik yang ingin diselesaikan daripada membawa solusi yang terlalu diabstraksikan. Ini bisa memberi peningkatan kecepatan yang skalabel dalam orde besaran, bukan sekadar perbaikan faktor konstan dari pemanfaatan hardware yang lebih baik
Jumlah iterasi tetap bagus untuk branch predictor, dan loop intinya juga dioptimalkan cukup ketat untuk hardware target sambil menghindari perkalian. Semua upaya untuk membuatnya lebih pintar justru memperburuk loop dan tidak menutup biayanya. Ini juga format array-of-structs ukuran 12, dan N umumnya kecil, jadi makin sulit
Saya lupa membookmark-nya, jadi kira-kira dua kali setahun saya mencarinya lagi
Karena itu B-tree menjadi standar di basis data. Datanya bisa apa saja, dan jika Anda menarik banyak row baru sekaligus, sifatnya bisa berubah besar kapan saja
Anda bisa merancang sesuatu yang mencoba mempercepat lookup dengan algoritma seperti gradient descent, tetapi B-tree sudah sangat cepat, punya performa terburuk dan kebutuhan I/O yang bisa diprediksi, serta banyak keunggulan seperti range scan, ordered traversal, dan kondisi prefix
Anda memang bisa membuat algoritma lookup yang lebih efisien untuk distribusi data tertentu, tetapi sering kali kehilangan sifat-sifat penting. Algoritma lain mungkin bisa memberi tebakan awal yang lebih dekat, tetapi justru lebih lambat menemukan item akhirnya, atau rata-ratanya lebih cepat namun performa terburuknya buruk sekali sehingga sulit dipakai di dunia nyata
Bahkan pada kamus kertas, setelah tebakan pertama orang biasanya hampir selalu memakai binary search, dan itu hanya mengurangi beberapa langkah perpindahan. Setelah sampai ke beberapa halaman yang benar, kita lalu cenderung melakukan pencarian linear lebih dari yang perlu; binary search yang ketat mungkin bisa lebih cepat, tetapi harus diseimbangkan dengan waktu membalik halaman
Ed Kmett kemudian mematangkan idenya menjadi library discrimination[2] untuk Haskell, dan presentasi teknis terkait[3] juga sangat menarik
[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
Jika salah, pekerjaan hasil tebakan dibuang dan ada penalty beberapa siklus sebelum mulai lagi dari titik lain
Artinya dari sudut pandang prosesor, semua loop sudah ter-unroll sampai tingkat tertentu, dan biaya utama binary search adalah mengambil data dari memori atau cache. Jadi permainan yang sebenarnya adalah mengeluarkan permintaan data yang akan dibutuhkan nanti sedini mungkin untuk mengurangi latensi
Quad search atau pencarian dengan derajat lebih tinggi melakukan prefetch dangkal untuk semua kemungkinan alih-alih prefetch dalam hanya di satu sisi tiap branch. Jadi pendekatan ini memastikan prefetch yang benar-benar akan dibutuhkan tetap dikeluarkan, sambil sedikit mengurangi bandwidth yang terpakai untuk data yang tidak akan dipakai di jalur eksekusi nyata
“Jumlah perbandingan” hampir tidak berguna sebagai metrik untuk membandingkan algoritma pencarian jika ingin memprediksi performa nyata. Bottleneck-nya bukan jumlah operasi perbandingan, melainkan seberapa baik memanfaatkan bandwidth memori dan cache. Jika perilaku branch pada prosesor modern ikut diperhitungkan, memang bisa dibilang ini semacam loop unrolling
Bagaimanapun, kedua pencarian sama-sama O(log N) dan hanya berbeda pada konstanta. Di kelas algoritma konstanta sering kurang penting, tapi di dunia nyata bisa cukup penting
Pada workload kami, ini memberi peningkatan kecepatan 8x
[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
Lalu lakukan binary search di antara 2048 dan 4096, dan Anda bisa menemukan user terbaru sekaligus, sebagai bonus, jumlah user-nya. Ini informasi yang cukup berguna saat meneliti perusahaan SaaS pesaing
Karena itu saya ingin mencoba pencarian ‘binary’ yang memeriksa semua nilai di “cache line tengah”, lalu jika tidak ada, bergerak ke kiri/kanan. Pencarian cache line bisa dilakukan dengan satu instruksi 512bit SIMD
Satu cache line berukuran 64 byte, yaitu 32 integer 16-bit, jadi pencarian seperti ini bisa hampir 32x lebih cepat daripada binary search sederhana. Setidaknya akses memorinya 32x lebih sedikit, dan pada sebagian besar program nyata inilah yang dominan
Dengan key 4 byte dan child pointer atau array index 4 byte, satu internal node bisa memuat 7 key, 8 child pointer, dan 1 next pointer sehingga pas memenuhi cache line 64 byte. Untuk 1 juta entri, depth tree turun dari sekitar 20 menjadi sekitar 7, dan beberapa level teratas besar kemungkinan tetap tinggal di cache
Anda juga bisa menerapkan SIMD pada node B-tree untuk mempercepat pencarian di dalam node, tetapi data dependency-nya sangat tinggi
Secara keseluruhan, saya suka artikel ini karena mengeksplorasi sesuatu yang sering saya penasaran dengan sangat tuntas sebagai semacam ablation study yang berguna
Yang tercepat untuk array kecil adalah linear branchless search. Loop-nya tidak pernah keluar di tengah dan selalu menyapu semua elemen. Misalnya, jika Anda hanya ingin tahu apakah suatu angka ada di array, cukup lakukan logical OR atas hasil pengecekan semua item
Saya tidak memakai SIMD, tetapi untuk array kecil biaya branch sangat mahal, jadi memeriksa semua elemen tanpa branch justru lebih cepat
Bagian SIMD hanya ada pada tahap akhir untuk mencari 16 elemen terakhir
Bagian Quad memeriksa 3 titik untuk membuat 4 jalur, tetapi yang dicari bukan sekadar key yang tepat, melainkan block yang tepat
Detailnya cukup menarik, karena penulis memakai elemen terakhir dari tiap block untuk quad search. Saya penasaran bagaimana algoritmanya berubah jika memakai elemen pertama tiap block atau elemen acak
Garis besarnya: 1) ambil beberapa elemen pada 16 posisi berjarak sama dengan gather, 2) bandingkan paralel dengan instruksi SIMD, 3) fokus ke block yang benar, 4) jika block sudah kecil pindah ke linear search, kalau belum ulangi siklus gather/compare
Instruksi gather membaca memori tak bersebelahan dan membaca lebih banyak daripada binary sort biasa, tetapi memungkinkan multiway compare dan melipat empat tahap binary search, jadi pada tabel besar mungkin menguntungkan
Namun, tidak semua tipe data mendukung perbandingan 16-arah penuh. Pencarian float64 terbatas pada 8-arah bahkan di AVX-512, tetapi int32 dan float32 bisa dibandingkan 16-arah
Saya rasa binary search tetap akan lebih cepat dalam praktik dibanding pendekatan berbasis gather
Begitu satu saja dari asumsi ini tidak berlaku, banyak tweak muncul untuk mendapatkan performa yang lebih baik
Kelebihan algoritma klasik adalah mereka memberi titik awal yang sangat bagus untuk mengembangkan solusi yang lebih optimal dan efisien ketika Anda sudah tahu lebih banyak tentang bentuk data tertentu atau karakteristik/quirk CPU tertentu
Semakin mendekati ujung tombak optimisasi, fokusnya biasanya bergeser ke bagaimana data disimpan dan diakses di memori, dan apakah perubahan untuk memperbaikinya tidak justru merugikan tahap-tahap berikutnya. Di pekerjaan saya dulu, seseorang menghabiskan banyak waktu mengoptimalkan bagian tertentu dari kode, tetapi optimisasi itu membuat lebih banyak informasi yang dibutuhkan nanti terdorong keluar dari cache sehingga seluruh aplikasi justru melambat
Ini mungkin sekadar parafrasa dari aturan pemrograman Rob Pike yang kelima, atau lebih jauh lagi dari Fred Brooks di The Mythical Man Month. Referensi: https://www.cs.unc.edu/~stotts/COMP590-059-f24/robsrules.htm...
Alih-alih membandingkan nilai tengah, bandingkan titik 1/3, lalu jika terlalu rendah bandingkan titik 2/3, dan seterusnya
Tetapi saya lalu menganggap bahwa dibanding binary search, ruang pencarian memang menyusut menjadi 1/3 alih-alih 1/2, namun jumlah perbandingan per langkah rata-rata jadi 3/2 kali lebih banyak, jadi akhirnya setara
Edit: seperti jawaban zamadatix, ternyata sebenarnya 5/3 kali. Untuk kasus 2/3 memang perlu 2 kali perbandingan
Sepertiga pertama butuh 1 perbandingan, sepertiga kedua 2, dan sepertiga ketiga juga 2, jadi rata-ratanya (1+2+2)/3 = 5/3. Mudah keliru karena terasa seperti “kadang 1 kali, kadang 2 kali, jadi 50%”, padahal peluang berhenti di perbandingan pertama hanya 1/3 dan peluang perlu 2 perbandingan adalah 2/3
Jadi ternary sedikit lebih buruk dalam jumlah perbandingan rata-rata keseluruhan. 5/3 * Log_3[n] = 1.052... * Log_2[n]
Artinya jumlah level memang berkurang, tetapi rata-rata Anda perlu lebih banyak perbandingan untuk sampai selesai. Ini berlaku cukup umum untuk pencarian dengan jumlah split lebih besar dari 2 dalam beberapa asumsi umum seperti nilai target berdistribusi seragam dan biaya operasi yang diidealkan. Perbedaan hardware nyata yang dibahas tulisan utama masuk tepat di titik ini
Hanya saja bukan sebagai versi ternary dari algoritma binary search. Itu bukan ternary search yang sebenarnya, melainkan lebih mirip binary search yang bias. Perbandingan itu pada dasarnya bersifat binary, jadi algoritma pencarian yang melibatkan perbandingan adalah sejenis binary search, dan memilih elemen selain yang di tengah kurang efisien dari sudut pandang kompleksitas algoritma. Tentu, pada hardware nyata dalam kondisi tertentu itu bisa lebih baik. Untuk benar-benar melakukan ternary search, operasi dasarnya perlu berupa 3-way comparison
Bagian menariknya muncul saat memikirkan radix efficiency[1], karena pilihan terbaik adalah bilangan bulat terdekat dengan e, yaitu 3. Ini juga relevan untuk tree search, sehingga ternary tree bisa lebih baik daripada binary tree
[1] https://en.wikipedia.org/wiki/Optimal_radix_choice
Ide itu mungkin tidak realistis pada CPU zaman dulu, tetapi bisa jauh lebih realistis pada CPU sekarang