Skip to content
advanced Phase 15 · Testing, Benchmarks, and Profiling

Benchmarks with Criterion

Measure throughput and regressions with statistical benchmarking output.

45m
0 problems
Topic Progress 0%

Wiring a Criterion Benchmark: benches/, harness = false, and cargo bench

Your parser "feels slower" after a refactor. Feelings do not ship; a benchmark that produces a number with a confidence interval does. Criterion is the standard tool, and wiring it up is mostly a Cargo.toml ceremony:

[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]]
name = "parse_bench"
harness = false   # Criterion replaces libtest's bench harness

harness = false is the line people miss. Without it, Cargo wraps your bench target in libtest's unstable #[bench] machinery and the build fails; with it, your main is the runner, and Criterion's macros provide it:

// benches/parse_bench.rs
use criterion::{criterion_group, criterion_main, Criterion, Throughput};
use mylib::parse;

fn bench_parse(c: &mut Criterion) {
    let input = "timeout=5&retries=3&cache=off";
    let mut group = c.benchmark_group("parse");
    group.throughput(Throughput::Bytes(input.len() as u64));
    group.bench_function("query", |b| b.iter(|| parse(input)));
    group.finish();
}

criterion_group!(benches, bench_parse);
criterion_main!(benches);

cargo bench compiles that file under the bench profile — which inherits release settings, opt-level 3 — and runs it. Each benchmark goes through a warm-up phase (3 s by default) so the CPU settles and caches fill, then a measurement phase (5 s, at least 100 samples), and reports:

parse/query     time: [1.2417 µs 1.2509 µs 1.2605 µs]
                 change: [-2.31% -1.14% -0.02%] (p = 0.06 > 0.05)
                 No change in performance detected.

Read that middle line carefully: the three numbers are the 95% confidence interval around the median, and Criterion's verdict is a statistical test against the previous run. "No change in performance detected" means the observed difference was indistinguishable from noise — not that nothing happened.

Useful controls:

cargo bench                      # run everything
cargo bench -- parse             # substring filter, like cargo test
cargo bench -- --sample-size 200 # more samples for a noisy benchmark
cargo bench -- --warm-up-time 5  # longer warm-up on a busy machine
Knob Default Raise it when
sample size 100 variance is high; interval stays wide
warm-up 3 s the machine throttles or has background load
measurement time 5 s each iteration is very short or very noisy
throughput unset you want bytes/elements per second, not just time

Two structural rules keep benchmarks honest. First, put setup outside b.iter — the closure runs thousands of times, so anything it does is measured; if setup must be redone per iteration (a consumed String, a fresh temp file), use iter_batched with a BatchSize instead. Second, name benchmarks after the case you would defend in a review: parse/empty, parse/1k-params, parse/already-valid. A single bench_parse number tells you nothing about which input regressed.

Parameterized benchmarks deserve their own shape, because one scalar hides which input got slower:

use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};

fn bench_parse_sizes(c: &mut Criterion) {
    let mut group = c.benchmark_group("parse");
    for size in [16usize, 256, 4096] {
        let input = "a=1&".repeat(size);
        group.bench_with_input(BenchmarkId::from_parameter(size), &input, |b, input| {
            b.iter(|| parse(input));
        });
    }
    group.finish();
}

That yields parse/16, parse/256, parse/4096 — so an O(n²) regression sneaking into an O(n) parser shows up as the shape of the curve across parameters instead of as one mystery aggregate.

The optimizer is the other half of the story — a benchmark can silently measure nothing at all, and Criterion's answer to it is black_box, covered in the next chapter, The Benchmark That Measured Nothing. If you have come from Test Doubles with Traits, note the shared move: both topics replace a judgment call with something mechanical.

The Benchmark That Measured Nothing: black_box Against the Optimizer

You run a benchmark for a hash function and get 0.4 ns per call. Real hashing of a 64-byte key cannot be 0.4 ns. Nothing is broken — the compiler proved your measured work was irrelevant and deleted it.

That is the central hazard of benchmarking in an optimizing compiler's language. Two optimizations are responsible:

  1. Dead-code elimination. If the return value of parse(input) is never observed, the call — and everything it does — may be removed entirely. Your loop then measures an empty loop.
  2. Constant folding. If input is a literal known at compile time, the compiler can precompute the whole result, or specialize the function for that constant, so the "measurement" covers an optimized special case rather than the code users run.

std::hint::black_box is the fence. It takes a value, returns it unchanged, and the compiler is forbidden from making any assumption about what happened to it:

use std::hint::black_box;

fn bench_hash(c: &mut Criterion) {
    let key = vec![0xABu8; 64];
    c.bench_function("hash/64-bytes", |b| {
        b.iter(|| black_box(hash(black_box(&key))));
    });
}

The two black_box calls solve different problems:

Placement Protects against
black_box(&key) — the input constant folding and specialization on a known value
black_box(result) — the output dead-code elimination of the work producing it

