Lewati ke konten

Dasar Performa Rust

Rust terkenal cepat, dan reputasi itu memang pantas. Tapi “Rust itu cepat” bukan berarti setiap program Rust pasti cepat. Algoritma yang lambat tetap lambat di bahasa apa pun, dan beberapa kebiasaan kecil bisa diam-diam membuatmu rugi banyak.

Kabar baiknya: sebagian besar peningkatan besar itu sederhana. Build dalam mode release. Ukur dulu sebelum mengubah apa pun. Jangan menyalin data yang hanya perlu kamu baca. Pilih koleksi yang tepat. Halaman ini membahas satu per satu, dengan pengukuran sungguhan di bagian yang perlu. Anggap saja seperti menyetel sepeda: pastikan dulu bannya sudah dipompa (mode release), baru pikirkan soal komponen yang lebih ringan.

Sebelum membuat sesuatu lebih cepat, kamu perlu tahu seberapa lambat sekarang. std::time::Instant adalah stopwatch yang sudah ada di standard library. Panggil Instant::now() sebelum pekerjaannya, lalu .elapsed() sesudahnya:

use std::time::Instant;
fn main() {
let start = Instant::now();
// Buat 5 juta angka pseudo-acak dengan xorshift sederhana.
let mut x: u64 = 12345;
let mut numbers = Vec::with_capacity(5_000_000);
for _ in 0..5_000_000 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
numbers.push(x % 1_000_000);
}
numbers.sort();
let sum: u64 = numbers.iter().take(1000).sum();
println!("jumlah 1000 angka terkecil: {sum}");
println!("waktu: {:?}", start.elapsed());
}

Format {:?} mencetak Duration dengan cara yang mudah dibaca, misalnya 250.271745ms. Sengaja tidak ada tombol “Coba sendiri” di sini: waktunya berubah setiap kali dijalankan dan berbeda di setiap mesin, jadi tidak bisa dicocokkan dengan output yang tetap. Jalankan saja di komputermu sendiri dengan cargo run.

Ini satu-satunya tips performa terbesar di Rust, dan caranya cuma satu flag.

Terminal window
cargo run # build debug: cepat di-compile, lambat dijalankan
cargo run --release # build release: lebih lama di-compile, jauh lebih cepat dijalankan

Build debug melewatkan sebagian besar optimasi supaya cepat di-compile dan mudah di-debug. Build ini juga memeriksa integer overflow. Build release menyalakan optimizer sepenuhnya.

Berikut contoh pengukuran dari menjalankan program di atas masing-masing tiga kali, di mesin yang kami pakai untuk menulis halaman ini (server cloud dengan CPU Intel Xeon). Angkamu akan berbeda; yang penting hanya selisihnya:

build debug: waktu 5.02s, 4.27s, 4.83s
build release: waktu 250ms, 297ms, 265ms

Itu kira-kira 15 sampai 20 kali lebih cepat, hanya dari satu flag. Selisihnya sangat bergantung pada kodenya; perulangan dan iterator paling banyak diuntungkan. Tapi selisihnya tidak pernah cukup kecil untuk diabaikan.

Jadi aturannya sederhana: jangan pernah menilai kecepatan dari build debug. Kalau ada yang bilang “Rust itu lambat” setelah mengukur waktu cargo run, hampir pasti inilah penyebabnya.

.clone() membuat salinan penuh. Untuk String atau Vec, artinya alokasi heap baru dan menyalin setiap byte. Kadang kamu memang butuh salinan, tapi sering kali kamu hanya perlu membaca datanya, dan borrowing (peminjaman) itu gratis.

Kasus paling umum adalah parameter fungsi. Kalau sebuah fungsi hanya membaca string, terima &str, bukan String:

fn count_long_words(text: &str, min_len: usize) -> usize {
text.split_whitespace().filter(|w| w.len() >= min_len).count()
}
fn main() {
let owned = String::from("the quick brown fox jumps over the lazy dog");
// &String otomatis berubah menjadi &str, tanpa perlu menyalin.
println!("{}", count_long_words(&owned, 5));
// String literal sudah berupa &str.
println!("{}", count_long_words("borrowing is cheap and cloning is not", 6));
// Kita tidak pernah menyerahkan `owned`, jadi masih bisa dipakai.
println!("{owned}");
}

