Sepanjang section ini kita mengklaim perbedaan — dev 273 milidetik, release 117 milidetik, LTO tidak mengubah apa pun — dan semua klaim itu bertumpu pada satu alat: pengukuran waktu yang jujur. Pengukuran sederhana pun punya jebakan yang bisa membuat hasilnya berbohong: optimizer bisa menyingkirkan loop yang kita timing, sistem operasi bisa menghentikan proses di tengah pengukuran, dan putaran pertama eksekusi sering lebih lambat tanpa alasan yang terlihat. Pada chapter penutup section ini kita akan membangun benchmark kecil memakai Instant dari pustaka standar, melihat betapa dramatisnya optimizer menipu pengukuran naive, membetulkannya dengan black_box, lalu menyusun harness lengkap dengan warm-up dan beberapa putaran. Semua angka pada chapter ini berasal dari eksekusi nyata di satu mesin — angka Anda akan berbeda, dan itulah inti pembelajarannya.
Jam Monotonik dengan Instant #
std::time::Instant adalah stopwatch pustaka standar. Nilainya dibaca dari jam monotonik sistem operasi yang hanya bergerak maju dan tidak terpengaruh penyelarasan NTP atau perubahan jam dinding — inilah alasan Instant dipilih di atas SystemTime untuk pengukuran durasi. Pola dasarnya tiga langkah: catat titik mulai, jalankan pekerjaan, panggil elapsed() untuk mendapatkan Duration:
use std::thread::sleep;
use std::time::{Duration, Instant};
fn main() {
let mulai = Instant::now();
sleep(Duration::from_millis(5));
let durasi = mulai.elapsed();
println!("mikrodetik : {}", durasi.as_micros());
println!("milidetik : {}", durasi.as_millis());
println!("detik : {:.3}", durasi.as_secs_f64());
}Output yang Dihasilkan:
mikrodetik : 5056
milidetik : 5
detik : 0.005Mari kita bedah baris-baris kuncinya:
- Baris 5 (
let mulai = Instant::now();): menangkap titik waktu sekarang dari jam monotonik; tipe balasannya adalahInstant. - Baris 6 (
sleep(Duration::from_millis(5));): pekerjaan contoh berupa tidur 5 milidetik — durasi nyatanya selalu sedikit lebih panjang karena penjadwalan thread. - Baris 7 (
let durasi = mulai.elapsed();): menghitung selisih darimulaisekarang; hasilnya bertipeDuration. - Baris 9-11 (
durasi.as_micros()dst.):Durationmenyimpan nanodetik mentah dan menyediakan konverteras_micros(),as_millis(), sertaas_secs_f64()untuk format bebas desimal. Angka mikrodetik pasti berbeda di setiap eksekusi — contoh di atas adalah satu tangkapan nyata.
Satu Duration bisa mewakili rentang dari nanodetik hingga ratusan tahun, dan ketiga konverter membulatkan ke bawah. Untuk benchmark, satuan mikrodetik biasanya paling pas: cukup halus untuk fungsi kecil, cukup kasar untuk kebal derau.
Jebakan Pertama: Optimizer Membuang Loop Kita #
Sekarang bagian yang mengejutkan. Program berikut menjumlahkan bilangan 0 sampai sembilan juta sembilan ratus sembilan puluh sembilan ribu — sepuluh juta iterasi penjumlahan — sambil mengukur waktunya:
fn main() {
let mut total: u64 = 0;
let mulai = std::time::Instant::now();
for i in 0..10_000_000u64 {
total += i;
}
let durasi = mulai.elapsed();
println!("Total: {}", total);
println!("Waktu eksekusi: {:?}", durasi);
}Kita build dua profil lalu menjalankan keduanya:
cargo build
cargo build --release
./target/debug/bench-jumlah
./target/release/bench-jumlahOutput yang Dihasilkan (profil dev):
Total: 49999995000000
Waktu eksekusi: 52.837866msOutput yang Dihasilkan (profil release):
Total: 49999995000000
Waktu eksekusi: 66nsProfil dev menunjukkan 52,8 milidetik — masuk akal untuk sepuluh juta iterasi. Profil release menunjukkan 66 nanodetik: waktu yang bahkan tidak cukup untuk satu iterasi penjumlahan. Angka ini bukan keajaiban kecepatan, melainkan bukti bahwa loop kita sudah tidak ada lagi. LLVM melihat seluruh loop menghitung deret aritmetika yang hasilnya bisa dihitung langsung, lalu mengganti sepuluh juta iterasi dengan satu rumus tertutup pada saat kompilasi. Instant::now() diukur, rumus dievaluasi, elapsed() dipanggil — total 66 nanodetik jujur mengukur pekerjaan nol.
black_box: Tembok bagi Optimizer #
Fungsi std::hint::black_box memberi tahu compiler bahwa sebuah nilai harus diperlakukan seolah-olah bisa diamati dari luar — nilainya tidak boleh dilipat konstan, tidak boleh dieliminasi, dan pemanggilannya tidak boleh disusun ulang melintasi titik tersebut. Kita gunakan untuk mengunci nilai masukan dan keluaran loop:
use std::hint::black_box;
fn main() {
let mut total: u64 = 0;
let mulai = std::time::Instant::now();
for i in 0..10_000_000u64 {
total = black_box(total + black_box(i));
}
let durasi = mulai.elapsed();
println!("Total: {}", black_box(total));
println!("Waktu eksekusi: {:?}", durasi);
}Output yang Dihasilkan (profil dev):
Total: 49999995000000
Waktu eksekusi: 73.859156msOutput yang Dihasilkan (profil release):
Total: 49999995000000
Waktu eksekusi: 21.44719msSekarang kedua angka masuk akal: dev 73,9 milidetik karena setiap iterasi benar-benar berjalan, release 21,4 milidetik karena loop tetap dieksekusi namun dioptimasi jujur. Mari kita bedah dua titik penting pada kode di atas:
- Baris 7 (
total = black_box(total + black_box(i));):black_box(i)menghalangi compiler mengasumsikan nilaiibisa diprediksi dari rentang loop, danblack_box(total + ...)memaksa hasil penjumlahan disimpan sungguhan ketotal. Kombinasi keduanya menutup semua pintu lipat konstan. - Baris 11 (
println!("Total: {}", black_box(total));): membungkustotalsaat mencetak mencegah optimizer menelusuri bahwa satu-satunya pemakaiantotaladalah pencetakan, jalur yang bisa saja dipangkas ulang. Aturan praktisnya: kunci input dan output dari kode yang diukur.
Perlu ditegaskan bahwa black_box tidak mematikan optimasi di dalam loop — penjumlahan tetap bisa jadi instruksi mesin tercepat, hanya saja keberadaan loop itu sendiri yang dijamin. Justru karena itu black_box adalah alat benchmark yang adil: kita mengukur kode yang dioptimasi, bukan kode yang dimatikan.
Warm-up dan Beberapa Putaran #
Pengukuran tunggal masih menyisakan dua sumber bias. Pertama, warm-up: eksekusi pertama sering lebih lambat karena cache prosesor masih dingin, frekuensi CPU belum naik dari mode hemat daya, dan halaman memori baru dialokasikan. Kedua, derau sistem: scheduler sistem operasi bisa menghentikan proses kita sesaat di tengah pengukuran. Keduanya membuat putaran lambat dengan pola berbeda — warm-up memperlambat putaran-putaran awal secara sistematis, sementara preemption menjatuhkan satu putaran acak sesekali. Sebagai bukti nyata, program berikut — dibangun dengan profil release — menjumlahkan vektor 80 juta elemen (640 MB) empat kali berturut-turut:
use std::hint::black_box;
use std::time::Instant;
fn main() {
let data: Vec<u64> = (0..80_000_000).map(|i| (i % 7) as u64).collect();
for putaran in 1..=4 {
let mulai = Instant::now();
let total = black_box(data.iter().copied().sum::<u64>());
let durasi = mulai.elapsed();
black_box(total);
println!("Putaran {}: {:?}", putaran, durasi);
}
}Output yang Dihasilkan:
Putaran 1: 55.225497ms
Putaran 2: 54.847161ms
Putaran 3: 54.624687ms
Putaran 4: 54.670563msPutaran pertama hanya sekitar satu persen lebih lambat pada mesin kami — server yang ramai dan CPU yang tidak turun frekuensi membuat efek warm-up kecil; di laptop hemat daya yang baru bangun, selisihnya bisa berkali lipat lebih besar. Prinsipnya tetap sama: jangan pernah mengambil putaran pertama sebagai wakil kecepatan kode.
Praktik lengkapnya menyusun harness: beberapa pemanggilan pemanasan yang hasilnya dibuang, lalu beberapa putaran pengukuran, lalu ringkasan. Kita pakai kembali fungsi hitung_prima, bungkus logikanya dalam fungsi benchmark, dan jalankan semuanya pada biner profil release:
use std::hint::black_box;
use std::time::Instant;
fn hitung_prima(batas: u64) -> u64 {
let mut jumlah = 0;
for n in 2..=batas {
let mut prima = true;
let mut pembagi = 2;
while pembagi * pembagi <= n {
if n % pembagi == 0 {
prima = false;
break;
}
pembagi += 1;
}
if prima {
jumlah += 1;
}
}
jumlah
}
fn benchmark(fungsi: fn(u64) -> u64, input: u64) -> Vec<u128> {
// Pemanasan: dua pemanggilan yang hasilnya dibuang
black_box(fungsi(black_box(input)));
black_box(fungsi(black_box(input)));
let mut hasil_us = Vec::new();
for _ in 0..5 {
let mulai = Instant::now();
let jumlah = black_box(fungsi(black_box(input)));
let durasi = mulai.elapsed();
println!(" putaran: {:>6} prima dalam {:?}", jumlah, durasi);
hasil_us.push(durasi.as_micros());
}
hasil_us
}
fn main() {
println!("Benchmark hitung_prima(200_000):");
let hasil = benchmark(hitung_prima, 200_000);
let tercepat = *hasil.iter().min().unwrap();
let rata_rata = hasil.iter().sum::<u128>() / hasil.len() as u128;
println!("Tercepat : {} mikrodetik", tercepat);
println!("Rata-rata : {} mikrodetik", rata_rata);
}Output yang Dihasilkan (eksekusi pertama):
Benchmark hitung_prima(200_000):
putaran: 17984 prima dalam 12.617763ms
putaran: 17984 prima dalam 12.581729ms
putaran: 17984 prima dalam 12.597918ms
putaran: 17984 prima dalam 12.577958ms
putaran: 17984 prima dalam 12.599205ms
Tercepat : 12577 mikrodetik
Rata-rata : 12594 mikrodetikOutput yang Dihasilkan (eksekusi kedua):
Benchmark hitung_prima(200_000):
putaran: 17984 prima dalam 12.623053ms
putaran: 17984 prima dalam 12.618177ms
putaran: 17984 prima dalam 12.687227ms
putaran: 17984 prima dalam 12.60216ms
putaran: 17984 prima dalam 12.625004ms
Tercepat : 12602 mikrodetik
Rata-rata : 12631 mikrodetikMari kita bedah bagian-bagian kuncinya:
- Baris 25-26 (
black_box(fungsi(black_box(input)));dua kali): dua pemanggilan pemanasan yang hasilnya diserapblack_box— dijalankan untuk memanaskan cache dan penjadwal, lalu dibuang dari statistik. - Baris 29-35 (loop
for _ in 0..5): lima putaran pengukuran; setiap putaran mengukur satu pemanggilan fungsi dengan input dan hasil dikunciblack_box, durasi disimpan sebagai mikrodetiku128. - Baris 43 (
let tercepat = *hasil.iter().min().unwrap();): mengambil waktu tercepat dari lima putaran. Angka tercepat lebih representatif daripada rata-rata karena derau sistem hanya bisa menambah waktu, tidak pernah menguranginya — putaran tercepat mendekati biaya sejati kode. - Baris 44 (
let rata_rata = ...): rata-rata tetap dicetak sebagai pembanding; jarak antara tercepat dan rata-rata pada dua eksekusi kita hanya 17-29 mikrodetik, tanda derau mesin ini rendah.
Perhatikan pula bahwa hasil antar eksekusi bergeser: tercepat 12577 mikrodetik lalu 12602 mikrodetik. Perbedaan seperlima persen antar proses adalah normal; keputusan “optimasi A lebih cepat dari B” baru layak diambil bila selisihnya jauh melebihi fluktuasi semacam ini.
Keterbatasan dan Opsi Lanjutan #
Harness Instant + black_box + warm-up sudah cukup untuk membandingkan dua implementasi secara kasar, tetapi pendekatan manual punya batas. Pendekatan manual tidak mengumpulkan statistik distribusi sehingga kita tidak tahu seberapa melebar waktunya, tidak mendeteksi regresi otomatis, dan mengukur wall time satu thread — kode paralel dan I/O butuh teknik berbeda. Untuk kebutuhan serius, ekosistem Rust menyediakan crate criterion yang menyusun harness statistik lengkap dengan perbandingan terhadap run sebelumnya; membahasnya keluar dari batas section ini, namun seluruh penjaga yang kita bangun — black_box, warm-up, putaran berulang, angka tercepat — berlaku sama di dalamnya.
Ringkasan #
Benchmark yang jujur di Rust berdiri di atas empat pilar: Instant dari jam monotonik sebagai stopwatch, black_box sebagai tembok yang mencegah optimizer menyingkirkan kode yang diukur, warm-up yang membuang putaran-putaran dingin pertama, serta beberapa putaran dengan angka tercepat sebagai wakil biaya sejati. Pengukuran naive kita membuktikan bahayanya secara nyata: loop penjumlahan sepuluh juta iterasi “selesai” dalam 66 nanodetik karena LLVM menggantinya dengan rumus tertutup, lalu pulih menjadi 21,4 milidetik setelah dikunci black_box. Semua angka pada chapter ini berasal dari eksekusi nyata di satu mesin dan akan berbeda di mesin Anda — dengan harness yang benar, perbedaan itu menjadi data, bukan derau.