Be precise about what Criterion already does for you: b.iter black-boxes the value your closure returns, so the output side is covered by default. What it cannot protect is the input side — a literal argument the optimizer can fold straight through your function. Wrapping both is the conservative habit; it costs a nanosecond-scale compiler barrier and removes an entire class of phantom results.

What to measure, and what to exclude

The closure inside b.iter runs tens of thousands of times. Everything in it is measured, including allocation you did not intend to time:

// measures parsing AND allocating the input every iteration
b.iter(|| parse(&format!("timeout={}", 5)));

// measures only parsing
let input = String::from("timeout=5");
b.iter(|| parse(black_box(&input)));

When the fixture must be rebuilt per iteration — a consumed Vec<u8>, a temp file that the code deletes — use iter_batched, which separates setup from the timed section:

use criterion::BatchSize;

b.iter_batched(
    || make_document(),            // not timed
    |doc| parse(&doc),             // timed
    BatchSize::SmallInput,
);

BatchSize decides how many setups are created per batch; LargeInput (one setup per iteration) is right when setup is expensive or the input is consumed.

Timing resolution is the last trap. Very fast operations — a handful of nanoseconds — sit at the edge of the platform timer's precision, so Criterion automatically scales up measurement time to accumulate enough iterations per sample. If a benchmark is still wide-interval after that, the fix is usually more work per sample (a larger input, or b.iter_custom running the loop N times yourself and returning the elapsed Duration) rather than more samples of an equally fuzzy measurement.

Sanity-check the number. Compare against a mental model: hash/64-bytes at ~50 ns is plausible; 0.4 ns means the loop body is empty or the work folded. A quick way to expose a lying benchmark is to change the input contents — if the time does not move at all, the compiler is not running your code. Cross-check the order of magnitude against perf or a flamegraph from Profiling with Flamegraphs and Perf, where the actual instruction counts are visible.

Interviews do not usually ask for black_box by name — they ask "how do you know your benchmark measured the right thing?" Answer with these two optimizations, the input/output placement table above, and the habit of sanity-checking against a physical lower bound.

Baselines and Confidence Intervals: Catching a Regression Before Merge

Criterion's real value shows up on the second run. Day one you measure; day thirty you need to know whether a pull request made anything slower — and eyeballing two time: lines is not a method.

Baselines are how you compare runs. Save the state of a known-good commit, then ask future runs to compare against it:

cargo bench -- --save-baseline main     # on main: record the reference
git checkout feature/fast-path
cargo bench -- --baseline main          # compare this run against `main`

Output becomes a verdict:

parse/query  time: [1.3821 µs 1.3905 µs 1.3996 µs]
             change: [+11.24% +12.87% +14.51%] (p = 0.00 < 0.05)
             Performance has regressed.

Read the array as before — median with 95% confidence bounds — then read change as a confidence interval on the difference. The rule that keeps you honest: if the interval crosses zero, the change is indistinguishable from noise, and Criterion prints change has inconclusive significance rather than declaring a win. p = 0.00 with a wholly positive interval is a regression; a wide interval spanning negative and positive means your benchmark needs more samples, not a rollback.

Baselines live in target/criterion/, one directory per benchmark, so they survive between runs but not between clean checkouts — CI jobs that start from a fresh workspace must either re-run --save-baseline on the merge base or store target/criterion/ as a cache artifact.

cargo bench -- --save-baseline pr      # candidate branch
cargo bench -- --baseline main         # compare, keep both for inspection
cargo bench -- --discard-baseline      # forget a bad reference

Turning down the noise. Microbenchmarks move for reasons unrelated to your change: turbo-boost ramps, background processes, page cache. Two settings cut the false alarms:

# Criterion.toml at the crate root
noise-threshold = 0.03   # ignore changes under 3%
sample-size = 200        # more samples per benchmark

A noise-threshold tells Criterion not to report a change smaller than the machine's own jitter, so a 0.4% "regression" stops waking anyone up. Raise sample_size when confidence intervals stay wide; raise measurement_time when individual iterations are so short that timer resolution dominates.

Making it fail the build. Criterion exits 0 regardless of verdicts — statistical judgment is left to you. For CI gating, add a small step that fails on a regression signal: parse the change: line for benchmarks you care about, or compare saved baselines with critcmp:

critcmp main +0.5%     # baseline vs candidate, changes >= 0.5%

Keep the gate to a handful of load-bearing benchmarks (the ones users hit per request); gating all forty benches turns every run into a coin flip.

Practice Why
One baseline per stable environment laptops and CI runners have different noise floors
--save-baseline on the merge base the comparison is against what you branched from
noise-threshold ≈ 3% filters jitter you cannot control
Gate only critical benches limits compounded false-positive rate

