36. Performance: release profiles, benchmarks, profiling, allocations
36.1 Always measure release builds
Debug builds are often 10 to 50 times slower than release builds for
iterator-heavy code. Never benchmark cargo run without --release.
[profile.release]
lto = "fat" # whole-program optimization; slower builds
codegen-units = 1
debug = "line-tables-only" # symbols for perf/flamegraph without much size cost
[profile.bench]
inherits = "release"
36.2 Micro-benchmarking without fooling yourself
use std::hint::black_box;
use std::time::Instant;
fn sum_index(v: &[u64]) -> u64 { let mut s = 0; for i in 0..v.len() { s += v[i]; } s }
fn sum_iter(v: &[u64]) -> u64 { v.iter().sum() }
fn bench(name: &str, f: impl Fn() -> u64) {
for _ in 0..3 { black_box(f()); } // warm-up: caches, branch predictor, CPU frequency
let runs = 50;
let t = Instant::now();
for _ in 0..runs { black_box(f()); } // black_box stops the optimizer from deleting the work
println!("{name:<10} {:?}/run", t.elapsed() / runs);
}
fn main() {
let v: Vec<u64> = (0..2_000_000).collect();
bench("index", || sum_index(black_box(&v)));
bench("iterator", || sum_iter(black_box(&v)));
}
On an Apple Silicon laptop this printed about 11 ms (index) and 7 ms
(iterator) per run in a debug build, and about 0.2 ms for both with
--release. The debug build was roughly 40 times slower, and in release
LLVM proved i < v.len() and removed the bounds check, so the two versions
compiled to nearly the same loop. A single run like this is noisy. Run it
several times before you trust any difference.
For real benchmarks use criterion (statistics, outlier detection,
comparison against a saved baseline) or divan. The methodology is the
same as Go §37:
warm up, repeat, compare distributions rather than single runs, and change
one thing at a time.
36.3 Profiling
cargo install flamegraph
cargo flamegraph --bin api # perf on Linux, dtrace/xctrace on macOS -> flamegraph.svg
perf record -g --call-graph dwarf target/release/api && perf report # Linux, by hand
These are the same tools as OS Ch 81
and C §42.1.
A Rust binary is a native binary, so everything that works for C works here.
Use dhat or heaptrack for allocation profiling.
36.4 The usual wins, in the order they usually pay off
- Algorithm and I/O. Buffer your writes (Ch 24), batch syscalls, and stream instead of loading whole files.
- Allocations.
Vec::with_capacity, reuse buffers (buf.clear()keeps the capacity), use&strinstead ofStringin hot paths, andCow<str>when you only sometimes need to allocate. - Clones. Search the hot path for
.clone()and.to_string(). - Data layout. Use contiguous
Vecs instead of pointer-chasing (Vec<Box<T>>, linked lists), and struct-of-arrays for scans (C §42.2). Pad hot atomics to separate cache lines to avoid false sharing (crossbeam_utils::CachePadded). - Allocator. Switching the global allocator to
mimallocorjemallocis often a 5–20% gain for allocation-heavy servers (Ch 43 shows how to install one). - Target CPU.
RUSTFLAGS="-C target-cpu=native"enables AVX2 and similar instructions, but the binary then runs only on CPUs like the build machine.