Benchmarking with Criterion

Reviewed & published by Brayan K

Guessing at performance is how regressions sneak in. Criterion gives Rust statistically rigorous benchmarks — on stable — so you measure real timings, catch slowdowns, and prove your optimizations actually worked.

Part of the free Rust course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll set up a benches/ target, write a Criterion benchmark with criterion_group! and criterion_main!, use b.iter and black_box correctly, and read the statistical reports.

What You'll Learn in This Lesson

1️⃣ Setting Up a benches/ Target

Criterion is a dev-dependency, and benchmark files live in a benches/ directory. A [[bench]] section with harness = false lets Criterion supply its own main.

# Criterion benchmarks live in benches/ and are declared in Cargo.toml.
# Cargo.toml:

[dev-dependencies]
criterion = "0.5"

# Declare the benchmark target. harness = false lets Criterion provide main.
[[bench]]
name = "my_benchmark"
harness = false

# File layout:
#   my_crate/
#     src/lib.rs
#     benches/my_benchmark.rs   <- the benchmark file

With that in place, cargo bench compiles in release mode and runs every benchmark target.

2️⃣ Writing a Benchmark

A benchmark function takes &mut Criterion, registers work with bench_function, and times the closure passed to b.iter. The criterion_group! and criterion_main! macros build the harness.

