← Rust: Zero to Depth
Full-stack DevRust: Zero to Depth

Performance Engineering: Measure First, Then Read the Assembly Yourself

Part 13 of the Rust: Zero to Depth series: the only loop that matters in performance work, the division of labor between micro-benchmarks and profilers, allocation as the invisible tax, measured Vec growth numbers and with_capacity, how to verify the zero-cost promise of iterators with your own eyes, and five rules for the hot path.

RustPerformanceProfilingBenchmarkingZero-Cost Abstractions

“Premature optimization is the root of all evil” — quoted for fifty years, almost always as only the first half. Knuth’s sentence continues: “Yet we should not pass up our opportunities in that critical 3%.” The engineer’s part is precisely that second half: the question was never “whether to optimize” but “how to find the 3%.” This is the series’ penultimate stop, and the place where twelve parts of knowledge cash out: the measure-first discipline, allocation as the invisible tax, a method for verifying the “zero-cost abstraction” promise with your own eyes, and the hot-path rules a profiler will keep proving to you.

Measure first: the only loop in performance work

Performance intuition is the least reliable organ a programmer owns. The hotspot is almost never where you think — it’s in a string concatenation called two million times, in a redundant clone, in a serialization hiding behind “just one layer of wrapping.” So performance work has exactly one loop:

Measure → locate the hotspot → change ONE thing → measure again, keep or revert, repeat. Tools split by question:

  • Macro: “where does the time go?” — profilers and flamegraphs (cargo flamegraph, samply, perf). Run one before touching code; it’s the only reliable way to find the 3%.
  • Micro: “did this function get faster?” — benchmarks. cargo bench paired with the criterion crate (statistically rigorous, optimizer-cheat-proof, auto-compares against history) is the measuring cup for steps two and three.

Two iron rules welded onto this loop: always measure with --release (Part 8 callback — debug and release differ by 10–100×; optimizing on debug data is driving blindfolded); change one thing at a time — change two and get faster, and you’ll never know which one earned it, or whether the other was quietly dragging you down.

Allocation is the invisible tax

Rust has no GC pauses, but heap allocation is still a tax: one malloc is hundreds of cycles of system bookkeeping, and in code it’s often invisible — format!, to_string(), Vec::push, every .clone(). Its most compounding form is repeated reallocation. Measured, on a 64-bit platform:

let mut v: Vec<i32> = Vec::new();
let mut last = 0;
for i in 0..20 {
    v.push(i);
    if v.capacity() != last {
        println!("len {:>2} → capacity {}", v.len(), v.capacity());
        last = v.capacity();
    }
}
// len 1 → capacity 4
// len 5 → capacity 8
// len 9 → capacity 16
// len 17 → capacity 32

Twenty pushes, four allocations, three full copies (4+8+16 elements moved house three times). The strategy is doubling — which amortizes a push to O(1), already a good design; but if you know you’re holding twenty, Vec::with_capacity(20) is one allocation, zero copies, the entire ladder skipped. The same tax applies to String, HashMap, and every growable buffer you own. Hot-path spelling therefore becomes fixed: build buffers outside the loop, reuse inside; pre-allocate when the size is known; join strings with push_str instead of format! in a loop (each format! is a fresh allocation).

Verify “zero-cost” yourself

Part 5 promised iterators are zero-cost abstractions. Don’t believe it — measure, then read the assembly. Take a million numbers, keep the evens, square them, sum, two spellings:

// iterator chain
data.iter().filter(|x| *x % 2 == 0).map(|x| x * x).sum()

// hand-written index loop
let mut acc = 0u64;
for i in 0..data.len() {
    let x = data[i];
    if x % 2 == 0 { acc += x * x; }
}
acc

Measured in release mode: the chain runs 4.0ms, the loop 5.1ms (20 runs) — the chained spelling ties or wins outright. The reason is a real, visible optimization: iterators eliminate bounds checks internally, while every data[i] in the index loop carries one. Zero-cost isn’t the slogan “as fast as hand-written” — it’s occasionally the fact “faster than hand-written.”

Two self-service verification tools: cargo asm shows the assembly a function compiles to; Compiler Explorer (godbolt.org) lets you paste both spellings side by side. The first time you watch filter+map+sum collapse into a tight vectorized loop, “zero-cost” stops being faith and becomes evidence — the standard of evidence you should demand for your own performance claims. And the converse holds: when you catch an abstraction failing to collapse (classic signals: hot calls through Box<dyn Trait>, an unnecessary intermediate collect), that’s your measured invitation to optimize.

Five rules for the hot path

All drawn from the previous twelve parts, gathered here as rules:

  1. Zero allocations inside the loop. Create buffers outside, reuse inside; with_capacity when the size is known.
  2. Borrow reads, don’t clone. Part 2’s .clone() pressure valve gets audited valve by valve on the hot path — each one is an allocation tax.
  3. Iterators over index loops. More readable, and bounds-check elimination comes free; only when a profiler shows a counterexample do you go back.
  4. Buffer all I/O. BufReader/BufWriter turns “one syscall per line” into “one per 8KB” — the best value-per-line change in the standard library.
  5. unsafe is the last step, not the first. Part 11’s door is still there — but before get_unchecked saves a bounds check, make the profiler testify that this check is the bottleneck. Nine times out of ten, it isn’t.

Practice, then Part 14

  1. Reproduce the growth ladder by hand: run this part’s capacity experiment, then swap Vec::new() for Vec::with_capacity(20) and confirm only one line prints. Now switch the element to String and ask: did the cost of each reallocation-copy change?
  2. Reproduce the iterator-versus-loop benchmark (remember --release and black_box). Then drop the filter and keep only map — predict the outcome before verifying. Building the “predict first, measure second” habit is how the discipline gets internalized.
  3. Find a “concatenating with format! in a loop” in code you’ve written (or write one now), rewrite it with push_str reusing one String, and compare with criterion or simple timing. Write the saved nanoseconds in a comment — your first performance-engineering dispatch.

Part 14, the finale: ecosystem selection and the voyage beyond. A selection framework for web frameworks, serialization, CLI, logging, and error handling; a due-diligence checklist for “how to read a crate” (five signals beyond download counts); and from Part 1’s cargo new to here — after the borrow checker turns from adversary into mentor, the road ahead for a Rustacean.

guest@swangnice:~$