Viscosity
Review
Part IX · Rust for TypeScript developers · Day 36

142. Measuring Rust performance

The first Rust timing most people take is wrong by a factor of ten, or of a million. Build for release, stop the optimizer from deleting your work, and let statistics tell you when a difference is real, and when it is just the machine.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Explain what a release build changes, and why debug timings mislead.
  • Use black_box so a benchmark measures the work you meant.
  • Read criterion's output, including a change it reports that isn't real.
  • Judge drip-rs's and engine/rust's benchmark methods, noise included.

A number from the wrong build

You port a hot loop to Rust, time it with cargo run, and it is slower than your JavaScript. Before you blame Rust, look at the first line Cargo prints: Finished `dev` profile [unoptimized + debuginfo]. You measured the build made for debugging.

Intuition

A debug build is a car on the test track with sensors on every bolt and a speed limiter on. It is great for finding faults and useless for lap times. A release build takes the sensors off.

Debug and release

Cargo builds with a profile. cargo build and cargo run use dev; --release uses release. Their defaults differ like this[1]:

[profile.dev]
opt-level = 0
debug-assertions = true
overflow-checks = true

[profile.release]
opt-level = 3
debug-assertions = false
overflow-checks = false

opt-level = 0 means no optimization at all. How much that matters: I built drip-rs both ways (rustc 1.97, the book’s shared cloud VM) and ran fib.drip three times each. Debug took 16.0 to 16.6 seconds, release 1.4 to 1.6. Ten times. drip-rs’s own Cargo.toml goes further, with lto = true and codegen-units = 1, which let the optimizer see the whole program at once at the price of slower builds.

The two builds also behave differently. Integer overflow panics in a debug build and wraps around in release[2]. Run this one: it panics with attempt to add with overflow. Built with -O on the same machine, it printed 255 + 1 = 0.

Try itOverflow: a panic in debug, a wrap in release
use std::hint::black_box;

fn main() {
    // black_box hides the 255, so the
    // check happens at run time.
    let x: u8 = black_box(255);
    let y = x + 1;
    println!("255 + 1 = {y}");
}
Open in Rust Playground ↗
Definition

A release build (cargo build --release) compiles with the release profile: opt-level = 3, debug assertions and overflow checks off. Only release-build timings say anything about speed. The default dev profile is unoptimized, for fast builds and easy debugging.

A note on this book. The Run button sends Rust to the official Rust Playground in debug mode, so any time it prints is a debug time, on a server you don’t control. The “Open in Rust Playground” link lets you switch the mode to Release there.

The loop that vanished

Now build for release and time a sum of a million f64s, done 100 times. The example below does it twice: once plainly, once with every input and output wrapped in black_box. On the book’s VM, with -O, three runs printed:

plain: 401ns    boxed: 69.389644ms
plain: 190ns    boxed: 65.083303ms
plain: 191ns    boxed: 68.060892ms

A hundred million additions in 190 ns is about 500 per picosecond. They didn’t happen. sum has no side effects and its result was dropped, so the optimizer deleted the loop: chapter 24’s dead code elimination, now in a compiler that is far better at it than a JIT with a time budget. In debug, both loops took 285 to 343 ms, because nothing was deleted.

Try itThe loop that vanishes
use std::hint::black_box;
use std::time::Instant;

fn sum(xs: &[f64]) -> f64 {
    let mut s = 0.0;
    for x in xs {
        s += x;
    }
    s
}

fn main() {
    let xs: Vec<f64> = (0..1_000_000)
        .map(|i| i as f64)
        .collect();

    let t = Instant::now();
    for _ in 0..100 {
        sum(&xs);
    }
    let d = t.elapsed();
    println!("plain: {d:?}");

    let t = Instant::now();
    for _ in 0..100 {
        black_box(sum(black_box(&xs)));
    }
    let d = t.elapsed();
    println!("boxed: {d:?}");
}
Open in Rust Playground ↗

std::hint::black_box is the fix. It returns its argument unchanged, but the compiler is told to assume the value could be used in any way at all. Wrapping the input stops the compiler from reasoning about xs; wrapping the result stops it from throwing the sum away[3].

Definition

black_box(x) (in std::hint) is an identity function the compiler must treat as opaque, so work feeding into it can’t be removed and values coming out of it can’t be predicted. It is best-effort: the docs say it may vary by platform and must not be relied on for correctness[3].

Pitfall

black_box hides values; it doesn’t stop the compiler from being clever with the code between them. A loop summing i * i for i below a black-boxed n still took about 1 to 2 ns per call in release on this VM, so a million iterations must have been replaced by a formula. Benchmark work shaped like the real thing.

Statistics: criterion

One time is one sample from a noisy process. Stable Rust’s cargo bench runs a benches/ target, but the built-in #[bench] attribute is nightly-only, so the Cargo book points to criterion for stable Rust[4]. The book’s runner can’t run it (it compiles single files with no crates, in debug mode), so I ran it with cargo 1.97, which fetched criterion 0.5.1 from crates.io. The benchmark, in benches/sum.rs, with harness = false in Cargo.toml:

fn bench(c: &mut Criterion) {
    let xs: Vec<f64> = (0..1000)
        .map(|i| i as f64)
        .collect();
    c.bench_function("sum 1000", |b| {
        b.iter(|| sum(black_box(&xs)))
    });
}
criterion_group!(benches, bench);
criterion_main!(benches);