Output:

3
2
the quick brown fox jumps over the lazy dog
▶ Coba sendiri

(Kalimat contohnya sengaja dibiarkan dalam bahasa Inggris, karena hasilnya bergantung pada panjang setiap kata.)

Parameter &str menerima String (dipinjam dengan &) maupun string literal. Parameter String akan memaksa pemanggil menyerahkan string-nya atau meng-clone-nya. Ide yang sama berlaku untuk list: terima &[T], bukan Vec<T>. Lihat Borrowing dan Slice untuk detailnya.

Waspadai juga clone di dalam perulangan. for name in names.clone() menyalin seluruh list hanya untuk melihat isinya. for name in &names meminjamnya secara gratis.

Saat sebuah Vec kehabisan tempat, ia mengalokasikan blok yang lebih besar dan memindahkan semua isinya ke sana. Kalau kamu tahu ukurannya dari awal, Vec::with_capacity memesan tempat itu sekaligus. Program ini menghitung berapa kali setiap Vec harus membesar saat diisi 1000 angka:

fn count_growths(mut v: Vec<u32>, n: u32) -> u32 {
let mut growths = 0;
let mut cap = v.capacity();
for i in 0..n {
v.push(i);
if v.capacity() != cap {
growths += 1;
cap = v.capacity();
}
}
growths
}
fn main() {
println!("Vec::new(): membesar {} kali", count_growths(Vec::new(), 1000));
println!("Vec::with_capacity(): membesar {} kali", count_growths(Vec::with_capacity(1000), 1000));
}

Output:

Vec::new(): membesar 9 kali
Vec::with_capacity(): membesar 0 kali
▶ Coba sendiri

Sembilan kali membesar bukan bencana: Vec menggandakan ukurannya setiap kali, jadi biayanya tetap murah. Tapi di perulangan yang sangat sering jalan dan membangun jutaan vektor, biaya itu menumpuk. (Pola pertumbuhan persisnya adalah detail internal standard library dan bisa berubah.)

Lebih mudah lagi: biarkan collect() dan join() yang bekerja. Keduanya sering sudah tahu ukuran akhirnya dan hanya mengalokasikan sekali:

fn main() {
// collect() membangun Vec sekaligus.
let squares: Vec<u64> = (1..=5).map(|n| n * n).collect();
println!("{squares:?}");
// join() menghitung total panjangnya dulu, lalu menyalin sekali.
let colors = ["merah", "hijau", "biru"];
println!("{}", colors.join(", "));
}

Output:

[1, 4, 9, 16, 25]
merah, hijau, biru
▶ Coba sendiri

Mungkin kamu khawatir .iter().filter().map().sum() lebih lambat daripada perulangan biasa, karena kelihatannya banyak sekali pemanggilan fungsi. Ternyata tidak. Di build release, compiler menyisipkan (inline) semua closure kecil itu, dan kamu mendapat kode mesin yang sama rapatnya dengan perulangan yang ditulis manual. Rust menyebut ini zero-cost abstraction (abstraksi tanpa biaya): kamu tidak membayar apa pun untuk gaya penulisan yang lebih enak dibaca.

fn sum_even_squares_loop(nums: &[u64]) -> u64 {
let mut total = 0;
for i in 0..nums.len() {
if nums[i] % 2 == 0 {
total += nums[i] * nums[i];
}
}
total
}
fn sum_even_squares_iter(nums: &[u64]) -> u64 {
nums.iter().filter(|&&n| n % 2 == 0).map(|&n| n * n).sum()
}
fn main() {
let nums: Vec<u64> = (1..=10).collect();
println!("loop: {}", sum_even_squares_loop(&nums));
println!("iter: {}", sum_even_squares_iter(&nums));
}

Output:

loop: 220
iter: 220
▶ Coba sendiri

Versi iterator bahkan bisa sedikit lebih cepat. Dengan nums[i], Rust memeriksa bahwa i ada di dalam slice pada setiap akses. Iterator sudah tahu bahwa ia tidak mungkin keluar batas, jadi pemeriksaan itu hilang. Pakai yang lebih enak dibaca, biasanya iterator.

