Level 4 · Deep Cuts

Performance & Profiling 🏎️

People pick Rust for speed, then measure nothing and guess everything. This lesson is about the stopwatch: what actually costs time, what is folklore, and how to know the difference.

Rule zero: release mode 🏁

cargo run builds with no optimization so it compiles fast and debugs nicely. cargo run --release turns the optimizer loose. The difference is not subtle:

use std::time::Instant;

fn main() {
    let start = Instant::now();
    let sum: u64 = (1..=10_000_000).sum();
    println!("sum = {sum}");
    println!("took {:?}", start.elapsed());
}

On the machine this course was written on: 72ms in debug, 55µs in release. Over a thousand times faster, from one flag. Every "Rust is slow" thread on the internet contains at least one person benchmarking a debug build. Never judge speed without --release. (The playground's Run button uses debug mode, so run these locally to see release numbers.)

What actually costs: allocations and clones 💸

CPUs add numbers absurdly fast. What is comparatively slow is asking the allocator for heap memory and copying data around. That is why .clone() deserves a raised eyebrow in a loop:

use std::time::Instant;

fn total_len_take(words: Vec<String>) -> usize {
    words.iter().map(|w| w.len()).sum()
}

fn total_len_borrow(words: &[String]) -> usize {
    words.iter().map(|w| w.len()).sum()
}

fn main() {
    let words: Vec<String> = (0..200_000).map(|n| format!("word number {n}")).collect();

    let start = Instant::now();
    let a = total_len_take(words.clone());  // clones 200,000 strings first
    println!("clone + take: {:?}", start.elapsed());

    let start = Instant::now();
    let b = total_len_borrow(&words);       // just lends them
    println!("borrow:       {:?}", start.elapsed());

    assert_eq!(a, b);
}

In release mode: 3.7ms for the cloning version, 74µs for the borrowing one, fifty times faster. All those Level 2 borrowing rules were never just about safety: every & you write is also a copy you did not make. Ownership is the performance model.

Folklore vs the stopwatch ⏱️

Classic advice says: if you know a Vec's final size, create it with Vec::with_capacity(n) so it never has to reallocate as it grows. True! But measure what that is worth:

use std::time::Instant;

fn main() {
    let n = 1_000_000;

    let start = Instant::now();
    let mut sized = Vec::with_capacity(n);
    for i in 0..n {
        sized.push(i);
    }
    println!("pre-sized:        {:?}", start.elapsed());

    let start = Instant::now();
    let mut grown = Vec::new();
    for i in 0..n {
        grown.push(i);
    }
    println!("growing as we go: {:?}", start.elapsed());

    assert_eq!(grown, sized);
}

On this machine, release mode: 0.9ms pre-sized, 1.1ms growing. A real win, but a modest one, because Vec doubles its capacity each time it grows, so a million pushes trigger only about twenty reallocations. And a dirty secret: swap the order of those two blocks and the numbers shift, because the first block warms up the allocator for the second. Benchmarking is hard, folklore is often stale, and the only trustworthy answer comes from measuring your code, both orders, several runs.

Zero-cost abstractions, verified 🎁

"Zero-cost abstraction" is Rust's most famous promise: the pretty high-level version compiles to the same machine code as the manual loop. You do not have to take that on faith:

fn main() {
    let nums: Vec<i64> = (1..=1_000).collect();

    let mut total = 0;
    for n in &nums {
        if n % 3 == 0 {
            total += n * n;
        }
    }

    let total2: i64 = nums.iter().filter(|n| *n % 3 == 0).map(|n| n * n).sum();

    assert_eq!(total, total2);
    println!("both ways agree: {total}");
}

In release builds these produce essentially identical assembly; the optimizer fuses filter and map into one loop, and often vectorizes it. Write whichever version is clearer, which is usually the iterator chain. In garbage-collected languages, "pretty" code has a tax; in Rust it does not, and that is precisely why companies rewrite hot paths in it.

The real toolbox 🧰

ToolWhat it answers
std::time::Instant"roughly how long does this take?" - built in, good enough to start
criterionproper benchmarks: many runs, statistics, "did my change actually help?"
cargo flamegraph"where does the time GO?" - a picture of your program's hot spots
hyperfinetiming whole programs against each other, warm-up handled for you

The workflow pros actually follow: make it work, make it right, then profile before making it fast. The hot spot is nearly never where you guessed, and optimizing the wrong thing costs clarity for nothing.

Exercise

The 200x data structure

Build a "library" of 5,000 book titles and a 5,000-title wishlist, then count how many wishlist titles the library owns, twice: once with library.contains(...) (a Vec scan per lookup), once by first collecting the library into a HashSet. Time both. Before you run it: write down your guess.

Reveal solution
use std::collections::HashSet;
use std::time::Instant;

fn main() {
    let library: Vec<String> = (0..5_000).map(|n| format!("book-{n}")).collect();
    let wishlist: Vec<String> = (0..5_000).map(|n| format!("book-{}", n * 2)).collect();

    let start = Instant::now();
    let slow = wishlist.iter().filter(|w| library.contains(w)).count();
    println!("Vec::contains: {slow} owned, {:?}", start.elapsed());

    let start = Instant::now();
    let shelf: HashSet<&String> = library.iter().collect();
    let fast = wishlist.iter().filter(|w| shelf.contains(w)).count();
    println!("HashSet:       {fast} owned, {:?}", start.elapsed());
}

Release mode on this machine: 21ms vs 95µs, about 220 times faster, and the gap widens with size. The Vec version does 25 million string comparisons; the HashSet does 5,000 hash lookups. No clever trick beats picking the right data structure, which is why Lesson 10 introduced HashMap right after Vec. 📚

🎓 Deep Cuts complete! That is the whole curriculum: 23 lessons, from "what is a CPU" to reading assembly-level promises with a stopwatch in hand. Take the Deep Cuts quiz, then the only thing left is the Project Workshop and the wide open world. Rusty has never been prouder. 🦀🧡