With the html_reports feature enabled, every run writes target/criterion/report/index.html — per-benchmark pages with the time distribution, the line chart of every saved run, and the change plot. Opening that chart before approving a "regression" is the fastest way to see whether the jump is a step (your commit) or a single noisy outlier (someone's browser build on the same runner).

Finally, a benchmark says that it got slower. It never says why — which is the job of sampling profilers, so continue with Profiling with Flamegraphs and Perf.

Quiz

1. cargo bench fails because the harness does not understand your benchmark file. What Cargo.toml change makes Criterion run it?

Question 1 options

2. Criterion reports `time: [1.2417 µs 1.2509 µs 1.2605 µs]`. What are the three numbers?

Question 2 options

3. A benchmark of a constant input reports 0.4 ns per call — clearly too fast. Which explanation is correct?

Question 3 options

4. After `cargo bench -- --baseline main`, a benchmark prints `change: [-2.1% +0.4% +3.0%]` with p = 0.55. What is the right verdict?

Question 4 options

Flashcards

Question

Why does a Criterion bench target need `harness = false`, and what profile does cargo bench use?

Answer

Cargo's default bench harness is libtest's unstable #[bench] machinery; `harness = false` tells Cargo the target runs its own main, which criterion_group!/criterion_main! generate. cargo bench builds with the bench profile (opt-level 3, like release), so measurements reflect optimized code — and any dev-dependency such as criterion is available because benches are test-only targets.

Question

What do warm-up, sample size, and measurement time control in Criterion?

Answer

Warm-up (default 3 s) runs the closure before recording so caches, turbo states, and allocator pools settle. Sample size (default 100) is how many timed samples form the distribution that yields the median and 95% CI. Measurement time (default 5 s) is how long it collects them; raise sample size when the interval stays wide, measurement time when iterations are tiny or noisy.

Question

What does std::hint::black_box do, and where must it be placed?

Answer

It takes a value and returns it, forbidding the compiler from assuming anything about it. Wrap the output of the measured closure to stop dead-code elimination from deleting unobserved work, and wrap inputs (especially literals) to stop constant folding and specialization. Criterion already black-boxes the closure's return value; the input side is your responsibility.

Question

How do you compare a branch against a known-good run with Criterion?

Answer

On the reference commit: `cargo bench -- --save-baseline main`. On the branch: `cargo bench -- --baseline main` (or --discard-baseline to drop a bad reference). Baselines persist in target/criterion/, so CI must cache that directory or re-save on the merge base; each benchmark then prints a change interval and a verdict — regression only when the interval clears zero.

Question

What is a noise threshold and why set it around 3%?

Answer

noise-threshold (Criterion.toml, e.g. 0.03) tells Criterion to treat changes smaller than that fraction as uninteresting, because machine jitter — turbo-boost ramps, background load, timer granularity — routinely moves microbenchmarks by a percent or two. It cuts false 'regressions' without hiding real ones; large structural benchmarks can use a tighter value.

Revision Notes

Key Takeaways

  • 1. harness = false hands the bench target to Criterion's own main; cargo bench builds it at opt-level 3, so you are timing optimized code
  • 2. The reported triple is a median with a 95% confidence interval — statistics, not three runs — and 'change has crossed zero' means noise, not a tie
  • 3. black_box defeats the two optimizations that fake benchmarks: dead-code elimination on outputs and constant folding on inputs
  • 4. Setup outside b.iter, names like parse/1k-params, and parameterized groups are what make a benchmark's number interpretable later
  • 5. Saved baselines in target/criterion plus a noise threshold turn 'feels slower' into a verdict you can gate in CI

Interview Tips

  • • "How do you benchmark in Rust?" — Criterion with harness = false, release-profile timing, warm-up plus 100 samples, and the median/CI output; naming cargo bench as the entry point shows you know the workflow end to end
  • • "How do you know the compiler didn't optimize your benchmark away?" — dead-code elimination and constant folding, black_box placement on both sides, and the sanity check that changing the input changes the time
  • • "You see +12% on a branch — what do you do?" — check whether the interval clears zero and p < 0.05, confirm against the saved baseline on the same machine, then look at the HTML change plot for a step versus an outlier
  • • "When is a microbenchmark useless?" — when the measured region is dominated by noise or allocation you did not intend to time; point at iter_batched and at profiling as the next tool

Cheat Sheet

Benchmarks with Criterion

Wire-up:

[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]]
name = "parse_bench"
harness = false
criterion_group!(benches, bench_parse);
criterion_main!(benches);

Reading output:

time: [1.2417 µs 1.2509 µs 1.2605 µs]   median + 95% CI
change: [-2.31% -1.14% -0.02%] (p = 0.06)  interval vs baseline
  • Interval crossing zero → no detectable change; never judge by a single bound.

Honesty rules:

Risk Fix
Dead-code elimination black_box the output (Criterion does this in iter)
Constant folding black_box the inputs
Setup inside the timer move it out, or iter_batched + BatchSize
Wide interval raise --sample-size / --measurement-time
Machine jitter alarms noise-threshold = 0.03 in Criterion.toml

Baselines:

cargo bench -- --save-baseline main   # reference commit
cargo bench -- --baseline main        # compare branch
critcmp main +0.5%                    # CI gate output
cargo bench -- parse                  # filter like cargo test

Continue Learning