b.iter passes each result through black_box itself. cargo bench builds with the bench profile, which inherits from release[1]. Step through what happens next:

Step throughOne benchmark, as criterion measures it
Criterion's samples, fitted line, confidence interval and outlierswarm-up: 1, 2, 4, 8, … iterations12481632until 3 s have passed, then:pick d so d + 2d + … + 100d iterations ≈ 5 s
Warm-up: run the routine once, then twice, then four times… until 3 seconds have passed. That fills the caches and gives a rough time per iteration.
1/6
Definition

Criterion is a statistical benchmarking crate. It warms up, takes many samples of growing size, estimates the time per iteration as the slope of a fitted line, gives a bootstrap confidence interval, flags outliers, and compares each run with the last[5].

The last step is the honest lesson. I ran the same code twice, and the second run said change: [-6.08% -4.28% -2.57%] (p = 0.00 < 0.05) Performance has improved. The difference was real, in the sense that the timings really did differ. Its cause was the VM’s other tenants. Criterion’s own guide warns that small, deterministic benchmarks are this sensitive, and its default noise threshold of 1% was too small here[5].

Under the hood

drip-rs doesn’t use criterion. scripts/bench.sh builds with --release, runs each benchmark/*.drip program in a fresh process three times, and keeps the best wall-clock time, compile time included, measured by the shell’s date +%s%N. That suits programs of 0.3 to 5 seconds, compared against the TypeScript VMs timed the same way, and keeps drip-rs free of dependencies. Read the results with care: the TypeScript numbers it compares with were one run each, so best-of-three against best-of-one slightly flatters drip-rs, and drip-rs’s README says to allow for noise of tens of percent. engine/rust measures in-process instead: its compile_speed example warms up with 100 compiles, then times 500 with Instant and black_box(r), and reports the median and minimum; its results file lists three rounds. Even then, ledger’s minimum went 74, 73, 109 µs from round to round. Report how you measured, alternate what you compare, and treat a 10% difference on a shared VM as a rumour.

Your turn

ExerciseOutliers the way criterion counts them

quartiles is given. Write fences(xs, k), which returns (q1 - k·iqr, q3 + k·iqr) where iqr = q3 - q1. Then write outliers(xs), which returns (mild, severe): a sample is severe if it lies outside the k = 3.0 fences, and mild if it lies outside the k = 1.5 fences but not outside the 3.0 ones. Count both low and high samples.

// Quartiles by nearest rank: sort, then
// take the values at n/4 and 3n/4.
fn quartiles(xs: &[f64]) -> (f64, f64) {
    let mut s = xs.to_vec();
    s.sort_by(f64::total_cmp);
    let n = s.len();
    (s[n / 4], s[3 * n / 4])
}

fn fences(xs: &[f64], k: f64)
    -> (f64, f64) {
    todo!()
}

// (mild, severe), as criterion counts.
fn outliers(xs: &[f64])
    -> (usize, usize) {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • Time only a release build: opt-level = 3, no overflow checks. The dev default was ten times slower on drip-rs’s fib.
  • Debug panics on integer overflow; release wraps.
  • An unused result invites the optimizer to delete the work. black_box hides inputs and keeps results, on a best-effort basis.
  • Criterion fits a line through many samples, gives a confidence interval, counts outliers with Tukey’s fences, and compares runs. On a noisy machine it can call noise an improvement.
  • drip-rs times whole programs, best of three; engine/rust times warm calls in process. Both report the machine and the noise.

Check your understanding

0/6
  1. 1.

    You time a function with cargo run and get 300 ms. What should you do before believing it?

  2. 2.

    In a release build, a loop summing a million f64s 100 times reports 190 ns. What is the best explanation?

  3. 3.

    What does black_box(sum(black_box(&xs))) achieve?

  4. 4.

    Criterion reported time: [585.95 ns 600.57 ns 617.36 ns]. What are the three numbers?

  5. 5.

    Can the book’s Run button run a criterion benchmark?

  6. 6.

    engine/rust’s compile-speed file gives each figure as “median / min” over three rounds. Why report both?

References

  1. [1] The Cargo Team. Profiles (The Cargo Book). The Cargo Book. doc.rust-lang.org/cargo/reference/profiles.html (checked 2026-10-31)
  2. [2] The Rust Reference contributors. Operator expressions: Overflow. The Rust Reference. doc.rust-lang.org/reference/expressions/operator-expr.html#overflow (checked 2026-10-31)
  3. [3] The Rust Project Developers. Function core::hint::black_box (std::hint::black_box), Rust standard library documentation. Rust standard library API documentation; source in library/core/src/hint.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/hint/fn.black_box.html (checked 2026-10-31)
  4. [4] The Cargo Team. cargo bench (The Cargo Book). The Cargo Book. doc.rust-lang.org/cargo/commands/cargo-bench.html (checked 2026-10-31)
  5. [5] Brook Heisler, the Criterion.rs contributors. Criterion.rs User Guide: Analysis Process. Criterion.rs documentation (github.com/criterion-rs/criterion.rs, formerly bheisler/criterion.rs; the README links the user guide at this URL). criterion-rs.github.io/book/index.html (checked 2026-10-31)