Setelah mengendalikan cabang, kita menyerang asumsi paling dasar dari model eksekusi yang selama ini kita pakai: satu instruksi memproses satu data. Prosesor x86 modern membawa register lebar — 128, 256, bahkan 512 bit — beserta keluarga instruksi yang mengoperasikan seluruh isinya sekaligus. Pendekatan ini disebut SIMD (Single Instruction, Multiple Data): satu instruksi penjumlahan memproses empat pasang float dalam satu waktu, tanpa empat kali iterasi loop. Untuk program yang kerjanya seragam di atas banyak data — grafika, audio, simulasi numerik — SIMD adalah salah satu pengganda kecepatan terbesar yang tersedia, dan GCC sering memasangkannya untuk kita tanpa kita sadari.

Satu Instruksi, Banyak Data #

Bayangkan menambahkan dua kumpulan empat float. Cara scalar melakukan empat kali muat, empat kali tambah, empat kali simpan — ditambah empat kali iterasi loop dengan pengurusan penghitungnya. Cara SIMD: muat empat float ke satu register, muat empat float lagi, satu instruksi tambah, satu instruksi simpan. Register yang dipakai dibedakan namanya menurut lebarnya, dan lebar itu menentukan berapa elemen yang diproses sekaligus:

RegisterLebarIsi khas
SSE (__m128)128 bit4 × float atau 2 × double
AVX (__m256)256 bit8 × float atau 4 × double
AVX-512 (__m512)512 bit16 × float atau 8 × double

Angka-angka itu adalah kapasitas maksimum; instruksi yang benar-benar dipakai bergantung pada target arsitektur yang kita berikan ke compiler. Secara default GCC menyasar basis x86-64 yang menjamin hanya SSE2 (128 bit) — itulah mengapa laporan optimasi GCC di platform ini berbicara tentang “16 byte vectors”. Flag seperti -march=native membuka lebar yang lebih besar, dengan konsekuensi binary yang tidak lagi jalan di prosesor lama.

Auto-Vectorization: Compiler Menyusun SIMD untuk Kita #

Kabar baiknya: untuk loop yang reguler, compiler membangun versi SIMD-nya sendiri — proses yang disebut auto-vectorization, aktif pada -O2 dan lebih agresif pada -O3. Program berikut adalah dua loop yang tampak biasa; outputnya identik di semua level optimasi (kita sudah memeriksanya: -O0, -O2, dan -O3 menghasilkan byte yang sama), yang berubah hanya instruksi di balik layar:

cpp
// Loop reguler di atas data kontinu: kandidat ideal auto-vectorization.
// Output program identik pada semua level optimasi; yang berubah hanya
// instruksi yang disusun compiler di balik layar.
#include <iostream>
#include <vector>

long long jumlah(const std::vector<int>& data) {
    long long total = 0;
    for (int x : data) total += x;
    return total;
}

void kaliDua(std::vector<float>& data) {
    for (float& x : data) x *= 2.0f;
}

int main() {
    std::vector<int> a(4096);
    for (std::size_t i = 0; i < a.size(); ++i) a[i] = static_cast<int>(i % 7);

    std::vector<float> b(4096);
    for (std::size_t i = 0; i < b.size(); ++i) b[i] = static_cast<float>(i) + 0.5f;
    kaliDua(b);

    std::cout << "jumlah a : " << jumlah(a) << '\n';
    std::cout << "b[0]     : " << b[0] << '\n';
    std::cout << "b[4095]  : " << b[4095] << '\n';
    return 0;
}

Output yang Dihasilkan:

text
jumlah a : 12285
b[0]     : 1
b[4095]  : 8191

Untuk melihat apa yang compiler lakukan di balik layar, GCC menyediakan opsi diagnostik -fopt-info-vec yang melaporkan loop mana yang berhasil divetorisasi (laporan ditulis ke stderr):

bash
g++ -std=c++23 -O3 -fopt-info-vec vektorisasi.cpp -o vektorisasi

Hasil Eksekusi (laporan optimasi; stabil untuk file dan flag yang sama):

