Rahasia Zero-Cost Abstraction di Rust, Dibedah dari Assembly

"Abstraksi tinggi selalu lebih lambat" itu anggapan lama. Iterator Rust bisa menyamai loop manual, dan assembly-nya bisa kita baca sendiri. Yuk, kita bongkar!

Ambil satu array angka, lalu tulis ini di JavaScript:

JS
const total = angka
  .filter((x) => x % 2 === 0)
  .map((x) => x * 2)
  .reduce((a, b) => a + b, 0);

Satu baris, enak dibaca, rapi banget, kan? 😅 Tapi kita semua tahu harganya: filter bikin array baru, map bikin array baru lagi, lalu reduce jalan di atas hasilnya. Buat array berisi sejuta angka, itu artinya dua array sementara yang harus dialokasikan, dibuang, dan dibersihkan Garbage Collector, padahal hasil akhirnya cuma satu bilangan.

Itu sebabnya programmer yang berburu performa tinggi sering turun kasta: nulis loop for mentah, pakai pointer manual, dan merelakan kodenya jadi lebih panjang demi satu-dua milidetik. Selama beberapa dekade, itu memang aturan tak tertulisnya: makin tinggi level abstraksi, makin mahal ongkos runtime-nya.

Rust menantang aturan itu dengan prinsip yang diwarisi dari dunia C++: Zero-Cost Abstractions. Bjarne Stroustrup, pencipta C++, merumuskannya seperti ini:

Secara umum, implementasi C++ mengikuti prinsip zero-overhead: yang nggak kamu pakai, nggak kamu bayar. Dan yang kamu pakai, nggak bisa kamu tulis sendiri dengan lebih efisien.

Kedengarannya kayak slogan marketing, ya. Makanya kita nggak akan percaya begitu saja. Yuk, kita bongkar assembly-nya sendiri, pakai rustc 1.99 dengan LLVM di belakangnya.

Poin Penting

  • Zero-cost abstraction bukan berarti bebas biaya, tapi memindahkan biaya itu ke tahap kompilasi supaya runtime-nya bersih.
  • Monomorphization menyalin fungsi generik untuk tiap tipe konkret, jadi kompiler bisa membuang lapisan abstraksinya sepenuhnya. Fungsi yang isinya dua pemanggilan trait menyusut jadi mov eax, 8 dan ret.
  • Rantai iterator di Rust terukur secepat loop manual karena nggak ada alokasi heap, pengecekan batas hilang, dan loop-nya berubah jadi instruksi SIMD.
  • Closure diterjemahkan jadi struct anonim di stack, dan trait Fn, FnMut, atau FnOnce yang dipilih kompiler menunjukkan cara dia memakai variabel di sekitarnya.
  • Yang tetap dibayar: waktu build lebih lama dan ukuran biner yang bisa membengkak akibat duplikasi fungsi untuk tiap tipe konkret.

Jadi apa sih "zero-cost" itu?

Kata "zero" di sini bukan berarti gratis tanpa ongkos sama sekali. Ongkosnya tetap ada, cuma tempat membayarnya yang dipindah: dari runtime ke compile-time. Biayanya pindah, bukan hilang.

rustc dan LLVM bekerja ekstra keras saat proses build. Mereka menyalin fungsi generik untuk tiap tipe konkret, membuka abstraksi berlapis jadi kode datar, lalu membuang seluruh pembukuan yang cuma jadi beban. Yang tersisa di berkas biner adalah instruksi mesin biasa, sering kali sama persis dengan versi manualnya.

Tiga mekanisme utama yang dipakai untuk itu: monomorphization, iterator, dan closure.

Rahasia 1: monomorphization, polimorfisme tanpa lompatan runtime

Di bahasa yang pakai dynamic dispatch seperti Java atau Python, memanggil method dari sebuah interface berarti CPU harus mencari alamat fungsinya dulu di runtime, biasanya lewat vtable. Biaya satu lompatan emang kecil, tapi efeknya besar: kompiler cuma bisa melihat dari luar fungsi yang dipanggil, jadi dia kehilangan kesempatan membuang kode mati atau menyatukan perhitungan.

Rust bisa memilih jalur lain. Coba kode ini:

RUST
trait Hitung {
    fn nilai(&self) -> i32;
}

struct A;
impl Hitung for A {
    fn nilai(&self) -> i32 { 3 }
}

struct B;
impl Hitung for B {
    fn nilai(&self) -> i32 { 5 }
}

