Benchmarking & Profiling
Prerequisites: Performance & memory layout
Optimising without measuring is guessing. Rust has good tools for both questions you need to answer: how fast is this? (benchmarking) and where does the time go? (profiling).
Rule one: always --release
A debug build skips optimisations and adds overflow checks; timings from it
say nothing about production. Every benchmark and profile in this lesson
uses optimised builds (cargo bench does this automatically).
For a quick, rough check, Instant is fine:
use std::time::Instant;
fn main() {
let start = Instant::now();
let total: u64 = (0..50_000_000u64).filter(|n| n % 3 == 0).sum();
println!("{total} in {:?}", start.elapsed());
}
Run it with cargo run --release. The time varies between runs and machines,
and a single measurement can't tell you whether a 5% difference is real.
That is what a benchmark harness is for.
Benchmarks with criterion
criterion runs your code many times, discards warm-up, and reports a
confidence interval. On later runs it tells you whether performance changed
significantly.
[dev-dependencies]
criterion = "0.7"
[[bench]]
name = "slugify"
harness = false # let criterion provide main() instead of the built-in harness
The benchmark lives in benches/slugify.rs. Here it compares two
implementations of the same function:
use criterion::{Criterion, criterion_group, criterion_main};
use std::hint::black_box;
fn slugify_naive(s: &str) -> String {
let mut out = String::new();
for word in s.split_whitespace() {
if !out.is_empty() {
out = out + "-";
}
out = out + &word.to_lowercase();
}
out
}
fn slugify_fast(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for word in s.split_whitespace() {
if !out.is_empty() {
out.push('-');
}
out.extend(word.chars().flat_map(char::to_lowercase));
}
out
}
fn bench_slugify(c: &mut Criterion) {
let title = "Building A Web API With Axum And SQLx In Rust ".repeat(20);
assert_eq!(slugify_naive(&title), slugify_fast(&title));
let mut group = c.benchmark_group("slugify");
group.bench_function("naive", |b| b.iter(|| slugify_naive(black_box(&title))));
group.bench_function("fast", |b| b.iter(|| slugify_fast(black_box(&title))));
group.finish();
}
criterion_group!(benches, bench_slugify);
criterion_main!(benches);
Run it with cargo bench. Criterion prints a time range per function (the
exact numbers depend on your machine), and writes HTML reports under
target/criterion/.
Two details matter:
black_box(std::hint::black_box) hides a value from the optimiser. Without it, the compiler may notice the input is constant, compute the result once, and you end up benchmarking nothing.- The
assert_eq!checks both versions agree before timing them. A fast wrong answer is not an optimisation.
Keep benchmarks small and focused on one operation, and run them on a quiet machine — background load shows up as noise.
Profiling: where does the time go?
A benchmark tells you that something is slow; a profiler tells you where. A sampling profiler interrupts the program thousands of times per second and records the call stack, so hot functions show up proportionally.
cargo flamegraph wraps perf (Linux) or dtrace (macOS), builds with
the release profile by default, and renders a flame graph SVG — each box is
a function, and its width is the share of samples spent in it:
cargo install flamegraph
cargo flamegraph --bin myapp -- --input big.csv
Release builds strip most debug information, which makes stacks hard to read. Keep optimisations but add symbols for profiling:
[profile.release]
debug = true # or "line-tables-only" for smaller binaries
Other useful tools: samply (cross-platform, opens in the Firefox
profiler), perf directly, Instruments on macOS, and dhat or heaptrack
for allocation profiling, which often explains "mysterious" slowness.
A workflow that works
- Reproduce the slowness with realistic input.
- Profile to find the hot spot.
- Write a criterion benchmark around that function.
- Change one thing, re-run the benchmark, keep it only if it helps.
- Re-profile — the hot spot often moves.
Common mistakes
- Timing
cargo runwithout--release. - Forgetting
black_box, so the optimiser deletes the work. - Including setup (building the input, reading a file) inside
b.iter. Prepare it outside the closure. - Benchmarking on a laptop on battery power or with other heavy programs running, then trusting small differences.
- Optimising a function that the profile shows takes 1% of the runtime.
Practice
Add a third benchmark to the slugify group for an implementation that
collects the lowercased words into a Vec<String> and calls .join("-").
Before timing, assert it produces the same output.
Show solution
use criterion::{Criterion, criterion_group, criterion_main};
use std::hint::black_box;
fn slugify_fast(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for word in s.split_whitespace() {
if !out.is_empty() {
out.push('-');
}
out.extend(word.chars().flat_map(char::to_lowercase));
}
out
}
fn slugify_join(s: &str) -> String {
s.split_whitespace()
.map(str::to_lowercase)
.collect::<Vec<_>>()
.join("-")
}
fn bench_slugify(c: &mut Criterion) {
let title = "Building A Web API With Axum And SQLx In Rust ".repeat(20);
assert_eq!(slugify_fast(&title), slugify_join(&title));
let mut group = c.benchmark_group("slugify");
group.bench_function("fast", |b| b.iter(|| slugify_fast(black_box(&title))));
group.bench_function("join", |b| b.iter(|| slugify_join(black_box(&title))));
group.finish();
}
criterion_group!(benches, bench_slugify);
criterion_main!(benches);
join allocates one String per word plus a Vec and the final result.
Run cargo bench to see how much that costs on your machine rather than
guessing.