text
vektorisasi.cpp:9:18: optimized: loop vectorized using 16 byte vectors
vektorisasi.cpp:14:21: optimized: loop vectorized using 16 byte vectors
vektorisasi.cpp:14:21: optimized: loop vectorized using 8 byte vectors
vektorisasi.cpp:9:18: optimized: loop vectorized using 16 byte vectors
vektorisasi.cpp:14:21: optimized: loop vectorized using 16 byte vectors
vektorisasi.cpp:22:31: optimized: loop vectorized using 16 byte vectors

Tiga loop program kita dilaporkan: baris 9 adalah loop penjumlahan di jumlah, baris 14 adalah loop kaliDua, dan baris 22 adalah loop pengisi vector di main. “16 byte vectors” berarti empat int atau empat float per instruksi; laporan bisa menyebut baris yang sama lebih dari sekali karena compiler menyiapkan beberapa versi loop — versi utama dan versi ekor untuk sisa elemen yang tidak mengisi register penuh (versi 8 byte pada baris 14, misalnya). Syarat utama loop agar layak diverisasi bisa diringkas: akses data kontinu dan beraturan, jumlah iterasi yang bisa ditentukan sebelum loop berjalan, dan tidak ada ketergantungan antar iterasi yang memaksa urutan. Kabar buruknya: begitu loop bertemu cabang per elemen atau data tersebar, compiler menyerah dan kembali ke loop scalar — tanpa suara apa pun, jadi biasakan memeriksa laporannya.

Intrinsics: Menulis SIMD Secara Eksplisit #

Bila kita ingin memegang kendali penuh — memilih operasi, register, dan urutannya — C++ menyediakan intrinsics: fungsi khusus yang dipetakan compiler hampir satu-satu ke instruksi SIMD. Platform uji ini mendukung SSE, sehingga contoh pertama bisa kita jalankan dan verifikasi langsung:

cpp
// Pengantar intrinsics: keluarga fungsi _mm_* dari <immintrin.h> dipetakan
// hampir satu-satu ke instruksi SIMD x86. _mm_add_ps menjumlahkan dua
// kelompok empat float dalam satu instruksi; versi scalar dibandingkan
// di bawahnya untuk membuktikan hasil kerjanya sama persis.
#include <immintrin.h>
#include <iomanip>
#include <iostream>

int main() {
    float a[4] = {1.5f, 2.5f, 3.5f, 4.5f};
    float b[4] = {10.0f, 20.0f, 30.0f, 40.0f};
    float c[4] = {};

    // Jalur SIMD: muat dua kelompok, jumlahkan sekaligus, simpan hasilnya.
    __m128 va = _mm_loadu_ps(a);      // muat 4 float (tidak wajib 16-byte aligned)
    __m128 vb = _mm_loadu_ps(b);
    __m128 vc = _mm_add_ps(va, vb);   // satu instruksi untuk empat penjumlahan
    _mm_storeu_ps(c, vc);

    // Jalur scalar: empat penjumlahan terpisah, hasil yang sama.
    float s[4] = {};
    for (int i = 0; i < 4; ++i) s[i] = a[i] + b[i];

    std::cout << std::fixed << std::setprecision(2);
    std::cout << "simd   : " << c[0] << ' ' << c[1] << ' ' << c[2] << ' ' << c[3] << '\n';
    std::cout << "scalar : " << s[0] << ' ' << s[1] << ' ' << s[2] << ' ' << s[3] << '\n';

    __m128 vd = _mm_mul_ps(vc, va);   // vc * va elemen-per-elemen
    _mm_storeu_ps(c, vd);
    std::cout << "c * a  : " << c[0] << ' ' << c[1] << ' ' << c[2] << ' ' << c[3] << '\n';
    return 0;
}

Output yang Dihasilkan:

text
simd   : 11.50 22.50 33.50 44.50
scalar : 11.50 22.50 33.50 44.50
c * a  : 17.25 56.25 117.25 200.25