fn pakai<T: Hitung>(x: T) -> i32 {
    x.nilai()
}

pub fn statis() -> i32 {
    pakai(A) + pakai(B)
}

pub fn dinamis(d: &dyn Hitung) -> i32 {
    d.nilai()
}

Dua fungsi terakhir hasilnya sama-sama 8, tapi jalannya beda jauh. Ini yang dihasilkan rustc -O untuk statis() nih:

ASM
statis:
    mov     eax, 8
    ret

Dua instruksi 🤯 Tanpa pemanggilan fungsi, tanpa tabel method, tanpa jejak A atau B. Kompiler sudah menghitung semuanya di compile-time dan menyisakan jawabannya saja.

Sekarang versi &dyn, satu-satunya cara Rust memaksa dynamic dispatch:

ASM
dinamis:
    jmp     qword ptr [rsi + 24]

Satu instruksi, tapi isinya lompat ke alamat yang baru diketahui saat program jalan, dibaca dari vtable pada offset 24. Kompiler berhenti di situ: dia nggak tahu fungsi apa yang bakal dipanggil, jadi nggak ada yang bisa di-inline.

Proses penyalinan inilah yang disebut monomorphization. Waktu kamu menulis pakai::<A>() dan pakai::<B>(), Rust benar-benar membuat dua fungsi berbeda di berkas biner, satu untuk A dan satu untuk B. Kamu tetap bisa memaksa dynamic dispatch kapan pun kamu mau, dan kadang itu memang keputusan yang benar. Bedanya, di Rust itu pilihan, bukan satu-satunya jalan.

Rahasia 2: iterator secepat loop manual

Balik ke masalah di pembuka: rantai .filter().map().sum() di Rust.

RUST
let numbers: Vec<i32> = (1..=10).collect();

let functional: i32 = numbers
    .iter()
    .filter(|&&x| x % 2 == 0)
    .map(|&x| x * 2)
    .sum();

let mut manual = 0;
for &x in &numbers {
    if x % 2 == 0 {
        manual += x * 2;
    }
}

println!("iterator = {functional}, for loop = {manual}");

Dijalankan, hasilnya iterator = 60, for loop = 60, sama seperti yang kita harapkan.

Yang menarik bukan hasilnya, tapi apa yang terjadi setelah kompilasi. Dua fungsi itu dikompilasi terpisah dengan rustc -O -C panic=abort, lalu instruksinya dihitung satu per satu:

  • Versi iterator: 53 instruksi, nol instruksi call.
  • Versi loop manual: 57 instruksi, nol instruksi call.

Versi iterator malah punya empat instruksi lebih sedikit, bukan lebih banyak. Dua-duanya juga berisi instruksi vektor yang sama: movdqu, pand, paddd, dan pcmpeqd. Artinya loop-nya diubah jadi SIMD 128-bit yang memproses empat bilangan i32 sekaligus. Kalau -C target-feature=+avx2 ditambahkan, keduanya naik ke instruksi ymm dan jumlahnya jadi 103 lawan 107, dengan pola yang sama.

Gimana kompiler bisa sejauh itu? Tiga hal:

  1. Tanpa alokasi di heap. Iterator Rust tuh struct kecil di stack yang cuma menyimpan posisi penelusuran. Nggak ada objek baru yang dibuat di tengah rantai.
  2. Pengecekan batas ikut hilang. Waktu kamu menulis array[i], kompiler wajib menyisipkan pemeriksaan i < len di runtime supaya akses di luar batas bisa dicegah. Lewat iterator, panjang dan arah penelusurannya bisa dibuktikan di compile-time, jadi pengecekan per elemen itu nggak perlu ada lagi. Di assembly di atas, yang tersisa cuma penambahan pointer biasa.
  3. Hasilnya jadi ramah vektorisasi. Rantai iterator adalah mesin status yang bersih dan tanpa efek samping, jadi LLVM gampang mengenalinya sebagai pola map-filter-reduce dan menggantinya dengan instruksi SIMD.

Ini bukan cuma teori di kertas. Buku resmi The Rust Programming Language pernah mengukur dua versi fungsi pencarian kata di teks Sherlock Holmes: versi loop 19.620.300 ns per iterasi, versi iterator 19.234.900 ns. Versi iterator sedikit lebih cepat. Angka itu dari mesin penulis buku di edisi pertama, jadi anggap petunjuk arah, bukan patokan mutlak.

Rahasia 3: closure itu cuma struct anonim

