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.
Langkah nol: ukur dengan Instant
Section titled “Langkah nol: ukur dengan Instant”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.
Build debug vs release
Section titled “Build debug vs release”Ini satu-satunya tips performa terbesar di Rust, dan caranya cuma satu flag.
cargo run # build debug: cepat di-compile, lambat dijalankancargo run --release # build release: lebih lama di-compile, jauh lebih cepat dijalankanBuild 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.83sbuild release: waktu 250ms, 297ms, 265msItu 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.
Pinjam, jangan clone
Section titled “Pinjam, jangan clone”.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:
32the quick brown fox jumps over the lazy dog(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.
Alokasikan sekali, jangan berulang-ulang
Section titled “Alokasikan sekali, jangan berulang-ulang”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 kaliVec::with_capacity(): membesar 0 kaliSembilan 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, biruIterator itu zero-cost
Section titled “Iterator itu zero-cost”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: 220iter: 220Versi 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.
Pilih koleksi yang tepat
Section titled “Pilih koleksi yang tepat”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::containsmelihat setiap elemen, satu per satu. Pencarian makin lambat seiring list membesar.HashSet::contains(danHashMap::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 6667Jawabannya 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.97msHashSet: 6667 hits in 551.60µsSekitar 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.
Profiling dulu, baru optimasi
Section titled “Profiling dulu, baru optimasi”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:
# flamegraph: menggambar di mana saja waktu habis (Linux, macOS)cargo install flamegraphcargo flamegraph
# samply: membuka hasilnya di Firefox Profiler di browser-mucargo install samplycargo build --releasesamply record ./target/release/my_appProfiler 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 = trueBenchmark dengan criterion
Section titled “Benchmark dengan criterion”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 = falseTaruh 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:
cargo benchSebuah 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 profile release
Section titled “Pengaturan profile release”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 ukuranlto = true # link-time optimization: optimasi lintas semua crate (build lebih lama)codegen-units = 1 # satu unit = lebih banyak peluang optimasi, tapi compile lebih lamapanic = "abort" # lewati kode unwinding; binary lebih kecil, tapi panic tidak bisa ditangkapBiasanya 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.
Kesalahan umum
Section titled “Kesalahan umum”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 incorrectArtinya 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 1baris 2baris 3baris 4baris 5Dalam contoh pengukuran kami, mencetak sejuta baris ke file butuh sekitar 570 sampai 670ms dengan println! dan sekitar 40 sampai 60ms dengan BufWriter (build release).
Ringkasan
Section titled “Ringkasan”- 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(), danjoin()menghindari pembesaran berulang. - Iterator itu zero-cost; pilih
HashSet/HashMapuntuk pencarian yang banyak. - Profiling dulu, lalu optimalkan titik panas yang ditunjukkan profiler.
Kuis singkat
Section titled “Kuis singkat”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
Bacaan lebih lanjut
Section titled “Bacaan lebih lanjut”- The Rust Performance Book (bahasa Inggris): panduan singkat dan praktis tentang pengaturan build, profiling, dan cara umum mempercepat program.
- Comparing Performance: Loops vs. Iterators — The Rust Book (bahasa Inggris): kenapa iterator di-compile menjadi kode yang cepat.
- Profiles — The Cargo Book (bahasa Inggris): setiap pengaturan
[profile], beserta nilai default-nya. std::time::Instant(bahasa Inggris): stopwatch dari standard library.- Dokumentasi criterion (bahasa Inggris): crate benchmark yang dipakai di atas.