Bedah mekanismenya:

  1. __m128: tipe register 128-bit yang berisi empat float. Tipe ini tidak untuk dibaca langsung — ia dikelola compiler dan umumnya menetap di register SIMD sungguhan.
  2. _mm_loadu_ps(a): memuat empat float dari array ke register. Akhiran u berarti unaligned — tidak menuntut alamat kelipatan 16 byte; versi _mm_load_ps sedikit lebih cepat tetapi berdakwa alamatnya sejajar (siapkan dengan alignas(16)).
  3. _mm_add_ps(va, vb): satu instruksi addps yang menjumlahkan keempat pasangan float sekaligus — inilah inti SIMD-nya.
  4. _mm_mul_ps(vc, va): perkalian elemen-per-elemen pada hasil sebelumnya, menunjukkan bahwa operasi berantai tetap berada di dalam register.
  5. _mm_storeu_ps(c, vc): menuliskan empat float dari register kembali ke array, sehingga hasilnya bisa diperiksa oleh jalur scalar — yang tercetak identik, membuktikan hasil kerjanya sama.

Alternatif Menengah: Vector Extensions GCC #

Di antara “biarkan compiler bekerja” dan “tulis intrinsics penuh”, GCC dan Clang menyediakan vector extensions: tipe vektor yang dideklarasikan dengan atribut vector_size, lalu dioperasikan dengan operator biasa:

cpp GCC vector extensions — operator + dan * bekerja per elemen
#include <iostream>

int main() {
    using V4 = float __attribute__((vector_size(16)));   // 4 float
    V4 a{1.0f, 2.0f, 3.0f, 4.0f};
    V4 b{10.0f, 20.0f, 30.0f, 40.0f};
    V4 c = a + b;    // satu operasi untuk empat pasangan
    V4 d = c * a;    // berantai tanpa intrinsics eksplisit
    for (int i = 0; i < 4; ++i) std::cout << d[i] << ' ';
    std::cout << '\n';
    return 0;
}

Pada platform uji, program ini menghasilkan 11 44 99 176 — operasi yang sama mudahnya dengan intrinsics tetapi tanpa satu pun nama _mm_*. Atribut vector_size bukan bagian standar ISO C++, tetapi didukung GCC dan Clang dan jauh lebih mudah dibaca daripada intrinsics mentah; compiler yang memilih instruksi SIMD yang cocok untuk target yang diberikan.

Kapan SIMD Tepat #

SIMD menggandakan kecepatan ketika pekerjaannya seragam, bukan ketika kodenya terlihat matematis. Ciri-ciri situasi yang cocok:

  • Loop aritmetis panjang di atas data kontinu — penjumlahan, perkalian, penyaringan nilai; makin reguler akses memorinya, makin besar porsinya yang bisa di-SIMD-kan (pola struct of arrays dari chapter Data-oriented Design adalah pasangan idealnya).
  • Jumlah data besar — biaya memuat dan menyimpan register lebar terbayar oleh banyaknya elemen per instruksi; untuk empat elemen, loop scalar yang sudah dioptimasi bisa saja menang.
  • Tanpa cabang per elemen — SIMD menjalankan instruksi yang sama untuk seluruh slot register; cabang yang berbeda-beda per elemen memaksa trik mahal seperti pemaskeran.

Sebaliknya, jangan mengharapkan banyak dari array pendek, data yang tersebar di memori, atau logika yang bercabang untuk setiap elemen. Dan karena ini section yang menuntut bukti: setiap klaim “SIMD lebih cepat” pada kode Anda sendiri harus lolos benchmark ala chapter Benchmarking, lengkap dengan pemeriksaan -fopt-info-vec bila mengandalkan auto-vectorization.

Sekarang seluruh kotak alat section ini sudah terisi — dari profiling sampai SIMD. Tinggal satu keputusan yang menentukan apakah kotak alat itu menjadi berkah atau bencana: kapan waktunya memakainya. Chapter penutup section ini membahasnya.

Rujukan resmi: GCC Vector Extensions dan GCC Developer Options (-fopt-info) .