Di bahasa ber-Garbage Collector, closure yang menangkap variabel dari luar biasanya butuh alokasi di heap: nilai yang ditangkap dibungkus jadi objek supaya umurnya lebih panjang dari fungsi asalnya.

Di Rust caranya beda. Closure diterjemahkan jadi struct anonim yang isinya variabel yang ditangkap, dan disimpan di stack seperti nilai biasa:

RUST
let batas = 5;
let dibaca = |x: i32| x > batas;                 // cuma membaca  -> Fn
let mut hitungan = 0;
let mut menambah = |x: i32| { hitungan += x; };  // mengubah      -> FnMut
let pemilik = String::from("halo");
let memindah = move || pemilik.len();            // memindahkan   -> FnOnce

Bedanya cuma soal apa yang dilakukan closure itu ke variabel di sekitarnya, dan kompiler yang menentukannya:

  • Kalau closure-nya cuma membaca, dia meminjam referensi biasa dan mengimplementasikan trait Fn.
  • Kalau dia mengubah nilainya, pinjamannya jadi &mut dan trait-nya FnMut.
  • Kalau dia mengambil kepemilikannya, nilainya pindah ke dalam struct, dan trait-nya FnOnce.

Nggak ada allocator yang dilibatkan, karena semuanya hidup di stack. Dijalankan, outputnya: dibaca(7) balik true, hitungan berakhir di 7, dan panjang pemilik tetap 4 walaupun isinya sudah dipindah masuk ke dalam closure.

Pajak yang tetap harus dibayar

Kalau abstraksinya benar-benar nol ongkos, kenapa nggak semua bahasa begitu? Karena ongkosnya nggak hilang, cuma pindah tempat.

Waktu build lebih lama. Analisis borrow checker, monomorphization, dan optimasi agresif memang kerja berat. Tapi hati-hati dengan penjelasan populer yang menyalahkan monomorphization sendirian: salah satu pengembang kompiler Rust pernah bilang eksperimen di masa awal Rust menunjukkan monomorphization bukan penyebab utamanya, karena versi dinamis pun butuh banyak kode pembukuan yang harus dioptimasi. Penyebab sebenarnya tersebar, dari pohon dependensi yang besar sampai tahap optimasi LLVM itu sendiri.

Ukuran biner bisa membengkak. Setiap tipe konkret menghasilkan salinan fungsinya sendiri. Pakai Vec<i32>, Vec<u64>, dan Vec<String> di satu program, kamu dapat tiga salinan kode yang mirip. Di program kecil efeknya tipis; di proyek besar, code bloat itu nyata. Salah satu obatnya: pakai &dyn Trait untuk kode yang dipanggil jarang dan nggak butuh cepat. Seperti yang kita lihat di atas, itu memang benar-benar menghemat kode.

Jadi harga zero-cost abstraction bukan nol. Yang berubah cuma siapa yang membayarnya: mesin build kamu dan pembaca kodenya nanti, bukan CPU pengguna.

Penutup

Yang bikin prinsip ini menarik bukan sekadar performanya, tapi izin yang dia berikan: kamu nggak perlu merasa bersalah nulis iterator berantai, nyusun trait modular, atau bikin closure di kode yang kritis secara performa. Kalau memang ada biayanya, biaya itu ditaruh di tempat yang jauh lebih murah buat kamu bayar.

Kalau kamu mau lihat sendiri assembly-nya, tempel kode di atas ke Compiler Explorer, pilih rustc versi stabil, lalu tambahkan -O -C panic=abort di kolom argumen. Cara paling cepat buat berhenti percaya dan mulai memeriksa. Selamat ber-systems programming ria! 🦀

Biar makin jago ngoding.

Yuk gabung bareng temen-temen developer lainnya buat dapet update teknologi, tips, dan tutorial santai tiap minggu.

Biar Nggak Kudet

Update teknologi, tips ngoding, dan insight santai langsung ke email kamu tiap minggu.

Santai, anti spam. Bisa berhenti langganan kapan aja.

Baca Selanjutnya

Lihat semua
karierKetika Pilih Framework Cuma Biar Kelihatan Beda

Terakhir kali kamu milih framework atau library baru, itu karena masalahmu butuh alat itu, atau karena alatnya kedengaran keren dan belum banyak yang pakai? Pertanyaan ini nggak enak dijawab. Soalnya keputusan teknologi sering diambil dengan alasan yang nggak pernah ditulis di dokumen teknis mana pun.

© 2026 Inva.dev.