Memilih struktur data yang tepat sering kali lebih ampuh daripada penyetelan sehalus apa pun. Contoh klasiknya: memeriksa “apakah item ini ada di list-ku?” berkali-kali.

  • Vec::contains melihat setiap elemen, satu per satu. Pencarian makin lambat seiring list membesar.
  • HashSet::contains (dan HashMap::get) melompat hampir langsung ke jawabannya. Pencarian tetap cepat berapa pun ukurannya.
use std::collections::HashSet;
fn main() {
let banned: Vec<u32> = (0..20_000).map(|n| n * 3).collect();
let queries: Vec<u32> = (0..20_000).collect();
// Lambat untuk list besar: setiap contains() menelusuri seluruh Vec.
let hits_vec = queries.iter().filter(|q| banned.contains(q)).count();
// Cepat: buat HashSet sekali, lalu setiap pencarian jadi kilat.
let banned_set: HashSet<u32> = banned.iter().copied().collect();
let hits_set = queries.iter().filter(|q| banned_set.contains(q)).count();
println!("Vec menemukan {hits_vec}, HashSet menemukan {hits_set}");
}

Output:

Vec menemukan 6667, HashSet menemukan 6667
▶ Coba sendiri

Jawabannya sama, biayanya sangat berbeda. Saat kami menambahkan timer Instant di sekitar setiap bagian dan menjalankan build release, inilah contoh pengukuran dari mesin kami:

Vec: 6667 hits in 27.97ms
HashSet: 6667 hits in 551.60µs

Sekitar 50 kali lebih cepat, dan itu sudah termasuk waktu untuk membangun set-nya. Tapi untuk list berisi 10 item, Vec sama sekali tidak masalah dan bahkan bisa menang. Pelajari lebih lanjut di halaman HashSet dan HashMap.

Programmer terkenal payah dalam menebak di mana waktu habis. Jadi jangan menebak: lakukan profiling. Profiler mengamati programmu saat berjalan dan memberi tahu fungsi mana yang paling lama. Biasanya hanya ada satu atau dua titik panas (hot spot), dan sisa kodemu sama sekali tidak berpengaruh.

Dua profiler populer dan gratis yang bisa dipakai dengan Rust:

Terminal window
# flamegraph: menggambar di mana saja waktu habis (Linux, macOS)
cargo install flamegraph
cargo flamegraph
# samply: membuka hasilnya di Firefox Profiler di browser-mu
cargo install samply
cargo build --release
samply record ./target/release/my_app

Profiler butuh debug symbol untuk menampilkan nama fungsi. Tambahkan ini ke Cargo.toml supaya build release tetap menyimpannya (kodenya tidak jadi lebih lambat, hanya file-nya yang lebih besar):

[profile.release]
debug = true

Mengukur waktu sekali saja dengan Instant hasilnya mudah meleset. Benchmark menjalankan kode berkali-kali dan memakai statistik untuk memberi angka yang bisa dipercaya. #[bench] bawaan Rust hanya bisa dipakai di nightly, jadi kebanyakan orang memakai crate criterion. Siapkan seperti ini:

[dev-dependencies]
criterion = "0.8"
[[bench]]
name = "fib_bench"
harness = false

Taruh kode yang ingin kamu uji di library-mu (src/lib.rs), lalu tulis benchmark-nya di benches/fib_bench.rs:

use criterion::{criterion_group, criterion_main, Criterion};
use my_crate::fibonacci;
use std::hint::black_box;
fn bench_fib(c: &mut Criterion) {
c.bench_function("fibonacci 20", |b| b.iter(|| fibonacci(black_box(20))));
}
criterion_group!(benches, bench_fib);
criterion_main!(benches);

black_box mencegah compiler bertindak terlalu pintar dan menghitung jawabannya lebih dulu, yang akan membuat benchmark-nya tidak berarti. Jalankan dengan:

Terminal window
cargo bench

Sebuah contoh hasil dari mesin kami (criterion menampilkan perkiraan terendah, perkiraan terbaik, dan perkiraan tertinggi):

fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]

Jalankan lagi setelah ada perubahan, dan criterion akan memberi tahu apakah kodenya jadi lebih cepat atau lebih lambat, dan apakah perbedaannya nyata atau hanya noise.

Pengaturan release default sudah bagus. Kalau kamu butuh sedikit lebih, ini pengaturan yang umum di Cargo.toml:

[profile.release]
opt-level = 3 # default untuk release; "s" atau "z" mengoptimalkan ukuran
lto = true # link-time optimization: optimasi lintas semua crate (build lebih lama)
codegen-units = 1 # satu unit = lebih banyak peluang optimasi, tapi compile lebih lama
panic = "abort" # lewati kode unwinding; binary lebih kecil, tapi panic tidak bisa ditangkap

Biasanya ini hanya memberi beberapa persen, bukan keajaiban, dan membuat build lebih lama. Coba satu per satu, dan ukur masing-masing. lto = "thin" adalah jalan tengah yang populer karena build-nya lebih cepat daripada lto = true.

Mengukur waktu build debug. Sudah dibahas, tapi ini kesalahan nomor satu: selalu ukur dengan --release.

Meng-clone supaya compiler senang. Misalnya ada fungsi yang menerima String, lalu kamu memanggilnya dengan pinjaman:

fn shout(text: String) -> String {
text.to_uppercase()
}
fn main() {
let name = String::from("ferris");
let loud = shout(&name);
println!("{loud} {name}");
}

Compiler bilang:

error[E0308]: mismatched types
--> src/main.rs:7:22
|
7 | let loud = shout(&name);
| ----- ^^^^^ expected `String`, found `&String`
| |
| arguments to this function are incorrect

Artinya tipenya tidak cocok: fungsi mengharapkan String, tapi menerima &String. Godaannya adalah menulis shout(name.clone()). Itu memang berhasil, tapi string-nya disalin setiap kali dipanggil. Solusi yang lebih baik adalah mengubah parameternya menjadi text: &str; dengan begitu shout(&name) bisa di-compile dan tidak ada yang disalin.

println! di perulangan yang sering jalan. Setiap println! mengunci stdout dan langsung menulis barisnya. Untuk beberapa baris, itu tidak masalah. Untuk sejuta baris, itu lambat. Bungkus stdout dengan BufWriter supaya ia menulis dalam potongan besar:

use std::io::{self, BufWriter, Write};
fn main() {
let mut out = BufWriter::new(io::stdout().lock());
for i in 1..=5 {
writeln!(out, "baris {i}").unwrap();
}
}

Output:

baris 1
baris 2
baris 3
baris 4
baris 5
▶ Coba sendiri

Dalam contoh pengukuran kami, mencetak sejuta baris ke file butuh sekitar 570 sampai 670ms dengan println! dan sekitar 40 sampai 60ms dengan BufWriter (build release).

  • Selalu ukur kecepatan dengan cargo run --release; build debug bisa berkali-kali lipat lebih lambat.
  • Pakai Instant::now() dan .elapsed() untuk mengukur waktu dengan cepat, dan criterion untuk benchmark yang bisa dipercaya.
  • Pinjam (&str, &[T]), jangan clone, kalau kamu hanya perlu membaca data.
  • Alokasikan sekali: Vec::with_capacity, collect(), dan join() menghindari pembesaran berulang.
  • Iterator itu zero-cost; pilih HashSet/HashMap untuk pencarian yang banyak.
  • Profiling dulu, lalu optimalkan titik panas yang ditunjukkan profiler.

Kuis singkat

1. Programmu terasa lambat saat dijalankan dengan cargo run. Apa yang sebaiknya kamu coba pertama kali?

2. Sebuah fungsi hanya membaca string. Tipe parameter mana yang menghindari penyalinan yang tidak perlu?

3. Kamu perlu memeriksa apakah 50.000 ID ada di daftar 50.000 ID yang diblokir. Mana pilihan yang lebih baik untuk daftar yang diblokir?

4. Apa arti "iterator itu zero-cost"?

5. Kenapa benchmark criterion memakai black_box?

Skor: 0 / 5