Skip to Content
Course content

57: Benchmarking Rust Code

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

I've spent a lot of my career guessing why a piece of code feels sluggish. I'll tell myself, "I bet this loop is the problem," rewrite it, and then feel reasonably confident it's faster. But "feeling" fast isn't engineering. In Rust, where we have so many choices—heap vs. stack, different collection types, various iterator patterns—guessing is a dangerous game.

The Quick and Dirty Way

When I first suspect a bottleneck, I usually start with something simple. Let's say I'm building a system that aggregates a massive list of IDs into a single comma-separated string. I'll write a quick function and wrap it in std::time::Instant to see what's happening.

use std::time::Instant;

fn naive_concat(items: &[&str]) -> String {
    let mut result = String::new();
    for item in items {
        result.push_str(item);
        result.push_str(",");
    }
    result
}

fn main() {
    let data = vec!["id_123"; 10_000];
    let start = Instant::now();
    let _res = naive_concat(&data);
    let duration = start.elapsed();
    println!("Took: {:?}", duration);
}

I run this with cargo run and it takes, say, 15 milliseconds. Seems fine. But wait—I know that appending to a string repeatedly causes multiple re-allocations as the buffer grows. I suspect that if I pre-allocate the memory, it'll fly.

Wait, why is this so slow?

I rewrite it using String::with_capacity, run it again, and... it's almost exactly the same. I'm confused. I know the logic is more efficient. I check my terminal and realize I'm running in debug mode. This is the classic Rust trap.

In debug mode, Rust doesn't optimize your code. It keeps all the checks and doesn't inline functions. I run it again with cargo run --release. Suddenly, the with_capacity version is an order of magnitude faster. But here's the problem: Instant is a one-shot deal. If my OS decides to run a background update or a browser tab spikes in CPU usage during that one run, my numbers are garbage.

Moving to a Professional Rig

If I'm going to make a design decision based on performance, I need a statistical sample, not a single snapshot. This is where criterion comes in. It's the gold standard for benchmarking in Rust because it runs the function thousands of times, calculates the mean, and tells you if a change is actually a statistically significant improvement.

First, I have to tell Cargo that I'm adding a benchmark suite. I'll add this to my Cargo.toml:

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

[[bench]]
name = "concat_bench"
harness = false

The harness = false part is crucial; it tells Rust not to use the built-in test runner, because criterion provides its own.

Measuring the Win

Now I'll create a file at benches/concat_bench.rs. I want to compare my naive approach against the optimized one side-by-side. This is where the "exploration" becomes real data.

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

fn naive_concat(items: &[&str]) -> String {
    let mut result = String::new();
    for item in items {
        result.push_str(item);
        result.push_str(",");
    }
    result
}

fn optimized_concat(items: &[&str]) -> String {
    // Pre-calculating capacity to avoid re-allocations
    let total_size: usize = items.iter().map(|s| s.len() + 1).sum();
    let mut result = String::with_capacity(total_size);
    for item in items {
        result.push_str(item);
        result.push_str(",");
    }
    result
}

fn criterion_benchmark(c: &mut Criterion) {
    let data = vec!["id_123"; 10_000];

    c.bench_function("naive", |b| b.iter(|| naive_concat(black_box(&data))));
    c.bench_function("optimized", |b| b.iter(|| optimized_concat(black_box(&data))));
}

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

Notice that black_box call? That's a little piece of magic. Without it, the Rust compiler is too smart. It might realize that the input data never changes and that the output is never used, and it might just optimize the entire function call away into a constant. black_box tells the compiler, "Don't try to be clever here; actually execute this code."

I run cargo bench, and criterion gives me a detailed breakdown. It tells me the optimized version is 3x faster and generates an HTML report with graphs. Now I'm not guessing; I have a proof. This process—identifying a suspicion, trying a quick Instant check, and then verifying with a rigorous criterion bench—is exactly how I handle performance tuning in production.




📋 Practical Task

Optimizing a Large Vector Filter

You have a function that filters a large vector of integers to find only the even numbers and collects them into a new vector. Your goal is to benchmark two different approaches and prove which one is faster.

The Setup:

  1. Create a new Rust project and add criterion to your dev-dependencies in Cargo.toml.
  2. Configure the [[bench]] section in Cargo.toml to point to a benchmark file named filter_bench.rs.
  3. Implement two functions in benches/filter_bench.rs:
    • filter_naive: Use a for loop and push() to move even numbers into a new Vec.
    • filter_iterator: Use .iter().filter().cloned().collect().
  4. Set up a criterion_group that benchmarks both functions using a vector containing 100,000 random integers.
  5. Run cargo bench and identify which method performs better.

Requirement: Ensure you use black_box to prevent the compiler from optimizing away your filtering logic.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.