- SIMD bukan teknik rumit yang hanya untuk perangkat lunak dengan performa tertinggi, melainkan sarana optimasi sehari-hari yang mempercepat loop biasa dengan memproses data berurutan beberapa nilai sekaligus
- Kode SIMD umum mengikuti struktur 5 tahap: broadcast konstanta, iterasi per vektor, operasi paralel, reduksi/penyimpanan hasil, dan penanganan ekor skalar
- Loop pencarian code point di Ghostty membandingkan 4, 8, atau 16
u32 sekaligus, dan secara teoretis dapat meningkatkan throughput hingga 4x pada ARM NEON, 8x pada AVX2, dan 16x pada AVX-512
- Pada desktop Intel dengan AVX2, throughput terminal secara keseluruhan menjadi sekitar 5x lebih cepat, dan bila tidak ada lebar vektor yang didukung atau masih ada input tersisa, loop skalar lama akan menangani seluruh input atau sisanya
- Auto-vectorization dari compiler dapat melewatkan peluang bahkan pada loop sederhana, jadi sebaiknya periksa dulu output yang sudah dioptimalkan, tetapi untuk hot loop penting, SIMD eksplisit dapat menjaga perilaku dan performa tetap dapat diprediksi
Apa yang dilakukan SIMD
- SIMD memungkinkan CPU memproses banyak nilai secara paralel dengan satu instruksi
- Alih-alih membandingkan byte satu per satu, Anda bisa membandingkan 4, 8, atau lebih sekaligus
- Loop seperti
for (byte in bytes), for (character in string), for (value in array) punya peluang untuk diubah menjadi pemrosesan per lebar vektor
- Jika datanya ratusan, ribuan, atau jutaan byte, Anda bisa memperoleh percepatan lokal 4x, 8x, atau lebih bergantung pada lebar paralelnya
- Jika datanya hanya beberapa atau puluhan elemen, menerapkan SIMD tidak layak dilakukan
- simdutf dan simdjson memakai teknik SIMD yang kompleks, tetapi SIMD sehari-hari tidak perlu serumit itu
- Contohnya menggunakan Zig, tetapi struktur 5 tahap ini juga berlaku di bahasa lain, dan tiap bahasa memiliki cara berbeda dalam mendukung instruksi SIMD
Struktur 5 tahap yang berulang
- Broadcast konstanta yang diperlukan ke semua lane, dan jika perlu inisialisasi akumulator vektor
- Iterasi input sebesar lebar vektor sekaligus
- Jalankan perbandingan atau operasi aritmetika secara paralel di semua lane
- Reduksi atau simpan hasil vektor sesuai algoritme
- Tangani sisa yang tidak muat dalam vektor penuh dengan ekor skalar (scalar tail) dari loop lama
- Jika sudah terbiasa dengan struktur ini, Anda bisa memecah loop biasa ke dalam 5 tahap yang sama sehingga menulis SIMD menjadi sesederhana loop skalar
- Jika tidak bisa dinyatakan sederhana dengan struktur ini, untuk saat ini lebih tepat melewatkan penerapan SIMD
Loop pencarian nyata di Ghostty
- Ghostty mengonsumsi data dari array code point yang sudah didekodekan sampai menemukan nilai
0xF atau lebih kecil
- Sebagian besar data terminal adalah karakter biasa yang akan ditampilkan, jadi ini diproses berkelompok
- Loop ini mencari akhir rentang yang bisa ditampilkan berikutnya secepat mungkin
- Implementasi skalar aslinya memeriksa code point satu per satu
while (end < cps.len and cps[end] > 0xF) end += 1;
- Implementasi vektornya memakai vektor umum tanpa intrinsic khusus CPU, dan menambah 12 baris kode dibanding implementasi skalar
- Peningkatan throughput yang diharapkan sesuai dengan jumlah lane vektor
- ARM NEON dan Apple Silicon: hingga 4x
- AVX2, yang didukung sebagian besar CPU x86 modern: hingga 8x
- AVX-512, yang didukung sebagian CPU Intel dan AMD Zen 4 ke atas: hingga 16x
- Pada desktop Intel dengan AVX2, throughput keseluruhan yang diukur dari input program terminal hingga status terminal akhir menjadi sekitar 5x lebih cepat
- Tidak semua percepatan teoretis tercapai karena ada pekerjaan tambahan di sekitar SIMD
- Karakter kontrol C0 juga ada setelah
0xF, tetapi 0xF adalah ambang yang dipakai di jalur kode Ghostty ini
- ESC dan urutan kontrol lain ditangani di jalur terpisah
Tahap 1: Broadcast konstanta
if (simd.lanes(u32)) |lanes| {
const V = @Vector(lanes, u32);
const threshold: V = @splat(0xF);
simd.lanes(u32) di Ghostty mengembalikan jumlah u32 yang bisa diproses CPU target secara bersamaan
- Tiap nilainya disebut lane
- ARM mengembalikan 4, AVX2 mengembalikan 8, dan AVX-512 mengembalikan 16
- Jika tidak ada ukuran vektor yang bisa dipakai, fungsi ini mengembalikan
null dan melewati kode SIMD
@Vector(lanes, u32) membuat tipe vektor dengan jumlah lane tersebut
- Jika
lanes adalah 8, maka satu V memuat 8 u32 yang bisa diproses paralel
- Karena perbandingan vektor memerlukan kedua sisi berupa vektor,
@splat(0xF) menggandakan 0xF ke semua lane
{ 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
- Algoritme ini tidak memerlukan akumulator vektor, tetapi algoritme lain bisa menginisialisasi akumulator pada tahap ini
Tahap 2: Iterasi satu vektor demi satu vektor
while (end + lanes <= cps.len) : (end += lanes) {
const values: V = cps[end..][0..lanes].*;
- Jika
lanes adalah 8, loop hanya masuk ketika masih tersisa setidaknya 8 nilai, lalu memuat 8 nilai itu ke values
- Di akhir tiap iterasi,
end ditambah bukan 1, melainkan sebesar jumlah lane
- Karena harus bisa memuat vektor penuh, jika hanya tersisa 5 nilai maka vektor 8 lane tidak akan dibaca
- Nilai yang tidak masuk ke vektor akan ditangani oleh ekor skalar di tahap 5
Tahap 3: Perbandingan paralel di semua lane
const greater_than_threshold = values > threshold;
- Karena
values dan threshold sama-sama vektor, > membandingkan tiap lane yang bersesuaian sebagai satu operasi vektor
- Jika 8 lane, maka 8 perbandingan yang setara dengan
cps[end] > 0xF dijalankan secara paralel
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 }
- Tidak ada loop internal eksplisit, dan hasilnya adalah vektor yang berisi boolean per lane
- Selain perbandingan, struktur yang sama juga bisa diterapkan pada penjumlahan, perkalian, nilai minimum, maksimum, dan operasi lain yang didukung tipe vektor
- Perbandingannya sendiri adalah satu operasi vektor, tetapi pemuatan vektor, reduksi hasil, dan pencarian lane yang gagal tetap memerlukan instruksi tambahan
Tahap 4: Reduksi hasil vektor
if (@reduce(.And, greater_than_threshold)) continue;
@reduce(.And, ...) menggabungkan semua boolean dengan and menjadi satu boolean
- Jika semua lane bernilai
true, lanjut ke vektor berikutnya; jika ada satu saja false, cari posisi pasti yang gagal
const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;
@bitCast mengubah vektor boolean menjadi mask integer 1 bit per lane
1 berarti nilainya lebih besar dari 0xF
0 berarti perbandingannya gagal
- Jika mask dibalik, perbandingan yang gagal menjadi
1, dan @ctz menghitung jumlah bit 0 sebelum 1 pertama
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 }
- Dalam contoh ini,
@ctz(~mask) mengembalikan 3, dan memindahkan end ke lane ke-3 tempat karakter kontrol pertama 0x0A berada
- Reduksi hasil adalah bagian yang paling berbeda antaralgoritme dalam 5 tahap ini
- Penjumlahan dapat mereduksi akumulator vektor menjadi satu angka
- Transformasi dapat menyimpan seluruh vektor ke buffer output
- Pencarian ini membuat mask bit untuk menemukan posisi lane tertentu
Tahap 5: Penanganan ekor skalar
while (end < cps.len and cps[end] > 0xF) end += 1;
- Jika panjang input bukan kelipatan tepat dari lebar vektor, loop skalar asli akan menangani sisanya
- Setelah loop vektor 8 lane, bisa tersisa 0 sampai 7 nilai
- Pada CPU yang
simd.lanes(u32)-nya null, bagian SIMD dilewati dan loop skalar menangani seluruh input
- Implementasi asli sekaligus menangani pemrosesan sisa dan fallback kompatibilitas
- Vektor umum hanya menghilangkan sintaks khusus CPU, bukan berarti menghapus pembuatan kode khusus CPU
- Zig menerjemahkan operasi vektor ke instruction set yang aktif pada target
Yang terlewat oleh auto-vectorization
- Compiler dapat melakukan auto-vectorization pada kode sederhana seperti loop aritmetika teratur tanpa alur kontrol yang rumit
- Sebelum menulis SIMD manual, Anda perlu mengompilasi versi skalar dengan opsi optimasi dan memeriksa kode yang dihasilkan
- Compiler produksi sering melewatkan peluang vektorisasi, dan auto-vectorization sudah diteliti selama puluhan tahun, tetapi riset terbaru pun masih berangkat dari masalah ini
- Jika sebuah loop cukup penting hingga percepatan 5x berarti besar, Anda dapat menulis vektorisasinya secara eksplisit agar perilakunya tetap dapat diprediksi
- Ini membantu menghindari situasi ketika perubahan kode yang tidak terkait atau pembaruan compiler diam-diam mengembalikan loop vektor menjadi loop skalar
Sejauh mana SIMD perlu dikuasai pengembang
- Saat menemukan hot loop yang mencari, membandingkan, menghitung, atau mentransformasikan data berurutan dalam jumlah besar, Anda perlu bisa mempertimbangkan pemrosesan per lebar vektor
- SIMD sehari-hari mengikuti bentuk yang teratur: menyiapkan konstanta, memuat vektor, operasi paralel, reduksi hasil, dan ekor skalar
- Jika bahasanya mendukung SIMD dengan baik, Anda bisa meningkatkan performa tanpa harus memahami assembly atau detail spesifik CPU secara langsung
- Tingkat yang dibutuhkan semua pengembang bukanlah teknik rumit ala
simdutf atau simdjson, melainkan kemampuan mengenali peluang SIMD dan memanfaatkan struktur umumnya
1 komentar
Komentar Hacker News
Tulisan yang bagus, tetapi memulai dengan mengatakan bahwa SIMD mudah dipahami dan semudah menulis loop for, lalu dari contoh pertama langsung mengubah satu baris kode skalar menjadi 12 baris terasa kurang meyakinkan
Lebih baik jujur saja mengatakan bahwa SIMD itu sulit, tetapi hasilnya sepadan. Jika sasarannya pemula, istilah khusus SIMD seperti
broadcastseharusnya tidak dipakai tanpa penjelasan sejak tahap 1, sementara tahap 5 yang menjelaskan penanganan ekor skalar tersusun dengan baikKita harus memahami berapa banyak item yang bisa diproses hardware sekaligus, mengelompokkan pekerjaan sesuai ukuran itu, membongkar lagi hasilnya, menangani item tersisa secara terpisah, dan membuat konstanta sebagai vektor yang diduplikasi. Masing-masing tidak sulit, tetapi menambah beban kerja dan membuatnya merepotkan
Fitur favorit saya adalah
par(; ; ), yang di bawah beberapa kondisi batas tertentu memungkinkan compiler memparalelkan loop for secara otomatisJika topiknya memang rumit, pecahlah menjadi bagian yang lebih kecil dan lebih sederhana, lalu susun urutannya dengan baik agar orang bisa menaiki kurva belajar yang curam, sekaligus meyakinkan mereka bahwa itu memang sepadan
Saran yang lebih baik adalah semua orang perlu memahami pemrograman array. Optimasi SIMD umumnya memerlukan cara berpikir itu, dan teknik yang benar-benar khusus untuk packed SIMD ternyata tidak terlalu banyak
Pemrograman array memudahkan compiler melakukan auto-vectorization, sehingga meski tidak menulis SIMD secara langsung, biasanya tetap menghasilkan kode dengan performa baik
Pengalaman saya tidak banyak, tetapi Julia tampaknya paling dekat dengan bahasa yang lebih modern dan ekspresif dengan kemampuan vektorisasi serupa
Beberapa hari terakhir saya mengoptimalkan operasi matriks dalam proyek bioinformatika dengan AVX-512, dan hasilnya sangat memuaskan
Kebanyakan aplikasi terhambat oleh proses membaca dataset besar dari memori, jadi alih-alih membacanya berulang kali untuk beberapa operasi, semuanya bisa diselesaikan sekaligus dengan register AVX dan kernel terfusi. Peningkatan 5x itu umum terjadi, dan meski saya memakai intrinsic secara langsung, operasi umum menjadi sangat mudah jika memakai crate
wide: https://docs.rs/wide/latest/wide/Mayoritas besar developer sama sekali tidak perlu belajar SIMD. Saya heran kenapa ini dibuat seolah semua developer harus tahu ini agar dianggap developer sungguhan
Judulnya lebih baik diubah menjadi “semua orang harus tahu kapan SIMD tidak diterapkan”
Compiler modern sangat bagus dalam vektorisasi, tetapi bisa tiba-tiba mundur ke kode skalar hanya karena satu asumsi atau satu percabangan yang bergantung pada data. Mengetahui cara memeriksa laporan optimasi compiler mungkin lebih berharga daripada mengetahui cara menulis SIMD
Kalau cuma bisa mengidentifikasi masalahnya, akhirnya mentok di “sayang sekali”
Tahun lalu saya mulai mempelajari SIMD x86 dan ARM sambil membuat synthesizer audio: https://github.com/seclorum/SIMDSynth
Arsitektur synthesizer multi-timbre dan polifonik sangat cocok untuk mempelajari prinsip SIMD karena menerapkan pemrosesan yang sama ke beberapa aliran data. Namun debugging cukup sulit, sehingga saya sangat membutuhkan simulator yang bisa membantu memahami status tiap pipeline pemrosesan, dan menyelidiki alat SIMD tampaknya memerlukan investasi besar lagi
Tulisannya bagus dan akan menyenangkan jika lebih banyak bahasa mendukung SIMD, tetapi dalam keadaan dua bahasa paling populer masih tidak mendukung SIMD secara native, ungkapan “semua programmer harus tahu” terasa agak aneh
Meski Anda tidak berniat menulis SIMD sendiri atau berencana menyerahkannya ke AI, Anda tetap perlu tahu pekerjaan seperti apa yang bisa dipercepat dengan SIMD di hardware tertentu. Dengan begitu Anda bisa merancang algoritme dan struktur kode agar memungkinkan penerapan SIMD
Dampak dependensi data, biaya memperlebar lebar elemen vektor dan cara menghindarinya, cara mengubah kondisi dan percabangan menjadi mask, serta sifat seperti “tidak ada instruksi pembagian” jauh lebih mudah dipahami jika pernah sedikit saja memakai SIMD secara langsung
SIMD bekerja baik saat memeriksa atau mengubah data kontinu berukuran besar sekaligus, tetapi jika Anda harus mengambil keputusan setiap beberapa byte input, performanya bisa sama saja atau malah lebih lambat daripada pendekatan skalar. SIMD bukan tombol akselerasi ajaib
Ini video yang berguna ketika Casey Muratori menjelaskan bagaimana tim pengembang The Witness menyelesaikan masalah performa nyata dengan SIMD: https://www.youtube.com/watch?v=Ge3aKEmZcqY
Ini contoh bagus integrasi vertikal demi performa: setelah memahami kenapa abstraksi umum itu ada dan kenapa ia harus bersifat general-purpose, mereka menunjukkan bagaimana untuk kasus penggunaan tertentu orang bisa mengintegrasikan semuanya secara vertikal dari definisi masalah sampai SIMD dan mendapat keuntungan besar
Sebelum masuk ke mikro-optimalisasi seperti SIMD, struktur data dan pola akses harus ditinjau serius terlebih dahulu
Dulu saya menerapkan SIMD pada kode Zig, tetapi model struktur datanya justru berlawanan dengan arah optimisasi, seperti memasang ban balap berperforma tinggi pada mobil rongsokan dengan mesin rusak. Itu adalah contoh klasik optimisasi prematur: tidak mengukur performa dan bahkan tidak mempertimbangkan lokasi alokasi memori
Sekarang saya melihat data seperti tabel SQL dan merancang strukturnya dengan berfokus pada kemungkinan primary key serta pola akses. Dulu saya memakai tree yang menunjuk ke struct lain di heap, sehingga sekaligus menanggung kelemahan linked list, fragmentasi dari banyak vector di heap, dan biaya pembuatan/pembebasan yang lambat; bahkan
Dropsaja memakan porsi besar dari waktu eksekusiTree selalu bisa dilinearkan, jadi saya meninjau pola akses/penyisipan, apakah itu benar-benar tree atau graf lain, dan apakah sebaiknya disimpan dalam
Vecatau struct dari beberapaVec. Hasilnya, kode menjadi lebih cepat dan lebih sederhana, data terkumpul dalam array homogen sehingga lebih mudah memanfaatkan optimisasi SIMD dari compiler dan cache L1, dan saat perlu saya juga bisa menulis kode SIMD tanpa branch secara langsungReferensi terkait: https://hn.algolia.com/?dateRange=all&page=0&prefix=true&que...
Pada bottleneck yang nyata, alokasi memori, lookup virtual function table, dan indirection yang berlebihan harus dihindari. Bahkan
vectordi C++ juga tidak selalu menjadi pilihan terbaik jika bisa memicu alokasi yang tak terdugaSebaliknya, kode berorientasi data hampir selalu mudah mendukung threading dan SIMD
Menariknya, kode CPU pada akhirnya juga ditulis dengan gaya GPU, dan salah satu caranya adalah memakai struct of arrays ala Parquet alih-alih array of objects