Skip to Content
Course content

133: criterion for Benchmarking

Click on the "Edit" button in the top corner of the screen to edit your slide content.

We've all been there: you write a piece of logic, you have a hunch that a map is slower than a for loop in a specific context, or you've just swapped out a HashMap for a BTreeMap and you want to see if it actually mattered. Your first instinct is probably to reach for std::time::Instant, wrap your function in a loop, and print the elapsed time. It feels intuitive, and for a quick-and-dirty sanity check, it's fine. But if you're trying to make an engineering decision based on those numbers, you're walking into a trap.

The Stopwatch Fallacy

Let's look at the naive way. Imagine we're benchmarking a simple function that parses a string of comma-separated integers into a Vec<i32>. You might write something like this in your main.rs:

let data = "1,2,3,4,5,6,7,8,9,10".repeat(100);
let start = std::time::Instant::now();
for _ in 0..1000 {
    let _res: Vec<i32> = data.split(',').map(|s| s.parse().unwrap()).collect();
}
let duration = start.elapsed();
println!("Took: {:?}", duration);

On the surface, this looks reasonable. But here is the problem: you aren't just measuring your code; you're measuring the noise of your entire operating system. A background process might wake up, the CPU might throttle its clock speed, or the OS might decide to move your thread to a different core mid-execution. One run says 12ms, the next says 15ms. You have no idea if that 3ms difference is your code change or just Windows Update deciding to check for patches in the background.

When the Compiler Cheats

There's a more insidious problem here: LLVM is too smart for its own good. In the example above, I assigned the result to _res, but I never actually used _res. If you compile this in --release mode, the compiler looks at that loop and realizes the result of the parsing is never read. It concludes that the entire operation has no side effects and simply deletes the whole loop. Your benchmark suddenly reports that the code took 2 microseconds, and you're left wondering why your parser is suddenly the fastest piece of software ever written.

You can try to fight this by marking the result as volatile or using some hacks, but that's a losing battle. This is why we move away from manual timing and toward a dedicated benchmarking framework. In the Rust ecosystem, that means criterion.

Statistical Rigor with Criterion

Criterion doesn't just run your code once; it runs it thousands of times, analyzes the distribution, and uses linear regression to filter out the noise. It handles the "warm-up" phase—where the CPU caches are populated and the branch predictor starts to learn—so you aren't penalizing your code for the first few cold iterations.

To use it, you don't put your benchmarks in src/. You create a benches/ directory in your project root. Here is how that same parsing benchmark looks using Criterion:

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

fn parse_csv(data: &str) -> Vec<i32> {
    data.split(',').map(|s| s.parse().unwrap()).collect()
}

fn bench_parsing(c: &mut Criterion) {
    let data = "1,2,3,4,5,6,7,8,9,10".repeat(100);
    c.bench_function("csv_parse", |b| b.iter(|| {
        // We wrap the input in black_box to prevent the compiler 
        // from optimizing the call away or pre-calculating the result.
        parse_csv(black_box(&data))
    }));
}

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

The black_box function is the secret sauce here. It tells the compiler: "I know you think you know what's happening here, but pretend you have no idea what this value is." This forces the compiler to actually execute the function every single time, preventing the "cheating" we saw earlier.

The Cost of Precision

Is Criterion overkill? Sometimes. It adds a dependency to your dev-dependencies, it requires a specific project structure, and it takes significantly longer to run because it's doing actual statistics. If you just want to know if a function takes 1 second or 10 seconds, Instant::now() is fine. But if you're trying to decide between two different algorithms where the difference is measured in nanoseconds, you cannot trust anything other than a statistically sound benchmark. I've spent hours chasing "performance regressions" that turned out to be nothing more than a Chrome tab eating CPU cycles in the background—don't make that mistake.




📋 Practical Task

Benchmarking String Concatenation Strategies

In this exercise, you will compare two ways of building a large string: using the format! macro in a loop versus using a String buffer with push_str. Your goal is to prove which one is faster using criterion.

  1. Add criterion to your [dev-dependencies] in Cargo.toml and add the [[bench]] target configuration.
  2. Create a file at benches/concat_bench.rs.
  3. Implement two functions: bench_format (which uses format!("{}{}", acc, item) in a loop) and bench_push (which uses acc.push_str(item)).
  4. Wrap your inputs in black_box to ensure the compiler doesn't optimize the loops away.
  5. Run cargo bench and observe the results.
  6. Requirement: Identify the exact time difference per iteration and write a brief comment in your code explaining why the push_str approach is more efficient in terms of memory allocation.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.