// benches/my_benchmark.rs
// A complete Criterion benchmark for a fibonacci function.
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn fibonacci(n: u64) -> u64 {
    match n {
        0 => 0,
        1 => 1,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

fn bench_fib(c: &mut Criterion) {
    // b.iter runs the closure many times so Criterion can time it.
    // black_box hides the input so the optimizer can't precompute it.
    c.bench_function("fib 20", |b| {
        b.iter(|| fibonacci(black_box(20)))
    });
}

// These macros generate the benchmark harness and its main().
criterion_group!(benches, bench_fib);
criterion_main!(benches);

Criterion reports a low/median/high interval, not a single number, because it samples many runs and analyzes the distribution statistically.

3️⃣ black_box and Avoiding Pitfalls

The biggest benchmarking trap is the optimizer deleting work whose result is unused, producing impossibly fast times. black_box is an opaque barrier: wrap the input so it can't be precomputed, and let the output escape so it can't be removed.

// Why black_box matters: without it, the optimizer can delete your work.
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn sum_to(n: u64) -> u64 {
    (1..=n).sum()
}

fn bench_sum(c: &mut Criterion) {
    c.bench_function("sum_to 1000", |b| {
        // WRONG: a constant unused result may be optimized to nothing.
        //   b.iter(|| sum_to(1000));
        //
        // RIGHT: black_box the input AND let the result escape.
        b.iter(|| black_box(sum_to(black_box(1000))))
    });
}

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

When the html_reports feature is on, Criterion also writes plots and a comparison against the previous run under target/criterion.

Mini-Challenge: Benchmark a Reversal

Write a small function and a Criterion benchmark that times it with black_box.

// 🎯 MINI-CHALLENGE: benchmark a string reversal.
//
// 1. Write  fn reverse(s: &str) -> String  using chars().rev().collect().
// 2. In a bench fn, call c.bench_function("reverse", |b| ...).
// 3. Inside b.iter, call reverse(black_box("hello world")).
// 4. Wire it up with criterion_group! and criterion_main!.

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

fn reverse(s: &str) -> String {
    // your code here
    String::new()
}

// add bench_reverse, criterion_group!, and criterion_main! yourself

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Criterion

GoalRust Syntax
Dev dependency[dev-dependencies] criterion = "0.5"
Bench target[[bench]] name = "..." harness = false
Register a benchc.bench_function("name", |b| { })
Time a routineb.iter(|| work())
Stop optimizerblack_box(value)
Runcargo bench

🎉 Lesson Complete!

Practice quiz

What is Criterion used for in Rust?

  • Statistics-driven benchmarking on stable Rust
  • Unit testing
  • Code formatting
  • Async scheduling

Answer: Statistics-driven benchmarking on stable Rust. Criterion is a benchmarking library that runs on stable Rust and analyzes timings statistically.

Where do Criterion benchmark files conventionally live?

  • In src/
  • In a tests/ directory
  • In a benches/ directory
  • In examples/

Answer: In a benches/ directory. Cargo looks for benchmark targets in the benches/ directory at the crate root.

Which command runs your benchmarks?

  • cargo test
  • cargo bench
  • cargo run --bench
  • cargo check

Answer: cargo bench. cargo bench compiles and runs the benchmark targets, including Criterion ones.

What does black_box do in a benchmark?

  • Logs results
  • Hides output
  • Stops the timer
  • Prevents the optimizer from eliminating the code under test

Answer: Prevents the optimizer from eliminating the code under test. black_box is an opaque barrier that stops the compiler from optimizing away the work being measured.

What does Bencher::iter (the closure passed to b.iter) do?

  • Repeatedly runs the routine so Criterion can time it
  • Sorts the input
  • Spawns threads
  • Runs the routine once

Answer: Repeatedly runs the routine so Criterion can time it. b.iter runs the supplied closure many times so Criterion can collect a reliable timing sample.

What pair of macros wires up a Criterion benchmark binary?

  • main! and group!
  • criterion_group! and criterion_main!
  • bench_start! and bench_end!
  • setup! and teardown!

Answer: criterion_group! and criterion_main!. criterion_group! collects benchmark functions and criterion_main! generates the harness main.

Why is Criterion's built-in alternative, #[bench], not commonly used?

  • It needs the internet
  • It is deprecated and removed
  • It only works on Windows
  • It requires unstable nightly Rust

Answer: It requires unstable nightly Rust. The built-in #[bench] attribute and test::Bencher are unstable and only available on nightly.

What kind of report can Criterion generate after a run?

  • A SQL dump
  • A PDF invoice
  • HTML reports with plots and statistics
  • A Dockerfile

Answer: HTML reports with plots and statistics. Criterion produces detailed HTML reports including plots, distributions, and regression analysis.

How does Criterion help detect performance changes between runs?

  • It deletes old results
  • It compares against saved previous results and reports regressions/improvements
  • It only prints the latest time
  • It requires a paid service

Answer: It compares against saved previous results and reports regressions/improvements. Criterion stores baseline results and statistically compares new runs to flag regressions or speedups.

A common pitfall when benchmarking pure functions is...

  • The compiler optimizing the work away because the result is unused
  • Forgetting to import std
  • Running on a laptop
  • Using too many threads

Answer: The compiler optimizing the work away because the result is unused. If a result is never used, the optimizer can delete the computation; wrapping inputs/outputs in black_box prevents this.

Continue this course

Frequently asked questions

What is Criterion and why use it over the built-in benchmarker?

Criterion is the de-facto benchmarking library for Rust. Unlike a simple timer, it runs your code many times, collects a statistical sample of the measurements, estimates a confidence interval, and can compare a run against previously saved results to flag regressions or improvements. Crucially, it works on stable Rust. The standard library does include a built-in #[bench] attribute with test::Bencher, but that feature is unstable and only available on the nightly toolchain, so most projects cannot rely on it. Criterion fills that gap with a richer, statistically rigorous harness that any project on stable can use, which is why it has become the community standard.

How do I set up and run a Criterion benchmark?

Add criterion as a dev-dependency, then create a benches/ directory at your crate root and put a benchmark file there, for example benches/my_benchmark.rs. In Cargo.toml you declare the target with a [[bench]] section that sets name = "my_benchmark" and harness = false; the harness = false tells Cargo not to use the default test harness so Criterion can supply its own main via the criterion_main! macro. Inside the file you write one or more functions that take &mut Criterion, register them with criterion_group!, and finish with criterion_main!. Running cargo bench compiles in release mode and executes every benchmark, printing timing estimates to the terminal.

What do b.iter and black_box actually do?

The closure you pass to c.bench_function receives a Bencher, and calling b.iter(|| ...) runs the supplied routine repeatedly so Criterion can gather enough samples to time it accurately — you measure the inner closure, not the surrounding setup. black_box is an optimization barrier: it takes a value and returns it in a way the compiler must treat as opaque, so the optimizer cannot fold a constant input into a precomputed answer or, worse, delete the computation entirely because its result is never used. Wrapping the inputs (and often the output) of the code under test in black_box is what keeps a microbenchmark honest.

What output does Criterion produce and what pitfalls should I watch for?

On the terminal Criterion prints a time estimate as a low/median/high interval and notes outliers; when the 'html_reports' feature is enabled it also writes detailed HTML reports under target/criterion with plots of the timing distribution, line charts across runs, and a comparison against the previous baseline so you can see whether a change made things faster or slower. The biggest pitfall is letting the optimizer remove the work you meant to measure, producing implausibly tiny times — guard against it with black_box. Other pitfalls include benchmarking in debug mode (cargo bench uses release, but ad-hoc timing might not), measuring too little work per iteration so timer resolution dominates, and ignoring system noise; running on a quiet machine and trusting Criterion's statistics rather than a single number gives reliable results.