Guidebook · Rust performance engineering

Rust Performance Engineering: From "It Feels Faster" to "I Can Prove It"

Status of this file: Complete. All 9 Parts (40 chapters), the 11 capstone projects, the teaching kit, the glossary, and the reading map are written. Every Part ends with its own verification notes (what was run, what was checked against sources, and what was not). Corrections to earlier chapters are marked where they were made, and listed in Chapter 40.


What this guidebook promises

By the end, you will be able to take a slow Rust program (a parser, an interpreter, a compiler pass, a matrix routine) and do four things with it:

  1. Measure it honestly, so you know the number you see is real.
  2. Find where the time goes, using the right tool for the question.
  3. Explain why it is slow, down to cache lines, memory pages, pipelines, and cores.
  4. Fix it, and prove the fix worked, and teach the whole thing to other people.

You will not just know the tools. You will know why each tool works, what it can see, and what it cannot see. That is the difference between running a profiler and understanding performance.

How to use this guidebook

Every major idea is taught in three layers.

  • The model (the "what"). The theory. The parts, the rules, the costs, and the numbers. This is what lets you define an idea and explain it to someone else.
  • Build it (the "how"). Real code you run. Small first, then break it, then fix it. This is what lets you implement the idea in more than one way.
  • Why it is this way (the "why"). The mental model. Why the designers chose this, what they gave up, and what would make the idea wrong. This is what lets you improve an idea or invent a new one.

Each chapter ends with exercises. Please do them. Reading gives you the feeling of understanding. Exercises give you the real thing. Each Part ends with a mini project, and the whole guidebook ends with 10 capstone projects.

Every number in this guidebook comes from a real run, unless it says otherwise. When I could not check something, I say so.

What you need before you start

  • You can read basic Rust: structs, vectors, loops, closures, Option, and Result.
  • A Linux machine (x86-64 is the default in this guidebook). A cloud VM or WSL2 works for most chapters. Some tools (like perf hardware counters) work best on real hardware, and I will say when.
  • Rust stable. Part I and Part III used rustc 1.75 for the standalone programs, and Parts II and IV to IX mostly rustc 1.91.1 (each Part says which). Newer versions should work, and a few numbers will change.
  • Later Parts will also use perf, valgrind, and a few Rust tools. Each one is introduced where it is first needed, with its install command.

The machine behind the numbers in this guidebook: an Intel Xeon at 2.1 GHz (the nominal speed the machine reports; in Chapter 37 I measure the core actually running at about 3.2 to 3.3 GHz), 1 core, running in a cloud virtual machine. L1 data cache 48 KiB, L2 2 MiB (as reported by lscpu). Your machine will give different numbers. The shapes should match.

Study paths

  • I have a weekend. Part I (Ch 1 to 4), Part III (Ch 8, 9, 11), and Part IV (Ch 14 to 16). You will be able to measure, and to find hot spots with a flamegraph.
  • I want to give a talk. Part I, Part III, Part IV, and the "Teach it back" box at the end of every Part. Then the teaching kit near the end.
  • I want to speed up my parser, VM, or compiler. Part I, Part III, Part V, then Part VII. Do the mini projects.
  • I want to know it all. Read in order. Do every Core exercise and the Part mini projects. Then the capstones.

Table of contents

Part I. Measuring honestly

  1. What does "fast" even mean?
  2. Noise, clocks, and distributions
  3. Designing the workload and keeping the work alive
  4. Build a tiny benchmark harness

Part II. Benchmarking in practice with Criterion

  1. Install Criterion and read your first report
  2. Estimates, outliers, and "is this change real?"
  3. Groups, inputs, throughput, and baselines

Part III. The machine: memory and the CPU

  1. Memory is not a box. It is a ladder.
  2. Cache lines, the write path, and locality
  3. Pages, address translation, and the TLB
  4. Data layout: AoS, SoA, and hot/cold splits
  5. Access patterns and the prefetcher
  6. Pipelines, dependency chains, and branch prediction

Part IV. Profiling: finding where the time goes

  1. Choose the evidence before the tool
  2. perf stat and perf record
  3. Flamegraphs, from folded stacks to a picture
  4. Samply and timeline profiling
  5. Instrumentation without fooling yourself (hotpath-rs and friends)
  6. Top-down analysis and the roofline model
  7. Cachegrind and Callgrind

Part V. Memory and allocation

  1. What an allocation really costs
  2. Heap profiling with DHAT
  3. Reuse, arenas, SmallVec, and interning
  4. What the Rust compiler does to your loop: bounds checks, iterators, inlining, LTO

Part VI. Many cores, shared state, and wide instructions

  1. Cache coherence and false sharing
  2. Atomics, locks, and contention
  3. NUMA and finding contention with perf c2c
  4. SIMD and auto-vectorization
  5. Threads and data parallelism

Part VII. Applying it: four real systems

  1. Matrix multiplication, from naive to blocked
  2. Parsers
  3. Interpreters and VMs
  4. Compiler data structures: ASTs, interning, and arenas

Part VIII. Inside the tools

  1. Inside Criterion
  2. Inside perf and the hardware counters
  3. Inside Valgrind
  4. The silicon contract: frequency, cores, and what "a cycle" means

Part IX. Production practice

  1. Performance as a maintained contract
  2. From profile to safe change
  3. Contribute and teach

Capstone projects (11) · Teaching kit · Glossary · Reading map


Part I. Measuring honestly

After this Part, you will be able to say with a straight face: "I measured it, and here is why you can trust the number."

Most performance work fails before it starts. Not because people write slow code. Because they measure wrong, and then they "fix" things that were never broken. So we start here. No tricks to make code fast yet. Only how to know what is true.


Chapter 1. What does "fast" even mean?

In this chapter: You will learn what a performance question really is, why a fast-looking number can be completely fake, and the simple loop that every good performance fix follows.

You need first: Nothing.

The problem first

Here is how a lot of performance work goes.

You have a slow function. You read it. You spot something that "looks slow". You change it. You run the program. It feels faster. You commit the change and tell the team you made it faster.

But did you?

Maybe the program was faster only because the machine was less busy that minute. Maybe the part you changed was only 2% of the time, so you could never have made a real difference. Maybe the compiler already did the same trick for you. Maybe you made one case faster and another case slower and only tested the first.

You don't know. You feel. And feelings about performance are wrong a lot of the time, even for very good engineers.

So the first thing to learn is not how to make code fast. It is how to know.

The model (the "what")

A performance claim is an observation

Start with a plain idea. Every performance claim is secretly a sentence with five blanks:

"This code takes this long on this machine with this input under these conditions."

If any blank is missing, the claim is incomplete. "My parser is fast" fills in nothing. "My parser handles a 10 MB JSON file in 41 ms, on a 2.1 GHz Xeon, one core, file already in memory, release build" fills in all five. The second claim can be checked. The first can only be argued about.

Remember this: performance is not a property of the code alone. It is a property of the code, the data, and the machine, all together. Change any one, and the number changes.

Two different things people call "fast"

A few words you need, because people mix them up.

  • Latency is how long one piece of work takes, from start to finish. "This request took 20 ms."
  • Throughput is how much work gets done per unit of time. "This server handles 5,000 requests per second."

They are related, but they are not the same. Think of a road. Latency is how long one car takes to drive the road. Throughput is how many cars pass per hour. Widen the road to four lanes and the throughput goes up, but one car is not any faster. And a very fast car alone on the road has great latency and tiny throughput.

Why does this matter? Because the fix is different. If you need lower latency, you shorten the path. If you need higher throughput, you often add parallel lanes, or reduce wasted work per item. We will use both words all through this guidebook.

Two more words:

  • Wall-clock time is the time you would measure with a stopwatch on the wall.
  • CPU time is how long the CPU actually worked on your program. If your program waited for the disk, that waiting is wall time but not CPU time.

When you see a gap between the two, you have already learned something: the program is waiting for something, not computing.

Where speed-ups can and cannot come from

Say a program takes 100 seconds. A function inside it takes 80 of those seconds. You make that function 10 times faster. How fast is the whole program now?

Not 10 times faster. The 80 seconds become 8. The other 20 seconds did not change. So the total is 28 seconds. That is a speed-up of 100 / 28, about 3.6 times.

This is called Amdahl's law. In symbols: if a fraction p of the time is sped up by a factor s, the whole program speeds up by

            1
speedup = ─────────────
          (1 − p) + p/s

Let us try it for the same function (p = 0.8) with different values of s:

Speed-up of the function (s)Whole-program speed-up
2x1.67x
10x3.57x
100x4.81x
infinite5.00x

Look at the last row. Even if you made the function take zero time, the program only gets 5 times faster. Because the other 20% is still there. That is the limit.

Think about what this teaches. Before you spend a week on a clever trick, ask: how big is the part I am about to change? If it is 2% of the time, the best possible win is 2%. This is why we will spend a whole Part on profilers. A profiler tells you p.

Build it (the "how")

Now let's feel how easily a measurement can lie.

An experiment: the number that was too good

We write a function that does real work. It runs a small random-number loop 200 million times. The compiler cannot turn it into a simple formula.

use std::hint::black_box;
use std::time::Instant;

// A loop the compiler cannot turn into a simple formula.
fn heavy(n: u64) -> u64 {
    let mut x: u64 = 88172645463325252;
    for _ in 0..n {
        x ^= x << 13;
        x ^= x >> 7;
        x ^= x << 17;
    }
    x
}

fn main() {
    let n = black_box(200_000_000u64);

    // Version 1: we never use the answer.
    let t = Instant::now();
    let _ = heavy(n);
    let ns1 = t.elapsed().as_nanos();

    // Version 2: we use the answer through black_box.
    let t = Instant::now();
    let r = heavy(n);
    black_box(r);
    let ns2 = t.elapsed().as_nanos();

    println!("answer never used : {:>12} ns", ns1);
    println!("answer black_boxed: {:>12} ns", ns2);
}

Build it with optimizations on, the way real programs are built:

rustc -C opt-level=3 a_deleted.rs -o a_deleted && ./a_deleted

Here is what I got:

answer never used :          121 ns
answer black_boxed:    417133002 ns

Read those two lines again.

The first version says: 200 million loop steps took 121 nanoseconds. That is less than the time light needs to travel 40 meters. It is not possible. A real loop of that size needs about 0.4 seconds, which is what the second line shows.

So what happened? In the first version, nobody uses the answer. The compiler can see this. It thinks: "This function has no side effects, and the result is thrown away. I can delete the whole call." So it does. We timed (almost) nothing. The 121 ns is most likely just the cost of asking the clock for the time, twice. (I did not check the machine code to confirm. Exercise 1.4 shows you how to test it yourself.)

In the second version, we pass the result to black_box. black_box is a function that tells the compiler: "assume this value is used somewhere you cannot see." Now the compiler must really compute it. And black_box(200_000_000) on the input also stops the compiler from knowing the value of n in advance, so it cannot pre-compute the answer at build time.

The lesson is simple and a little scary. The compiler is on your side when you ship the code, and against you when you test it. It is allowed to remove any work whose result you do not use. A benchmark that doesn't keep its work alive can report a speed that is off by a factor of three million.

(One honest note: black_box is a best-effort hint from the Rust standard library, not a guarantee. It works well in practice, and we will use it everywhere. Chapter 3 shows where else to be careful.)

Why it is this way (the "why")

Why can the compiler delete work? Because of one deep rule: the compiler only has to keep the program's observable behavior the same. It does not promise to keep your code's steps. If deleting a step changes nothing anyone can observe, then deleting is allowed. That rule is the reason Rust code can run so fast. Your benchmark is the one place where "nothing observable" is a trap.

This leads to the habit that all good performance engineers share. It is a loop. Write it down and use it every time:

  1. QUESTION      "How long does X take on input Y?"  (all five blanks filled)
        │
        ▼
  2. HYPOTHESIS    "I think the time is in Z, because ..."  (write it BEFORE you measure)
        │
        ▼
  3. MEASURE       with a method you can defend
        │
        ▼
  4. EXPLAIN       Why is the number what it is?  (If you can't explain it, you don't understand it yet)
        │
        ▼
  5. CHANGE        one thing
        │
        ▼
  6. MEASURE AGAIN and compare with step 3
        │
        └──────────► back to 1, or stop when the goal is met

Two of these steps are usually skipped. Step 2: if you do not write down what you expect, you cannot be surprised, and surprise is where learning comes from. Step 4: a number you cannot explain is a warning sign. In the experiment above, "121 ns for 200 million steps" should have made you stop and ask why. That one question found the deleted loop.

There is a bigger idea behind this. A measurement is not the truth. It is a model of the truth, made by a tool, under conditions. A famous 2009 paper (Mytkowicz and co-authors, in the reading list) showed that even tiny, harmless-looking choices in an experiment, like the order in which a program is linked, can change the result enough to flip the conclusion. The authors checked 133 papers from top conferences and found that none of the ones with experimental results considered this kind of bias properly. So if experts at top conferences get it wrong, you should assume that you can get it wrong too. The right attitude is not "my number is true". It is "my number is evidence, and here is how I tried to break it."

Common mistakes

  • Fixing what feels slow. The slow-looking part is often not the slow part. Measure first.
  • Timing a debug build. Debug builds are often many times slower than release builds, and they can have a different shape. Always measure --release (or -C opt-level=3).
  • Trusting a number that is too good. If it is impossible, it is probably a deleted loop.
  • Comparing numbers from different days or machines. Compare runs from the same machine, back to back, with only one change between them.
  • Forgetting Amdahl. Optimizing a part that is 2% of the time.

Say it simply

A performance claim always has five blanks: the code, the time, the machine, the input, and the conditions. A number with blanks is just a feeling. The compiler can delete any work whose result you don't use, so a careless benchmark can measure nothing at all. Good performance work is a loop: ask a precise question, guess the answer, measure, explain the number, change one thing, and measure again. The size of the part you change decides the most you can win.

Check yourself

Exercise 1.1 (Warm-up): Fill the blanks. Rewrite each claim so all five blanks are filled, inventing reasonable details: (a) "Our API is fast." (b) "Rust is faster than Python." (c) "This sort is O(n log n), so it is fast."

Exercise 1.2 (Warm-up): Amdahl by hand. A program spends 30% of its time in function A and 60% in function B. (a) You make A 100 times faster. What is the whole-program speed-up? (b) You make B 2 times faster. What is it now? Which change is better? Surprised?

Exercise 1.3 (Warm-up): Latency or throughput? For each, say which one is the problem and why: (a) A web page takes 3 seconds to show up. (b) A nightly job must process 10 million records in 6 hours, and it takes 9. (c) A game drops frames when many enemies appear.

Exercise 1.4 (Core): Delete the work yourself. Run a_deleted.rs. Then pick a different pure function (for example, one that sums the squares of 0..n) and test three cases: (a) the result is never used, (b) the result goes through black_box but the input n is a plain constant, (c) both the input and the result go through black_box. Time each one. Which cases did the compiler shrink or delete, and which did it not? Done when: you can explain each of the three cases in one sentence.

Exercise 1.5 (Core): The five-blanks report. Take any loop you wrote in the past (a parser step, a sort, a hash-map lookup). Write the full question with five blanks, then a hypothesis ("I expect about X ns per item, because ..."). Do not measure yet. Save the page; you will test it in Chapter 4.

Exercise 1.6 (Stretch): Design and defend. Your team lead says: "Let's rewrite the tokenizer in assembly. It is the slowest part of the compiler." You have no profile. Write half a page: what would you ask for before agreeing, and what would make you say yes?

What a strong answer includes: the question of how big p is, a request for a profile, a note about Amdahl's limit, the cost of maintaining assembly, and what would count as a successful result.


Chapter 2. Noise, clocks, and distributions

In this chapter: You will learn why running the same code twice gives two different times, how to describe a pile of timings honestly, and which clock to use.

You need first: Chapter 1.

The problem first

Run a function 2,000 times. You will not get one number. You will get 2,000 numbers.

Which one is "the time"?

The first one? The average? The smallest? Many programmers pick one without thinking, and the pick changes the story. Let me show you with real data, and then we will build the words to talk about it.

The model (the "what")

Where the wobble comes from

The same code on the same data takes a different time on every run. The reasons:

  • Other programs on the machine use the CPU, the caches, and the memory.
  • The operating system interrupts your program to do its own jobs. (These are called interrupts: a signal that makes the CPU stop what it is doing for a moment and handle something else.)
  • The CPU changes its speed to save power or to avoid getting too hot. This is called frequency scaling.
  • The first run is slower because the data is not in the cache yet. Later runs are faster.
  • In a cloud VM, other users share the same physical machine.

All of this wobble is called noise: changes in the measured time that are not caused by your code.

We cannot remove noise. We can only describe it honestly and keep it from fooling us.

The words for describing a pile of numbers

You have many timings. Here are the summaries, in plain words:

  • Minimum (min): the fastest run. It shows what the code can do when nothing gets in the way.
  • Mean (average): add them all and divide. Easy, but one huge slow run drags it up.
  • Median: sort the numbers and take the middle one. Half the runs are faster, half are slower. A few huge values barely move it.
  • Percentile: the value below which a given share of runs fall. The 95th percentile (p95) means 95% of runs were at or below this number. The p99 is the same for 99%. Percentiles describe the slow tail.
  • Median absolute deviation (MAD): a measure of spread that ignores outliers. Take each timing's distance from the median, then take the median of those distances. It tells you how far a "typical" run sits from the typical value.
  • Distribution: the full picture of how the numbers are spread out. Not one number. All of them.

A good habit: never report one number alone. Report at least the median and some measure of spread (p95 or MAD).

Which clock?

Computers have more than one clock, and mixing them up is a common bug.

  • A wall clock (in Rust, SystemTime) tells you the date and time. The operating system can change it, for example when it syncs with a network time server. It can even go backwards. Never use it to time code.
  • A monotonic clock (in Rust, std::time::Instant) only moves forward, and it is not changed when the date changes. Monotonic means "never goes backwards". This is the one to use for timing.

Even a monotonic clock is not free. Asking it for the time takes a little time. If your code takes 5 ns, and reading the clock takes 30 ns, you are measuring the clock. We will fix this in Chapter 4 by timing many runs at once and dividing.

Build it (the "how")

Here is a small program. It sums 100,000 numbers and times each run. It does this 2,000 times, then prints the summary and a histogram. (A histogram counts how many values fall in each range, so you can see the shape.)

use std::hint::black_box;
use std::time::Instant;

fn work(data: &[u64]) -> u64 {
    let mut s = 0u64;
    for v in data { s = s.wrapping_add(*v); }
    s
}

fn main() {
    let data: Vec<u64> = (0..100_000u64).collect(); // 800 KB
    let runs = 2000;
    let mut t: Vec<u64> = Vec::with_capacity(runs);
    for _ in 0..runs {
        let s = Instant::now();
        black_box(work(black_box(&data)));
        t.push(s.elapsed().as_nanos() as u64);
    }
    let mut sorted = t.clone();
    sorted.sort();
    let q = |p: f64| sorted[((runs as f64 - 1.0) * p) as usize];
    let mean = t.iter().sum::<u64>() as f64 / runs as f64;
    println!("runs   = {}", runs);
    println!("min    = {:>9} ns", sorted[0]);
    println!("median = {:>9} ns", q(0.5));
    println!("mean   = {:>9.0} ns", mean);
    println!("p95    = {:>9} ns", q(0.95));
    println!("p99    = {:>9} ns", q(0.99));
    println!("max    = {:>9} ns", sorted[runs - 1]);
    // (the histogram code is in the exercises' starter file; the output is shown below)
}

What I got on this machine:

runs   = 2000
min    =     11756 ns
median =     12427 ns
mean   =     13891 ns
p95    =     19099 ns
p99    =     28683 ns
max    =    102613 ns

histogram (last row = everything above 2x median):
    11756 ns | ################################                   1282
    12848 ns | #####                                              201
    13939 ns |                                                    23
    15030 ns | #                                                  79
    16122 ns | ######                                             278
    17214 ns |                                                    29
    18305 ns |                                                    27
    19396 ns |                                                    34
    20488 ns |                                                    3
    21580 ns |                                                    3
    22671 ns |                                                    2
    23762 ns |                                                    5
    24854 ns |                                                   34

Let's read this like a detective.

The spread is large. The fastest run took 11.8 µs. The slowest took 102.6 µs. That is 8.7 times the fastest. Same code, same data.

Mean and median disagree. The mean is 13.9 µs and the median is 12.4 µs. The slow tail pulled the mean up. This is exactly why the median is the safer "typical" value.

The histogram has two humps. One big hump near 11.8 µs (most runs) and a second, smaller hump near 16.1 µs (278 runs). It is not one smooth bell shape. I can't tell you from this data why there are two. Possible causes include the CPU switching speeds, another tenant on the same VM host, or the way the loop was placed in memory. A good next step would be to repeat the test with a fixed CPU frequency, but I could not do that in this VM. This is a reminder: if you only report the mean, you never see the two humps. The shape of the distribution often holds the story.

So which number is "the time"? It depends on the question:

Question you are askingGood summary
"What can this code do at best, with the least interference?"min
"What will a typical run take?"median
"How bad does it get for unlucky users?"p95 or p99
"Did my change help?"compare the medians (and check the spread)

Why it is this way (the "why")

Why is noise almost always on the slow side? Think about what can go wrong in a run. Another program can steal the CPU. An interrupt can pause you. The cache can be cold. All of these add time. Almost nothing makes your code run faster than its natural best. So a distribution of timings has a hard floor near the best case and a long tail stretching up. This shape is called a right-skewed distribution (a long tail on the right, to the slow side).

That one fact explains a lot of what comes later:

  • The mean is a bad summary of a skewed shape, because the tail pulls it.
  • The min is a good estimate of the best case, but a poor estimate of what users see.
  • Tail latency (p99, p99.9) matters a lot in real services, because a user who makes many requests will meet the slow tail often. The slow tail is not an edge case. It is the experience of some real person.

There is a deeper idea here. When we time code, we are not measuring a number. We are sampling from a distribution. One sample tells you almost nothing. Many samples tell you the shape. The honest way to say "this code takes X" is "when I sampled it N times, the median was X, and 95% of runs were below Y." That is a smaller claim than "it takes X". It is also true.

What would make this model wrong? The "noise only adds time" idea fails sometimes. A CPU can boost its speed for short bursts, so the first runs can be faster than the long-run speed. Caches can also help one run and hurt the next. So the min is not always the "true" cost. This is why we describe the whole distribution, and why Chapter 37 will explain what "a cycle" even means.

Common mistakes

  • Reporting only the mean. One slow run can wreck it.
  • Reporting only the min. You will promise speeds that users never see.
  • Timing a single run. One run is one sample from a distribution.
  • Using SystemTime to time code. It can jump.
  • Ignoring the shape. Two humps, a long tail, or slow drift each mean something. Look at the histogram.

Say it simply

Running the same code many times gives many different times, because other things on the machine add noise. Noise nearly always makes runs slower, so the timings form a long tail on the slow side. The mean is dragged by that tail, so the median and the percentiles are safer. Use a monotonic clock, Instant, because the wall clock can jump. Never say "it takes X". Say "the median was X, and 95% of runs were under Y."

Check yourself

Exercise 2.1 (Warm-up): Predict, then check. Before running anything, guess: for the program above, will the mean be higher or lower than the median? By how much? Then run it. Were you right?

Exercise 2.2 (Warm-up): Summary by hand. Timings in ns: 100, 102, 101, 99, 103, 100, 400, 101, 100, 102. Compute the min, median, mean, and the MAD by hand. Which one best matches "a typical run"? Which is most changed by the 400?

Exercise 2.3 (Warm-up): Which clock? A friend times code with SystemTime::now() and gets a negative duration. Explain, in four sentences, how that can happen and what to use instead.

Exercise 2.4 (Core): Your noise report. Run b_noise.rs on your own machine three times: (a) with nothing else running, (b) while another program is compiling a big project, (c) while a video plays. Make a table with min, median, p95, and max for each. Which number moved the most? Which moved the least? Done when: you have a 3-row table and 5 sentences about what changed and why.

Exercise 2.5 (Core): Find the second hump. Add the histogram code to the program (bins from the min to twice the median). Does your machine show one hump or two? If two, try pinning the program to one core with taskset -c 0 ./b_noise and compare. What changes?

Exercise 2.6 (Stretch): The cost of the clock. Write a loop that calls Instant::now() twice in a row, 1 million times, and records the gap between the two calls. What is the median? What does it tell you about the smallest time you can trust on your machine?

Exercise 2.7 (Stretch): Design and defend. A service owner says: "Our average latency is 20 ms, so we are fine." You see that the p99 is 900 ms. Write a short note to explain why the average hides a real problem, using one real-life analogy.

What a strong answer includes: the right-skewed shape, the idea that tail users are real users, and a request to look at the histogram or percentiles.


Chapter 3. Designing the workload and keeping the work alive

In this chapter: You will learn that a benchmark is a small world you build. If the world is wrong, a precise number is precise about the wrong thing. You will see how the shape of the input can change the time by 200 times.

You need first: Chapters 1 and 2.

The problem first

You want to know how fast sort is. So you make a vector of numbers and time sort. Easy.

But which numbers? Random ones? Already sorted ones? Do you copy the vector before sorting each time, or sort the same vector again and again? And does that copy count as part of the time?

Every one of these choices changes the answer. And most of the time we make them without noticing.

The model (the "what")

A workload is the input and the sequence of operations you give to the code in a test. A good workload has these properties:

  1. Representative. It looks like the real data the code will meet. A benchmark on tiny, neat data tells you about tiny, neat data.
  2. Deterministic. The same every time. If you use random data, use a seeded random generator (one that starts from a fixed number so it makes the same sequence each run). Otherwise two runs are not comparable.
  3. Sized on purpose. The size of the data decides which rung of the memory ladder it lives on (Chapter 8). A test with 1,000 items might sit in L1. The real program might use 100 million.
  4. Setup outside the timer. Building the input, cloning it, and allocating it should not be counted, unless that cost is exactly what you want to know.
  5. Alive. Every result must be used (with black_box), so the compiler cannot delete the work. Every input should pass through black_box too, so the compiler cannot pre-compute the answer from a constant it can see.
  6. Honest about state. Is the cache warm or cold? Is the data fresh or reused? State it, because the answers differ.

Think about it this way: the benchmark is a tiny model of the real world. All models are wrong in some ways. The skill is choosing what to keep.

Build it (the "how")

We time slice::sort on 1 million u32 numbers. But we try five different shapes of input. The clone of the input happens before the timer starts, so we only time the sort.

use std::hint::black_box;
use std::time::Instant;

fn xorshift(s: &mut u64) -> u64 { let mut x = *s; x ^= x << 13; x ^= x >> 7; x ^= x << 17; *s = x; x }

fn best_ms(input: &Vec<u32>) -> f64 {
    let mut best = f64::MAX;
    for _ in 0..9 {
        let mut v = input.clone();          // clone is outside the timer
        let t = Instant::now();
        v.sort();
        black_box(&v);
        best = best.min(t.elapsed().as_secs_f64() * 1000.0);
    }
    best
}

fn main() {
    let n = 1_000_000usize;
    let mut s = 12345u64;                   // fixed seed: same data every run
    let random: Vec<u32> = (0..n).map(|_| xorshift(&mut s) as u32).collect();
    let mut sorted = random.clone(); sorted.sort();
    let mut reversed = sorted.clone(); reversed.reverse();
    let few: Vec<u32> = random.iter().map(|x| x % 4).collect();
    let mut nearly = sorted.clone();
    for i in (0..n).step_by(1000) { nearly.swap(i, (i * 7919) % n); }

    println!("sorting {} u32 values with slice::sort (best of 9)", n);
    println!("random          : {:>8.2} ms", best_ms(&random));
    println!("already sorted  : {:>8.2} ms", best_ms(&sorted));
    println!("reversed        : {:>8.2} ms", best_ms(&reversed));
    println!("nearly sorted   : {:>8.2} ms", best_ms(&nearly));
    println!("only 4 distinct : {:>8.2} ms", best_ms(&few));
}

What I got (rustc 1.75):

sorting 1000000 u32 values with slice::sort (best of 9)
random          :    79.12 ms
already sorted  :     0.34 ms
reversed        :    16.61 ms
nearly sorted   :    37.40 ms
only 4 distinct :    72.15 ms

Same function. Same count of numbers. The time goes from 0.34 ms to 79 ms, a gap of about 230 times, just from the shape of the data.

Here is what I think each line is telling us:

  • Already sorted: 0.34 ms. The standard sort in this Rust version looks for runs of numbers that are already in order. If the whole input is one run, there is almost nothing to do. It just checks once and finishes.
  • Random: 79 ms. This is the true hard case. No pattern to use.
  • Nearly sorted: 37 ms. Long runs exist, but there are breaks. Half the work of the random case.
  • Only 4 distinct values: 72 ms. Only a little better than random. This sort does not take much advantage of lots of equal values.
  • Reversed: 16.6 ms. I expected this to be as fast as the sorted case, since a reversed run is just an ordered run backwards. I did not dig into why it is not. Treat it as an open question. (Also: newer Rust versions changed the sort algorithm, so your numbers may differ a lot. That is a lesson on its own: a benchmark number belongs to a specific version of the code.)

The point is not these exact numbers. The point is that if you had only tested one shape, you would have believed one of these numbers was "the speed of sort", and you would have been wrong for every other shape.

Why it is this way (the "why")

Why does the input shape matter so much? Because almost all fast code is fast by using something about the data. A sort that finds runs uses order. A hash map uses the spread of the keys. A cache uses repeated access. A branch predictor uses patterns in the choices. Faster code usually means more assumptions about the data. When the data breaks the assumption, the speed goes away.

This gives you a principle to carry: an algorithm's speed is a relationship between the code and the data, not a property of the code. When someone says "this is faster", always ask: "On what data?"

It also changes how you design a workload. Ask three questions:

  1. What does the real data look like? Where can I get a sample? (Real samples beat invented ones.)
  2. What are the best and worst shapes? Test both ends.
  3. What assumption does this code use, and does my test data keep that assumption true?

What would make this wrong? If your benchmark data is chosen after you see the results (you pick the shape where your change wins), you are not measuring. You are advertising. A defense: pick the workloads before you try the change, and write them down.

Common mistakes

  • Only one input shape or size. Test at least a small, a medium, and a large size, and the best and worst shapes.
  • Timing the setup. Clone, allocate, and build outside the timer.
  • Reusing an already-sorted vector. Sorting the same vector again and again means the second run sorts sorted data.
  • Random data without a seed. You cannot compare two runs.
  • Never passing the input through black_box. The compiler may compute the answer at build time.
  • Choosing the data after seeing the result.

Say it simply

A benchmark is a small world you build, and the answer is only true in that world. A good workload is realistic, repeatable, sized on purpose, free of setup cost, and kept alive so the compiler cannot delete it. The shape of the input can change a time by hundreds of times, because fast code works by using something about the data. So never say "this code is fast." Say "this code is fast on this kind of data."

Check yourself

Exercise 3.1 (Warm-up): Spot the bugs. This benchmark has three problems. Find them. let v = vec![5, 3, 1, 4, 2]; let t = Instant::now(); for _ in 0..1000 { let mut c = v.clone(); c.sort(); } println!("{:?}", t.elapsed());

Exercise 3.2 (Warm-up): Predict. Before running, rank these four inputs from fastest to slowest for HashMap lookups: (a) 1,000 keys looked up repeatedly, (b) 10 million keys looked up in random order, (c) 10 million keys looked up in insertion order, (d) 1,000 keys but each lookup is for a key not in the map. Give a one-sentence reason for each rank.

Exercise 3.3 (Core): Run the sort experiment. Run c_inputs.rs on your machine and on a second Rust version if you can (rustup toolchain install a newer one). Compare tables. Which lines changed the most? Explain one change. Done when: a table with both versions and 5 sentences of explanation.

Exercise 3.4 (Core): Size sweep. Pick a function (for example, summing a Vec<u64>) and time it at sizes from 1,000 up to 100 million items, doubling each time. Plot time per item against size. Where do jumps appear? (Keep this plot. You will explain it fully in Chapter 8.)

Exercise 3.5 (Core): Break the benchmark. Write a loop that "benchmarks" x * 3 + 1 summed over 0..n and finds a time per step of 0.0 ns. Explain exactly which optimization removed the work. Then fix it so it is a fair test.

Exercise 3.6 (Stretch): Design and defend. You are benchmarking a JSON parser. List five input files you would choose, and what each one tests. Explain what you would do to make sure you did not pick them to flatter your code.

What a strong answer includes: tiny and huge files, deeply nested vs flat, many short strings vs a few long ones, number-heavy vs string-heavy, and real files from real sources written down before the change.


Chapter 4. Build a tiny benchmark harness

In this chapter: You will build a 60-line benchmark tool from scratch, one decision at a time. You will test the tool itself with a function of known cost. And you will see a case where the tool is right and the result is still surprising.

You need first: Chapters 1 to 3.

The problem first

Why build your own harness when tools like Criterion exist?

Because you cannot trust a tool you do not understand. Criterion is excellent (Part II), and you will use it in real work. But if you have once built the simplest version, you will know what every part of it is for, and why it is there. Later, when it gives a strange number, you will know where to look.

We also keep the harness small on purpose. A small tool that says clearly what it does not do is better than a big one that hides its guesses.

The model (the "what")

A benchmark harness has to solve three problems:

Problem 1: The clock is slow compared to fast code. If your code takes 5 ns and reading the clock takes 30 ns, one run tells you nothing. Solution: run the code many times in a batch and time the whole batch, then divide. This is called amortizing the clock cost: spreading one fixed cost over many items so it becomes tiny.

Problem 2: How big should the batch be? Too small and the clock cost still dominates. Too big and the test takes forever. Solution: calibration. Start with a batch of 1 call. Double it until one batch takes at least a chosen time (we use 2 ms). Now the clock cost is a tiny share.

Problem 3: One batch is one sample from a noisy distribution. Solution: take many samples (we use 100), then summarize with robust numbers (the median, p95, and MAD from Chapter 2).

And the harness must keep the work alive: pass each result to black_box.

Build it (the "how")

Here is the whole harness. Read it top to bottom. Each piece matches a decision above.

use std::hint::black_box;
use std::time::{Duration, Instant};

pub struct Report {
    pub iters_per_sample: u64,
    pub samples: usize,
    pub min: f64,    // all numbers are nanoseconds per call
    pub median: f64,
    pub p95: f64,
    pub mad: f64,    // median absolute deviation
}

fn median_of_sorted(v: &[f64]) -> f64 {
    let n = v.len();
    if n % 2 == 1 { v[n / 2] } else { (v[n / 2 - 1] + v[n / 2]) / 2.0 }
}

fn time_batch<F: FnMut() -> R, R>(f: &mut F, iters: u64) -> Duration {
    let start = Instant::now();
    for _ in 0..iters {
        black_box(f());
    }
    start.elapsed()
}

pub fn bench<F: FnMut() -> R, R>(mut f: F) -> Report {
    // Step 1: calibrate. Double the batch size until one batch takes at least 2 ms.
    let target = Duration::from_millis(2);
    let mut iters: u64 = 1;
    loop {
        if time_batch(&mut f, iters) >= target || iters >= (1 << 40) { break; }
        iters *= 2;
    }

    // Step 2: take many samples. Each sample is one batch, divided by its size.
    let samples = 100;
    let mut per_call: Vec<f64> = Vec::with_capacity(samples);
    for _ in 0..samples {
        let d = time_batch(&mut f, iters);
        per_call.push(d.as_nanos() as f64 / iters as f64);
    }

    // Step 3: summarize with numbers that ignore rare big spikes.
    per_call.sort_by(|a, b| a.partial_cmp(b).unwrap());
    let median = median_of_sorted(&per_call);
    let p95 = per_call[(samples as f64 * 0.95) as usize - 1];
    let mut dev: Vec<f64> = per_call.iter().map(|x| (x - median).abs()).collect();
    dev.sort_by(|a, b| a.partial_cmp(b).unwrap());
    let mad = median_of_sorted(&dev);

    Report { iters_per_sample: iters, samples, min: per_call[0], median, p95, mad }
}

(The small print helper that formats one line is in the starter file for the exercises.)

A few notes on the choices:

  • FnMut lets the closure keep and change its own state between calls.
  • The 1 << 40 limit stops the calibration loop if the closure does almost nothing, so we never loop forever.
  • The calibration batches double as a warmup: they run the code a few times before we collect the real samples, so caches and branch predictors have settled.

Test the tool before the workload

Here is the most important habit in this chapter. Before you trust a measuring tool on something you don't know, test it on something you do know. A doctor calibrates the scale with a known weight first.

We have two known cases:

  1. An empty closure. The true cost is almost zero. So whatever the harness reports is the harness overhead, the least it can ever show.
  2. A function that spins for exactly 50 microseconds. The true cost is 50,000 ns. The harness must say about 50,000.
empty closure          median       0.36 ns  p95       0.45  min       0.33  MAD     0.01  (8388608 calls/sample)
spin 50 us (known)     median   50309.42 ns  p95   50774.86  min   50069.55  MAD   194.12  (64 calls/sample)

Read it:

  • The empty closure says 0.36 ns per call. So the harness's own cost is about a third of a nanosecond. Anything we measure above a few ns is dominated by the real work. And look: calibration chose 8.4 million calls per sample. That is what it takes to make a 0.36 ns call last 2 ms.
  • The 50 µs spin says 50,309 ns. That is within 0.6% of the truth. The harness passes. (It is slightly above 50,000, which makes sense: the spin loop checks the clock and can only stop after the time has passed.)

Now a real workload, summing 100,000 numbers:

sum 100k u64           median   11993.62 ns  p95   13051.83  min   11345.94  MAD   147.43

About 12 µs, with a small MAD. Stable and believable. (And it agrees with Chapter 2's plain loop: a median of 12.4 µs.)

The surprise

Here is something I did not plan. In the first version of this test, I ran the same summing code a second time, in a second closure, right after the first. I expected the same number. I got a different one, and I got it on both of two separate runs of the program:

sum 100k u64           median   11993.62 ns
sum 100k u64 (again)   median   17670.51 ns      <- 47% slower, same code

The second run was about 47% slower, and the MAD was small, so it was not random noise. It was stable and wrong.

I checked one idea: maybe it is about running second. So I made a function sum(&data) and called it from four closures in a row. Result:

run 1   median 11973.60 ns
run 2   median 11685.59 ns
run 3   median 12153.55 ns
run 4   median 12036.77 ns

All four are the same, within about 4%. So the slowdown is not about being second. It followed the code: the version where the loop was written directly inside the closure behaved differently from the version that called a shared function.

I can't tell you the exact cause. I have not looked at the machine code. Good guesses: the compiler made a different copy of the loop (for example, one vectorized and one not), or the two copies landed at different memory addresses, and the CPU treats those differently. In Chapter 14 and later, you will learn the tools to look at the machine code and settle this question properly.

This looks like the same kind of problem the Mytkowicz paper from Chapter 1 warns about, showing up in 60 lines of code. (I have not proven it is the same cause.) A harness can be perfectly correct, and the number can still depend on details that have nothing to do with the algorithm. That is why the answer to "my benchmark says B is faster than A" is always: "Did you check that it does not depend on code placement and compiler choices?"

Why it is this way (the "why")

Every part of the harness is a trade-off.

  • Median, not mean. Because of the long slow tail (Chapter 2). The harness gives up the "exactness" of the mean to get stability.
  • Many calls per sample. We trade a longer test for a lower clock cost.
  • A 2 ms target. Why 2? Because the clock overhead is about tens of nanoseconds, so 2 ms makes it under 0.01% of the batch. Longer would be more precise and slower. This is a choice, not a law. It is okay to change it, as long as you know why.
  • 100 samples. More samples give a better picture of the shape. But there is a limit: if the machine drifts (slow warm-up, other tenants), more samples just record the drift.

And now the important part: what does this harness NOT do? Writing this down is the honest thing, and it is how you avoid lying with the tool:

  • It does not pin the program to one CPU core or fix the CPU frequency.
  • It does not detect and remove outliers or give confidence intervals.
  • It does not check that two copies of the same code behave the same (see the surprise above).
  • It does not compare two versions with any statistics. If A shows 12.0 µs and B shows 11.7 µs, the harness cannot tell you whether that is a real difference.
  • It does not reset the cache between samples, so it measures the warm case.

Criterion (Part II) addresses several of these. When you read its output, you will see each one of these problems handled in a specific way. That is the real reason to build the small one first.

The philosophy: a tool should make it easy to tell the truth and hard to tell a lie. The empty-closure test and the known-duration test are not extras. They are how a measuring tool earns the right to be believed.

Common mistakes

  • Trusting a harness you never tested on a known case.
  • Too few iterations per sample. The clock cost leaks into the answer.
  • Forgetting black_box on the result. The work disappears (Chapter 1).
  • Comparing two numbers 2% apart without any idea of the spread. Check the MAD first.
  • Believing a number just because the MAD is small. A small spread says the result is repeatable. It does not say it is right. The surprise above had a small MAD and an odd result.

Say it simply

A benchmark harness fixes three problems: the clock is slow compared to fast code, the right batch size is unknown, and one batch is only one noisy sample. It fixes them by timing a big batch and dividing, doubling the batch size until it lasts a couple of milliseconds, and taking many samples and reporting the median and the spread. You must test the harness on something with a known cost before trusting it. Even a correct harness can give a stable but odd number when the compiler or the code placement changes, so always ask what the harness does not control.

Check yourself

Exercise 4.1 (Warm-up): Predict. The empty closure reported 0.36 ns. At a clock speed of 2.1 GHz, how many CPU cycles is that? (One cycle at 2.1 GHz lasts about 0.48 ns. The real core clock of this machine turned out to be about 3.3 GHz, see Chapter 37; do the exercise with both.) What does that number suggest the loop is doing?

Exercise 4.2 (Warm-up): Explain. In 5 sentences, explain to a friend why we time a batch of calls and divide, instead of timing each call. Use an analogy (for example, weighing a single sheet of paper).

Exercise 4.3 (Core): Build and validate. Type in the harness. Run the two known-cost tests on your machine. Report the error as a percent. Now write a third known-cost test: a function that does a fixed, known amount of work, and check it against what you expect. Done when: a table with 3 rows: harness overhead, the 50 µs spin, your own third test, each with the error.

Exercise 4.4 (Core): Is the second run really slower? Reproduce the surprise. Write two closures with the loop written out inline, and one that calls a shared function. Run all three on your machine. Do you see a difference? (It may not appear on your machine, and that is also a valid result.) Report what you saw. Hint: compile with rustc -C opt-level=3 --emit asm and compare the loops.

Exercise 4.5 (Core): Add what is missing. Extend the harness so it also reports the relative spread: MAD divided by the median, as a percent. Run the sum benchmark and write one sentence on what this percent tells you.

Exercise 4.6 (Stretch): Build the comparison. Write compare(a, b) that runs two closures interleaved (A, B, A, B, ...) with 100 samples each, and reports the ratio of medians. Why might interleaving give a fairer answer than running all of A and then all of B? Test it by comparing a function with itself. What ratio should you get? What do you get?

Exercise 4.7 (Stretch): Design and defend. A teammate wants to use the mean in the harness because it is "simpler and more standard". Write a half-page argument for or against, using a distribution from Chapter 2 as evidence.

What a strong answer includes: the skewed tail, the example of the mean being 12% above the median in Chapter 2's run, when the mean is still the right tool (total time for a big job), and what to do if the team still prefers the mean (report both).


Part I wrap-up

What you can now do:

  • Turn a vague "it's fast" into a five-blank claim you can check.
  • Explain how the compiler can delete a benchmark, and stop it.
  • Describe a pile of timings with the median, the percentiles, and the spread, and pick the right one for the question.
  • Design a workload on purpose, with a fixed seed, setup outside the timer, and realistic shapes and sizes.
  • Build a small benchmark harness, test it on a known cost, and say exactly what it does not control.

Mini project: The honest benchmark report (2 to 4 hours). Pick a small function you care about (a tokenizer step, a hash, a sort, a matrix row sum). Using your harness:

  1. Write the five-blank question and your hypothesis first.
  2. Design at least three workloads (a small, a medium, and a large size, or three shapes) and write down why you picked them before measuring.
  3. Measure with the harness. Include a known-cost test in the same run.
  4. Report the median, p95, and MAD for each workload.
  5. Check the stability: run the whole thing three times. How much did the medians move?

Done when: you have a one-page report where every claim has the five blanks filled, and a short list of "what this report does not show."

Write-up prompt: Half a page. What did you expect, what did you see, and what surprised you?

Teach it back: In 5 sentences, explain to a friend why "my benchmark says 3 ns" might be completely meaningless.

Reading list for Part I:

  • Todd Mytkowicz, Amer Diwan, Matthias Hauswirth, and Peter F. Sweeney, "Producing Wrong Data Without Doing Anything Obviously Wrong!", ASPLOS 2009 (pages 265 to 276). Short and shocking. It shows that tiny, innocent choices in an experiment can change the conclusion. Read the introduction and the first case study.
  • John Ousterhout, "Always Measure One Level Deeper", Communications of the ACM, 2018. It turns the fear from the paper above into practical advice: when you measure a result, also measure what causes it.
  • The Rust standard library documentation for std::hint::black_box. Read what it promises and, more importantly, what it does not promise.

Verification notes for this file so far: All code in Part I was compiled with rustc 1.75 and run on a 1-core Xeon VM (2.1 GHz). All printed outputs are real runs, shortened only where marked. The Mytkowicz paper details (title, venue, year, page numbers, the 133-paper survey) were checked against the authors' page and a course blog. The Ousterhout article is recommended based on its mention in a course reading and not read in full for this file. Not confirmed: the cause of the two-humped histogram in Chapter 2, the reason reversed sorts slower than expected in Chapter 3, and the cause of the 47% slowdown in Chapter 4. Each is marked as open and has an exercise to investigate.


Part II. Benchmarking in practice with Criterion

After this Part, you will be able to run a real benchmark tool, read every line of its report, and say whether a change is real, tiny, or just noise.

In Part I, we built a small harness by hand. Now we use the standard tool that most Rust projects use: Criterion.rs. Because you built the small one, Criterion will not feel like magic. Every line it prints answers a problem you already met.


Chapter 5. Install Criterion and read your first report

In this chapter: You will set up Criterion, write one benchmark, and read each line of the report. You will see that Criterion and your own harness agree on the same function.

You need first: Part I.

The problem first

You have a harness. It gives a median, a p95, and a MAD. So why use something else?

Because a harness like ours leaves out several jobs: it does not give a confidence interval for the answer, it does not compare with your last run, it does not draw plots, and it does not tell you if a change is real. Doing those jobs well takes statistics. Criterion does them for you, and does them in a way many people have checked.

But a tool you do not understand will lie to you politely. So let's learn what each line means.

The model (the "what")

Criterion.rs is a benchmarking library for Rust. You write a benchmark function. Criterion runs it many times, collects timings, and does statistics on them.

Here is the plan it follows for every benchmark:

  1. WARM UP     Run the code for a few seconds, so caches and the CPU settle.
        │
        ▼
  2. COLLECT     Take N samples (default 100). Each sample runs the code many times
        │        in a batch, like our harness. (Batches grow from sample to sample.)
        ▼
  3. ANALYZE     Estimate the time per call, and a range around it, using a statistics
        │        method called the bootstrap. (Chapter 34 opens this up.)
        ▼
  4. COMPARE     If there is a saved result from an earlier run, say whether the
                 time changed. (Chapter 6.)

Do not worry about the statistics yet. Think of it as our harness, with a better summary and a memory.

Some words:

  • A sample is one timed batch.
  • A confidence interval is a range that probably contains the true value. A 95% confidence interval is built by a method that, if you repeated the experiment many times, would capture the true value about 95 times out of 100. It is not "the true value is inside this range with 95% chance". It is a statement about the method. For our purpose: a narrow interval means a stable measurement, and a wide one means a noisy measurement.
  • A bootstrap is a way to get a confidence interval by re-using your own samples many times (re-sampling with replacement) and seeing how much the answer wobbles.

The defaults of Criterion 0.8 (I checked these in its source code): 100 samples, 3 seconds of warm-up, 5 seconds of measuring, 100,000 bootstrap re-samples, a 95% confidence level, a 5% significance level, and a 1% noise threshold. The last two matter in Chapter 6.

Build it (the "how")

Set up

Criterion 0.8 needs a recent Rust. Its changelog says the minimum supported Rust version is 1.86. (Older versions are fine for older Criterion: 0.5 works with much older Rust. Check your own version with rustc --version.) For this guidebook, I ran Criterion 0.8.2 with rustc 1.91.1. The plain programs in Part I used rustc 1.75. That is fine, because those programs do not depend on newer compiler features.

Create a project:

cargo new --lib pc
cd pc

Edit Cargo.toml:

[package]
name = "pc"
version = "0.1.0"
edition = "2021"

[dev-dependencies]
criterion = "0.8"

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

Two things to understand:

  • [dev-dependencies] means Criterion is only used for tests and benchmarks, not in your final program.
  • harness = false tells Cargo: "do not use the built-in test runner for this file. Criterion has its own main."

Optional: install gnuplot for nicer plots. Without it, Criterion uses another plot method. The numbers are the same.

Write the benchmark

Create benches/sum.rs:

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

fn sum(data: &[u64]) -> u64 {
    let mut s = 0u64;
    for v in data {
        s = s.wrapping_add(*v);
    }
    s
}

fn bench_sum(c: &mut Criterion) {
    let data: Vec<u64> = (0..100_000u64).collect();
    c.bench_function("sum 100k u64", |b| b.iter(|| sum(black_box(&data))));
}

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

Read the pieces:

  • c.bench_function("name", |b| ...) registers one benchmark with a name.
  • b.iter(|| ...) is the heart. Criterion calls this closure again and again and times it. The setup (building data) is outside, so it is not timed. This is the "setup outside the timer" rule from Chapter 3.
  • We use std::hint::black_box. Since 0.8, Criterion itself uses the standard one, and the old criterion::black_box is no longer the way to go.
  • criterion_group! and criterion_main! are macros that create the main function.

Run it:

cargo bench --bench sum

The first build takes a while because Cargo compiles Criterion and its dependencies. (On this 1-core VM, it took about 2 minutes. Later runs are fast.)

Read the report

Benchmarking sum 100k u64: Warming up for 3.0000 s
Benchmarking sum 100k u64: Collecting 100 samples in estimated 5.0590 s (404k iterations)
Benchmarking sum 100k u64: Analyzing
sum 100k u64            time:   [12.024 µs 12.046 µs 12.070 µs]
Found 9 outliers among 100 measurements (9.00%)
  3 (3.00%) low mild
  2 (2.00%) high mild
  4 (4.00%) high severe

Go line by line.

"Warming up for 3.0000 s" is step 1. It ran the code for 3 seconds without recording.

"Collecting 100 samples in estimated 5.0590 s (404k iterations)" is step 2. Criterion guessed how many total runs fit in about 5 seconds: 404,000 runs of sum, split over 100 samples. (In this default mode, the batches grow from sample to sample, which lets Criterion separate the fixed cost per sample from the cost per call.)

time: [12.024 µs 12.046 µs 12.070 µs] is the answer. Three numbers: the lower end of the 95% confidence interval, the best estimate (middle), and the upper end. So the best guess is 12.046 µs, and Criterion is 95% confident, in the confidence-interval sense, that the true value is between 12.024 and 12.070. The interval is only 0.4% wide. That is a very stable measurement.

Compare with Part I. Our harness said a median of 11.99 µs. The plain loop in Chapter 2 said 12.4 µs. Criterion says 12.05 µs. Three different tools, within a few percent. When different methods agree, you can start to believe the number.

"Found 9 outliers among 100 measurements" reports samples that sit far from the others. We unpack these in Chapter 6.

Criterion also writes an HTML report, with plots, to target/criterion/report/index.html. Open it in a browser. It shows the distribution of samples, and a plot of time against the number of iterations. You will use it when a number looks strange.

Why it is this way (the "why")

Why does Criterion give a range and not a single number? Because of what we learned in Chapter 2: a timing is a sample from a distribution. A single number hides how much you can trust it. A range says: "this is how sure I am."

Why does it warm up for 3 seconds? Because the first runs are not typical (cold caches, CPU speed changing, memory pages not mapped yet). The warm-up is a decision: I want to measure the steady state. That is a choice about the question. If you care about the first call (a command-line tool that starts, runs once, and exits), steady-state numbers will mislead you. We said this in Chapter 3: be honest about state.

Why 100 samples and 5 seconds? These are defaults that work for many cases. Not laws. For a very fast function you may want fewer seconds. For a noisy machine, more samples. You can change them in code or on the command line. You will see --warm-up-time 1 --measurement-time 3 in later runs in this guidebook, to keep tests short.

One more idea. Criterion is a measuring instrument, not an oracle. It measures what you put inside b.iter. All the lessons from Part I still apply: keep the work alive, choose the workload on purpose, say what the state is. A beautiful confidence interval of 0.4% on a deleted loop is a very precise measurement of nothing.

Common mistakes

  • Putting setup inside b.iter. Then you time the setup.
  • Forgetting black_box on the input. The compiler may fold the answer at build time.
  • Reading a narrow interval as "correct". It means stable, not right.
  • Using the wrong Rust version for the Criterion version. Check the minimum supported version in the changelog.
  • Benchmarking in debug mode. cargo bench builds in release-like mode already. Do not use cargo run or a debug build for timing.

Say it simply

Criterion is a benchmarking library that warms up, collects 100 timed samples, and reports a best estimate with a range around it. You write the code to test inside b.iter, keep setup outside, and pass inputs and outputs through black_box. The range tells you how stable the measurement is, not whether it is correct. On our summing loop, Criterion, our harness, and the plain loop all agreed within a few percent.

Check yourself

Exercise 5.1 (Warm-up): Read the line. time: [8.201 ms 8.455 ms 8.730 ms]. What is the best estimate? How wide is the interval, as a percent of the estimate? Is this a stable or a noisy measurement? Compare with [12.024 µs 12.046 µs 12.070 µs].

Exercise 5.2 (Warm-up): Explain. In 5 sentences, explain to a friend what a confidence interval is not. Use the "method, not the value" idea.

Exercise 5.3 (Core): Install and run. Set up the project above, run the benchmark, and open target/criterion/report/index.html. Describe what the plots show. Compare Criterion's number with your own harness from Chapter 4. Report the difference as a percent. Done when: you have both numbers side by side and one sentence on why they differ.

Exercise 5.4 (Core): Break it on purpose. Remove black_box from the input in the benchmark (write sum(&data)). Does the number change? Now also make data a constant array known at compile time. What happens? What did the compiler do? Hint: look at the HTML report. A tiny time per call is a sign of folded work.

Exercise 5.5 (Core): Move the knobs. Run with -- --warm-up-time 1 --measurement-time 2 and then with -- --sample-size 20. How does the interval width change? Which run would you trust more, and why?

Exercise 5.6 (Stretch): Design and defend. A command-line tool is run once, reads a file, parses it, prints a result, and exits. A teammate benchmarks the parse function with Criterion and finds it takes 40 µs. In real use, the parse takes 2 ms. Give three reasons the numbers could disagree.

What a strong answer includes: cold caches and cold code on the first call, warm-up hiding page faults, the file read cost, and a different input shape.


Chapter 6. Estimates, outliers, and "is this change real?"

In this chapter: You will learn the three ideas that decide whether Criterion says "improved", "regressed", or "no change". You will see a case where the same code gets flagged as changed, and learn why that happens.

You need first: Chapter 5.

The problem first

You change a function. You run the benchmark. It says 12.2 µs, and last time it said 12.0 µs.

Did you make it slower? Or is that just wobble?

Without a rule for deciding, every small difference starts an argument. With a rule, you get a decision. Criterion has a rule. But the rule has two parts, and you must know both, because each part can fool you in its own way.

The model (the "what")

When a saved result from an earlier run exists, Criterion runs the new benchmark and compares. It reports a line like:

change: [−0.6926% −0.0294% +0.5305%] (p = 0.93 > 0.05)
No change in performance detected.

Three pieces:

  1. The change estimate with its interval: [low, middle, high] as a percent change in the time. Negative means faster. Positive means slower. The middle is the best guess.

  2. The p-value: Criterion asks: "If the old and new code were truly the same speed, how likely would I be to see a difference at least this big, just from noise?" That chance is the p-value. A tiny p-value means "that would be very surprising if nothing changed." The cut-off is the significance level, 0.05 by default (5%).

  3. The noise threshold: a size cut-off, 1% by default. Even if a change is statistically real, it is ignored if it is too small to matter.

Here is the exact rule, from reading Criterion's source:

IF  p-value is NOT below 0.05
        →  "No change in performance detected."
ELSE (the change looks real, statistically)
    IF  the WHOLE confidence interval of the change is below −1%
            →  "Performance has improved."
    ELIF the WHOLE confidence interval is above +1%
            →  "Performance has regressed."
    ELSE
            →  "Change within noise threshold."

Look at this carefully, because it tells you what each part does:

  • The p-value asks: is there a difference at all?
  • The noise threshold asks: is the difference big enough to care about?
  • "Whole interval beyond ±1%" means the tool wants to be sure even the least extreme end of the interval is bigger than 1%.

Two different kinds of "not important" can happen. A difference can be not statistically real (p is big). Or it can be real but tiny (p is small, but the change is under 1%). Both end in "not worth acting on", but for different reasons.

Outliers

The report also says things like:

Found 9 outliers among 100 measurements (9.00%)
  3 (3.00%) low mild
  2 (2.00%) high mild
  4 (4.00%) high severe

An outlier is a sample far from the others. Criterion uses a classic rule from statistics (Tukey's fences): take the first quartile (the value that 25% of samples fall below), the third quartile (75%), and the gap between them, called the IQR (interquartile range). A sample more than 1.5 × IQR beyond the quartiles is a mild outlier, and one beyond 3 × IQR is a severe outlier. "Low" means faster than most, "high" means slower than most. (I confirmed the 1.5 factor in the source. The 3× severe step is the standard rule.)

What do outliers tell you? Not that the answer is wrong. They are a hint about noise. Many high outliers means something slowed some samples: other programs, interrupts, a noisy VM neighbor. A lot of outliers (more than about 10%) is a signal to quiet the machine and run again.

Build it (the "how")

I ran the benchmark from Chapter 5 several times, changing the code between some runs. Here is what Criterion said. (I used -- --warm-up-time 1 --measurement-time 3 for runs 2 to 5, to keep them short.)

Run 2: the same code, run again.

sum 100k u64            time:   [12.116 µs 12.200 µs 12.285 µs]
                        change: [−0.6926% −0.0294% +0.5305%] (p = 0.93 > 0.05)
                        No change in performance detected.

Good. The change is −0.03%, the p-value is 0.93, very far above 0.05. The code is the same, and Criterion says so.

Run 3: the same code, run once more.

sum 100k u64            time:   [12.143 µs 12.242 µs 12.377 µs]
                        change: [+0.6944% +1.7265% +2.8612%] (p = 0.00 < 0.05)
                        Change within noise threshold.

This is the interesting one. The code did not change at all. Yet Criterion says the p-value is 0.00, far below 0.05, so the difference looks "statistically real". The best estimate is +1.7% slower. But the interval is [+0.69%, +2.86%]. The lower end, +0.69%, is not above the 1% threshold. So the whole interval is not beyond +1%, and the verdict is "within noise threshold".

Why would identical code give a "real" difference? Because the machine was a bit different between the two runs: another tenant on the VM host, a different CPU speed, a slightly different cache state. Criterion measures the machine and the code together. The p-value is honest: the two sets of samples really did differ. But the cause was not your code. Statistical significance is not the same as caused by your change.

And that is exactly why the noise threshold exists. Without it, Run 3 would be flagged as a regression. With it, a 1.7% shift that is likely caused by the environment is quietly set aside.

Run 4: I made the code slower on purpose (a black_box call inside the loop for each element, which stops the compiler from combining the work):

sum 100k u64            time:   [54.748 µs 56.503 µs 58.303 µs]
                        change: [+356.13% +369.08% +382.42%] (p = 0.00 < 0.05)
                        Performance has regressed.

A real change. The whole interval is far beyond +1%. Criterion says "regressed". Also, the interval got wider (about 6% wide, versus 0.7% before). The new code is less stable.

Run 5: I put the fast code back.

sum 100k u64            time:   [12.633 µs 12.832 µs 13.034 µs]
                        change: [−78.948% −78.364% −77.738%] (p = 0.00 < 0.05)
                        Performance has improved.

A real change in the other direction. The new time (12.8 µs) is also a bit higher than runs 1 to 3 (about 12.0 to 12.2 µs). So, even for the "same fast code", the level drifts by about 5% between runs on this VM. Remember that number. It is the size of change this machine can invent.

The summary of what we saw

RunWhat changedCriterion's verdictThe truth
2nothingNo change detectedcorrect
3nothingWithin noise threshold (+1.7%)correct, but p was 0.00
4made code ~4.7x slowerRegressed (+369%)correct
5revertedImproved (−78%)correct

The runs show a big change is easy to see, and a small one is hard.

Why it is this way (the "why")

Why two tests (p-value and threshold) and not one? Each one answers a different question.

Think about a thermometer with a very high resolution. It can tell you the room warmed by 0.0003 degrees. That is statistically real. It is also useless. A p-value can only tell you if a difference is detectable. As your measurement gets more precise (more samples, a quieter machine), you can detect smaller and smaller differences, down to ones nobody cares about. The noise threshold is how you say "below this size, I don't care". It is a human decision about what matters, put into the tool.

So the philosophy is: statistics tells you what is real. Your goals tell you what is important. Both are needed.

Now the dangerous part. The 5% significance level means that if you compare identical code many times, about 1 in 20 comparisons will look "significant" by pure chance. (That is the definition of the level, in the ideal case where noise is purely random.) On a real machine, noise is not purely random: it drifts, and it has patterns. So the real false-alarm rate can be higher. Chapter 7 shows a real false alarm from this very machine.

What would make the rule wrong? Three things:

  1. The noise is not random. The t-test and the bootstrap assume a certain kind of independent noise. A machine that drifts breaks that.
  2. The comparison is not like for like. If the old result was saved a week ago on a different machine state, Criterion cannot know.
  3. You make many comparisons. If you compare 40 benchmarks at once, you should expect about 2 false alarms at the 5% level. A single flagged benchmark among forty is weak evidence.

A good habit follows: measure your own measurement. Run the unchanged code twice (as in Runs 2 and 3) and see how much it moves. That tells you the size of the wobble on your machine. A real change should be clearly bigger than that wobble. On this VM, I'd call anything under about 5% suspicious.

Common mistakes

  • Treating "p < 0.05" as "my change caused it". It only says the two sets of runs differ.
  • Ignoring the size. A "significant" 0.8% change is rarely worth any work.
  • Skipping the unchanged-code test. You have no idea how big your machine's wobble is.
  • Reading outliers as errors. They are a hint about noise, not a verdict.
  • Reading a single flagged benchmark among many. Expect some false alarms.
  • Comparing against a baseline from a different day, machine, or Rust version.

Say it simply

Criterion decides "improved", "regressed", or "no change" with two tests. The p-value asks whether the two sets of timings differ more than noise would explain. The noise threshold, 1% by default, asks whether the whole confidence interval of the change lies beyond that size. A change can be real but too small to matter, and identical code can still look "real" when the machine drifts. So run your unchanged code twice to learn the size of your own wobble, and treat anything near that size as noise.

Check yourself

Exercise 6.1 (Warm-up): Read the verdict. For each, say what Criterion's rule gives: (a) change: [−0.4% +0.1% +0.7%] (p = 0.60) (b) change: [+2.1% +3.0% +3.9%] (p = 0.00) (c) change: [+0.2% +1.4% +2.6%] (p = 0.01) (d) change: [−9.0% −7.5% −6.0%] (p = 0.00).

Exercise 6.2 (Warm-up): Explain. In 5 sentences, explain to a friend why a very precise thermometer is a good analogy for the difference between "statistically real" and "important".

Exercise 6.3 (Core): Measure your own wobble. Run the unchanged benchmark 10 times in a row (cargo bench --bench sum -- --warm-up-time 1 --measurement-time 3). Record the middle estimate and Criterion's verdict each time. How big is the largest swing? How many runs got a verdict other than "no change"? Done when: you have a table of 10 rows and a sentence like "on my machine, a change under X% is indistinguishable from noise".

Exercise 6.4 (Core): Make a real change. Make the function slower in a small way (for example, add one extra wrapping_add per element). What is the smallest change Criterion can reliably flag on your machine? Try three sizes of change.

Exercise 6.5 (Core): The outlier test. Run the benchmark while another program uses the CPU heavily. How does the outlier count change? Does the best estimate change? Does the interval get wider? Write 4 sentences about what the outlier report told you.

Exercise 6.6 (Stretch): Move the threshold. Run with --noise-threshold 0.05 and then --noise-threshold 0.001, and re-run the unchanged-code test. How does the number of false alarms change? What does this tell you about choosing the threshold? Hint: the flag is --noise-threshold. I saw it in the source code.

Exercise 6.7 (Stretch): Design and defend. Your team runs 60 benchmarks on every pull request, and the CI fails if any benchmark is flagged as "regressed". After a month, people ignore the failures. Explain why this happens using what you learned, and propose a better rule.

What a strong answer includes: false alarms from many comparisons at a 5% level, noisy shared CI machines, the idea of a larger threshold or a repeat-before-fail rule, comparing on dedicated hardware, and the cost of crying wolf.


Chapter 7. Groups, inputs, throughput, and baselines

In this chapter: You will learn to benchmark the same idea at many sizes, to report speed as "items per second", and to save a result and compare against it later. You will see a real false alarm, and learn that one trick can help at one size and do nothing at another.

You need first: Chapters 5 and 6.

The problem first

One number from one input is almost never what you need. You want to know: "How does this behave as the data gets bigger?" And: "Is version A better than version B, at which sizes?" And later: "Did my change since last week help?"

Criterion has tools for all three: groups with inputs, throughput, and baselines.

The model (the "what")

  • A benchmark group is a named set of related benchmarks. Their results are shown together, with a shared plot.
  • A benchmark ID names one run inside a group, like one_acc/100000: a function name and a parameter.
  • Throughput tells Criterion how much work one iteration does: Throughput::Elements(n) for n items, or Throughput::Bytes(n) for n bytes. Criterion then reports a rate like "Gelem/s" (billions of elements per second) next to the time. Rates make runs of different sizes comparable: 100 ns for 1,000 items and 12 µs for 100,000 items look very different as times, but both are about 8 to 9 billion items per second.
  • A baseline is a saved set of results with a name. You can save one (--save-baseline name) and compare a later run against it (--baseline name). Without names, Criterion compares with the last run. Named baselines let you keep a "before my change" reference for as long as you want.

Build it (the "how")

We compare two ways to sum numbers, at three sizes. The first, one_acc, is the plain loop. The second, multi_acc, uses four separate running totals (accumulators), so four additions can happen side by side.

Create benches/groups.rs (and add a second [[bench]] entry named groups with harness = false to Cargo.toml):

use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use std::hint::black_box;
use std::time::Duration;

fn one_acc(m: &[u64]) -> u64 {
    let mut s = 0u64;
    for v in m { s = s.wrapping_add(*v); }
    s
}

fn multi_acc(m: &[u64]) -> u64 {
    let (mut a, mut b, mut c, mut d) = (0u64, 0u64, 0u64, 0u64);
    for ch in m.chunks_exact(4) {
        a = a.wrapping_add(ch[0]);
        b = b.wrapping_add(ch[1]);
        c = c.wrapping_add(ch[2]);
        d = d.wrapping_add(ch[3]);
    }
    a.wrapping_add(b).wrapping_add(c).wrapping_add(d)
}

fn bench_sums(c: &mut Criterion) {
    let mut group = c.benchmark_group("sum");
    group.warm_up_time(Duration::from_secs(1));
    group.measurement_time(Duration::from_secs(2));
    for &n in &[1_000usize, 100_000, 4_000_000] {
        let data: Vec<u64> = (0..n as u64).collect();
        group.throughput(Throughput::Elements(n as u64));
        group.bench_with_input(BenchmarkId::new("one_acc", n), &data, |b, d| {
            b.iter(|| one_acc(black_box(d)))
        });
        group.bench_with_input(BenchmarkId::new("multi_acc", n), &data, |b, d| {
            b.iter(|| multi_acc(black_box(d)))
        });
    }
    group.finish();
}

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

New pieces: benchmark_group makes the group. bench_with_input passes the data as an input, so the same code runs for each size. BenchmarkId::new("name", n) builds the ID. group.throughput(...) must be set before the benchmarks it applies to.

Run and save the result as a baseline named before:

cargo bench --bench groups -- --save-baseline before

Here is what I got (the time line and the rate line for each):

SizeData sizePlain loopFour accumulators
1,0008 KB107.8 ns (9.28 Gelem/s)76.7 ns (13.05 Gelem/s)
100,000800 KB12.02 µs (8.32 Gelem/s)12.19 µs (8.20 Gelem/s)
4,000,00032 MB1.107 ms (3.61 Gelem/s)1.089 ms (3.67 Gelem/s)

Read the table like a detective.

At 1,000 numbers, the four-accumulator version is about 29% faster. The data is tiny (8 KB), so it sits in the L1 cache, and the loop is limited by how fast it can do the additions.

At 100,000 and 4,000,000 numbers, the two are tied. The trick does nothing. Let's do some math: 8.3 billion numbers per second × 8 bytes each is about 66 GB/s at 100,000 numbers. At 4 million, 3.6 billion × 8 is about 29 GB/s. In both cases, the data arrives from the cache or memory at some rate, and the loop does not even need to be fast, because it spends its time waiting for data. When the loop is waiting for memory, making the adding faster changes nothing. (This is the Chapter 8 lesson in action. Treat these GB/s numbers as rough, because the machine is a shared VM.)

So the same trick helps at one size and does nothing at two others. If you had benchmarked only 1,000 numbers, you would have called multi_acc a 29% win. If you had benchmarked only 4 million, you would have called it useless. Both are true, at different sizes. This is why we use groups with several inputs.

One more surprise. I expected the plain loop to be slow here, and it wasn't. At 100,000 numbers, it moves data about as fast as the four-accumulator version. I think the compiler already turned the plain integer loop into wide instructions, since adding integers in any order gives the same answer, so the compiler is allowed to reorder. But I did not read the machine code to check that. (With floating-point numbers, the order does change the answer slightly, so the compiler may not reorder them, and the multi-accumulator trick pays off much more. Chapters 13 and 28 return to this.)

Baselines: compare against a saved result

Now I changed multi_acc to use eight accumulators and compared with the baseline before:

cargo bench --bench groups -- --baseline before
sum/one_acc/1000        time:   [111.54 ns 112.59 ns 113.96 ns]
                 change:  Performance has regressed.
sum/multi_acc/1000      time:   [66.837 ns 67.023 ns 67.207 ns]
                 change:  Performance has improved.
sum/one_acc/100000      time:   [12.073 µs 12.116 µs 12.164 µs]
                 change:  Change within noise threshold.
sum/multi_acc/100000    time:   [12.380 µs 12.587 µs 12.842 µs]
                 change:  Change within noise threshold.
sum/one_acc/4000000     time:   [1.1095 ms 1.1206 ms 1.1324 ms]
                 change:  Change within noise threshold.
sum/multi_acc/4000000   time:   [1.0726 ms 1.0905 ms 1.1087 ms]
                 change:  No change in performance detected.

(I removed the numeric percent lines to keep this short. The verdicts are exactly as printed.)

Look at what happened:

  • multi_acc/1000 improved: from 76.7 ns to 67.0 ns, about 12.6% faster. That is the change I made, and it shows up where the loop was limited by additions.
  • multi_acc at the big sizes: no change. As before, memory is the limit there.
  • Now look at one_acc/1000. I did not touch that function. Yet Criterion says "Performance has regressed". The time moved from 107.8 ns to 112.6 ns, about 4.4% slower, and the whole interval landed beyond the threshold.

This is a false alarm, on a real machine, with unchanged code. It is the same effect as Run 3 in Chapter 6, but bigger this time. A tool with a 1% threshold can't tell a 4% wobble from a real change if the wobble happens to be steady during the whole run. How would you know it was a false alarm? Because you know you did not change the function. That is the point: a flagged regression is a question, not an answer. The next step is to ask "what changed?" and to re-run.

Provenance: remember where the number came from

A number saved in a baseline is only useful if you know where it came from. When you save a baseline or share a result, also write down:

  • the Rust version (rustc --version) and Criterion version,
  • the CPU model and whether the machine was idle,
  • the commit of the code,
  • the exact command used.

Provenance just means "where the data came from". Without it, three weeks later, nobody can tell why this month's number is different.

Why it is this way (the "why")

Why does the same trick help at one size and not at another? Because speed is limited by the slowest part in the chain, and which part is slowest depends on the size of the data.

Think of a kitchen. A fast cook (the CPU) and a slow delivery of ingredients (memory). If the ingredients are right on the counter (tiny data in L1), the cook's speed is the limit, so a faster cook helps. If the ingredients come from a far warehouse (big data), the delivery is the limit, and hiring a faster cook changes nothing. The analogy breaks in one place: a real kitchen cannot start preparing the next ingredient before the last one arrives, but a real CPU can, up to a point. (It can look ahead and fetch early. We study that in Chapter 12.)

This gives a general principle: before you optimize, find the limit. Is it the arithmetic (compute-bound), or the data arriving (memory-bound)? An optimization for the wrong limit is wasted effort. The tool for finding this is the throughput number plus the size sweep. We will make this precise with the roofline model in Chapter 19.

And the philosophy of baselines: a performance claim needs a reference. "It is 12 µs" means little. "It is 12.6% faster than the version from last Tuesday, measured the same way on the same machine" is a claim someone can check. A baseline turns a number into a comparison, and comparisons are what we use to decide.

What would make this wrong? Benchmarking at three sizes is still a sample of the real range of sizes. And the cache is warm in all these tests. If real use always begins cold, the table could look different.

Common mistakes

  • One size only. You cannot see where behaviour changes.
  • Forgetting group.throughput before the benchmark. The rate lines will not appear (or apply to the wrong ones).
  • Reading a flagged regression on code you did not touch as a real regression. Check what changed, and re-run.
  • Saving a baseline and forgetting its context. Write down the machine, the versions, and the commit.
  • Comparing a baseline across Rust versions or machines. The compiler and the CPU are part of the measurement.
  • Judging a trick at one size. It may help at one size and do nothing at another.

Say it simply

A benchmark group runs the same test over many inputs and shows them together. Throughput turns "time per call" into "items per second", so different sizes can be compared. A named baseline saves a result so you can compare a later run against it. Our summing trick was 29% faster at 1,000 numbers and did nothing at 100,000 or 4 million, because at large sizes the loop waits for data. A flagged regression on code you did not change is a false alarm, so always ask what really changed.

Check yourself

Exercise 7.1 (Warm-up): Predict, then check. You sum 10,000 u64 numbers (80 KB) and 400,000 u64 numbers (3.2 MB) on a CPU with a 48 KB L1 and a 2 MB L2. Before running, predict whether the four-accumulator trick helps more at 10,000 or at 400,000, and why. Then run it.

Exercise 7.2 (Warm-up): Rates. A function takes 12.0 µs for 100,000 u64 numbers. What is the throughput in billions of elements per second? In GB/s? Show the math.

Exercise 7.3 (Core): Add a third size. Add 16,000 and 1,000,000 elements to the group. Draw the throughput against size. Where are the steps? Do they match your CPU's cache sizes (lscpu)? (Chapter 8 explains the steps.)

Exercise 7.4 (Core): Baseline discipline. Save a baseline, change one function, compare. Then run the comparison again without changing anything. Did the verdicts stay the same? Which ones moved? Report how many "verdict flips" you saw across 3 repeated comparisons.

Exercise 7.5 (Core): Floats vs integers. Write the same two benchmarks for f64 (sum with one accumulator and with four). Does the four-accumulator version help at the large sizes too? Why might the integer and float cases differ? (Check the machine code for the plain loop in both cases if you can: cargo rustc --release -- --emit asm.) Hint: floating-point addition is not associative. (a + b) + c may differ from a + (b + c) in the last digits.

Exercise 7.6 (Stretch): Find the false-alarm rate. Write a script that runs the unchanged group benchmark 20 times against the same baseline, and counts how many of the 6 benchmarks per run got "regressed" or "improved". What was the false-alarm rate on your machine?

Exercise 7.7 (Stretch): Design and defend. You are comparing two string-search algorithms. List the groups, inputs, and rates (items or bytes per second) you would use, and what you would write down as provenance. Explain what result would make you pick algorithm A, and what would make you pick B.

What a strong answer includes: several text sizes and several pattern lengths, hit-heavy and miss-heavy inputs, bytes-per-second as the rate, sizes that cross the cache boundaries, the Rust and CPU versions, and the idea that the answer may depend on the workload.


Part II wrap-up

What you can now do:

  • Set up Criterion, write a benchmark, and read every line of its report.
  • Explain the confidence interval, the p-value, and the noise threshold, and give the exact rule Criterion uses for its verdicts.
  • Measure your own machine's wobble and use it to judge changes.
  • Benchmark the same idea across sizes with groups, inputs, and throughput.
  • Save a baseline, compare against it, and write down where a number came from.
  • Say why one optimization can help at one size and do nothing at another.

Mini project: The bake-off (3 to 5 hours). Choose two or three versions of a small function you care about (for example, three ways to count bytes equal to a value, or three ways to find the maximum). Using Criterion:

  1. Write the question and the workloads first (at least 3 sizes, and 2 shapes of data).
  2. Build a group with inputs and throughput.
  3. Save a baseline. Run the unchanged code 5 more times and measure your wobble.
  4. Make one change to one version. Compare against the baseline and decide, using the rule from Chapter 6, whether the change is real.
  5. Run your own Chapter 4 harness on the same functions and compare its numbers with Criterion's.

Done when: a one-page report with a table of times and rates, your measured wobble, one real change and one false alarm (if you saw one), and a provenance block.

Write-up prompt: Half a page. What did you expect, what did you see, and which result did you least trust, and why?

Teach it back: In 5 sentences, explain to a friend why "Performance has regressed" does not always mean you made the code slower.

Reading list for Part II:

  • The Criterion.rs user guide and its repository (now at criterion-rs/criterion.rs). The guide explains the analysis steps and every command-line flag. Read "Analysis Process" first. Also read the changelog for the version you use.
  • John W. Tukey, Exploratory Data Analysis (1977). The source of the box-plot fences used for outliers. Optional, but short chapters on the idea are rewarding.
  • Bradley Efron, "Bootstrap Methods: Another Look at the Jackknife", The Annals of Statistics, 1979. The original bootstrap paper. Read the introduction only.

Verification notes for Part II: Criterion 0.8.2 and rustc 1.91.1 were installed and run in this session on the same 1-core Xeon VM; all benchmark outputs are real. The defaults (100 samples, 3 s warm-up, 5 s measurement, 100,000 re-samples, 0.95 confidence, 0.05 significance, 0.01 noise threshold), the verdict rule, and the 1.5 × IQR outlier fence were read from the Criterion 0.8.2 source code. The minimum Rust version (1.86 for 0.8) comes from the Criterion changelog as shown in search results. The 3 × IQR severe fence, the way Criterion picks batch sizes (growing batches), and the three reading-list items were not checked against fresh sources. Not confirmed: why the plain integer loop matches the four-accumulator loop at large sizes (I think the compiler vectorizes it, but I did not read the machine code), and the exact cause of the 4.4% drift on the unchanged one_acc/1000 benchmark.


Part III. The machine: memory and the CPU

After this Part, you will be able to look at a loop and say, before running it, which rung of the memory ladder it will sit on, how many cache lines it touches, and whether the CPU can guess what it wants next.

Part I taught you to measure. Part II gave you a tool. Now we open the machine, because every surprising number so far (the 8x gap in a sum, the 4% wobble at 1,000 numbers, the trick that helped at one size and not at another) has its cause here.

The plain programs in this Part were compiled with rustc 1.75 and -C opt-level=3, and run on the 1-core Xeon VM described at the start. Your numbers will differ. The shapes should not.


Chapter 8. Memory is not a box. It is a ladder.

In this chapter: You will learn why the same line of code can take 1.7 nanoseconds or 137 nanoseconds, depending only on where its data lives. You will build a small tool that finds the cache sizes of your own machine, by measuring, and you will measure both latency and bandwidth.

You need first: Part I.

The problem first

Take two loops. Both add up numbers. Both do the same math. One finishes in 3 milliseconds. The other takes 16 milliseconds. Same machine, same compiler, same count of additions.

What is different is how far the data had to travel before the CPU could use it.

Most of us learn that memory is one big box. You ask for an address, you get a value, and all addresses cost the same. This is a good lie for writing correct programs. It is a bad lie for writing fast ones. Memory is a ladder, and every rung down costs much more time.

The model (the "what")

A picture first: the desk, the shelf, and the library

Think of a person studying at a desk. The books on the desk are right there: grab and read. The books on the shelf in the room take a few seconds. The books in the library across town take an hour. A smart student keeps the books they need now on the desk.

Your CPU is the same. It has a few very fast, very small places to keep data close, and one huge, slow place far away.

Where does the analogy break? A student picks one book at a time. A CPU never picks one value at a time. It brings a whole chunk at once. We meet that chunk in Chapter 9.

The real ladder

A cache is a small, fast memory that keeps copies of recently used data, so the CPU does not need to go to the slow memory again.

 fastest, smallest                                   slowest, biggest
 ┌───────────┐  ┌───────────┐  ┌───────────┐  ┌───────────┐  ┌───────────────┐
 │ Registers │→ │  L1 cache │→ │  L2 cache │→ │  L3 cache │→ │  Main memory  │
 │ (inside   │  │ tens of   │  │ ~ MiB     │  │ many MiB, │  │  (RAM)        │
 │  the CPU) │  │ KiB       │  │           │  │ shared    │  │  GiB          │
 └───────────┘  └───────────┘  └───────────┘  └───────────┘  └───────────────┘
   ~0 ns          ~1-2 ns        ~4-7 ns        ~15-50 ns       ~100+ ns

Look at the bottom row of numbers. They are real times measured on the machine used here. Each step down is slower, and the jump to main memory is the biggest.

Two words that people mix up:

  • Latency is how long you wait for one piece of data after you ask.
  • Bandwidth is how much data flows per second once things are moving.

Latency is "how long for the first drop of water to arrive". Bandwidth is "how wide the pipe is". A pipe can be very wide and very long at the same time.

A third word: the working set is the amount of data your code is actively using at one time. If it fits in L1, the code is fast. If it fits in L2 but not L1, a bit slower. And so on down the ladder. Speed depends on the working set, not only on the instruction count.

Build it (the "how")

Experiment 1: measure latency with pointer chasing

Make an array where every slot holds the index of the next slot to visit, arranged as one big loop in random order. Follow the chain for millions of steps. Each step needs the answer from the step before, so the CPU cannot guess ahead. The time per step is the latency of whichever rung holds the data.

Random order matters. If the order were 0, 1, 2, ..., the CPU would notice the pattern and fetch ahead. That feature is called the prefetcher, and Chapter 12 explains it. Random order defeats it, so we see the true latency.

use std::hint::black_box;
use std::time::Instant;

fn next(state: &mut u64) -> u64 {
    let mut x = *state;
    x ^= x << 13; x ^= x >> 7; x ^= x << 17;
    *state = x;
    x
}

// One big loop that visits every slot exactly once, in random order (Sattolo's algorithm).
fn build_cycle(slots: usize) -> Vec<usize> {
    let mut next_slot: Vec<usize> = (0..slots).collect();
    let mut rng = 0x9E3779B97F4A7C15u64;
    for i in (1..slots).rev() {
        let j = (next(&mut rng) as usize) % i; // j in 0..i, never i itself
        next_slot.swap(i, j);
    }
    next_slot
}

fn chase(cycle: &[usize], steps: usize) -> usize {
    let mut at = 0usize;
    for _ in 0..steps { at = cycle[at]; } // the next address depends on this load
    at
}

fn main() {
    println!("{:>12} {:>10}", "bytes", "ns/step");
    let mut size = 4 * 1024usize;
    while size <= 256 * 1024 * 1024 {
        let slots = size / std::mem::size_of::<usize>();
        let cycle = build_cycle(slots);
        let steps = 20_000_000usize;
        black_box(chase(&cycle, slots.min(steps))); // warm up: map the pages
        let mut best = f64::MAX;
        for _ in 0..5 {
            let t = Instant::now();
            black_box(chase(black_box(&cycle), steps));
            best = best.min(t.elapsed().as_nanos() as f64 / steps as f64);
        }
        println!("{:>12} {:>10.2}", size, best);
        size *= 2;
    }
}

Why Sattolo's algorithm and not a plain shuffle? A plain shuffle can create several small loops. Then the chase could get stuck going around a tiny loop that fits in L1 and we would measure the wrong rung. Sattolo's algorithm always makes one loop through all slots.

Result:

       bytes    ns/step
        4096       1.73
        8192       1.71
       16384       1.74
       32768       1.72      <- still L1
       65536       2.85      <- the first step up
      131072       4.22
      262144       4.76
      524288       5.69
     1048576       6.95      <- L2 range
     2097152      15.11      <- the second step up
     4194304      23.60
     8388608      31.54
    16777216      46.23
    33554432     108.84      <- the big step: main memory
    67108864     125.15
   134217728     133.40
   268435456     137.46

It is not a smooth curve. It is a staircase. Each flat part is one rung. Each jump is the moment the data stopped fitting.

Now compare with what the machine says about itself (lscpu | grep -E 'L1d|L2|L3'): L1d 48 KiB, L2 2 MiB, L3 260 MiB.

  • L1: flat at about 1.7 ns up to 32 KiB, and it rises at 64 KiB. That matches a 48 KiB L1.
  • L2: the second jump comes right at 2 MiB. That matches the 2 MiB L2.
  • L3: here the report and the measurement disagree. The report says 260 MiB. But times reach main-memory levels at about 32 MiB. I could not confirm why. Two likely reasons: in a cloud VM, the L3 is shared with other users, so we only get a slice; and at large sizes the CPU also pays to translate addresses (Chapter 10). Both may play a part.

The lesson: what a machine reports and what you measure can disagree, and the measurement tells you how your program will behave.

Experiment 2: measure bandwidth with a sequential sum

Now the other side: how fast can data stream when the access pattern is easy? Sum a big array in order, at growing sizes, and compute gigabytes per second.

use std::hint::black_box;
use std::time::Instant;

fn main() {
    println!("{:>12} {:>10}", "bytes", "GB/s");
    let mut size = 16 * 1024usize;
    while size <= 256 * 1024 * 1024 {
        let n = size / 8;
        let v: Vec<u64> = (0..n as u64).collect();
        let reps = (512 * 1024 * 1024 / size).max(2); // about 512 MiB read in total
        let mut best = 0.0f64;
        for _ in 0..4 {
            let t = Instant::now();
            let mut s = 0u64;
            for _ in 0..reps { for x in black_box(&v) { s = s.wrapping_add(*x); } }
            black_box(s);
            best = best.max((size * reps) as f64 / t.elapsed().as_secs_f64() / 1e9);
        }
        println!("{:>12} {:>10.1}", size, best);
        size *= 4;
    }
}
       bytes       GB/s
       16384       70.2
       65536       66.2
      262144       66.6
     1048576       66.5
     4194304       29.2
    16777216       28.1
    67108864       28.6
   268435456        9.9

Same staircase, upside down. Bandwidth is about 66 GB/s while the data sits in L1 or L2 (up to 1 MiB here), drops to about 28 GB/s from 4 MiB to 64 MiB (the L3 range on this VM), and drops to about 10 GB/s at 256 MiB, which is main memory.

Compare the two experiments at 256 MiB. Latency was 137 ns per dependent access. Yet a sequential sum moves data at 10 GB/s, which is about 0.8 ns per 8-byte number. That is a 170x difference. The same memory, the same machine. The difference is the pattern: when the CPU knows what comes next, it can fetch many lines at once, and the long latency is hidden. Chapter 12 explains how.

Why it is this way (the "why")

Why does the ladder exist? Because of a trade-off you can never escape. Memory can be big, fast, or cheap: pick two. Fast memory (like L1) takes a lot of chip area and power per byte. Big memory (like RAM) must be dense and cheap. So designers build a ladder, and each rung gives the CPU the feeling of a big fast memory, as long as the program has good locality.

The philosophy to carry: the cache is a bet. The hardware bets that the future looks like the recent past. When the bet wins, your program flies. When it loses, you wait.

Three questions work for almost any performance problem:

  1. How big is my working set right now? Which rung does it fit on?
  2. How much of each cache line I carry do I actually use? (Chapter 9.)
  3. Can the hardware guess what I will want next? (Chapter 12.)

And the harder question: what would make this model wrong? The staircase assumes a simple "keep the recent stuff" store. Real caches have more rules (Chapter 9 shows one that bites), and a VM adds its own noise. A model is a tool. Know where it stops.

The idea also shows up outside the CPU. A web cache, a database buffer pool, and a CDN are the same bet, one rung up. Once you see the ladder, you will see it everywhere.

Common mistakes

  • Trusting the instruction count. "It's O(n), so it's fast." Big-O does not see the ladder.
  • Benchmarking only a tiny input. If it fits in L1, you measured the best case.
  • Believing the reported cache size without measuring. Shared machines disagree with their own reports.
  • Mixing latency and bandwidth. A dependent chain measures latency. A streaming sum measures bandwidth. A real program is usually in between.
  • Letting the chase loop turn into small cycles. Use a single-cycle shuffle.

Say it simply

Memory is a ladder, not a box. The top rungs are tiny and fast, and the bottom rung is huge and slow. Latency is the wait for one item, and bandwidth is how fast data can stream. You can see the ladder yourself by timing a chain of random reads as the data grows, which gives a staircase of latencies. A streaming sum gives the upside-down staircase of bandwidths.

Check yourself

Exercise 8.1 (Warm-up): Predict, then check. Your CPU has a 32 KiB L1 and a 1 MiB L2. Sketch the staircase you expect from the pointer-chasing test: where are the jumps? Then run it and compare. Where were you wrong?

Exercise 8.2 (Warm-up): Explain. In 5 sentences, explain to a friend the difference between latency and bandwidth, with an analogy that is not water pipes.

Exercise 8.3 (Warm-up): Convert. The sequential sum moved 10 GB/s at 256 MiB. How many nanoseconds is that per 8-byte number? How does this compare with the 137 ns per dependent access?

Exercise 8.4 (Core): Find your own ladder. Run both experiments on your machine. Mark where each jump happens. Compare against lscpu (or sysctl hw.cachesize on macOS). Write down one place where the report and the measurement disagree, and give your best guess why. Done when: a table of your own numbers and a short written comparison.

Exercise 8.5 (Core): Break the single cycle. Replace Sattolo's algorithm with a plain shuffle (use j in 0..=i). Run it at 1 MiB and at 256 MiB. What happens, and why? How could you detect that the chase fell into a short loop?

Exercise 8.6 (Stretch): Two threads on one chip. Run the chase on two threads at the same time, each with its own 8 MiB array. Does the latency per step change compared with one thread? What does this say about the L3?

Exercise 8.7 (Stretch): Design and defend. You are building an interpreter whose hot data is a table of 50,000 entries of 40 bytes each. Where does it sit on the ladder? What would you change if the table grew to 5 million entries?

What a strong answer includes: the working set size (2 MB for the first, 200 MB for the second), the rung each lands on, and a design response such as a smaller entry size, splitting hot and cold fields, or a cache-friendlier structure.


Chapter 9. Cache lines, the write path, and locality

In this chapter: You will learn the single most important fact about memory speed: data moves in 64-byte chunks. You will measure it, see what happens when you ignore it (a 16x slowdown from walking a matrix the wrong way), and learn why a perfectly "random" access can still fight itself inside the cache.

You need first: Chapter 8.

The problem first

Here are two ways to add up every number in a 4096 by 4096 matrix of u32 values. Both read every number once. Both do the same additions.

On this machine, one takes 9.4 ms and the other takes 152.8 ms. The second is 16.2 times slower.

The only difference: which direction you walk.

The model (the "what")

The cache line

Memory does not move one byte at a time. It moves in fixed-size chunks called cache lines. On almost every modern x86-64 chip, a cache line is 64 bytes. (Some Arm chips use 128 bytes in places. Always check your own machine.)

When your code reads one byte, the CPU brings the whole 64-byte line that contains it. The other 63 bytes come along for free.

  • If your next read is inside the same line, it is nearly free. You already have it.
  • If your next read is in a different line, you pay again.

Think of a library that lets you borrow only whole boxes of 64 books. If you needed one book, you still carry the box. So you had better need the other 63 books too.

Where does a line start? Lines are aligned: a line always starts at an address that is a multiple of 64. Alignment just means "the address is a multiple of some number". So an 8-byte number at address 120 sits in the line that begins at 64 (bytes 64 to 127), and bytes 120 to 127 are the end of that line. A number that starts at address 124 and is 8 bytes long would straddle two lines (bytes 124 to 131), which costs two line fetches.

 address:   0                 64                128               192
            ├──────────────────┼──────────────────┼─────────────────┤
            │     line 0       │      line 1      │     line 2      │
            └──────────────────┴──────────────────┴─────────────────┘
                                          ▲▲▲▲
                                an 8-byte value starting at 124
                                touches line 1 AND line 2

Two kinds of "nearby"

Fast programs get help from two habits. Both have names you will hear in talks:

  • Temporal locality: if you used something just now, you will probably use it again soon. So the cache keeps it.
  • Spatial locality: if you used something at an address, you will probably use the addresses next to it soon. So the cache brings the whole line.

Locality means "the data you use is close together, in time or in space".

How a cache finds a line: sets and ways

A cache is not one big pile. It is organized so that finding a line is fast. Here is the plan:

  • The cache is split into many sets.
  • Each address belongs to exactly one set, decided by some bits of the address.
  • Each set holds a small number of lines, called its ways. (A "12-way" cache holds 12 lines per set.)
  • A line can live in any of the ways of its own set, and nowhere else.

This is called a set-associative cache. A picture, for a tiny cache with 4 sets and 2 ways:

            way 0        way 1
  set 0   [ line A ]   [ line E ]
  set 1   [ line B ]   [ line F ]
  set 2   [ line C ]   [ empty  ]
  set 3   [ line D ]   [ line G ]

Which set does an address use? Take the address, throw away the lowest 6 bits (those pick the byte inside the 64-byte line), and use the next few bits as the set number. For a 48 KiB, 12-way L1 with 64-byte lines, there are 48 KiB / (64 B × 12) = 64 sets, so 6 bits pick the set: address bits 6 to 11. That means addresses exactly 4096 bytes apart land in the same set.

If many of your hot lines land in the same set, they fight for those few ways. A line gets thrown out even while the rest of the cache is empty. This is a conflict miss. A miss is when the data you want is not in the cache and must be fetched from further down.

The write path

So far we only read. What happens on a write? The CPU can only change data that sits in its cache. So when you write to a line that is not in L1, the CPU must first fetch that line (this is called write-allocate), then change it. The changed line is marked dirty. A dirty line must be written back to the level below when it is thrown out, which costs bandwidth later.

This has a surprising cost: writing 1 byte to cold memory causes a 64-byte read, and later a 64-byte write. A loop that only writes still reads. I did not measure this in this chapter. Exercise 9.6 asks you to. The idea comes from standard computer-architecture textbooks and from Ulrich Drepper's long paper on memory (reading list).

Build it (the "how")

Experiment 1: find the cache line size by striding

Make a 64 MiB array of u32. Add 1 to every stride-th element, and measure the time for one pass. When the stride is small, many reads share a line. When the stride gets large, every access touches a new line.

let n = 16 * 1024 * 1024;           // 16M u32 = 64 MiB
let mut v = vec![1u32; n];
for &stride in &[1usize, 2, 4, 8, 16, 32, 64, 128, 256] {
    // time one pass of: v[i] += 1 for i in (0..n).step_by(stride)
}
  stride  bytes apart    touched  ms per pass
       1            4   16777216        14.69
       2            8    8388608        12.22
       4           16    4194304         9.96
       8           32    2097152         2.92
      16           64    1048576         2.74
      32          128     524288         2.94
      64          256     262144         1.53
     128          512     131072         0.77
     256         1024      65536         0.37

What to notice, from the bottom up:

  • Strides 64, 128, 256 (256 bytes or more apart): each doubling of the stride halves the time. Every access touches a brand-new line, so the time is proportional to the number of accesses. That is the signature of "one line per access".
  • Strides 8, 16, 32 (32 to 128 bytes apart): the time is almost flat at about 2.8 ms, even though we touch half as many elements each time. Why? Because the number of lines fetched did not change much. With a 32-byte stride, two accesses share one 64-byte line. At 64 bytes, every access is a new line. The flat part is the machine hauling in the same lines.
  • Strides 1, 2, 4: much slower (14.7 ms at stride 1) even though the number of lines fetched is the same. Here the time is the cost of the instructions: 16 million loads, adds, and stores, not memory. These strides are limited by the instruction count, not the cache. This is a reminder that a loop can be limited by different things at different sizes (Chapter 7).

One result surprised me. With a 64-byte stride we touch one line per access, and with a 128-byte stride we touch every other line. I expected the time to halve from 2.74 ms to about 1.4 ms. It stayed at 2.94 ms. It only halved at 256 bytes. I did not confirm the reason. A likely cause is a hardware feature that fetches the neighbor line along with the requested one (many Intel chips do something like this, often called an adjacent-line prefetch), so the machine effectively moves 128-byte pairs. If that is true, "the unit that matters is 64 bytes" is a good rule, and "sometimes 128" is the fine print. Exercise 9.5 asks you to test it.

Experiment 2: walk a matrix two ways

A matrix in Rust is stored row by row: element (r, c) lives at index r * n + c. This is called row-major order. So elements in the same row are neighbors in memory, and elements in the same column are n * 4 bytes apart.

let n = 4096usize;                       // 4096 x 4096 u32 = 64 MiB
let m: Vec<u32> = (0..(n * n) as u32).collect();

// Way 1: row by row. Inner loop moves along memory.
for r in 0..n { for c in 0..n { s += m[r * n + c] as u64; } }

// Way 2: column by column. Inner loop jumps 16,384 bytes each step.
for c in 0..n { for r in 0..n { s += m[r * n + c] as u64; } }
row by row       :     9.42 ms
column by column :   152.79 ms  (16.2x slower)

Row by row uses every byte of every line: 16 numbers per line, all useful. Column by column uses one number per line (the other 15 in the line are not used until much later, and by then the line is gone). And that is not the only problem. A jump of 4096 × 4 = 16,384 bytes is a multiple of 4096, so every step lands in the same cache set (that is the conflict miss from above). It also lands in a new memory page every time, which we study in Chapter 10. I did not separate these three causes in this run. A matrix that is exactly 4096 wide is the worst case. Exercise 9.4 asks you to see how much a width of 4097 helps.

Experiment 3: watch the cache fight itself

Now let's see the set conflict directly. Visit k cache lines in a random loop (a pointer chase, as in Chapter 8). In one test the lines are exactly 4096 bytes apart, so they all fall in the same L1 set. In the other, they are 4160 bytes apart (4096 plus one line), so they spread across different sets.

 lines    gap 4096 (ns)    gap 4160 (ns)
     4             1.77             1.78
     8             1.77             1.77
    10             1.76             1.76
    12             2.42             1.76
    14             5.66             1.77
    16             5.67             1.76
    24             5.65             1.80
    32             5.19             1.77
    64             5.67             1.77

In the second column, everything is L1-speed (1.77 ns) all the way to 64 lines. The cache has room: 64 lines is only 4 KiB. In the first column, the time jumps as soon as we go past about 12 lines. Only 12 lines! That is a tiny working set (768 bytes), yet it behaves like data that does not fit in L1.

The reason is the L1's 12 ways per set (from lscpu: 48 KiB, and my calculation of 64 sets). Thirteen lines that all map to one set cannot all stay. Each new one evicts another. The time (5.7 ns) is about the L2 latency from Chapter 8: the lines keep falling out of L1 and coming back from L2. (The 12-way number is my reading of a 48 KiB L1 plus the jump at 12 to 14 lines. I did not look up the chip's manual.)

So: a working set can fail to fit, not because it is big, but because of where its addresses fall. This is why power-of-two sizes are dangerous. Arrays or matrix rows whose size is a large power of two put neighbors in the same set. This is one reason performance code sometimes pads rows (makes each row a little longer) on purpose.

Why it is this way (the "why")

Why 64 bytes? Because it is a trade-off. A bigger line brings more neighbors for free (good for spatial locality) but wastes bandwidth when you only needed a little, and makes the cache hold fewer distinct lines. A smaller line wastes less, but needs more fetches and more bookkeeping for the same data. Chip designers tried many sizes. 64 bytes has been the sweet spot on x86 for a long time.

Why sets and ways instead of "any line anywhere"? Because of speed and cost. A cache where any line can go anywhere (fully associative) would need to compare the address against every line at once. That takes a lot of hardware and power. A cache where each line has exactly one possible place (direct-mapped) is fast and cheap, but two hot lines that share a place keep throwing each other out. Set-associative is the compromise: a few places per address.

The philosophy: every clever hardware idea is a bet on a pattern. Lines bet on spatial locality. Sets and ways bet that addresses spread out evenly. When your data breaks the bet (a stride of exactly 4096, one useful number per line), the hardware gives you its worst case.

This gives you a rule of thumb for any loop: count the cache lines touched, not the elements. Two loops with the same number of elements can touch 16 times as many lines.

What would make this wrong? Hardware is not the same everywhere. Some chips use a hashed set index (so a stride of 4096 does not always collide), some have different line sizes, and virtual machines hide details. Our results hold on this machine. Always re-measure.

Common mistakes

  • Walking a 2D array against its storage order. Check which index changes fastest in memory.
  • Counting elements, not lines. One useful field per line is a bandwidth leak.
  • Using sizes that are large powers of two for strides, row lengths, or buffer sizes in hot loops.
  • Ignoring the write cost. Writes to cold memory read the line first.
  • Assuming the line is 64 bytes everywhere. Check.
  • Mixing up "does not fit" with "is big". A working set can fail to fit because of where it lives.

Say it simply

Memory moves in 64-byte cache lines, so reading one byte brings its 63 neighbors for free, and a program is fast when it uses all of them. A cache is made of sets, and each address can live in only one set, so lots of hot lines that share a set push each other out even when the cache is mostly empty. Writing to cold memory first reads the line. Walking a matrix against its storage order can be 16 times slower, with the same number of additions.

Check yourself

Exercise 9.1 (Warm-up): Count the lines. A struct is 24 bytes and you store 1000 of them in an array that starts at a 64-byte-aligned address. How many cache lines does the array cover? How many structs straddle two lines? Draw the first three lines on paper.

Exercise 9.2 (Warm-up): Which set? An L1 cache has 64 sets and 64-byte lines. Which set does address 0x12345 belong to? Which address, 4096 bytes later, is in the same set? Show the bit math.

Exercise 9.3 (Warm-up): Predict. You sum a Vec<u8> of 100 million bytes in order. How many cache lines are fetched? If the data comes at 10 GB/s, about how long does it take?

Exercise 9.4 (Core): Fix the matrix. Run the row-vs-column test. Then change the width to 4097 (and index with r * 4097 + c) and run the column walk again. How much faster is it? What does this tell you about conflict misses versus plain locality? Done when: a table with widths 4096 and 4097 for both walk orders, and 4 sentences of explanation.

Exercise 9.5 (Core): Test the neighbor-line guess. In the stride experiment, add strides of 48, 80, and 96 elements, and re-run. Try the stride test with the data being touched in a random order of lines instead of a fixed stride. If the "adjacent line" idea is right, what should change? Hint: use the pointer-chase idea so the order is random.

Exercise 9.6 (Core): Measure the write cost. Write two loops over a 256 MiB array: one that only writes (v[i] = 1) and one that reads and writes (v[i] += 1). Compare the times. Then compare with the sum loop from Chapter 8. Do the writes cost more than reads? (Beware that the compiler might turn the write-only loop into a memset. How can you check?)

Exercise 9.7 (Stretch): Find the ways. Using the set-conflict experiment, find the associativity of your own L1 and L2 by changing the gap (for example 4096, 32768, and 262144 bytes) and the number of lines. What numbers do you get? Do they match your CPU's manual?

Exercise 9.8 (Stretch): Design and defend. You write a game where entities are structs, and each frame you update only position. The struct has 12 fields and is 96 bytes. Argue for or against changing the layout. (We return to this in Chapter 11.)

What a strong answer includes: lines touched per entity (96 bytes straddles two lines), the share of bytes used per line, and how the answer changes with the number of entities and with how often the other fields are read.


Chapter 10. Pages, address translation, and the TLB

In this chapter: You will learn that the addresses your program uses are not the real addresses, and that translating them has its own cache. You will measure it: the same number of cache lines can take 1.8 ns or 131 ns per access, depending only on how many different memory pages they touch.

You need first: Chapters 8 and 9.

The problem first

In Chapter 9, we put the lines in a pointer chase and found jumps at the cache sizes. But a puzzle was left over. In Chapter 8, the time at 32 MiB was already as slow as main memory, even though the L3 should be much bigger. We guessed "address translation". Now we test the guess.

The model (the "what")

Virtual memory

Your program does not see real memory addresses. It sees virtual addresses. The operating system and the CPU keep a map from virtual addresses to physical addresses (real locations in RAM). This gives each program its own private address space, and lets the system move things around.

Virtual memory is divided into pages, usually 4096 bytes (4 KiB). The map works one page at a time. (You can check with getconf PAGESIZE. It printed 4096 here.)

An address is split in two parts:

 virtual address:  [ page number | offset inside the page ]
                     (upper bits)   (lowest 12 bits for a 4 KiB page)
                          │
                          ▼  look up in the page table
 physical address: [ frame number | same offset ]

The map is stored in page tables, which are themselves in memory. On x86-64 they form a tree four levels deep. To translate one address, the CPU may have to walk four levels, each one a memory read. That is called a page walk. If each walk step missed the cache, a single load would cost five memory trips.

The TLB

To avoid this, the CPU keeps a small cache of recent translations. It is called the translation lookaside buffer (TLB). A TLB entry says "virtual page X is at physical frame Y".

  • A TLB hit: the translation is already there. Almost free.
  • A TLB miss: the CPU must do a page walk. Costly.

The TLB is small, often in two levels. A typical first-level TLB holds on the order of 64 entries, and the second level on the order of a thousand or two. One entry covers one page (4 KiB). So a first-level TLB with 64 entries covers only 64 × 4 KiB = 256 KiB of memory at a time. The second level might cover a few megabytes. (These sizes vary by chip. I did not look up the chip behind this VM. We measure instead.)

This is another rung of the ladder, but a different kind: instead of caching data, the TLB caches the map.

Huge pages

If you use bigger pages, each TLB entry covers more memory. On Linux, x86-64 supports 2 MiB (and 1 GiB) pages, called huge pages. A 64-entry TLB with 2 MiB pages covers 128 MiB instead of 256 KiB. The cost: memory is handed out in bigger pieces, which can waste space, and the setup is more involved. Linux can use huge pages automatically in some modes (transparent huge pages). On this VM the setting was always [madvise] never, which means "only if the program asks".

Build it (the "how")

We want to separate the TLB from the data cache. The trick: do two pointer-chase tests with the same number of cache lines, but arranged differently.

  • Packed: the lines are side by side in memory. 64 lines fit in 4 KiB, so very few pages are touched.
  • Spread: one line per 4 KiB page, and the line offset inside each page changes from page to page (so the lines don't all pile into one cache set). Now every line is on a different page.

Both tests have the same number of lines to fetch. Only the number of pages differs.

// `spread`: line i lives in page i, at an offset that changes with i.
// `packed`: the same lines, side by side.
let words_per_page = 4096 / 8;
let line_words = 8; // 64 bytes
let pos = |i: usize| -> usize {
    if spread { i * words_per_page + (i % 64) * line_words }
    else      { i * line_words }
};
// each line's first word holds the position of the next line in a random loop
   lines    packed ns    spread ns
      16         1.76         1.77
      32         1.78         1.77
      64         1.77         1.77
     128         1.77         4.25
     256         1.76         4.26
     512         1.78         4.29
    1024         5.63         8.19
    2048         5.70        10.66
    4096         5.68        18.55
    8192         6.29        22.05
   16384         7.55        32.41
   32768        19.37        42.45
   65536        35.53        55.13
  131072        35.93       131.63

Read it column by column.

Packed is a plain cache staircase, like Chapter 8: 1.8 ns while the lines fit in L1 (up to 512 lines, 32 KiB), then about 5.7 ns (L2), then up again near 32,768 lines (2 MiB, the L2 size), then about 36 ns.

Spread is slower in the same places the packed one is fast. Look at 128 lines. In both tests we fetch the same 128 lines: 8 KiB of data, which easily fits in L1. Packed takes 1.77 ns. Spread takes 4.25 ns. The data cache is not the difference, because both fit. The difference is that spread touches 128 different pages, and 128 is more than the first-level TLB can hold (it was fine at 64 pages, 1.77 ns, and not fine at 128). The extra 2.5 ns per access is the cost of a first-level TLB miss that is served by the second-level TLB. This is consistent with a first-level TLB of about 64 entries.

The next step up in the spread column starts at 1024 pages (8.2 ns), and keeps rising. That is where the second-level TLB starts to run out, and the CPU has to do real page walks. The data cache cannot explain this: the spread test still only touches one line per page, so 1,024 lines is just 64 KiB of data.

At the end, 131,072 lines: packed takes 35.9 ns, spread takes 131.6 ns. Both fetch the same 131,072 lines (8 MiB of actual data). The extra ~96 ns per access is the cost of translation: page walks, and the page-table entries themselves falling out of the caches. Remember that we measured in a VM. A VM has two layers of address translation (guest and host), which makes a TLB miss even more costly than on bare metal. So the numbers here are probably worse than on real hardware.

And look back at Chapter 8's puzzle. The chase there used 4 KiB pages too, so at 32 MiB (8,192 pages) it was touching many pages, and part of its 108 ns was page walks. A big part of "the cost of main memory" in that chase was not the memory itself. I haven't separated the two for that exact table, but this experiment shows the size of the effect.

I did not run a huge-page version. The setting here is madvise, so a program has to ask with the madvise system call. Exercise 10.5 asks you to try it. It is a good example of an experiment where the expected answer ("much faster") might not appear on your machine, and finding out why is the lesson.

Why it is this way (the "why")

Why do we need virtual memory at all, if translation is so costly? Because the benefits are huge. Every program gets its own clean address space, programs cannot read each other's memory, memory can be given out in scattered pieces, and the system can move pages to disk. These are some of the most important ideas in operating systems. Translation is the price, and the TLB is how we pay less of it.

Why is the TLB so small? A TLB lookup happens on every memory access, so it must be extremely fast, and fast storage is small (the same trade-off as Chapter 8). It works because of locality again: programs usually stay on a few pages for a while. The TLB is another bet on locality, this time of pages, not lines.

This produces a new way to count: count the pages touched, as well as the lines. A loop that touches one line on each of 100,000 pages is slow even if all the lines are in cache. This is the pattern of:

  • a hash table with large entries, accessed randomly,
  • a big linked structure whose nodes were allocated at different times,
  • a compiler's AST or symbol table that grew over time,
  • a database index with huge fan-out.

The honest counter-argument: for many programs, the TLB is not the bottleneck. Our spread test is a worst case on purpose. The point is to know when it matters (many pages, random access, big working set) and to have a way to test it.

What would make this model wrong? Hardware that caches page-walk steps, prefetches translations, or has very large TLBs hides the effect. And huge pages can change everything. As always: measure on your machine.

Common mistakes

  • Blaming the cache for what the TLB is doing. A cache-friendly layout can still be page-unfriendly.
  • Treating page size as fixed. Huge pages change the answer.
  • Benchmarking in a VM and assuming the same numbers on bare metal. TLB misses are costlier in a VM.
  • Counting only data size. A big sparse structure costs more than a dense one of the same byte count.

Say it simply

Programs use virtual addresses, and the CPU must translate each one into a physical address using a map made of 4 KiB pages. The TLB is a small cache of those translations, so touching more pages than it can hold makes every access slower, even when the data itself fits in the cache. In our test, the same 128 cache lines took 1.8 ns when packed and 4.3 ns when spread across 128 pages, and a large spread cost 131 ns against 36 ns packed. Count pages touched, not just lines.

Check yourself

Exercise 10.1 (Warm-up): Do the math. A TLB has 64 entries and pages are 4 KiB. How much memory can it cover? How much if the pages are 2 MiB?

Exercise 10.2 (Warm-up): Split an address. Split the virtual address 0x7f12_3456_789A into the page number and the offset, for 4 KiB pages. What changes for 2 MiB pages?

Exercise 10.3 (Core): Run the TLB test. Build and run the packed-versus-spread test. Mark where the spread column departs from the packed column. How many pages is that? Does it match what you know about your CPU's TLB? Done when: your own two-column table and 5 sentences about what you found.

Exercise 10.4 (Core): Page count by structure. For a Vec<Node> of 1 million nodes of 48 bytes each, versus a Vec<Box<Node>> where each box was allocated at a random time, estimate the number of 4 KiB pages touched by a full traversal in each case. Then measure the traversal time of both.

Exercise 10.5 (Stretch): Try huge pages. Allocate 512 MiB with mmap (use the libc crate), call madvise with MADV_HUGEPAGE, and repeat the spread test. Check grep -i huge /proc/meminfo while it runs. Did it help? If not, find out why. Hint: you may need to align the allocation to 2 MiB.

Exercise 10.6 (Stretch): Design and defend. A server keeps 200 million small records in a hash table with random access. A teammate says "enable huge pages" and another says "shrink the records". Argue which comes first, and what you would measure before and after.

What a strong answer includes: pages touched per lookup, TLB reach (entries times page size), the cost of a miss in a VM, memory waste from huge pages, and the idea of testing both changes in a controlled experiment.


Chapter 11. Data layout: AoS, SoA, and hot/cold splits

In this chapter: You will learn how the shape of your data in memory decides how many cache lines a loop must fetch. You will measure a 5.6x difference from a layout change alone, learn what Rust does to your struct fields, and see why a Vec<Box<T>> can be 3 to 19 times slower than a Vec<T>.

You need first: Chapters 8 to 10.

The problem first

Say you have a few million particles. Each has position x, y, z, a mass, and four other numbers. That is 8 numbers of 8 bytes: 64 bytes, exactly one cache line.

You want to add up all the masses.

The loop does one useful read per particle. But the CPU fetches a full line (64 bytes) to read 8 bytes. 87.5% of what comes in is wasted.

The model (the "what")

There are two common ways to lay out many records.

Array of Structs (AoS): one array, each element a whole record.

 AoS: one line per particle, we only want MASS
 line 0: [ x  y  z  MASS  o  o  o  o ]
 line 1: [ x  y  z  MASS  o  o  o  o ]
 line 2: [ x  y  z  MASS  o  o  o  o ]
          ^^^^^^ ^^^^ ^^^^^^^^^^^^
          unused used  unused         -> we carry 64 bytes to use 8

Struct of Arrays (SoA): one array per field.

 SoA: all masses side by side
 line 0: [ MASS MASS MASS MASS MASS MASS MASS MASS ]
          -> every byte we carry is a byte we use

Neither is "better". It depends on which fields each loop reads. If a loop uses all the fields of one record, AoS is good (one line gives everything). If it uses one or two fields across many records, SoA is good.

A middle way is the hot/cold split: keep the fields that are read often ("hot") together in one array, and the rarely-used ones ("cold") in another.

What Rust does to your struct

Rust is allowed to reorder the fields of a struct to save space. C is not. The reason is alignment: each field must start at an address that is a multiple of its own size, so the compiler adds empty padding bytes between fields when needed. Look at this struct with a u8, then a u64, then a u8:

 repr(C), fields in the order written:        Rust default (reordered):
 [a][pad pad pad pad pad pad pad]             [b b b b b b b b][a][c][pad ×6]
 [b b b b b b b b]                            = 16 bytes
 [c][pad pad pad pad pad pad pad]
 = 24 bytes

I measured this: size_of is 16 for the Rust default, 24 for repr(C), and 10 for repr(C, packed) (which removes padding but makes some reads misaligned and slow). Another small fact, from the same run: Option<Box<u64>> is 8 bytes (the null pointer stands for None), but Option<u64> is 16 bytes.

The lesson: bytes per record is a performance number. Fewer bytes per record means more records per line.

Build it (the "how")

Experiment 1: AoS versus SoA

Four million particles of 64 bytes, and we sum the mass field.

#[derive(Clone, Copy)]
#[repr(C)]
struct Particle {
    x: f64, y: f64, z: f64, mass: f64,
    other: [f64; 4], // fields we do not need in this loop
}

fn sum_mass_aos(ps: &[Particle]) -> f64 {
    let mut s = 0.0;
    for p in ps { s += p.mass; }
    s
}

fn sum_mass_soa(masses: &[f64]) -> f64 {
    let mut s = 0.0;
    for m in masses { s += *m; }
    s
}
LayoutData the loop walksTime (best of 10)
AoS256 MiB16.36 ms
SoA32 MiB2.94 ms

The SoA loop is about 5.6 times faster, with the same additions. The only change: every line we pull in now holds eight masses.

But SoA moved 8 times less data and was only 5.6 times faster. Why not 8? My guess: after the layout fix, the loop stops waiting on memory and starts waiting on itself. In s += *m, each addition needs the result of the previous one. That chain has its own speed limit (Chapter 13). Test the guess: split the sum over four separate totals.

VersionTime (4M values)
One accumulator2.95 ms
Four accumulators1.19 ms

The guess held. Against the original AoS loop, we are now about 13 times faster, with the same math. (The compiler does not make this split for you on floats, because adding in a different order can change the last digits. Chapter 13 explains.)

Experiment 2: the hot/cold split

Two million items of 16 bytes: a 4-byte kind, a 4-byte value, and 8 more bytes. A loop that counts items with kind == 3 only reads kind.

LayoutBytes the loop must walkTime
Vec<Item> (16 B each)32 MB1.21 ms
Vec<u32> of just the kinds (4 B each)8 MB0.49 ms

4 times less data, 2.5 times faster. Less than 4x again, because at small sizes the loop is no longer only limited by memory. The split paid off, but it has a cost you must count: now the kind and the rest of the item live in different places, and code that needs both has to look in two arrays.

Experiment 3: Vec<T> versus Vec<Box<T>>

This one matters a lot for interpreters and compilers, where trees and graphs are built from boxes.

We have 2 million items. We sum the value field in three layouts:

LayoutTime
Vec<Item> (the items live inside the vector)1.15 ms
Vec<Box<Item>>, visited in allocation order3.55 ms
the same boxes, visited in a random order21.38 ms

Think about what each one does:

  • Vec<Item>: a straight walk through contiguous memory. The prefetcher (Chapter 12) loves it.
  • Vec<Box<Item>> in order: the vector holds pointers, and each pointer leads to a separate heap allocation. Because they were allocated one after another, the allocator mostly placed them in order, so the walk is still fairly smooth. But each box also has allocator overhead, so each item is spread over more memory. Cost: 3.1x.
  • Shuffled: the same boxes, but visited in a random order, like a linked structure that has been edited for a long time. Every access is a random jump. Cost: 18.6x compared with the plain vector.

(I did not measure the allocator overhead or the exact spacing of the boxes. The 3x is the measured result. The explanation is my reading of it.)

The data is identical. Only the placement changed. A lesson for later chapters: the AST or the symbol table you wrote with Box everywhere may be paying this cost. We will fix it with arenas and index-based trees in Chapters 23 and 33.

Why it is this way (the "why")

Why do languages make Vec<Box<T>> so easy to write? Because it matches how we think. An object is a thing with an identity. Pointers make it easy to share and change things. This is the object-oriented picture, and it is convenient for humans.

Memory does not care about our convenience. Memory wants the data for the next step to be next to the data for this step. This is the data-oriented picture: organize data by how it is used, not by what it is called. SoA and hot/cold splits are two examples.

The trade-off is real. A data-oriented layout is harder to read and change. A new field means touching several arrays. Do not do it everywhere. Do it where a profile (Part IV) says a loop is bound by memory and uses a small share of each record.

A useful question for any data structure: "What is the access pattern?" Then ask:

  1. How many bytes per record does the hot loop actually read?
  2. How many bytes does it drag in? (Line size, padding, pointers.)
  3. How many records does it visit in a row, and in what order?

What would make SoA wrong? If a loop reads all the fields of each record, SoA makes you fetch from several arrays at once, which uses more cache lines and more prefetcher streams. Then AoS is better. And if the data is small enough to sit in L1, layout hardly matters. The right answer depends on the workload, which is the lesson of Chapter 3 again.

Common mistakes

  • Using SoA everywhere. It helps loops that read a few fields. It hurts loops that read all fields.
  • Forgetting padding. Check size_of for hot structs. Reordering fields (or letting Rust do it) can save bytes.
  • Using repr(C) without need. It stops Rust from reordering fields.
  • Using packed to save space. Misaligned reads can be slower. Measure.
  • Box everywhere in hot structures. Each box is a pointer to chase.
  • Optimizing layout before profiling. The loop may not be the hot one.

Say it simply

How you lay out your data decides how many cache lines a loop must fetch. If a loop reads one field out of eight, an array of structs wastes most of every line, and a struct of arrays does not: we got a 5.6x speedup, and a split-and-unroll took it to 13x. A hot/cold split keeps the often-used fields together. Rust reorders fields to reduce padding, and repr(C) stops that. Boxes scatter your data: a vector of boxes was 3x slower in order and 19x slower in random order.

Check yourself

Exercise 11.1 (Warm-up): Size it. Compute the size and padding of struct A { a: u8, b: u32, c: u16, d: u64 } with repr(C). Then find the smallest possible size by reordering. Check with size_of.

Exercise 11.2 (Warm-up): Lines per record. A record is 40 bytes. How many records fit in a 64-byte line? On average, how many lines does one record touch? How about 32 bytes, or 48?

Exercise 11.3 (Core): Reproduce AoS vs SoA. Run the test. Then change the loop to read x, y, z, and mass all together. Which layout wins now? At what number of fields read does the winner change? Done when: a table of time against number of fields read, for both layouts.

Exercise 11.4 (Core): Hot and cold split. Take a struct with 3 fields you read every time and 5 fields you read once in a while. Write one benchmark where AoS wins and one where the split wins. Explain what made the difference in each.

Exercise 11.5 (Core): The box tax. Reproduce Experiment 3. Then build a version with indices into one big Vec<Item> instead of boxes, and visit in the random order. Where does its time land?

Exercise 11.6 (Stretch): A mixed layout. Try an "AoSoA" layout: groups of 8 particles, with all masses of the group together, all x values together, and so on. When might it beat both AoS and SoA?

Exercise 11.7 (Stretch): Design and defend. You are building an interpreter. Each Value has a type tag, a number, and a pointer. Your loop checks the tag of millions of values and only sometimes needs the rest. Would you store values as AoS or SoA? What would change your mind? Write a half-page defence that names a trade-off you accept.

What a strong answer includes: the working set size, the share of bytes used per line, how often each field is read, the code simplicity cost, and how the answer changes if the loop is not the hot one.


Chapter 12. Access patterns and the prefetcher

In this chapter: You will learn how the CPU hides memory latency by guessing, and when the guess fails. You will measure a 100x difference between a dependent and an independent random access, and see a software prefetch that did not help.

You need first: Chapters 8 to 11.

The problem first

In Chapter 8, one dependent access to main memory cost 137 ns. A sequential sum over the same size of memory moved 8 bytes in about 0.8 ns. How can the same memory be 170 times faster in one test than the other?

Because the CPU does not wait for one load at a time. It overlaps them.

The model (the "what")

Two separate tricks hide the latency of memory.

Trick 1: the hardware prefetcher. The CPU watches the addresses your program touches. If it sees a pattern (for example, one line after another, or every third line), it starts fetching lines before you ask. When you do ask, they have already arrived. Prefetching means "fetching data before it is needed".

 You read:     line 10, line 11, line 12, ...
 Prefetcher:   "I see a pattern!"  →  fetches 13, 14, 15, 16 ahead of time
 You read:     line 13  →  already in cache.

Trick 2: memory-level parallelism (MLP). A modern CPU can have many loads in flight at the same time (often ten or more per core). It does this by out-of-order execution: the CPU does not stop on a load that is waiting. It looks ahead in your code, finds the next loads that don't depend on it, and starts them too. Memory-level parallelism is the number of memory requests in flight at once.

The two tricks need different things:

  • The prefetcher needs a predictable pattern.
  • MLP needs independent loads: the address of the next load must not depend on the result of the previous one.

A dependent access (a pointer chase) has neither: the address is unknown until the last load finishes, and there is no pattern to see. That is why it shows the raw latency.

A rule of thumb from queueing theory ties these together (it is called Little's law): throughput = parallelism ÷ latency. If one load takes 100 ns, and you can have 10 in flight, you can complete about one load every 10 ns. Memory-level parallelism divides the latency.

Build it (the "how")

We make 8 million accesses into a 256 MiB array of u64, in five different ways, and compute the time per access.

8000000 accesses each, 256 MiB of data
sequential                :    1.41 ns/access
stride 136 bytes          :    6.29 ns/access
random, independent       :   12.03 ns/access
random, dependent (chase) :  116.37 ns/access  (table is only 30 MiB)
random + prefetch 16 ahead:   11.67 ns/access

(In the first three tests, we read an index from a list, then load data[index]. The index list itself is read in order. The chase uses a table of 8 million 4-byte entries, so only 30 MiB, which is smaller than the 256 MiB of the others. The comparison is rough, but the gap is large enough that the point stands.)

Go through the table.

Sequential: 1.41 ns. The prefetcher sees the simple pattern and streams lines ahead. Memory is not the bottleneck at all.

Stride of 136 bytes: 6.29 ns. A fixed stride is still a pattern, and the prefetcher can follow many strides. But we touch a new line (sometimes two) on every access, so we fetch about 4 to 5 times more lines than in the sequential case. Fine, but not free.

Random, independent: 12.03 ns. No pattern, so no prefetcher help. But the loads don't depend on each other, so the CPU runs many at once. Each load still takes about 100 ns, but with roughly ten in flight, we complete one every 12 ns. This is MLP in action.

Random, dependent: 116.37 ns. Same randomness, but each address depends on the last load. No overlap. We pay the full latency every time. This is 9.7 times slower than the independent case, from only one change: whether the next address needs the last result.

Random with a software prefetch: 11.67 ns. I added an instruction to ask for the element 16 steps ahead (_mm_prefetch). It made no real difference: 11.67 against 12.03 ns. That surprised me a little. The reason is probably that the out-of-order CPU was already overlapping the loads, as MLP above, so there was no latency left to hide. A prefetch helps when the CPU cannot look far enough ahead by itself. Here it could. (I think this is the reason, and I did not confirm it by counting events. Chapter 19 shows how.)

The point of the table: the same memory, the same amount of data, and a 100x range in time per access (1.41 up to 116 ns), decided only by the pattern and the dependencies.

Why it is this way (the "why")

Why do chips spend so much hardware on prediction and overlap? Because memory has been the slowest part for decades. CPU speed grew much faster than memory speed. This gap is sometimes called the memory wall. Chip designers could not make memory faster, so they made the CPU less sensitive to its slowness, by guessing and overlapping.

This leads to the central idea: modern performance is about hiding latency, not removing it. You cannot make a DRAM access take less than about 100 ns. You can arrange that you never wait for one.

What does that tell you about code?

  1. Prefer sequential and strided access. The prefetcher is on your side.
  2. Prefer independent loads. If you have many lookups to do, issue them all, then use the results. A hash table lookup done 100 times in a batch can overlap. The same lookups done one after another, each needing the last result, cannot.
  3. Break dependency chains. A linked list or a deep tree is a chain by nature. A flat array, or an index-based tree stored in an array, can be easier on the hardware.
  4. Do not assume a software prefetch helps. It is a wager: you spend instructions and cache space to guess the future. If the CPU was already doing it, you lose a little.

There is a limit on the whole model. The number of loads in flight is limited (the CPU has a fixed number of slots for outstanding cache misses). If a loop issues loads faster than memory can serve, the slots fill up and it becomes bandwidth-bound. Our independent-random test at 12 ns is probably near this limit. Chapter 19 draws this limit as the roofline.

What would make it wrong? The prefetcher is a black box that changes between chips. A pattern that works on one machine may confuse another. Software prefetch behavior also differs. This is why we measure, and why Chapter 35 looks inside the hardware counters.

Common mistakes

  • Writing a dependent chain when the work is independent. For example, following a pointer to find the next pointer when you could first collect all the addresses.
  • Assuming random access is always slow. Random and independent can be 10x faster than random and dependent.
  • Adding software prefetch without measuring. It often does nothing and sometimes hurts.
  • Testing access patterns on tiny data. Everything is in L1 and the patterns look the same.
  • Ignoring the index list. In a gather, the index list itself must be read too.

Say it simply

The CPU hides memory latency in two ways: the prefetcher spots patterns and fetches ahead, and out-of-order execution runs many independent loads at once. A pointer chase, where each address depends on the last load, defeats both, and paid the full 116 ns per access. The same random accesses made independent took 12 ns. A manual prefetch did nothing here because the CPU was already overlapping the loads. You cannot make memory faster, but you can arrange never to wait for it.

Check yourself

Exercise 12.1 (Warm-up): Little's law. A memory access takes 100 ns. If the CPU can keep 10 accesses in flight, what is the best throughput in accesses per second? What if it can keep only 2?

Exercise 12.2 (Warm-up): Dependent or independent? For each, say which it is: (a) summing an array, (b) walking a singly linked list, (c) looking up 1000 keys in a hash map one at a time, (d) binary search on a sorted array, (e) following parent links in a tree to the root.

Exercise 12.3 (Core): Reproduce the table. Run the five tests. Then change the data size from 256 MiB to 4 MiB. Which rows change the most? Which barely change? Explain.

Exercise 12.4 (Core): Batch your lookups. Take 8 million random lookups in a big array. Write a version where each lookup's address depends on the previous result, and a version where they are independent. Then write a middle version that processes 8 independent chains at once (interleaved). How fast is it? Hint: interleaving several independent chains is how you speed up many tree or hash lookups.

Exercise 12.5 (Core): Try the prefetch distance. Repeat the software-prefetch test with distances 1, 4, 16, 64, and 256. Make a table. Is there a best distance? Does it ever help?

Exercise 12.6 (Stretch): Find the stride limit. Run a strided sum with strides from 1 to 4096 bytes. Where does the prefetcher stop helping? What does the answer say about page boundaries (Chapter 10)?

Exercise 12.7 (Stretch): Design and defend. You have a binary search tree with 10 million nodes. Lookups are slow. List three changes that reduce latency per lookup, and three that raise lookups per second without reducing latency. Which would you try first?

What a strong answer includes: a flat or B-tree style layout for latency, batching and interleaving for throughput, the idea that these are different goals, and a measurement plan.


Chapter 13. Pipelines, dependency chains, and branch prediction

In this chapter: You will learn how a CPU runs many instructions at once, why a chain of dependent additions is slow, and what a mispredicted branch really costs. You will measure a branch that is 8 times slower on random data than on sorted data, and an addition loop that gets 7 times faster with more independent totals.

You need first: Chapters 8 to 12.

The problem first

In Chapter 11, the SoA loop was held back by "waiting for itself". In Chapter 7, the multi-accumulator trick helped at one size and not at another. Both used the same hidden idea: a CPU is not a machine that runs one instruction after another. It runs many at once. Here is how, and when it breaks.

The model (the "what")

The pipeline

A single instruction goes through several steps: fetch it, decode it, execute it, write the result. A simple CPU does all steps for one instruction and then starts the next. A pipelined CPU works like an assembly line: while one instruction is being executed, the next is being decoded, and the one after is being fetched. Each stage works on a different instruction at the same time.

 time →     1     2     3     4     5     6
 instr 1   fetch decode exec  write
 instr 2         fetch decode exec  write
 instr 3               fetch decode exec  write

Modern CPUs go much further:

  • Superscalar: several instructions are executed in the same cycle, on separate execution units (little calculators inside the core). A core may do several integer operations, loads, and floating-point operations every cycle.
  • Out-of-order: the CPU does not follow your program order strictly. It looks at a window of upcoming instructions, and runs whichever have their inputs ready. It then retires them (makes them official) in the original order, so the result looks as if they ran in order.

Two words, latency and throughput, are used again here, for a single instruction:

  • Instruction latency: how many cycles from when an instruction starts to when its result can be used.
  • Instruction throughput: how many instructions of that kind can be started per cycle.

A floating-point add might have a latency of a few cycles but a throughput of two per cycle. That means: one add takes a few cycles to finish, but the CPU can have many in flight.

Dependency chains

An instruction can only run when its inputs are ready. If instruction B needs the result of instruction A, B must wait for A's latency. A sequence where each needs the last is a dependency chain.

 s += a[0];   ← needs s
 s += a[1];   ← needs the s from the line above
 s += a[2];   ← needs the s from the line above

This chain runs at the speed of the latency, no matter how many execution units the CPU has. Splitting the work into independent chains lets the CPU run them side by side, at the speed of the throughput:

 s0 += a[0];   s1 += a[1];   s2 += a[2];   s3 += a[3];    ← four chains, independent
 s0 += a[4];   s1 += a[5];   ...

Branches and the predictor

A branch is an if, a loop condition, or any place where the next instruction depends on a test. The CPU does not want to wait for the test to finish, because the pipeline would sit empty. So it predicts which way the branch will go, and keeps working speculatively on that guess. Speculative execution means running instructions before you know that you need them.

  • If the guess is right, nothing is lost.
  • If the guess is wrong (a misprediction), everything done after that branch must be thrown away, and the pipeline restarts from the correct path. This costs the time to refill the pipeline.

The part of the CPU that guesses is the branch predictor. It learns from history: "this branch went left the last ten times, so it will probably go left again." It is very good on patterns (always taken, alternating, loops). It is useless on random outcomes, where it can only be right about half the time.

Build it (the "how")

Experiment 1: the branch that depends on the data

We scan a million random bytes and collect those that are at least 128 (about half of them). The test is a plain if. We use a Vec::push inside the if so the compiler cannot turn the branch into branch-free code.

#[inline(never)]
fn collect_big(data: &[u8], out: &mut Vec<u8>) {
    out.clear();
    for &x in data { if x >= 128 { out.push(x); } }
}

// Branch-free: always write, and move the write position by 0 or 1.
#[inline(never)]
fn collect_big_branchless(data: &[u8], out: &mut [u8]) -> usize {
    let mut n = 0usize;
    for &x in data {
        out[n] = x;
        n += (x >= 128) as usize;
    }
    n
}
1000000 bytes, about half pass the test (x >= 128)
branchy,    random order :  4.700 ns/item
branchy,    sorted order :  0.571 ns/item
branchless, random order :  0.521 ns/item
branchless, sorted order :  0.519 ns/item

Look at the first two lines. Same code, same bytes, only in different order. Random: 4.70 ns per item. Sorted: 0.57 ns. That is 8.2 times slower. In the sorted data, the branch goes "no, no, no, ..., yes, yes, yes". The predictor learns it instantly. In the random data, the branch is a coin flip.

We can estimate the cost of a single misprediction. About half the items are mispredicted in the random case, so the extra time per item, 4.70 − 0.57 = 4.13 ns, is half of a misprediction's cost: about 8.3 ns per misprediction. At the core clock measured in Chapter 37 (about 3.3 GHz, not the nominal 2.1), that is about 27 cycles. (I first guessed 17 cycles from the nominal clock, which was wrong. The 8.3 ns is the measured part.)

Now the branch-free version. It is 0.52 ns per item for both random and sorted. The data pattern no longer matters, because there is no branch to predict. It does extra work (a write for every item), but that work is cheap compared with a misprediction.

Note the last two lines against the second line: sorted-branchy (0.571) is nearly the same as branchless (0.519). When the branch is predictable, the branch costs almost nothing. A branch is not slow. A mispredicted branch is slow.

Experiment 2: how many chains does the CPU want?

We sum 1,024 f64 numbers (8 KiB, which lives in L1, so memory is not the limit) with 1, 2, 4, 8, and 16 separate running totals.

fn sum_n<const K: usize>(m: &[f64]) -> f64 {
    let mut acc = [0.0f64; K];
    for ch in m.chunks_exact(K) {
        for i in 0..K { acc[i] += ch[i]; }
    }
    acc.iter().sum()
}
sum of 1024 f64 in L1: ns per element
plain loop (1 chain) :  0.653
1  accumulator       :  0.635
2  accumulators      :  0.293
4  accumulators      :  0.161
8  accumulators      :  0.116
16 accumulators      :  0.092

Things to see:

  • The plain loop and the 1-accumulator version are the same: a single dependency chain. The compiler is not allowed to reorder floating-point additions, because the order can change the last digits of the answer.
  • 2, 4, 8 accumulators: each step roughly halves the time at first (0.635 to 0.293 to 0.161), then returns shrink (0.116, then 0.092). By 16, we are 6.9 times faster than the plain loop, with the same additions.
  • The returns shrink because we run out of something else: execution units, or the rate at which the CPU can issue instructions, or the loads. You cannot get more than the hardware's throughput.

Two honest notes. First, the 1-chain cost of 0.635 ns is the latency of one addition. Chapter 37 measures the latency: about 2.25 cycles for a dependent addsd at a measured clock of about 3.3 GHz, so 0.635 ns is close to one add latency. On many older chips the effect is bigger, because the add latency is longer. Second, at 8 and 16 accumulators, the compiler may have turned the array of totals into SIMD instructions (one instruction that adds several numbers at once; Chapter 28), which would explain part of the speed-up beyond pure overlap. I did not read the machine code to check.

Why it is this way (the "why")

Why does the CPU predict and run ahead at all? Because there is a gap, again, between what is possible and what is certain. A CPU could wait to be sure. But waiting wastes the hardware, and a long pipeline with many units would sit idle most of the time. So designers made a bet: guess, run ahead, and clean up if wrong. The bet pays off enough of the time that all high-performance CPUs do it.

This is the same philosophy as the cache (Chapter 8): hardware bets on a pattern. Caches bet on locality. Prefetchers bet on strides. Branch predictors bet on history. MLP bets on independence. In every case, your program does well if it fits the bet, and badly if it breaks it. This is a good way to remember what all of Part III is about: write code the hardware can predict.

Practical consequences:

  1. Break dependency chains when you have independent work. Split a sum, a hash, a checksum, or a decoder into several interleaved streams.
  2. Make branches predictable. Sort or group the data so that decisions come in runs. Move rare cases out of the hot loop.
  3. Consider branch-free code when the branch is unpredictable and the work in both arms is cheap. Do not use it when the branch is predictable: you do extra work for nothing. Measure both.
  4. Look for the limit. Is the loop limited by latency (a chain), by throughput (units), or by branches? Different problems, different fixes.

What would make this wrong? Branch predictors on modern chips are very good, and can learn surprisingly long patterns. Your "random" data might have hidden structure that the predictor finds. And a compiler may turn your branch into a conditional move or SIMD on its own, which removes the problem (our test used push on purpose to stop it). The takeaway is not "always avoid branches". It is "know when the branch is a coin flip."

This is directly relevant for the systems you build. An interpreter's dispatch loop (a match on the opcode) is one big, hard-to-predict branch. A parser's "what kind of token is next" is a data-dependent branch. A compiler's passes over an AST are full of matches on node kinds. Part VII returns to all three.

Common mistakes

  • Treating every if as slow. Only the unpredictable ones are.
  • Making code branch-free by default. If the branch is predictable, you pay for nothing.
  • Leaving a long dependency chain (a running sum, a hash, a state machine) when the work could be split.
  • Splitting a chain without checking the result. Floating-point splits change the answer slightly. Know if that matters.
  • Testing a branch on sorted or patterned data and believing it is cheap on real data.

Say it simply

A CPU is an assembly line that runs many instructions at once, out of order, and guesses which way each branch will go so it can keep going. A chain where each step needs the last one runs at the speed of one step's latency, while independent chains run side by side: splitting one sum into 16 made it 7 times faster. A wrongly guessed branch throws away work and costs about 8 ns here, so the same loop was 8 times slower on random data than on sorted data. Write code the hardware can predict.

Check yourself

Exercise 13.1 (Warm-up): Chain or not? For each, say whether the loop is one dependency chain, and why: (a) s += a[i] for floats, (b) s += a[i] for integers (think about what the compiler may do), (c) x = x * 3 + 1 repeated, (d) out[i] = a[i] * 2.

Exercise 13.2 (Warm-up): The cost of a miss. A loop has 1 branch per item. It is mispredicted 10% of the time, and a misprediction costs 8 ns. What is the extra time per item? How does this compare with a loop body of 1 ns?

Exercise 13.3 (Core): Reproduce the branch test. Run both experiments. Then vary the share of items that pass the test: make 1%, 10%, 50%, 90%, and 99% of the items pass, in random order. Plot the branchy time against the share. Where is it worst? Why is it not worst at exactly 50%... or is it? Done when: a table and a plot, plus 4 sentences on the shape.

Exercise 13.4 (Core): Find your chain limit. Run the accumulator test for 1, 2, 4, 8, 16, 32 accumulators. Where does the improvement stop? Now do the same with integer addition. Is there a difference? Look at the machine code (--emit asm) for the 16-accumulator float version: is it SIMD?

Exercise 13.5 (Core): Predictable in disguise. Make the "random" data repeat a pattern of length 8 (for example 3 big, 5 small, over and over). Does the branchy loop get fast? What about a pattern of length 1000? What does this tell you about the predictor's memory?

Exercise 13.6 (Stretch): Branchless binary search. Write a binary search with a branch inside the loop, and a version that uses arithmetic instead (move the index by step * (a[mid] < key) as usize). Compare on random keys in a big array. Which wins? Does the answer change when the array fits in L1?

Exercise 13.7 (Stretch): Design and defend. A tokenizer reads text and, for each character, decides if it is a letter, a digit, whitespace, or punctuation. A teammate wants to replace the if/else chain with a 256-entry lookup table. Argue for or against. What would you measure?

What a strong answer includes: the unpredictability of the character classes in real text (not random, but irregular), the size of the table (256 bytes fits in one handful of lines), the data dependency on the table load, and testing on real text files, not random bytes.


Part III wrap-up

What you can now do:

  • Measure the latency and bandwidth of each rung of the memory ladder on your own machine, and compare with what the machine reports.
  • Explain cache lines, sets and ways, and the write path, and use them to predict why a loop is slow.
  • Explain virtual memory, the TLB, and huge pages, and show that pages touched matter as much as lines.
  • Choose between AoS, SoA, and a hot/cold split for a loop, and know what Box does to locality.
  • Explain why prefetching and independent loads hide latency, and why a dependent chain does not.
  • Explain pipelines, dependency chains, and branch prediction, and write code that suits them.

Mini project: The hardware profile of my machine (4 to 6 hours). Build one small Rust program (or a few) that produces a one-page report about your machine:

  1. A latency staircase from pointer chasing, with each rung's size and latency.
  2. A bandwidth staircase from a sequential sum.
  3. An estimate of the cache line size, from a stride test.
  4. A TLB test (packed vs. spread) that shows where the first-level TLB runs out.
  5. The cost of one branch misprediction, from the sorted-vs-random test.
  6. The number of independent chains the CPU needs to reach full throughput on additions.

Compare every number with lscpu and the manual for your CPU. Write down where they disagree, and your best guess why.

Done when: a one-page report with a table of 6 results, each with the program that made it, and a short list of "what this report does not show".

Write-up prompt: Half a page. Which measurement surprised you most, and what would you change in your next program because of it?

Teach it back: In 5 sentences, explain to a friend why walking a matrix by columns can be 16 times slower than by rows, even though the number of additions is the same.

Reading list for Part III:

  • Ulrich Drepper, What Every Programmer Should Know About Memory (2007). Long, and the clearest deep explanation of caches, cache lines, sets and ways, and the write path. Read the CPU caches and the virtual memory sections first.
  • John L. Hennessy and David A. Patterson, Computer Architecture: A Quantitative Approach. The chapters on the memory hierarchy and instruction-level parallelism explain the theory of locality, pipelines, and branch prediction.
  • Agner Fog, Optimizing software in C++ and his instruction tables and microarchitecture manuals. Free, and the best place to find the real latency and throughput of instructions for specific CPUs.
  • Your CPU maker's optimization manual (for example, Intel's 64 and IA-32 Architectures Optimization Reference Manual). The exact cache sizes, TLB sizes, and latencies for your chip live here. Check your chip, not this chapter.

Verification notes for Part III: All programs were compiled with rustc 1.75 (-C opt-level=3) and run on the 1-core Xeon VM; every table is real output (the Chapter 8 latency table and the Chapter 11 AoS/SoA tables are from the earlier sample chapter, same machine and settings). Page size (4096) and the transparent huge page setting (always [madvise] never) were read from the machine. Not confirmed, and marked in the text: why the neighbor-line effect appears in the stride test, the L1 associativity (my reading of the data), the TLB sizes (read from the shape of the data, not from the manual), the cause of the dependent-chase gap in Chapter 8 beyond what the TLB test suggests, why the software prefetch did not help, how much of the 16.2x matrix gap comes from locality versus set conflicts versus pages, whether the 8 and 16 accumulator loops used SIMD, and the actual clock speed of the VM. The reading-list items come from my own knowledge and were not freshly checked in this session. Not run: huge-page experiment, write-path experiment, two-thread L3 experiment (each is an exercise).


Part IV. Profiling: finding where the time goes

After this Part, you will be able to take a slow program you have never seen, and, in an afternoon, tell your team where the time goes, how you know, and how sure you are.

Part III told you what the machine likes. But a big program has thousands of loops. You cannot rewrite them all. You need to find the few that matter. That job is called profiling: a profile is a record of where a program spends its time (or its memory, or its cache misses), broken down by function or by line.

In this Part we use one running example, from start to finish, with five different tools. We will see where the tools agree, and, more important, where they disagree and why.

Honest note about the tools in this sandbox. I ran every tool in this Part on the 1-core cloud VM. One thing did not work: the hardware performance counters (the CPU's built-in event counters for cycles, instructions, cache misses). The VM does not pass them through, so perf stat printed <not supported> for them. Software-based tools all worked. I will tell you at each place what was run and what was not.


Chapter 14. Choose the evidence before the tool

In this chapter: You will learn the six kinds of evidence a profiler can give, which question each one answers, and you will meet the program we profile for the rest of the Part. You will also write down a guess before measuring, so that you can be surprised.

You need first: Parts I to III.

The problem first

A new engineer gets a ticket: "The word counter is slow. Make it faster." They open the code. The slow part is obviously the hash map. Everyone knows hash maps are slow. They spend two days replacing it with a faster one.

The program gets 3% faster.

What went wrong? They picked a tool (their eyes) and a theory ("hash maps are slow") before they had any evidence. The map was about a quarter of the time. The big cost was somewhere they never looked.

The model (the "what")

A profiler is any tool that tells you where a program spends something. But "where" can mean very different things, and each kind of evidence has strengths and blind spots. Here are the six kinds we use in this guidebook:

Kind of evidenceHow it worksGood atBlind to
Wall-clock timing (Part I, II)Start a clock, run, stop the clockThe true total, and "did it get faster?"Where the time goes
Sampling (perf record, samply)Interrupt the program many times per second and note what it is doingFinding hot spots with no code changes; low costVery short functions; exact call counts; why it is slow
Instrumentation (hotpath, your own Instant calls)Add code that records start and end of regionsExact counts and times for regions you choseAnything you did not mark; adds its own cost
Event counting (perf stat)The CPU or kernel counts events (page faults, cache misses, branch misses)The reason behind slowness: the count of eventsWhere in the code the events happen (by itself)
Simulation (Cachegrind, Callgrind)Run the program in a software model of a CPU and count everythingExact, repeatable counts of instructions and simulated cache missesReal timing; effects the model leaves out (such as the prefetcher and page tables); runs about 50 times slower
Allocation tracking (hotpath-alloc, DHAT in Part V)Intercept every malloc and freeWho allocates how much, and how oftenTime spent doing anything else

No single kind of evidence is enough. A good profile report uses at least two, and checks that they agree.

A word about sampling, because we use it the most. A sampling profiler interrupts the program at a fixed rate, say 1,999 times per second, looks at what the program is doing right then (which function is running, and the chain of callers above it, called the stack), and writes it down. After 1,800 samples, it counts: "this function appeared in 60% of them." The idea is a poll: you do not watch every second of the program, you take a random sample of moments, and the share of the samples is an estimate of the share of the time.

Two kinds of share, and you must keep them straight:

  • Self time of a function: the share of samples where that function's own code was running (not its callees).
  • Inclusive time (also called total or cumulative): the share of samples where that function was anywhere on the stack. This includes everything it called.

A function with a big inclusive time and a small self time is a manager: it does little itself, but it calls the things that take time. A function with a big self time is a worker: the cost is in its own code.

Build it (the "how")

The running example: a word counter

Here is the program. It makes 5 million words of fake text (so we can run it anywhere, with the same result every time), splits it into lowercase words, counts each word in a hash map, and prints the 5 most common ones. It is a stand-in for many real programs: a tokenizer, a map, and a sort.

use std::collections::HashMap;

// Make a deterministic pseudo-text: words from a fixed vocabulary, mixed case, with punctuation.
fn make_text(words: usize) -> String {
    let vocab = [
        "The", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "Rust", "compiler",
        "parser", "interpreter", "cache", "memory", "profile", "flame", "graph", "thread",
        "Vector", "array", "Struct", "enum", "trait", "borrow", "lifetime", "Iterator",
    ];
    let mut s = 0x2545F4914F6CDD1Du64;
    let mut out = String::with_capacity(words * 8);
    for i in 0..words {
        s ^= s << 13; s ^= s >> 7; s ^= s << 17;
        let w = vocab[(s % vocab.len() as u64) as usize];
        out.push_str(w);
        out.push(if i % 11 == 10 { '.' } else { ' ' });
        if i % 11 == 10 { out.push(' '); }
    }
    out
}

fn tokenize(text: &str) -> Vec<String> {
    let mut tokens = Vec::new();
    for raw in text.split_whitespace() {
        let cleaned: String = raw.chars().filter(|c| c.is_alphanumeric()).collect();
        if !cleaned.is_empty() {
            tokens.push(cleaned.to_lowercase());
        }
    }
    tokens
}

fn count(tokens: &[String]) -> HashMap<String, usize> {
    let mut m: HashMap<String, usize> = HashMap::new();
    for t in tokens {
        *m.entry(t.clone()).or_insert(0) += 1;
    }
    m
}

fn top(m: &HashMap<String, usize>, k: usize) -> Vec<(String, usize)> {
    let mut v: Vec<(String, usize)> = m.iter().map(|(a, b)| (a.clone(), *b)).collect();
    v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
    v.truncate(k);
    v
}

fn main() {
    let words: usize = std::env::args().nth(1).and_then(|a| a.parse().ok()).unwrap_or(5_000_000);
    let text = make_text(words);
    let tokens = tokenize(&text);
    let counts = count(&tokens);
    for (w, c) in top(&counts, 5) {
        println!("{:<12} {}", w, c);
    }
}

We build it with debug information turned on, so profilers can name functions and lines. This does not slow the program: debug info is just extra data in the file, kept apart from the machine code.

# Cargo.toml
[profile.release]
debug = true          # keep symbols and line info so profilers can name functions
cargo build --release

Step 1: write your guess down first

Before measuring, here is a guess. Write one of your own too.

My hypothesis: the hash map in count takes most of the time, because hashing a string and comparing strings is slow.

Step 2: wall-clock timing

Run three times, using the shell's time (this is evidence of the first kind):

1.098 s wall, 0.674 user, 0.160 sys
0.812 s wall, 0.687 user, 0.124 sys
0.819 s wall, 0.722 user, 0.096 sys

Remember Chapter 2. The first run was slower (1.098 s) because the machine was not warm. The next two agree at about 0.82 s. And there is something new: look at sys. user is time spent running our code, and sys is time spent in the operating-system kernel on our behalf (for example, handling page faults, as we will see). About 0.1 s of 0.8 s is not in our code at all.

So we now have a baseline: the 5-million-word run takes about 0.82 s. That is 164 ns per word. Whatever we find, we will compare with this.

But we know nothing about where. The rest of this Part is about that.

Why it is this way (the "why")

Why not just read the code and guess? Because intuition about performance is bad, and it gets worse as programs get complicated. The reasons are the same as Part III: modern machines do things (caching, prefetching, speculating, overlapping) that are invisible in the source code. Two lines that look equal in the code can differ by 100x at runtime. Code that looks very slow (a loop in a loop) can be fast, and code that looks innocent (a .to_lowercase()) can hide an allocation.

Profiling is a way of replacing opinions with evidence. And "evidence" has a precise meaning here: a number that could have turned out differently. If you write down a guess and the profile can prove it wrong, the profile is evidence. If you only look at the profile after deciding what you will see, you will see what you decided.

There is one more idea: a profile tells you where, and almost never why. "60% of the time is in tokenize" is a where. "Because each word makes three heap allocations" is a why. Getting from one to the other needs a second kind of evidence (counts, allocation tracking, a cache simulator) and some thinking. The structure of the next chapters follows that path: find where (sampling), confirm it (instrumentation), find why (counting and simulation).

What would make this approach wrong? Sampling profiles of short programs are noisy. Programs whose time is spent waiting (on the network, on locks, on the disk) look different under a CPU-time profiler than under a wall-clock profiler. And profiling changes the program a little (the observer effect). Always ask: did I change what I was measuring when I measured it?

Common mistakes

  • Optimizing before profiling. The most common mistake of all.
  • Profiling a debug build. The hot spots in a debug build are not the hot spots in the release build.
  • Profiling without symbols. You get addresses, not names.
  • Trusting one tool. Cross-check with a second kind of evidence.
  • Mixing up self time and inclusive time.
  • Profiling a program that is too short. Fewer than a few hundred samples means a noisy answer.

Say it simply

A profile says where a program spends its time. There are six kinds of evidence (timing, sampling, instrumentation, event counts, simulation, allocation tracking), each good at one question and blind to others. Sampling works like a poll: it asks "what are you doing right now?" many times a second, and the share of answers estimates the share of time. Self time is a function's own work. Inclusive time includes what it calls. Write a guess down first, so the profile can surprise you.

Check yourself

Exercise 14.1 (Warm-up): Pick the evidence. For each question, name the best kind of evidence and why: (a) "Which function takes the most time?" (b) "How many times is parse_token called?" (c) "Does my change make the whole program faster?" (d) "How many bytes does this function allocate?" (e) "Why is this loop slow: is it cache misses?"

Exercise 14.2 (Warm-up): Self or inclusive? A function run calls a and b and does almost nothing else. a takes 70% of the time and b takes 30%. What are the self and inclusive times of run, a, and b?

Exercise 14.3 (Core): Make your own guess. Type in the word counter, build it with debug info, and time it three times. Before profiling, write down (a) your guess for the share of time in make_text, tokenize, count, and top, and (b) the single line you think is the most expensive. Keep this page for the next chapters. Done when: a page with your timings, your guesses, and a number for the baseline.

Exercise 14.4 (Core): Debug versus release. Time the program in a debug build (cargo build, no --release). How many times slower is it? Does it still look like the same program, in terms of what you would guess is slow? (Do not profile it yet. Just time it.)

Exercise 14.5 (Stretch): Design and defend. Your team lead says: "Our service is slow. Let's profile it in production for a week and then decide." Write half a page: what would you ask for first, what could go wrong with profiling production, and what is the smallest first step you would take?

What a strong answer includes: a clear performance question and a target, the observer effect and overhead, the choice between sampling and instrumentation, the risk of private data in profiles, and a cheap first measurement (a baseline) before any large profiling.


Chapter 15. perf stat and perf record

In this chapter: You will learn the two most important commands of the Linux profiler perf: one that counts events and one that samples. You will see what happens when counters are missing, and learn how to estimate how much to trust a sampling profile.

You need first: Chapter 14.

The problem first

The Linux kernel comes with a profiler that can see more than any tool you can write yourself: every function in your program, in the C library, and inside the kernel itself. It is called perf. It looks hard at first, because it has dozens of sub-commands. But 90% of the time you need only two.

The model (the "what")

perf stat counts. You give it a program. It runs the program, and at the end it prints totals: how many CPU-seconds, how many page faults, how many context switches, and (on hardware that supports it) cycles, instructions, branch misses, and cache misses. It does not tell you where. It tells you how much.

perf record samples. It runs the program, interrupts it at a fixed rate, and saves what it saw into a file (perf.data). Then perf report reads that file and prints the results. This tells you where.

Two kinds of events can drive these tools:

  • Software events, counted by the kernel: task-clock (CPU time used), page-faults, context-switches, cpu-migrations. These work everywhere.
  • Hardware events, counted by the CPU's own performance monitoring unit (PMU): cycles, instructions, branch-misses, cache-misses, and many more. These are the ones that tell you why a loop is slow. But they need the CPU to expose its counters to your program. On a bare-metal Linux machine, they usually work. In many virtual machines, they do not.

Some terms for what a sampling profile contains:

  • A sample is one snapshot: the instruction the CPU was running, and (if you ask for it) the whole stack of callers.
  • Frame pointers are a convention where each function keeps a link to its caller, so that a profiler can walk the stack quickly. Rust usually leaves them out of release builds, for speed. To get good stacks cheaply, build with -C force-frame-pointers=yes. (The other way is to ask perf to read the stack with DWARF debug information, which works without frame pointers but is slower and makes much bigger files.)
  • Skid is a small error in where a hardware-triggered sample lands. The CPU takes a moment to react, so the sample may blame the instruction after the real one.

Permissions

perf uses a kernel setting, perf_event_paranoid. At 2 or higher, ordinary users can only profile their own programs in user space. At 1 they can see kernel code too, and at 0 or -1 even more. As root (as I was in this sandbox) most things work. On your own machine you may need sudo sysctl kernel.perf_event_paranoid=1.

Build it (the "how")

perf stat: the totals

perf stat -e task-clock,page-faults,context-switches,cpu-migrations ./target/release/wf
 Performance counter stats for './target/release/wf':

            825.37 msec task-clock                       #    0.996 CPUs utilized
             76485      page-faults                      #   92.668 K/sec
                34      context-switches                 #   41.194 /sec
                 0      cpu-migrations                   #    0.000 /sec

       0.828369888 seconds time elapsed

       0.714114000 seconds user
       0.116346000 seconds sys

Read it:

  • 825 ms of task-clock matches the 0.82 s wall time from Chapter 14, and "0.996 CPUs utilized" says the program used one core almost 100% of the time. It was never waiting for anything. It was computing (or at least, the CPU was busy for it).
  • 34 context-switches and 0 migrations: the program was almost never interrupted or moved. Good, the machine was quiet.
  • 76,485 page faults. This is the surprise. A page fault happens when your program touches a page of memory (Chapter 10) that the kernel has not yet given it. The kernel must stop, find a free page, zero it, and map it. Each costs on the order of microseconds. We took 76,485 of them. At 4 KiB per page, that is about 300 MiB of memory touched for the first time. The program asks the kernel for about 300 MiB, for a job whose answer is five lines. And that matches the sys time of 0.116 s, which is about 14% of the run. We now have a why clue for free: this program uses a lot of memory.

And now the part that did not work. I also asked for hardware counters:

perf stat -e task-clock,cycles,instructions true
   <not supported>      cycles
   <not supported>      instructions

This VM does not expose the PMU. On a real machine, you would also see lines like "2.31 insn per cycle" (IPC, instructions per cycle: how much of the CPU's width the program uses) and a branch-miss rate. These are exactly the numbers that would tell us whether the loops are stalled on memory or on branches. I cannot show them here. I will say what I would expect on real hardware, and I will mark it as a guess. (Chapter 19 explains how to use these counters, and Chapter 20 shows a software substitute.)

perf record: where is the time?

RUSTFLAGS="-C force-frame-pointers=yes" cargo build --release
perf record -e cpu-clock -F 1999 --call-graph fp -o wf.data ./target/release/wf
perf report -i wf.data --no-children --stdio -g none

The flags:

  • -e cpu-clock: sample by a software timer, because hardware cycles is not available here. (On real hardware, the default event, cycles, is usually better: samples then follow CPU work and not wall time.)
  • -F 1999: 1,999 samples per second. I chose an odd number on purpose, so that we never line up exactly with a periodic thing in the program that happens every millisecond.
  • --call-graph fp: record the stack by following frame pointers.
  • --no-children: show self time (without it, perf report shows inclusive time in an extra column).

Result: 1,804 samples. The top of the table, by self time:

    11.81%  wf  wf                 [.] wf::tokenize
     8.81%  wf  libc.so.6          [.] malloc
     6.76%  wf  wf                 [.] alloc::str::<impl str>::to_lowercase
     6.49%  wf  libc.so.6          [.] _int_free
     6.43%  wf  wf                 [.] <String as FromIterator<char>>::from_iter
     5.88%  wf  libc.so.6          [.] _int_malloc
     5.76%  wf  wf                 [.] hashbrown::rustc_entry
     5.21%  wf  wf                 [.] core::hash::BuildHasher::hash_one
     4.27%  wf  [kernel.kallsyms]  [k] do_user_addr_fault

(I shortened a few long names.) The [.] marks code in user space, and [k] marks code in the kernel. Look at what is on this list:

  • malloc, _int_malloc, _int_free: the memory allocator. Together with cfree (3.4% in an earlier profile of the same program), this is a big block. Our hash-map guess did not show up as the leader.
  • do_user_addr_fault: the kernel handling page faults. That is the 76,485 faults from perf stat, seen again from the other side. Two tools agree.
  • The string code (to_lowercase, from_iter) and the hash code (hash_one, rustc_entry) are each about 5% to 7%.

No single function owns the profile. The time is spread over many small pieces, which is common. It also means you cannot find the answer by looking for "the hot function". You need to add things up, by phase and by theme. We do that with flamegraphs in the next chapter.

A trap: inlining hides functions

My very first perf record, before I added #[inline(never)] to the four phase functions, showed this as the top line:

    16.30%  wf  wf   [.] wf::main

wf::main is the function that does almost nothing. It calls the other four. But the compiler had inlined them: it copied their code into main, so the profiler, which only sees addresses and the symbols attached to them, said "this is main". Nearly 16% of the time appeared to be in a function that does nothing, and the names of make_text, tokenize, and count vanished from the profile.

I fixed that by adding #[inline(never)] to the four phase functions, as we did in the final version of the program. That changes the program a little: a function call that was free is now a real call. For functions that run once and take hundreds of milliseconds, the change is far too small to matter. It would be a bad idea for a tiny function in a hot loop.

The lesson: a profile can only name what exists in the machine code. If your code seems to vanish, it was probably inlined. Debug info records the original line numbers, so line-level views (perf annotate) can still see through the inlining.

Why it is this way (the "why")

Why do sampling profilers work at all, given that they only look at the program a couple of thousand times a second? Because of statistics. If you look at a random moment of the program's life and see it inside tokenize, then the chance of that happening is exactly the share of time spent in tokenize. Repeat 1,800 times, and the share of your samples estimates the share of time. This is the same idea as a poll before an election.

And just like a poll, there is a margin of error. If the true share is p and you take n samples, the standard error of your estimate is about

standard error = sqrt( p × (1 − p) / n )

For our run, p is about 0.6 (as the next chapter will show for tokenize) and n is 1,804. That gives sqrt(0.6 × 0.4 / 1804) = about 0.0115, or 1.2 percentage points. A rough 95% range is about twice that: plus or minus 2.3 points. So "tokenize takes 60.6% of the time" really means "between about 58% and 63%". For a function that shows 1% of the samples (18 samples), the error is about 0.23 points, which is nearly a quarter of its own size: a small function's share is far less certain than a large one's. (This formula assumes the samples are independent random draws. Real samples are not perfectly random, so treat it as a rough guide.)

This gives two practical rules:

  1. To see small things, take more samples: run longer, or sample faster. The error shrinks with the square root of n: four times as many samples halve the error.
  2. Do not read meaning into differences smaller than the margin. A function at 5.2% and another at 5.8% are, in this profile, the same.

There is a deeper idea too. A sampling profiler trades detail for low overhead. It never touches your code. It adds little to the run time (I did not measure the overhead for this run). In exchange, it cannot give you exact counts. It can tell you where the time goes, not how many times a function was called. This is the right trade for "where?", the wrong one for "how many?". That is why we keep several tools.

What would make a sampling profile wrong? Bias. If something in your program happens at the same rhythm as the sampling timer, you can be fooled. Skid blames the wrong instruction. And a timer-based sample (cpu-clock) may be delivered only when the kernel is able to interrupt, which can slightly shift samples away from the exact moment. Hardware-event sampling and tools with more careful machinery reduce this but never remove it.

Common mistakes

  • Not building with debug info and frame pointers. You get flat, nameless results or empty stacks.
  • Reading perf report without --no-children and not knowing which column is which. Know whether you are reading self or inclusive time.
  • Believing tiny differences. Compute the margin.
  • Forgetting the kernel. Page faults, system calls, and scheduling show up as [k] entries.
  • Assuming hardware counters exist. Check with perf stat -e cycles true.
  • Profiling a run that is too short. A 10 ms run gives only 20 samples.

Say it simply

perf stat counts events for a whole run. perf record samples the program about 2,000 times a second and perf report shows where the samples fell. Our word counter showed about 300 MiB of page faults, a lot of time in the allocator, and no single dominant function. A sampling profile is a poll, so its error is about the square root of p(1−p)/n: with 1,800 samples a 60% share is good to about 1 point, but a 1% share is not. Inlined functions vanish from profiles unless you stop the inlining.

Check yourself

Exercise 15.1 (Warm-up): Margin of error. A profile has 400 samples, and function F has 40 of them (10%). What is the standard error, and a rough 95% range? How many samples would you need to get the range down to plus or minus 1 point?

Exercise 15.2 (Warm-up): Read the stats. A program shows task-clock of 2,000 ms, "0.35 CPUs utilized", and 2,300 context switches. What does it suggest about where the time goes? What would you check next?

Exercise 15.3 (Core): Profile it yourself. Build the word counter with frame pointers, record it at 999 Hz and 1999 Hz, and compare the top five self-time entries. How much do the percentages move between the two runs? Is that about what the margin of error says? Done when: a table of both runs and a note on which differences are bigger than the margin.

Exercise 15.4 (Core): Find the page faults. In your profile, find the [k] entries. What fraction of samples is in the kernel? Use perf stat to get the page-fault count, then multiply by 4 KiB. Does it match the peak memory use you see in /usr/bin/time -v ("Maximum resident set size")?

Exercise 15.5 (Core): Watch inlining. Remove #[inline(never)] from the four functions, build, and profile again. Which names vanish? Now use perf annotate on wf::main to find the lines that belong to the inlined functions.

Exercise 15.6 (Stretch): Compare the sampling event. If you have a machine with working hardware counters, record with -e cycles and with -e cpu-clock. Do the profiles differ? Where, and why?

Exercise 15.7 (Stretch): Design and defend. You must profile a program that runs for 50 ms. List three ways to get a meaningful profile, and what each one costs.

What a strong answer includes: run it in a loop many times, raise the sampling rate, use a simulator or instrumentation for exact counts, and the cost of each (cache warming effects, higher overhead, slower runs).


Chapter 16. Flamegraphs, from folded stacks to a picture

In this chapter: You will learn how a flamegraph is made, step by step, from the raw samples to a picture, by building one from real data. You will learn to read the numbers behind the picture. And you will see how to add up shares by theme ("everything that allocates"), which no single-function table can show you.

You need first: Chapter 15.

The problem first

A table of self-time, like the one in Chapter 15, answers: "which functions' own code takes the most time?" But our biggest question is different: "how much time is spent in tokenize including everything it calls?" And: "how much time is spent in the allocator, no matter who called it?" The table cannot answer, because the allocator's time is split across many callers.

We need to keep the stacks, not just the top function. A flamegraph is a way to see all stacks at once.

The model (the "what")

A flamegraph is a picture of all the sampled stacks, merged. Brendan Gregg invented it (reading list). Here is how it works.

  1. Take all the stack samples. A stack looks like main → tokenize → to_lowercase → malloc.

  2. Fold them: write each distinct stack as one line, with the count of how many samples had exactly that stack. This is called the folded stack format:

    main;tokenize;to_lowercase;malloc 50
    main;tokenize;to_lowercase 60
    main;count;hash_one 43

    Functions are separated by ;, from the outermost (left) to the innermost (right). The number is the sample count for exactly that stack.

  3. Draw: each function becomes a box. A box's width is the number of samples that had it on the stack (inclusive time). A box sits on top of its caller. The x-axis is not time: boxes are sorted alphabetically, so that identical stacks merge. The y-axis is stack depth.

 width = samples where this function was on the stack
 ┌──────────────────────────────────────────────────────────────┐
 │                           main                               │   ← depth 0
 ├────────────────────────────────────┬──────────────┬──────────┤
 │             tokenize               │    count     │  top     │   ← depth 1
 ├──────────────────────┬─────────────┼──────┬───────┴──────────┤
 │      to_lowercase    │  from_iter  │ hash │   entry          │   ← depth 2
 ├──────────┬───────────┴─────────────┘      │                  │
 │  malloc  │ ...                            │                  │   ← depth 3
 └──────────┘                                └──────────────────┘

How to read it: look for wide boxes, especially wide boxes at the top (these are functions doing work themselves, without handing it off). A wide box low in the picture is a manager (main). A wide box near the top is a worker. The picture answers "who is expensive?" and "who called it?" at a glance.

What the colors mean: usually nothing. They are random shades of warm colors, to make neighbors easy to tell apart.

Build it (the "how")

The tools: perf script writes the samples as text. A Rust tool called inferno (cargo install inferno) turns them into folded stacks and into a picture. The pipeline is:

perf script -i wf.data | inferno-collapse-perf > wf.folded
inferno-flamegraph < wf.folded > wf-flamegraph.svg

This produced a file of 140 distinct folded stacks, with a total weight of 0.902 s (the weights in this file are in nanoseconds, one sample is about 0.5 ms at 1,999 Hz; the sum, 0.90 s, is a little above the 0.82 s unprofiled run, and I did not check why). The picture, as an SVG file, is attached to this guidebook (wf-flamegraph.svg). Open it in a browser. You can click a box to zoom in. But the text version, the folded file, holds the same facts, and it is the thing you can compute with. Here are the heaviest folded stacks, with the runtime prefix (_start, main, lang_start, ...) removed, and the weight turned into milliseconds:

  106.6 ms  wf::tokenize
   60.0 ms  wf::tokenize;alloc::str::<impl str>::to_lowercase
   55.0 ms  wf::tokenize;<String as FromIterator<char>>::from_iter
   50.0 ms  wf::tokenize;to_lowercase;malloc;_int_malloc
   49.0 ms  wf::count;hashbrown::rustc_entry
   43.0 ms  wf::count;hashbrown::rustc_entry;core::hash::BuildHasher::hash_one
   32.5 ms  wf::tokenize;to_lowercase;malloc
   31.5 ms  wf::tokenize;from_iter;RawVec::reserve::do_reserve_and_handle;finish_grow ...

Each line is one exact stack. For example, the first line says: "106.6 ms were spent with tokenize as the innermost function": that is tokenize's own work (self time). The fourth says that 50 ms were spent in _int_malloc, called by malloc, called by to_lowercase, called by tokenize.

Now we add up, using plain text tools on the folded file. This is where the folded format shines. The inclusive time of a function is the sum of every line whose stack contains it.

awk '/wf::tokenize/ {s+=$NF} END {print s}' wf.folded     # and so on

Result, as a share of the total weight:

Function (any stack that contains it)Share of the run
wf::make_text3.3%
wf::tokenize60.6%
wf::count24.5%
wf::top0.0%
Stacks that contain no phase functionabout 11.6%
Any stack containing malloc/free code32.5%
Any stack containing page-fault code14.3%

Read this slowly, because it is the heart of the profile:

  • My hypothesis was wrong. I guessed that the hash map (count) was the main cost. It is 24.5%, a quarter. The real leader is tokenize at 60.6%, which is just splitting text and lowercasing it. Anyone who spent two days replacing the hash map would have found a 3% speed-up in the best case. (By Amdahl's law from Chapter 1, even a map that took zero time would give at most 1 / (1 − 0.245) = 1.32x.)
  • A theme cuts across the functions. One third (32.5%) of the whole run has the allocator on the stack. That is malloc and free, called from tokenize, from count, and from elsewhere. In a function-by-function table, no single entry would show it. Only the folded stacks let us add it up.
  • Page faults are 14.3%. The kernel's work to give the program fresh memory. It is another sign that the program uses far more memory than it needs.
  • About 11.6% is in stacks that go through none of the four phase functions. I did not break that down. Likely candidates are the cleanup at the end of main (freeing the 5 million tokens) and the program's startup. That is a hole in my profile, and it is the kind of thing I would chase next if I needed to.

The margin of error from Chapter 15 applies: with 1,804 samples, the 60.6% for tokenize is good to about plus or minus 2.3 points.

Reading the picture, in words

If you open the SVG, you will see this shape. A wide base (main). Above it, three boxes: a very wide one for tokenize (about three fifths of the width), a medium one for count (a quarter), a thin sliver for make_text. On top of tokenize there are towers: to_lowercase with malloc on top of it, from_iter with reserve and finish_grow on top of it. These towers all end in the allocator. The allocator is the "ground floor" of the cost: tokenizing is expensive mostly because of the memory it asks for, not because of text work.

Why it is this way (the "why")

Why does the folded format work so well? Because a tree of costs can be rebuilt from its paths. Every question you might ask about inclusive and self time is a sum over lines of the file. A flamegraph is just a picture of the sums. Once you see this, you do not depend on the picture: you can make your own tables, filter by any word, and compare two runs.

That last idea is powerful. A differential flamegraph takes the folded files of two runs (before and after a change) and colors the boxes by how much they grew or shrank. The tool for it is inferno-diff-folded. The question it answers is not "what is slow?" but "what changed?", which is often the more useful one.

There is also a philosophical point about where to look first. A table of self-time directs your eyes to the leaves of the tree, the functions where the CPU actually was. A flamegraph directs your eyes to the structure: which whole subtree is big. Real fixes usually come from the structure. Making malloc faster is a small win. Calling malloc fewer times is a big one. You can only see that if you see who calls it. This is why many performance engineers start with a flamegraph, even though the numbers could be read from text.

What would make this wrong?

  • The x-axis is not time. Two boxes side by side were not necessarily next to each other in time. A flamegraph hides when things happen. If your program has phases (first parse, then analyze), a flamegraph merges them. Chapter 17 shows a timeline view for that.
  • Missing frames (no frame pointers, or code without debug info) give incomplete stacks and boxes labeled [unknown].
  • Very short, very frequent functions may be so thin you cannot see them, yet add up.
  • Inclusive time can double-count recursion, if a function appears on a stack many times. Most tools handle this, and you should check how yours does.

Common mistakes

  • Reading the x-axis as time.
  • Reading only the widest box and ignoring the structure under it.
  • Comparing flamegraphs with different sample counts without normalizing.
  • Ignoring kernel frames like page faults.
  • Making decisions from a single flamegraph of a short run.
  • Forgetting the margin of error when comparing two boxes of similar width.

Say it simply

A flamegraph merges all the sampled stacks into one picture: a box's width is the share of samples with that function on the stack, and its position is its place in the call chain. The text behind it, the folded stack file, holds one line per distinct stack, and inclusive time is the sum of the lines that contain a function. For our word counter, the map I suspected was 24.5% of the time, tokenizing was 60.6%, and the allocator was on the stack in a third of all samples. Flamegraphs show structure, but they hide time order.

Check yourself

Exercise 16.1 (Warm-up): Fold by hand. These five samples were taken: a;b;c, a;b;c, a;b;d, a;e, a;e. Write the folded lines. What is the inclusive share of b? Of c? What is the self share of a?

Exercise 16.2 (Warm-up): Read the boxes. In a flamegraph, box X is wide and sits directly under a wide box Y, and there is nothing above Y. Which one is doing the work itself?

Exercise 16.3 (Core): Make your own flamegraph. Install inferno, run the pipeline on your own profile of the word counter, and open the SVG. Find the malloc towers. Then compute the inclusive shares from the folded file with awk and compare with the table above. Done when: an SVG, a table of shares, and a sentence for each place your numbers differ from the table.

Exercise 16.4 (Core): Write the folder. Write a small Rust program that reads perf script output (or a simpler text format you invent) and produces folded stacks, then computes inclusive and self time for every function. Check it against inferno-collapse-perf.

Exercise 16.5 (Core): Theme sums. Using the folded file, compute: (a) the share of stacks with hash_one on them; (b) the share with memcmp; (c) the share with memmove or memcpy. Which "theme" is bigger than you expected?

Exercise 16.6 (Stretch): Differential flamegraph. Make a change to tokenize (for example, avoid one allocation), profile again, and use inferno-diff-folded to compare. Which boxes shrank? Did anything grow?

Exercise 16.7 (Stretch): Design and defend. A flamegraph of your server shows a wide epoll_wait box on top, taking 70% of the width. Your colleague says "that's the bottleneck". Explain what is probably going on, what a CPU-time flamegraph cannot show, and what other kind of profile you would take.

What a strong answer includes: waiting versus computing, the idea of off-CPU profiling, and the difference between "time on the stack" and "time the CPU was working".


Chapter 17. Samply and timeline profiling

In this chapter: You will learn what a timeline profile shows that a flamegraph cannot, and how to record one with samply. I will be honest about what I ran in this sandbox (the recording) and what I did not (the graphical viewer).

You need first: Chapters 15 and 16.

The problem first

Chapter 16 ended with a warning: a flamegraph merges time. Our word counter has four clear phases that run one after another: make the text, tokenize, count, report. In a flamegraph, they are four boxes side by side, in alphabetical order, and you cannot tell which came first or how they overlap.

For a one-thread program with simple phases, that is fine. For a program with several threads, a thread pool, a startup phase, a long steady state, or a pause every few seconds, when things happen is the whole story. You need a timeline.

The model (the "what")

Samply is a sampling profiler for Rust (and any native program) that records stacks like perf does, but packs the result into a format that a web viewer can open: the Firefox Profiler. In the viewer you can see:

  • a call tree (like our folded stacks, but clickable),
  • a flame graph,
  • an inverted call tree (start from the functions with the most self time and click down to their callers),
  • and the stack chart / timeline: the time on the x-axis, with a row per thread, and the call stack shown over time.

The timeline view answers "what was thread 3 doing between second 10 and second 12?" That is a different question from "where does the program spend its time overall?"

Samply uses the same kernel facility as perf on Linux, so it has the same requirement: permission to open performance events. It samples at a fixed rate. The default is 1,000 Hz (one sample per millisecond). It also works on macOS, where perf does not exist, which is a big reason many people use it.

Build it (the "how")

Install.

cargo install samply --locked

This was the slowest install of the whole project. Samply 0.13.1 pulls in a web server and many other crates, and on this 1-core VM the build took well over ten minutes (my first attempt was cut off by the sandbox, and the second finished). On a normal laptop it takes a minute or two. I mention it so you are not surprised.

Permission. The first time I ran it, samply refused, because perf_event_paranoid was 2, and said what to do. Even though I was root, I had to lower it:

echo 1 | sudo tee /proc/sys/kernel/perf_event_paranoid

Record. Samply normally records and then opens a browser. In a sandbox with no browser, I used the --save-only option, which writes the profile to a file and stops:

samply record --save-only -o wf.samply.json.gz ./target/release/wf

Samply ran the program, and the program printed its normal output. Then I opened the saved file with a few lines of Python to see what was in it:

interval ms: 1.0 | product: wf
thread 'samply'   samples=    0  span=    0.0 ms
thread 'wf'       samples=  863  span=  867.7 ms

What this tells us:

  • A 1 ms sampling interval, as promised.
  • 863 samples over 867.7 ms: almost exactly one sample per millisecond, with none missing. The program was running the whole time.
  • There are two "threads" in the file. One is wf, our program, with all 863 samples. The other is samply, the profiler's own helper, with none. Our program has one thread, so a timeline would have one row.

What I did not do. I did not open the viewer. This sandbox has no browser, so I cannot tell you what the picture looked like, and I will not describe it as if I had seen it. On your own machine, you would run samply record ./target/release/wf (or samply load wf.samply.json.gz for a saved file), and a browser tab would open on the profile. Exercise 17.3 walks you through what to look for.

What you would see, from what we know. From the flamegraph and the instrumentation, the timeline of a single thread would show four blocks in order: a short make_text block (a few percent), a long tokenize block (about 60%), a count block (about a quarter), and nothing for top. If it does not look like that, the profile and the other tools disagree, and that is a result worth chasing. (This paragraph is a prediction, not an observation.)

Why it is this way (the "why")

Why do we need so many views of the same data? Because a profile is a very large table of samples, and every view is a different way of grouping it. A flame graph groups by call path and throws away time. A timeline groups by time and keeps the path. A table of self time groups by function and throws away the path. Each loses something to make something else easy to see. The skill is knowing which question you are asking and picking the grouping that fits.

Another reason to like samply is ease of use, which is a real performance feature of a tool. The easier a tool is, the more often it gets used. A tool that takes 15 steps to set up gets used only in emergencies. A tool that is one command gets used on Tuesday afternoon, when the problem is small and the fix is cheap. People who profile often build better intuition, which is worth more than any single profile.

There is also a philosophical point in the choice between tools that sample hardware events and tools that sample on a timer. Hardware sampling (events like cycles) follows the work the CPU does. Timer sampling follows wall time. For a CPU-bound single thread, they agree. For a program that sleeps or waits, they differ a lot. "Where does the CPU spend its time?" and "where does the program spend its time?" are two different questions. Know which one you asked.

What would make this view wrong? A timeline made from sampling at 1 ms cannot show anything shorter than about a millisecond. A 200 µs stall that happens 100 times will look like noise, or be invisible. For that, you need tracing (recording every event with a timestamp), which is a different kind of tool with a much higher cost.

Common mistakes

  • Using a flamegraph for a question about time order. Use a timeline.
  • Forgetting that sampling cannot see short events.
  • Leaving out debug info. The viewer then shows addresses instead of names.
  • Profiling with a different build than you run.
  • Not checking permissions first. A permission error costs nothing to fix, once you know about it.

Say it simply

A timeline profile keeps the time order that a flamegraph throws away, which matters when a program has phases or many threads. Samply records like perf but saves a file that the Firefox Profiler can show as a call tree, a flame graph, and a timeline. In this sandbox, samply recorded 863 samples over 868 ms at 1 ms spacing, but I could not open the viewer, so I only show what the file contains. Pick the view that matches the question.

Check yourself

Exercise 17.1 (Warm-up): Which view? For each question, say flame graph, timeline, or table: (a) "What is the single most expensive function?" (b) "Does the program stall every 5 seconds?" (c) "Which thread is idle while the others work?" (d) "Which call path leads to the most allocations?"

Exercise 17.2 (Warm-up): Sampling rate. A stall in a program lasts 300 µs and happens 50 times in a 10 s run. With a 1 ms sampling interval, about how many samples would land inside stalls? Would you see it?

Exercise 17.3 (Core): Open the viewer. Install samply on your machine, record the word counter, and open the profile. Find: (a) the four phases on the timeline, in order, (b) the inverted call tree with malloc at or near the top, (c) the flame graph. Write down one thing you can see in the timeline that you could not see in the flamegraph from Chapter 16.

Exercise 17.4 (Core): A phase that hides. Change main so it runs tokenize and count ten times in a loop, and profile it. How does the timeline look compared with the flamegraph? Do both views agree on the shares?

Exercise 17.5 (Stretch): Two threads. Split the counting into two threads, each handling half the tokens, then merge. Profile with samply. Which thread finishes first? Is there a gap where one thread waits for the other? (You will need this skill in Part VI.)

Exercise 17.6 (Stretch): Design and defend. You want to find out why a game drops a frame once every few seconds. Which tool kinds from Chapter 14 would you use, in which order, and what would each one rule in or out?

What a strong answer includes: a timeline to find when the drop happens, event counters or allocation tracking to look for a periodic cause (garbage collection or a big allocation), instrumentation of the frame loop with exact timestamps, and the point that a sampling rate of 1 ms may miss short spikes.


Chapter 18. Instrumentation without fooling yourself

In this chapter: You will learn when to measure by adding code rather than sampling, how the hotpath crate does it with two attributes, and how to measure allocation per function. You will meet a case where two tools gave different answers for the same function, and learn what to do about it.

You need first: Chapters 14 to 17.

The problem first

Sampling is great for finding hot spots you did not suspect. But it has weak spots: it cannot give exact call counts, and you cannot ask it "how many bytes did this function allocate?" It cannot measure an operation that is not CPU time, such as the time between two events.

When you want exact answers about regions you already know about, you add code that measures them. This is instrumentation.

The model (the "what")

Instrumentation means inserting measurement code into the program, either by hand or with a macro. The simplest version is two clock reads:

let t = std::time::Instant::now();
let tokens = tokenize(&text);
eprintln!("tokenize: {:?}", t.elapsed());

This works, and it is how every one of us starts. Its problems appear when the program grows: you have to edit many places, remove the lines afterwards, and keep the measurements consistent (count, total, percentiles).

The hotpath crate does the bookkeeping. It provides:

  • #[hotpath::main] on main: starts a background collector and prints a report at the end.
  • #[hotpath::measure] on any function: records the start and end time of every call.
  • A way to turn everything off at compile time with a Cargo feature, so that normal builds pay nothing.
  • Optional allocation tracking: how many bytes, and how many allocations, each function made.

The cost of instrumentation is per call. The project's documentation says #[hotpath::measure] adds around 40 ns per call (I read this, I did not measure it). That is nothing for a function called once and taking 500 ms. It is huge, relatively, for a function called 100 million times and taking 5 ns. So instrument the large pieces, not the tiny ones. For tiny, hot functions, use sampling.

Build it (the "how")

Add the crate with a feature gate

cargo add hotpath --optional

Cargo added hotpath v0.28.2. Then in Cargo.toml:

[features]
hotpath = ["dep:hotpath", "hotpath/hotpath"]

Here is a trap that cost me a few minutes, so I will share it. My first version of this line was hotpath = ["dep:hotpath"]. The program built and ran, but printed no report at all. No error, no warning. The reason: the hotpath crate has its own feature named hotpath, and without it, the whole library compiles to nothing (that is how it gets zero cost). Your feature must switch on the crate's feature too, which is the "hotpath/hotpath" part. A silent no-op is the worst kind of bug in a measuring tool. If your measurement tool prints nothing, do not believe "nothing happened": check that it was switched on. In Part I, we said the same about a benchmark with no black_box.

Then mark the functions:

#[inline(never)]
#[cfg_attr(feature = "hotpath", hotpath::measure)]
fn tokenize(text: &str) -> Vec<String> { /* ... */ }

// the same for make_text, count, and top

#[cfg_attr(feature = "hotpath", hotpath::main)]
fn main() { /* ... */ }

cfg_attr(feature = "hotpath", ...) means "apply this attribute only when the feature is on". When it is off, the code is exactly the original.

Run it:

cargo run --release --features=hotpath

The report

[hotpath] 877.92ms | timing, threads

timing - Execution duration of functions.
+---------------+-------+-----------+-----------+-----------+---------+
| Function      | Calls | Avg       | P95       | Total     | % Total |
+---------------+-------+-----------+-----------+-----------+---------+
| wf::main      | 1     | 877.84 ms | 878.18 ms | 877.84 ms | 100.00% |
| wf::tokenize  | 1     | 529.09 ms | 529.27 ms | 529.09 ms | 60.27%  |
| wf::count     | 1     | 209.26 ms | 209.32 ms | 209.26 ms | 23.84%  |
| wf::make_text | 1     | 62.61 ms  | 62.62 ms  | 62.61 ms  | 7.13%   |
| wf::top       | 1     | 13.05 µs  | 13.05 µs  | 13.05 µs  | 0.00%   |
+---------------+-------+-----------+-----------+-----------+---------+

(I removed the separator lines between rows. The numbers are as printed.)

Exact times, and the calls column: each function was called once. A sampling profiler could not have told us that. These are inclusive times: wf::main includes everything.

Allocation tracking

cargo run --release --features="hotpath,hotpath/hotpath-alloc"

This adds a table of bytes allocated by each function (not counting what its callees allocate):

alloc-bytes - Exclusive allocation bytes by each function (excluding nested calls).
Total: 515.7 MB
| wf::tokenize  | 1 | 451.3 MB | ... | 451.3 MB | 87.52% |
| wf::make_text | 1 | 38.1 MB  | ... | 38.1 MB  | 7.40%  |
| wf::count     | 1 | 26.2 MB  | ... | 26.2 MB  | 5.09%  |
| wf::main      | 1 | 1.3 KB   | ... | 1.3 KB   | 0.00%  |
| wf::top       | 1 | 975 B    | ... | 975 B    | 0.00%  |

This is a why. The program allocates 515.7 MB of memory in total, to process 5 million words. 87.5% of it, 451 MB, comes from tokenize. That is about 90 bytes allocated per token (451.3 MB / 5 million). And the largest peak memory use was about 300 MB (the report's "Max RSS" line said 303.7 MB), which matches the page faults from Chapter 15.

Put the three tools together now, because this is how a profile report is made:

QuestionEvidenceAnswer
Where is the time?sampling (Ch 15, 16)60.6% in tokenize, 24.5% in count
Is that exact?instrumentation (this chapter)60.3% and 23.8%, with exact milliseconds
Why is tokenize slow?allocation tracking451 MB allocated, 87.5% of all allocation
Is the memory use a problem for the kernel?event count76,485 page faults, 14% in kernel code

The four rows tell one story: tokenizing is slow mostly because it allocates a lot. And that suggests a plan (Part V): allocate less.

Where the tools disagree

Not everything lined up. Look at make_text:

ToolShare of the run
sampling (perf, flamegraph)3.3% (about 30 ms)
instrumentation (hotpath, three runs)7.1% (62.6 ms), 9.3% (84.2 ms), 6.2% (64.0 ms)

Both say "small". But the second is twice the first, and it moves between runs. I do not have a confirmed reason. A guess: make_text builds a 38 MB string, so its time includes a lot of first-touch page faults, which are kernel work. My folded stacks might have attributed some of that kernel time to a stack that does not pass through wf::make_text, which would make the sampling number too low. A different guess: the instrumented run waits for something extra at its start. I did not test either. What I will say is: when two tools disagree about a small share, the honest answer is the range, not a favorite. And note the instrumented run with allocation tracking was slower (1.03 s against 0.88 s): tracking every allocation is not free. So we do not trust absolute times from that run, only the byte counts.

Why it is this way (the "why")

Why have both sampling and instrumentation? Because they make opposite trades.

  • Sampling looks at everything but only occasionally. It finds things you did not think of. It is statistical and approximate. It costs little.
  • Instrumentation looks at only what you marked but at every moment. It is exact. But you must know what to mark, and it costs per call.

You can think of it as a police officer on patrol (sampling: sees the whole town, a bit at a time) against a security camera at one door (instrumentation: sees everything at that door, and nothing elsewhere). A good investigation uses both. The patrol tells you which door to put the camera on.

There is also an old idea in physics that applies here: the observer effect. To measure something, you must touch it, and touching changes it. A timer call takes about as long as a few dozen instructions. If the function you are measuring is that short, you are mostly measuring the timer. We saw the same thing in Chapter 4 (the empty closure) and in Chapter 1 (121 ns for a deleted loop). So, as with every measuring tool in this guidebook, test the instrument on something you know. If I mark an empty function and the report says 40 ns per call, that is the overhead, and I must subtract it, or avoid measuring things that small.

The philosophy is "measure one level deeper" (Ousterhout, reading list for Part I): when you find a slow piece, do not guess why. Instrument the pieces under it. The allocation table was exactly this: we had a slow function, and we measured one level deeper, what it allocates, and got a reason.

What would make instrumentation wrong? Measuring inside a loop that is very hot, so that the instrument becomes the cost. Measuring something that gets inlined (the macro may prevent inlining, changing the program). Forgetting that thread-related and async code needs special care (hotpath has separate features for these). And using the numbers from a run with extra tracking turned on as if they were normal timings.

Common mistakes

  • A feature that silently does nothing. Verify that your tool printed something.
  • Instrumenting tiny functions called millions of times. The overhead is the result.
  • Using numbers from an instrumented run as production timings.
  • Forgetting the call count. Total time alone hides "called once, slow" versus "called a million times, fast".
  • Choosing one tool's number when two tools disagree. Report the range and say why you think they differ.

Say it simply

Instrumentation adds code that measures what you point it at: exact times and counts, but only for the pieces you marked and with a small cost per call. The hotpath crate does the bookkeeping with two attributes and a Cargo feature, and can also count bytes allocated per function. For our word counter it showed 515.7 MB allocated, 87.5% of it in tokenize: a reason, not just a location. When two tools disagree on a small share, report the range. And check that a tool that prints nothing was really switched on.

Check yourself

Exercise 18.1 (Warm-up): Overhead budget. An instrument adds 40 ns per call. A function takes 20 ns and is called 100 million times. By how much does the instrument change the run time? What if the function takes 5 µs and is called 1,000 times?

Exercise 18.2 (Warm-up): Which tool? Choose sampling or instrumentation: (a) "Where is the time going in a program I have never seen?" (b) "How many times is this parser function called per request?" (c) "What is the p99 time of this one function?"

Exercise 18.3 (Core): Measure the instrument. Add the crate to a copy of the program. Mark an empty function (fn nothing() {}) and call it 1 million times. Compare the total time with and without the feature. What is the overhead per call on your machine? Done when: a number in nanoseconds, and a sentence saying how it compares with the documentation's figure.

Exercise 18.4 (Core): Reproduce the allocation table. Run the allocation-tracking build on your machine. Do your byte totals match 515.7 MB? Change the input to 10 million words. Do the bytes double?

Exercise 18.5 (Core): The silent failure. Remove the "hotpath/hotpath" part of the feature and rebuild. Confirm the report disappears. Write two sentences on why a measuring tool that fails silently is dangerous.

Exercise 18.6 (Stretch): Do it by hand. Write your own mini measure! macro that records the count and the total time of each labeled region in a global table and prints it at exit. Test it on the word counter. How does its overhead compare with hotpath?

Exercise 18.7 (Stretch): Design and defend. Two profilers disagree: one says a function takes 3% of the run, the other says 9%. Write a plan with at least three experiments to find out which is right, or why both are.

What a strong answer includes: checking for inlining, testing overhead on a known workload, comparing user versus kernel time, running more samples, and looking at what each tool counts (CPU time against wall time, self against inclusive).


Chapter 19. Top-down analysis and the roofline model

In this chapter: You will learn the two big models professionals use to say what is limiting a program: the top-down method (what the CPU pipeline is waiting for) and the roofline (compute-bound or memory-bound). I will show what I could not run (top-down needs hardware counters). And I will build a roofline from scratch, from my own measurements, on this machine.

You need first: Chapters 8 to 13 and 14 to 18.

The problem first

By now we know where the time goes, and some of the why. But there is a deeper kind of question: what is the limit? If a loop is slow, is it slow because the CPU has too little work to overlap, because it waits for memory, because the branches are unpredictable, or because it is simply doing a lot of arithmetic? The fix for each one is different. A wrong guess wastes a week.

Two models answer it. One looks inside the CPU pipeline. The other looks at the balance between arithmetic and data.

The model (the "what")

Part A: the top-down method

Chapter 13 described a CPU as an assembly line with a fixed width: each cycle it has a number of slots (say, 4 or 6) in which it could start a useful instruction. A perfect program fills every slot every cycle. A real one loses slots. The top-down method (Ahmad Yasin, ISPASS 2014; reading list) asks, for every lost slot: why was it lost? It sorts every slot of every cycle into exactly one of four buckets:

                   all pipeline slots
        ┌───────────────┬─────────────────────────────┐
        │               │                             │
     RETIRING      NOT RETIRING (wasted)
   (useful work)   ┌─────────────┬──────────┬────────────────┐
                   │             │          │                │
              BAD SPECULATION  FRONTEND   BACKEND
              (guessed wrong:  BOUND      BOUND
               work thrown     (can't get (can't finish:
               away)           instructions waiting for data
                               fast enough) or for a free unit)
  • Retiring: slots that did useful work that was kept. Higher is better. (But 100% retiring on a bad algorithm is still slow: it only says the CPU is busy, not that the work is needed.)
  • Bad speculation: slots wasted on work that was thrown away after a branch misprediction (Chapter 13).
  • Frontend bound: the CPU's "fetch and decode" part could not supply instructions fast enough. Often caused by a big, branchy code footprint that does not fit in the instruction cache.
  • Backend bound: instructions were ready, but the "execute" part was stuck. This one splits again:
    • Memory bound: waiting for data from the caches or main memory (Chapters 8 to 12).
    • Core bound: waiting for a free execution unit, or for the result of a long dependency chain (Chapter 13).

The method is top-down because you start with these four big buckets, find the biggest wasted one, and drill down into just that one with more detailed counters. You do not drown in 500 counters. You follow a tree.

On real hardware with the counters exposed, Linux perf can compute the first level for you:

perf stat --topdown ./program        # or:  perf stat -M TopdownL1 ./program

and its output is a line per bucket: retiring, bad speculation, frontend bound, backend bound, as percentages. (The Linux kernel change that added this to perf stat printed an example with four percentages in this order.) I could not run this here, because the VM does not expose the counters:

   <not supported>      cycles
   <not supported>      instructions

So I cannot tell you, from a measurement, what the word counter's top-down breakdown is. A guess, based on everything else we saw (heavy allocation, hash-map lookups of random strings, many small copies): the backend, specifically memory, would be the biggest wasted bucket. Treat that as a hypothesis to test on real hardware. Exercise 19.4 does that.

Part B: the roofline model

The roofline model (Williams, Waterman, and Patterson, CACM 2009; reading list) takes a different, simpler view. It says: every loop has two limits.

  1. A compute limit: the machine can do at most P floating-point operations per second (flop/s). This is the peak compute rate.
  2. A memory limit: the machine can move at most B bytes per second to and from main memory. This is the peak bandwidth.

Now take a loop and define its arithmetic intensity I: the number of floating-point operations it does per byte it moves from memory. (flop/byte.) Then the best speed the loop can reach is:

 attainable flop/s  =  min( P ,  B × I )

If the loop does little work per byte (low I), it is limited by B × I: memory-bound. If it does a lot of work per byte (high I), it hits the flat ceiling P: compute-bound. Drawn on a log-log plot, the line B × I rises like a slanted roof and then meets the flat ceiling P. The picture looks like a roof, which gives the model its name:

 flop/s
   ▲                         ________________  ← compute roof  P
   │                      ╱
   │                   ╱
   │                ╱   ← slanted roof  B × I   (bandwidth limit)
   │             ╱
   │          ╱
   └───────────────────────────────────►  intensity  I  (flop/byte)
                  ▲
               the "knee": where the loop switches from memory-bound to compute-bound

The point of the model: it tells you which fix to try. A loop on the slanted part gains nothing from faster arithmetic (vectorization, more accumulators). It needs less data movement (smaller types, better layout, cache blocking). A loop on the flat part gains nothing from better memory access. It needs faster arithmetic (SIMD, FMA instructions, more independent chains).

Build it (the "how")

I did not have hardware counters, so I measured the roofline directly, with a small kernel whose arithmetic intensity I can set. For every number loaded from memory, the kernel does K multiply-add steps (each is 2 flops), plus one add into a running total. So the flops per element are 2K + 1, and the bytes per element are 8 (one f64). Turn K up, and the intensity goes up.

// For every number we load, do K multiply-adds, then add the result to a running total.
// flops per element = 2*K + 1 ; bytes per element = 8 (one f64 read).
#[inline(never)]
fn kernel<const K: usize>(x: &[f64], a: f64, b: f64) -> f64 {
    let mut s = [0.0f64; 32];
    for ch in x.chunks_exact(32) {
        let mut v = [0.0f64; 32];
        v.copy_from_slice(ch);
        for _ in 0..K {
            for i in 0..32 { v[i] = v[i] * a + b; }   // 32 independent chains
        }
        for i in 0..32 { s[i] += v[i]; }
    }
    s.iter().sum()
}

I ran it on two array sizes: 16 KiB (fits in L1, so the data is always close: this finds the compute roof) and 256 MiB (main memory: this finds the memory roof). And I built it two ways: the default rustc build, and with -C target-cpu=native, which lets the compiler use every instruction this CPU has (on this machine: AVX2, AVX-512, and FMA, according to /proc/cpuinfo).

A story about getting it wrong first. My first version of the kernel used only four independent chains. At large K, its speed fell (from 16 to 4 GFLOP/s) instead of reaching a flat ceiling. The reason: with only four chains, the loop was limited by the latency of the multiply-add chain (Chapter 13), not by the arithmetic units. Four chains were not enough work to overlap. I was measuring a latency limit and calling it the compute roof. With 32 chains, the speed rose and flattened, as a roof should. The lesson: to find a roof, you must give the machine enough independent work. A roof that is too low is a bug in your experiment, not a fact about your CPU.

Results (32 chains). Intensity I is flop per byte. Speeds are GFLOP/s (billions of floating-point operations per second).

KI (flop/byte)default build, L1default build, memorynative build, L1native build, memory
10.3815.83.132.75.4
20.6215.34.536.27.5
41.1215.36.937.79.9
82.1213.09.940.314.5
164.1211.510.938.122.5
328.1211.211.128.725.6
6416.1210.810.724.924.9
12832.1210.610.623.623.4

And the data rate behind the "memory" columns, in GB/s:

K1248163264128
default, memory8.27.36.14.72.71.40.70.3
native, memory14.512.08.86.85.53.11.50.7

Now read the roofline out of the numbers.

The memory-bound slope (the roof's slanted part). Look at the memory columns at low intensity. At K = 1 (0.38 flop/byte), the default build gets 3.1 GFLOP/s, which is 8.2 GB/s of data. At K = 2, 4.5 GFLOP/s. At K = 4, 6.9. Doubling the arithmetic per byte nearly doubles the speed, because the program is waiting for data, and the extra arithmetic hides inside the waiting. This is the slanted roof: speed grows with intensity. The data rate, around 8 GB/s at the lowest intensity here, is in the same range as the sequential-read bandwidth we measured in Chapter 8 for 256 MiB (9.9 GB/s).

The compute roof (the flat part). For large K, both L1 and memory columns converge on the same number: about 10.6 to 11 GFLOP/s for the default build. When there is a lot of arithmetic per byte, it does not matter where the data lives. The ceiling is the arithmetic units, and that is P.

The knee. For the default build and memory-sized data, the curve reaches the ceiling at about K = 16 (about 4 flop/byte). That is the knee. A loop with an intensity below it is memory-bound on this machine. A loop above it is compute-bound. (The corner is not perfectly sharp in my data: at K = 4 to 8 the loop is both partly limited by memory and partly by arithmetic.)

The L1 line starts above the ceiling. In the L1 column, K = 1 gives 15.8 GFLOP/s, higher than the final roof of 10.6. That is because at tiny K the loop does mostly adds and loads, and (it seems) the machine can do those adds faster than it can do multiply-adds. The "roof" depends on the mix of operations. A roofline uses one number for P, which is a simplification. I did not investigate the exact cause.

The build flags change the roof, not just the loop. The native build's roof (the large-K columns) is 23 to 25 GFLOP/s, against 10.6 for the default build: about 2.2 times higher. Same source code, same CPU. The difference: the default Rust target is a baseline x86-64 CPU (instructions from around 2003, with 2 doubles per SIMD instruction and no fused multiply-add). The native build lets the compiler use AVX-512 and FMA. In the L1 column, the native build reaches 40 GFLOP/s at K = 8, almost four times the default build's roof, before dropping to about 24 for larger K. I do not know why it drops. A guess: heavy 512-bit instructions can make the CPU lower its clock, or the kernel runs out of registers. I did not test it.

The native build has a cost you must know: a binary compiled with -C target-cpu=native may crash with "illegal instruction" on an older CPU. It is for programs that run on the machine they were built on, or for builds that target a known CPU. For software you ship to others, you pick a lower baseline (or build several versions and choose at run time, Chapter 28).

Correction, added after Chapter 30. The "compute roof" I measured above (about 10.6 GFLOP/s for the default build and about 24 for the native build) is not the peak of this machine. In Chapter 30, a tuned matrix-multiplication library reaches about 131 GFLOP/s in f32 on the same single core. So the roofs in this chapter were the roofs of my kernel, which was limited by something other than the arithmetic units (I did not find out what: the kernel did one multiply-add per number and may not have kept the vector units busy). The shape of the roofline (a slanted memory part and a flat compute part, and the knee between them), and the lessons about independent chains and about build flags, still stand. The numbers for the roof do not. Use a strong reference, such as a tuned library, to find the real roof. A roof that is too low is a bug in your experiment, and mine was one.

Why it is this way (the "why")

Why do these two models exist, when we have a profiler? Because a profile tells you where, and these models tell you what limits you there. They are explanations, not measurements. They turn "this loop takes 40 ms" into "this loop is memory-bound at 80% of the bandwidth the machine can give, so rewriting the arithmetic is pointless, and the best you can do is reduce the bytes". That is far more useful. It also gives you something rare in performance work: a stopping rule. If you are at 90% of the roof, you are done. Nobody can make that loop much faster on this machine. If you are at 10% of the roof, there is a big win waiting, and you now know which direction to look in.

The philosophy behind both: performance is a conversation between a program and a machine, and each side has limits. The program has a demand (so many flops, so many bytes, so many branches). The machine has a capacity for each. The slowest match-up wins. The top-down method looks at the match-ups inside the core. The roofline looks at the match-up between arithmetic and bandwidth. A third model, for parallel programs, appears in Part VI.

It is also worth noticing what these models leave out, because that is where they stop being true:

  • The roofline has one number for peak compute, but real peaks depend on the instruction mix (we saw it in the L1 column), on SIMD width, and on the clock.
  • It has one memory bandwidth, but there are several levels (L1, L2, L3, memory), each with its own roof. A good roofline plot has several slanted lines.
  • It counts only floating-point work. Many programs (parsers, interpreters, compilers, our word counter) do almost no floating-point arithmetic. For those, the analogous question is "instructions per byte" or "operations per cache line", and the top-down view is more useful.
  • Top-down analysis depends on hardware counters, which are specific to each CPU generation, and which can be inaccurate. It is a guide, not a proof.

Common mistakes

  • Taking a roof from a badly built experiment. If the speed falls instead of flattening, you are probably latency-bound. Add independent work.
  • Using the roofline for a program with almost no floating-point work. Count something else.
  • Treating "retiring is high" as "the program is good". It may be doing useless work efficiently.
  • Optimizing arithmetic on a memory-bound loop (or the reverse).
  • Benchmarking with default build flags and assuming it is the machine's limit. Our native build was 2.2 times higher.
  • Shipping a target-cpu=native binary to machines you do not control.

Say it simply

The top-down method sorts every lost CPU slot into one of four buckets (bad speculation, frontend bound, backend bound, or useful work) and drills into the biggest waste. It needs hardware counters, which this VM did not give me. The roofline model says a loop's speed is the lower of a compute ceiling and the memory bandwidth times its arithmetic intensity. I measured two limits for my own kernel: about 10.6 GFLOP/s for the default build and about 24 for the native build (the real peak of this core is higher, see the correction above), with the loop switching from memory-bound to compute-bound at roughly 4 flop per byte. Knowing the limit tells you which fix to try, and when to stop.

Check yourself

Exercise 19.1 (Warm-up): Memory- or compute-bound? A machine has a peak of 20 GFLOP/s and a bandwidth of 10 GB/s. Where is the knee, in flop/byte? A loop has intensity 0.5. What is its best possible speed? A loop has intensity 8. What is its best speed?

Exercise 19.2 (Warm-up): Intensity by hand. For y[i] = a * x[i] + y[i] with f64 values (each x[i] and y[i] is read, and y[i] is written), count the flops and the bytes per element. What is the intensity? Is it likely memory-bound or compute-bound on the machine in this chapter?

Exercise 19.3 (Core): Build your roofline. Run the roofline experiment on your own machine, in both builds. Draw the two roofs on a log-log plot (or a table). Where are your knees? How do your numbers compare with this machine's 10.6 and 24 GFLOP/s? Done when: a plot or table, your peak compute rate, your memory bandwidth, and one paragraph on how they differ from the tables above.

Exercise 19.4 (Core): Top-down on real hardware. If you have a machine with working counters, run perf stat -M TopdownL1 (or --topdown) on the word counter. Which bucket is biggest? Did the guess in this chapter (backend/memory bound) hold? What does it say about whether to fix allocation or hashing first?

Exercise 19.5 (Core): Break the roof on purpose. Re-run the kernel with only 4 chains, then 8, 16, and 32. Plot the large-K speed against the number of chains. At what number does it stop rising? Connect this to Chapter 13.

Exercise 19.6 (Stretch): Find the build effect. Compile the kernel with -C target-feature=+avx2,+fma instead of native. Where does the roof land? Then with -C target-cpu=native -C target-feature=-avx512f (if your CPU has AVX-512). Which instruction set gave how much?

Exercise 19.7 (Stretch): Design and defend. Your matrix multiplication reaches 30% of the machine's compute roof, and a teammate says "use a faster algorithm". Another says "block it for the cache". Use the roofline to say what you would measure to decide between them.

What a strong answer includes: the arithmetic intensity of the naive and blocked versions, whether the loop sits on the slanted or the flat part, the data volume moved at each cache level, and the idea that if you are on the flat part, blocking helps little. (Chapter 30 does this.)


Chapter 20. Cachegrind and Callgrind

In this chapter: You will learn how to profile a program by simulating it. You will get exact instruction counts and a cache simulation, which the real hardware here would not give us (no counters). And you will see the most important lesson of the Part: instructions are not time.

You need first: Chapters 8 to 19.

The problem first

Part IV has relied on sampling, which is statistical: run it twice and you get slightly different numbers. We also lost the hardware counters. What if we want something exact and repeatable: the same numbers every time, on any machine, even a VM with no counters?

There is a way: run the program not on the real CPU, but on a software simulation of one.

The model (the "what")

Valgrind is a framework that runs your program inside a virtual machine. As the program runs, Valgrind translates its machine code on the fly into instrumented code (this is called dynamic binary instrumentation) and a tool plugs in to count whatever it likes. Two tools matter for us:

  • Cachegrind counts, for every instruction and every memory access: how many instructions ran, and, using a simulated cache hierarchy, how many accesses hit or miss in the first-level and last-level caches. It reports these per function and per source line.
  • Callgrind does the same and also records who called whom, and the inclusive cost of each function including its callees (like a flamegraph, but in instructions).

Because the simulation is software, the counts are deterministic: the same program on the same input gives (almost) the same numbers, every time. The price: the program runs about 20 to 50 times slower. So you run it on a small input.

The numbers you get are events, in three groups:

  • Ir: instructions executed ("instruction reads").
  • Dr / Dw: data reads and data writes.
  • I1mr, D1mr, D1mw, ILmr, DLmr, DLmw: misses in the first-level instruction or data cache (I1, D1) and in the last-level cache (LL), for reads (r) and writes (w).

An important limit: Cachegrind's cache is a simple model (by default it copies the sizes of your real machine's caches). It has no hardware prefetcher, no TLB, and no out-of-order overlap. So its "misses" are a cleaner, simpler picture than the real hardware. Think of it as a very good approximation for relative questions ("which function misses most?") and a poor one for absolute time.

Build it (the "how")

Run on 200,000 words (instead of 5 million), because of the slowdown:

valgrind --tool=cachegrind --cache-sim=yes ./target/release/wf 200000

Valgrind prints a summary:

I refs:        318,103,054
I1  misses:          2,084
LLi misses:          2,014

D refs:        106,898,142  (64,534,937 rd   + 42,363,205 wr)
D1  misses:        574,963  (   376,084 rd   +    198,879 wr)
LLd misses:        198,114  (     1,777 rd   +    196,337 wr)
D1  miss rate:         0.5%
LL misses:         200,128

What it tells us:

  • 318 million instructions for 200,000 words: about 1,590 instructions per word. That is a lot, for a job whose essence is "split, lowercase, count". (This includes building the fake text.) A simple tokenizer should need on the order of 50 to 100 instructions per word. We are at about 15 to 30 times that. (Those "50 to 100" are my rough expectation, not a measurement.)
  • Data reads and writes: 107 million, about a third of the instruction count: fine.
  • The D1 miss rate is only 0.5%. The simulated L1 data cache hits 99.5% of the time. For a program we already know is slow because of memory behavior, that looks wrong. The reason: 200,000 words is a small input. The working set (about 8 MiB of tokens and text) is small, and the simulator does not model page faults at all. The cache misses are not the problem here, at this size. At 5 million words, the program touches 300 MiB, and the real hardware sees page faults, TLB misses, and cache misses that this small simulation does not. A simulation answers only the question you ask it. We asked "how many instructions, and how well does a small input fit in cache?" and not "how does a 300 MiB working set behave?"

Callgrind: the inclusive costs by function

valgrind --tool=callgrind ./target/release/wf 200000
callgrind_annotate --inclusive=yes callgrind.out.<pid>

With the four phase functions marked #[inline(never)], the inclusive cost in instructions:

Function (inclusive)InstructionsShare
wf::tokenize182.2 M57.2%
wf::count98.0 M30.8%
to_lowercase (inside tokenize)63.9 M20.1%
free (all callers)55.2 M17.3%
malloc (all callers)53.6 M16.8%

So malloc plus free is about 34% of all instructions. And tokenize is the biggest phase, as before.

Now compare instructions with time, from the earlier chapters:

Share of instructions (Callgrind)Share of time (perf, flamegraph)
tokenize57.2%60.6%
count30.8%24.5%
malloc + freeabout 34%32.5%

The two views mostly agree: tokenize first, count second, the allocator a third of everything. Good. Two independent methods, one story, which is what we hoped for. But look at count: 30.8% of the instructions but only 24.5% of the time. And tokenize is the opposite: 57.2% of the instructions and 60.6% of the time. In plain words, count's instructions ran faster, on average, than tokenize's. A guess at why: the hash and compare code is tight, with good use of the cache, while tokenize pays more for page faults when it grabs fresh memory and fills a 120 MB vector of strings. (This is a hypothesis. Callgrind does not model page faults, so it cannot see this, and the real-hardware counters that could settle it are not available here.)

This is the great lesson of this chapter: instructions are not time. The CPU can do several instructions per cycle on good code, and fewer than one on code that waits for memory. Two functions with the same instruction count can differ by 5x in time. An instruction profile is exact, but it is a profile of work, not of waiting. A time profile (sampling) is approximate, but it includes the waiting. You need both to see the whole picture.

When is each one best?

You want...Use
A repeatable number to compare two versions in CI, on a noisy machineInstruction counts (Cachegrind/Callgrind). They do not wobble with machine noise.
To see which function wastes instructionsCallgrind
To see where real time goesSampling (perf, samply)
Why a loop misses the cache, in a simple modelCachegrind
Anything about page faults, the TLB, or the prefetcherReal hardware counters (not simulation)

The first row deserves a note. In Part II we found that a benchmark on this VM can wobble by 5% between runs, which hides small improvements. An instruction count has almost no noise: two runs of the same binary gave 318,103,054 (Cachegrind) and 318,101,668 (Callgrind), which differ by 0.0004%. (The tools instrument differently, so they are not identical, but a single tool run twice is nearly exact.) That makes it a good check for small regressions in a noisy environment, as long as you remember it measures work, not time. Chapter 38 returns to this idea.

Why it is this way (the "why")

Why simulate? Because a simulation can see everything and is not subject to noise. A real CPU is a black box with noisy, limited counters. A software model has a counter for every event we care about, and those counters are exactly repeatable. The cost is that the model is only as true as its design. A simple model of the cache answers questions about the cache in the model.

This is a recurring theme in science: models trade realism for control. A wind tunnel is not the sky, but you can repeat the experiment a thousand times and change one thing at a time. Cachegrind is a wind tunnel for your program's memory behavior. The question to ask before trusting a result is: what does the model leave out, and could that be what matters here? For Cachegrind, the omissions are the prefetcher, the TLB, page faults, and out-of-order overlap, which are exactly the things Part III showed to be big. So Cachegrind is good for finding wasted work (instructions, accesses that miss in a simple cache) and weak for latency hiding.

Another idea: exactness is a different kind of knowledge from accuracy. Callgrind's count of instructions is exact: it counts every one. But it is an exact count of the wrong thing if what you care about is time. A sampling profile is approximate about the right thing. Neither is "better". An experienced engineer uses an exact count to know what the program does, and a sampled profile to know what it costs.

What would make this wrong? Valgrind changes the program's behavior (it is much slower, and its memory layout differs from native), so threads and timing-dependent code behave differently under it. Valgrind may not support the newest instructions (a program built with target-cpu=native and AVX-512 may not run, or may be translated imperfectly). And a small input may not show the problem of a big one.

Common mistakes

  • Running a big input. It will take forever. Use a small, representative one.
  • Trusting simulated cache misses as real ones. They are model misses.
  • Reading instruction share as time share.
  • Forgetting debug info. You get no source lines.
  • Using it on multi-threaded timing questions. Valgrind serializes threads.
  • Treating a low simulated miss rate as "memory is fine". The model may be missing the real cause (page faults, TLB).

Say it simply

Cachegrind and Callgrind run your program inside a software model of a CPU and count everything: instructions, memory accesses, and simulated cache misses. The counts are exact and repeatable, so they work on noisy machines and VMs with no counters. For the word counter, the allocator was a third of all instructions, and tokenize was 57% of them, close to what sampling said about time. But count had more of the instructions than of the time, which shows that instructions are not time. The model leaves out the prefetcher, the TLB, and page faults.

Check yourself

Exercise 20.1 (Warm-up): Per word. The run had 318,103,054 instructions for 200,000 words. Compute the instructions per word. If you reduce malloc and free instructions to zero, what is the best speed-up, by Amdahl's law, from instructions alone?

Exercise 20.2 (Warm-up): Instruction or time? A function has 30% of the instructions and 10% of the time. Another has 10% of the instructions and 30% of the time. Describe in one sentence what each is probably doing.

Exercise 20.3 (Core): Run Cachegrind. Run Cachegrind and Callgrind on the word counter at 200,000 words. Use cg_annotate to find the line with the most instructions. Does it match the sampling profile? Then try 50,000 and 800,000 words: does the instruction count per word stay the same? Does the D1 miss rate change? Done when: a table of instructions per word and miss rate for three input sizes.

Exercise 20.4 (Core): A noise-free check. Make a tiny change that saves some work in tokenize (for example, call to_lowercase only if the word has an uppercase letter). Compare instruction counts before and after, using Callgrind. Then compare wall time with Criterion (Part II). Which tool can see the change more clearly? By how much did each report?

Exercise 20.5 (Core): Cache model. Run Cachegrind with a smaller simulated cache (--D1=8192,2,64 means 8 KiB, 2-way, 64-byte lines) on the matrix walk from Chapter 9 (row against column). Do the simulated miss counts reproduce the 16x gap? Which part of the gap does the model capture, and which not?

Exercise 20.6 (Stretch): Callgrind graph. Open a Callgrind output in KCachegrind (or callgrind_annotate --tree=caller) and find all callers of malloc. Which caller makes the most calls? How would you reduce them?

Exercise 20.7 (Stretch): Design and defend. Your CI has noisy machines, and you want to block pull requests that make the code more than 3% slower. Argue for or against using Callgrind instruction counts as the gate. What kinds of slowdowns would it miss?

What a strong answer includes: determinism and low noise as strengths, and cache effects, branch mispredictions, memory layout, and allocation latency as things instruction counts cannot see. A balanced approach: instruction counts as a fast first check, real timing on a quiet machine as a second.


Part IV wrap-up

What you can now do:

  • Name the six kinds of profiling evidence and pick the right one for a question.
  • Use perf stat to count events and perf record to sample, and estimate the margin of error of a sampling profile.
  • Make a flamegraph from folded stacks, read it, and compute inclusive time and theme sums yourself.
  • Record a profile with samply, and know what a timeline shows that a flamegraph does not.
  • Instrument a program with hotpath for exact timing and allocation counts, and check that your instrument is actually switched on.
  • Explain the top-down method and the roofline model, and measure a roofline on your own machine.
  • Run Cachegrind and Callgrind, and explain why instructions are not time.

What our word counter told us (and what we do next). Five tools, one story:

  • The run takes about 0.82 s for 5 million words (164 ns per word).
  • tokenize is about 60% of the time and 87.5% of all allocation (451 MB of 515.7 MB, about 90 bytes per token).
  • count is about a quarter of the time.
  • The allocator (malloc and free) is on the stack in about a third of the samples and takes about a third of the instructions.
  • The kernel handles 76,485 page faults (about 300 MiB of fresh memory), about 14% of the samples.
  • The hash map, our first suspect, was never the main problem.

The conclusion: this program is slow because it asks the allocator for far too much memory, far too often. The fix is not a smarter algorithm. It is allocating less. That is Part V.

Mini project: The profile report (4 to 6 hours). Pick a program of 100 to 500 lines that you wrote (a parser, an interpreter step, a small simulation), or use the word counter with a change you choose. Produce a one-page profile report that includes:

  1. The performance question and a baseline (Part I), measured with the right workload.
  2. Your written hypothesis, before profiling.
  3. A sampling profile (perf or samply) with a flamegraph and a note on the margin of error.
  4. At least one other kind of evidence (instrumentation, counters, allocation tracking, or Callgrind) that explains why the hot spot is hot.
  5. One place where two tools disagreed, with your best explanation.
  6. A short ranked list of what to change, each with the most it could save by Amdahl's law.

Done when: the report names the top three costs, says how sure you are of each, and says which one you would change first and why.

Write-up prompt: Half a page. Which guess did the profile prove wrong, and what did you almost spend time on?

Teach it back: In 5 sentences, explain to a friend why a profiler that only looks at the program two thousand times a second can still tell you that 60% of the time is in one function.

Reading list for Part IV:

  • Ahmad Yasin, "A Top-Down Method for Performance Analysis and Counters Architecture", IEEE ISPASS, 2014 (Monterey, California). The paper behind the four buckets. Read the introduction and the section that defines the first level.
  • Samuel Williams, Andrew Waterman, and David Patterson, "Roofline: An Insightful Visual Performance Model for Multicore Architectures", Communications of the ACM, 2009. The roofline model in a few pages. Read the first figure.
  • Brendan Gregg, "The Flame Graph", Communications of the ACM / ACM Queue, 2016, and his website's pages on flame graphs and on perf. The origin of the picture.
  • Nicholas Nethercote and Julian Seward, "Valgrind: A Framework for Heavyweight Dynamic Binary Instrumentation", PLDI 2007. How Valgrind works inside.
  • The perf wiki and man perf-stat, man perf-record. The commands, with all their options.
  • The documentation of inferno, samply, and hotpath, for the tools used here.

Verification notes for Part IV: The following were run in this session on the 1-core Xeon VM (rustc 1.91.1 for the word counter, perf 6.8 from the Ubuntu tools package, Valgrind 3.22, inferno 0.12.8, samply 0.13.1, hotpath 0.28.2): all wall-clock times, perf stat software events, the perf record tables, the folded-stack sums (computed with awk on the folded file), the samply recording (its sample count and timing), the hotpath timing and allocation tables, the Cachegrind totals, the Callgrind inclusive costs, and the roofline experiment (both builds). The wf-flamegraph.svg file was generated from the real profile. Checked against sources: the top-down paper title, venue, and year (found on a university course page and in the DBLP listing for ISPASS 2014), the four top-down categories in perf stat (from the Linux kernel patch that added them), and hotpath's usage (from its README and its crate source). Not verified in this session: the Williams, Gregg, and Nethercote citations (from my own knowledge), the 40 ns per call figure for hotpath (read in documentation, not measured), the exact causes of the make_text disagreement, the L1 roof drop for the native build, the missing 11.6% in the folded stacks, and every statement marked "guess" or "hypothesis". Not run: the samply viewer (no browser), perf stat hardware counters and --topdown (not exposed by the VM), perf annotate, KCachegrind, differential flamegraphs, and every exercise. The numbers for the word counter come from a single configuration, and the sampling margin of error applies to them.


Part V. Memory and allocation

After this Part, you will be able to read a heap profile, say how many allocations a piece of code makes and why, remove most of them, and prove that doing so helped.

Part IV ended with a clear verdict on our word counter: it is slow because it asks the allocator for far too much memory, far too often. Now we fix it. And we do it the honest way, with a measurement after every change, so you can see which ideas pay and which ones do not.

Here is the scoreboard we will fill in during this Part. Every number is the median of 5 runs of the full 5-million-word job, in milliseconds, not counting the time to build the fake text (the same for all versions):

VersionWhat changedTimePeak memory
v0the original772 ms300.6 MB
v1stream the words, reuse one buffer, allocate a key only once486 ms33.6 MB
v2v1 + a cheaper hash function471 ms33.7 MB
v3keep the token list, but as 4-byte ids from an interner499 ms52.7 MB
v4one pass over the bytes with a lookup table (ASCII input)151 ms33.7 MB

That is 5.1 times faster than v0 (from 154 ns per word to 30 ns), using about a ninth of the memory, with the same output on this ASCII input (I compared a checksum of the output of every version on the generated text; Chapter 39 shows that this was not enough to prove they agree on all inputs, and lists the two differences it found). The chapters explain each step, and, more important, why the biggest single step was not the one we expected.

The programs in this Part were built with rustc 1.91.1, on the same 1-core Xeon VM as before.


Chapter 21. What an allocation really costs

In this chapter: You will learn what happens when you call Vec::new, Box::new, or String::from. You will measure the cost of one allocation (it is small), the cost of fresh memory (it is not), and see why 3 allocations per word can add up to a 5x slowdown.

You need first: Parts III and IV.

The problem first

Most Rust programmers know that allocating is "slow", and that stack values are "fast". But how slow? If a malloc costs 15 ns, then 3 million of them cost 45 ms. In our program that is a small part of 770 ms. So why did removing allocations make the program 1.6 times faster in the first step, and why did the memory drop by a factor of nine?

The answer is that a heap allocation has several costs, and the obvious one is the smallest.

The model (the "what")

The heap and the allocator

Your program gets memory from the operating system in big chunks (pages, Chapter 10). Handing out small pieces of those chunks, one allocation at a time, is the job of the allocator: a library that offers malloc (get memory) and free (give it back). Rust's standard library calls the system allocator by default. On Linux, that is usually glibc's malloc. You can replace it with another allocator by declaring a global allocator in your program (we do this below with mimalloc).

A heap allocation is a request: "give me n bytes". The allocator looks in its bookkeeping for a free piece that fits. If it finds one, it marks it as used and returns a pointer. If not, it asks the operating system for more. A free puts the piece back in the bookkeeping, so it can be reused.

Modern allocators have a fast path for small requests: a per-thread list of recently freed pieces, so that free followed by malloc of the same size is often just a couple of pointer moves. glibc calls this its tcache (thread cache). I know that glibc keeps such a cache for small sizes, up to about a kilobyte by default. I did not look inside it here. What we can do is measure the effect.

The five costs of an allocation

When you allocate, you pay up to five different costs:

  1. The call itself. Finding a piece of memory and updating the bookkeeping. This is the "malloc cost" people think of.
  2. The free. A second call, later, to give it back. Often costs about as much as the allocation.
  3. The memory you did not need. Every allocation has a hidden header and is rounded up to a multiple of 16 bytes or so. A 5-byte string may take 24 or 32 bytes of heap. The waste is called overhead.
  4. The cache misses. Every separate allocation is a separate place in memory. Following 5 million pointers to 5 million little strings is the pointer chase of Chapter 8.
  5. The page faults. When the allocator needs more memory from the kernel, the kernel hands over pages that have never been touched. The first touch of each page causes a page fault (Chapter 15), which is much more costly than a normal memory access.

The first two are the "small" costs. The last three scale with how much memory you use, and that is why allocating less memory is a bigger win than allocating faster.

Build it (the "how")

Experiment 1: the cost of an allocate-and-free pair

Allocate a buffer of size bytes, write one byte to it (so the compiler cannot delete the allocation), and free it, 5 million times. Time per pair:

for _ in 0..n {
    let mut v: Vec<u8> = Vec::with_capacity(black_box(size));
    unsafe { *v.as_mut_ptr() = 1; v.set_len(1); }
    black_box(&v);
}
     bytes         ns
        16       13.6
        64       13.8
       256       13.6
      1024       13.6
      4096       26.9
     65536       27.5
   1048576       26.9

(I write unsafe { set_len(1) } only to touch one byte without writing a whole buffer. It is a measurement trick, not a style to copy.)

Two flat steps. Up to 1 KiB, an allocate-and-free pair costs about 13.6 ns. From 4 KiB up, about 27 ns: twice as much. The jump sits between 1,024 and 4,096 bytes, which fits what I know about glibc's small-size cache (it covers up to about 1 KiB), but I did not confirm that this is the cause.

The main fact: one allocation and one free cost about 14 ns on this machine, for small sizes. That is fast. About 40 cycles. Three million of them take about 41 ms, which is about 5% of our original 770 ms. So the "call" cost cannot explain what we saw.

Experiment 2: many live objects, not one at a time

The test above allocates and frees right away, so the allocator hands back the same piece each time and everything stays in cache. Real programs keep many allocations alive. Let's create two million small objects (32 bytes each) and keep them all, in three ways:

HowTime per object
Vec<Box<[u64; 4]>>: one malloc per object48.8 ns
Vec<[u64; 4]>: values stored inline, one big allocation14.3 ns
a bumpalo arena (Chapter 23): bump a pointer, free everything at once14.3 ns

The one-malloc-per-object version is 3.4 times slower than both the inline vector and the arena. Note that its cost per object (48.8 ns) is much higher than the 13.6 ns we measured for allocate-then-free. The difference is the other costs from the list: the allocator now has to find new memory for each object (not recycle a hot piece), the pieces are spread out, and the final free of two million separate pieces takes time too.

Experiment 3: small strings, heap or inline

Five million tiny byte strings (4 to 11 bytes each), built and then dropped:

HowTime per string
Vec<u8>: a heap allocation each16.7 ns
SmallVec<[u8; 16]>: stored inline when small (from the smallvec crate)4.2 ns

Four times faster, and no heap allocation at all, as long as the string fits in the inline space. (A SmallVec falls back to the heap for longer data. Part of the cost of this design is that the value is bigger: 16 bytes of inline space plus bookkeeping.)

Experiment 4: the cost of fresh memory

Allocate 256 MiB, and touch one byte in every 4 KiB page. Do it twice:

first touch :   111.9 ms =   1707 ns per 4 KiB page
second touch:     6.4 ms =     97 ns per 4 KiB page

The first touch of a page costs 1,707 ns. The second costs 97 ns. A fresh page is 17.6 times more expensive than a page we already own. That is the page fault: the kernel must find a physical page, fill it with zeros (so one program cannot read the old data of another), and map it. (This VM adds an extra layer of translation, so a bare-metal machine is likely faster here. Treat 1,707 ns as an upper-end number.)

Now put it together for our word counter. At 5 million words, v0 touched about 300 MiB, which is about 76,000 pages (and perf stat counted 76,485 page faults). At about 1.7 µs per fault, that is about 130 ms of page-fault time, which matches the sys time (0.1 to 0.16 s) from Chapter 14. And the improved versions have 8,129 page faults, and the kernel's share nearly disappears.

Experiment 5: just use a faster allocator?

A tempting shortcut: do not change the code, change the allocator. With the mimalloc crate, it is three lines:

#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

Results, median of 5 runs, the full job, same machine:

Versionglibc mallocmimalloc
v0 (the original, 3 allocations per word)772 ms594 ms (23% faster)
v1 (streamed, almost no allocations)486 ms474 ms
v4 (byte-wise)151 ms151 ms

For the original code, a better allocator is a free 1.3x speed-up: that is real, and it costs three lines. But look at the next rows. Once the code stops allocating, the allocator does not matter at all. And v4 with the worst allocator is 3.9 times faster than v0 with the best one.

The lesson is about ordering: first allocate less, then (maybe) use a faster allocator. A faster allocator makes a wasteful program a little less wasteful. It cannot make it efficient.

Why it is this way (the "why")

Why is a general-purpose malloc not "free" even though it is so well tuned? Because it must solve a hard problem: it knows nothing about your program. Any size may be requested at any time. Any piece may be freed in any order. Threads may allocate in one thread and free in another. The program may run for a month. The allocator must keep fragmentation (wasted gaps between used pieces) low, stay fast, stay safe, and use little memory for its own bookkeeping, all at once. It makes a good average choice, and it cannot use the one thing that makes a huge difference: you know when a group of allocations will die together, and it does not.

That is the key to everything in Chapter 23. If you know that a hundred allocations will all be freed at the same moment, you do not need a general allocator for them. A bump pointer is enough, and a single free at the end. You trade flexibility (freeing one object at any time) for speed and locality.

There is a deeper principle here, and it recurs in all of systems work: the cost of generality. A general mechanism pays for every case it might need to handle. A specialized mechanism pays only for the case you have. The way to speed is very often to find out which special case you are in. Berger, Zorn, and McKinley studied this question carefully in 2002 (reading list). They compared custom allocators written for eight real programs against a good general-purpose allocator. For six of the eight, the general allocator did as well or better. The two exceptions were programs that used regions (our "free everything at once" idea), where the custom approach gained up to 44%. So the answer is not "custom allocators are fast". It is "regions are fast, when your lifetimes fit them".

What would make this wrong? On some workloads, allocation really is the main cost: a server that makes millions of small allocations per second across many threads can hit allocator contention (threads waiting for each other, Part VI). A better allocator can help a lot there. And our experiments are one machine, one allocator version. So measure on your workload.

Common mistakes

  • Blaming malloc speed. One call is cheap. The memory footprint and the cache and page effects are not.
  • Swapping the allocator before reducing allocations.
  • Forgetting the free. Dropping millions of small objects at the end takes real time, and it appears in the profile as free and drop.
  • Counting only the allocation count. Also count bytes, and the peak.
  • Measuring allocation in a loop that frees right away. It shows the best case, which is about 14 ns, and hides the real cost of a big working set.
  • Ignoring page faults. They appear as kernel time, not in your function.

Say it simply

An allocation has five costs: the malloc call, the free, the wasted bytes in headers and rounding, the cache misses from scattered pieces, and the page faults from fresh memory. The call itself is cheap, about 14 ns here, but a one-malloc-per-object layout was 3.4 times slower than an inline vector, and a fresh memory page cost 17 times more than a reused one. A faster allocator gave our original program 1.3x, but removing the allocations gave 5x, and after that the allocator no longer mattered. Allocate less first.

Check yourself

Exercise 21.1 (Warm-up): Count the costs. A function builds a Vec<String> of 1 million 6-character strings. List the five allocation costs and say which ones apply, and roughly how many heap blocks and bytes this uses (a String is 24 bytes on a 64-bit machine, plus its heap buffer).

Exercise 21.2 (Warm-up): Page math. A program uses 300 MiB of memory it never touched before. How many 4 KiB pages is that? At 1,707 ns per fault, how long do the faults take? At 200 ns per fault?

Exercise 21.3 (Core): Your allocation cost. Run Experiment 1 on your machine. Find where the step in cost happens. Compare with the 1 KiB guess. Then run it with mimalloc as the global allocator. What changes? Done when: a table of both allocators and 4 sentences about the differences.

Exercise 21.4 (Core): Many live objects. Run Experiment 2. Then change the object size to 16 bytes and to 128 bytes. How does the gap between Box and inline change? Why?

Exercise 21.5 (Core): Fresh versus reused memory. Run Experiment 4. Then repeat it with huge pages (Chapter 10, Exercise 10.5) or with the memory pre-touched by a first pass. How much of the first-touch cost can you remove?

Exercise 21.6 (Stretch): Fragmentation. Write a program that allocates 1 million 100-byte blocks, frees every other one, then allocates 1 million 150-byte blocks. Compare peak memory with what you would expect. What happened to the freed 100-byte holes?

Exercise 21.7 (Stretch): Design and defend. A compiler front end makes about 20 million small allocations while parsing a big file, and frees them all when the file is done. List two ways to make it faster. Which one would you try first, and what would you measure to know it worked?

What a strong answer includes: a region or arena for allocations that die together, a faster general allocator as a cheap first experiment, the counts of allocations and peak memory as measures, and the trade-off that individual objects can no longer be freed early.


Chapter 22. Heap profiling with DHAT

In this chapter: You will learn to ask a program exactly where its allocations come from, how many bytes and blocks each place makes, and how long they live. We use dhat, a Rust heap profiler, on the word counter. It will show three allocations per word, and tell us which three.

You need first: Chapters 18 and 21.

The problem first

In Chapter 18, hotpath told us that tokenize allocated 451 MB. That is a where (which function) and a how much. But it does not say which line, how many separate allocations, or how long they lived. To fix an allocation problem, you need all four.

The model (the "what")

DHAT stands for "Dynamic Heap Analysis Tool". It started as a tool inside Valgrind (the family of Chapter 20). The Rust crate dhat (by Nicholas Nethercote) brings the same idea to Rust without Valgrind: you declare a special global allocator that wraps the normal one and records every allocation, with the call stack that made it. Because it runs inside your program, it is much faster than Valgrind, but it can only see Rust allocations, and it does not record how often the memory was read or written (that needs the Valgrind version).

For every allocation, the profiler records:

  • the stack that asked for it (so we can name the line),
  • the size in bytes,
  • when it was freed (so we know its lifetime).

Allocations that come from the same stack are grouped into a program point (the original tool's name for "a place in the code that allocates"). For each program point you get:

FieldMeaning
total bytes (tb)The sum of the sizes of all allocations from here
total blocks (tbk)How many separate allocations from here
max live (mb)The most bytes from here that were alive at the same moment
the stackThe call chain, with file and line

And three global numbers in the summary:

  • Total: all bytes and blocks, over the whole run.
  • At t-gmax: the live memory at the moment the heap was at its biggest (t-gmax = "time of global maximum"). This is your peak heap.
  • At t-end: what is still alive at the end. A big number here can mean a leak.

Build it (the "how")

Add the crate as an optional feature, so that normal builds are not affected:

[dependencies]
dhat = { version = "0.3.3", optional = true }

[features]
dhat-heap = ["dep:dhat"]
#[cfg(feature = "dhat-heap")]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;

fn main() {
    #[cfg(feature = "dhat-heap")]
    let _profiler = dhat::Profiler::new_heap();   // writes dhat-heap.json when it is dropped
    // ... the program ...
}

Run it with the feature on. I used 1 million words (not 5 million), because tracking is slow and the shape is the same:

cargo run --release --features dhat-heap -- 1000000 v0
dhat: Total:     77,954,373 bytes in 3,038,526 blocks
dhat: At t-gmax: 38,667,424 bytes in 1,000,034 blocks
dhat: At t-end:  1,024 bytes in 1 blocks
dhat: The data has been saved to dhat-heap.json, and is viewable with dhat/dh_view.html

Read the three lines:

  • 3,038,526 blocks for 1,000,000 words: 3.04 allocations per word. Exactly what the structure of the code suggested, and now it is a number we can check. We said it in Chapter 18 as "about 90 bytes per token". Here: 77.95 MB / 1 million words is 78 bytes per word, and 8 MB of that total is the fake text itself.
  • 38.7 MB live at the peak, in about 1 million blocks. That is the Vec<String> of tokens: 24 bytes per String plus its heap piece. Scale it up to 5 million words and you get roughly the 300 MB peak that we measured. The peak, not the total, is what drives the page faults.
  • 1 KB alive at the end. No leak.

The viewer (dh_view.html, which comes with Valgrind) shows the program points as a tree you can sort. I did not have a browser to open it, so I read the JSON file with a short script instead. The top program points, by total bytes:

ShareBytesBlocksAverage blockWhere
64.6%50,331,552192.6 MBtokenize, the tokens.push(...) that grows the vector
11.1%8,615,8081,038,4888.3 BString::push, growing a string char by char
10.3%8,000,00018 MBmake_text, the fake input (not our concern)
7.1%5,499,4241,000,0005.5 BString::clone in count
7.1%5,499,4241,000,0005.5 Bto_lowercase in tokenize

Now we have all four things. And look at the first row, which nobody guessed.

The biggest source of bytes is 19 allocations. Not millions. Nineteen. These are the growth steps of one vector: tokens: Vec<String> starts empty and is pushed to. Each time it is full, Rust allocates a new buffer twice as big, copies everything over, and frees the old one. The sizes go 4, 8, 16, ... up to millions. The total of all these buffers is about twice the final size, and every step copies all the data again. For a vector that ends up with 1 million String values (24 MB), this adds up to 50 MB allocated and a lot of copying. The final allocation is the largest, so it also causes the biggest page-fault storm.

You could not see this in a profile that counts calls (19 is nothing), only in one that counts bytes. This is why we look at both columns.

The rest of the picture is the three allocations per word:

  1. to_lowercase makes a new String (1 block per word).
  2. collect::<String>() of the filtered characters grows a String char by char (about 1 block per word, a bit more when it needs to grow).
  3. clone in count makes a copy of the key for the map on every lookup, even when the word is already in the map, which is almost always (there are only 26 distinct words in 5 million). That copy is thrown away right after.

The third one is a classic mistake. *m.entry(t.clone()).or_insert(0) += 1 allocates a String before the map has even looked to see if it has the key.

The same tool on the fixed version

cargo run --release --features dhat-heap -- 1000000 v4
dhat: Total:     8,007,892 bytes in 46 blocks
dhat: At t-gmax: 8,004,058 bytes in 13 blocks
dhat: At t-end:  1,024 bytes in 1 blocks

46 blocks, in total, for 1 million words. Compare with 3,038,526. Almost all of the 8 MB is the fake input text (8,000,000 bytes). The counting code itself allocates under 8 KB, in about 45 blocks: the map and its 26 keys. That is the result of Chapter 23. The profiler confirms that the change did what we meant: a program that makes 66,000 times fewer allocations.

Why it is this way (the "why")

Why does a heap profiler need stacks and not only totals? Because the same line of library code (String::push, Vec::reserve) is called from many places. If you only know "reserve allocates a lot", you learn nothing. If you know "reserve called from tokenize line 36", you can act. A stack turns a library's behavior into your behavior. This is the same reason flamegraphs keep stacks (Chapter 16).

It is also a good place to see that different measures show different things:

  • Total blocks tell you about the time cost of the calls (Chapter 21: cost per call).
  • Total bytes tell you about memory traffic and copying.
  • Max live (peak) tells you about memory footprint, page faults, and cache.
  • Lifetime tells you whether an arena could help (do these die together?).

The 19-block, 50-MB vector shows up only in the second and third measures, and the 1-million-block clone shows up only in the first. If you only watched one, you would miss half the story.

The deeper point: an allocation profile is a map of a program's data flow. Every allocation is a decision: "this value needs its own home on the heap". Reading the profile is asking, for each decision, "does it need to?" For most of the three allocations per word, the answer is no: the temporary String is only used to build the lowercase word, and the clone is only needed once, the first time a word is seen. This habit of asking whether a value needs its own allocation is more valuable than any tool.

What would make this wrong? dhat slows the program a lot, so the timing of the run is not meaningful (use it for counts and bytes). It sees only allocations that go through Rust's global allocator (not memory from mmap calls, or from C libraries that call malloc directly). It does not tell you how the memory was used (read or written). And one run on one input shows one pattern: an input with many distinct words would stress the map, where ours does not (26 words).

Common mistakes

  • Looking only at the number of blocks, or only at bytes. Use both, and the peak.
  • Trusting timing numbers from a profiled run.
  • Profiling an input unlike the real one. Our 26-word vocabulary hides the cost of map growth.
  • Forgetting that Vec and String growth is a series of allocations. Use with_capacity when you know the size.
  • Ignoring the allocation hidden inside clone, to_string, format!, collect, and to_lowercase. They are easy to miss in the source and obvious in the profile.

Say it simply

DHAT records every allocation with its call stack, size, and lifetime, and groups them by place in the code. On our word counter it showed 3.04 allocations per word, a 64% share of bytes from just 19 allocations (one vector growing by doubling), and a clone that made a copy of the key on every lookup. After the fix, the same input made 46 allocations in total. Totals, peaks, and lifetimes each tell a different part of the story, so read all of them.

Check yourself

Exercise 22.1 (Warm-up): Doubling. A Vec starts empty and grows by doubling from capacity 4. It receives 1 million elements of 24 bytes. How many allocations does it make? What is the sum of the sizes of all buffers, compared with the final one? (Use the doubling sum.)

Exercise 22.2 (Warm-up): Read the summary. A profile says: "Total: 2 GB in 50 million blocks. At t-gmax: 20 MB in 10,000 blocks." What does this say about the program? Is it more likely to suffer from allocation call cost or from a large footprint?

Exercise 22.3 (Core): Profile it yourself. Add dhat to the word counter as shown, and run v0 on 1 million words. Match each program point to a line of code. Then count how many blocks and bytes you save by adding Vec::with_capacity for the token list alone. Done when: a table of before and after for the three numbers (total bytes, total blocks, peak).

Exercise 22.4 (Core): Find the clone. In the original count, change m.entry(t.clone()) into a version that only clones when the key is new. Run dhat again. How many blocks did you save? How does time change?

Exercise 22.5 (Core): Lifetimes. Using the JSON file, compute for each program point the average lifetime of its blocks (the JSON file has lifetime fields for each point; check the file format notes in the dhat documentation for their names and units). Which allocations die the youngest? What does that suggest about reuse?

Exercise 22.6 (Stretch): Peak versus total. Write two programs that both allocate 1 GB in total, but one has a 10 MB peak and the other a 1 GB peak. Run dhat on both. Time them on your machine. Which is faster, and why?

Exercise 22.7 (Stretch): Design and defend. A teammate says: "Our service allocates 4 GB per request, but only has 200 MB live at once, so it is fine." Explain why this might or might not be fine, and what you would measure to decide.

What a strong answer includes: the difference between total and peak, the cost of allocation calls and of memory traffic at 4 GB, the cache and page-reuse behavior of a recycling allocator, and a measurement plan (allocation rate, time in malloc and free from a profile).


Chapter 23. Reuse, arenas, SmallVec, and interning

In this chapter: You will learn the four standard ways to remove allocations: reuse a buffer, stream instead of collecting, intern repeated values, and use an arena for things that die together. We apply them to the word counter one at a time, and measure every step. One of them gives almost nothing, and one of them gives 3x.

You need first: Chapters 21 and 22.

The problem first

DHAT showed us three allocations per word and a vector that grows by doubling. We know what is wasteful. Now: what do we change, in what order, and how do we know each change helped? A common mistake is to apply every trick at once and never learn which one mattered. We will do the opposite: one change per step, one measurement per step.

The model (the "what")

Four ideas, from the simplest to the most specialized. For each one, the question is: which allocation do I remove, and what do I give up?

1. Reuse a buffer. If you build a temporary String or Vec inside a loop, create it once outside the loop, and clear() it at the start of every iteration. clear() sets the length to zero but keeps the capacity, so after the first few iterations, no more allocation happens. Gives up: a little code clarity (the buffer is now state that lives outside the loop).

2. Stream, do not collect. If you build a big Vec only to loop over it once, loop over the source directly and skip the Vec. The vector exists to hold the data between two steps. If one step can feed the next, nothing needs to be stored. Gives up: the ability to look at the whole list again (a second pass, random access, sorting).

3. Intern. If the same value appears many times (a word, an identifier, a type name), store it once in a table, and refer to it by a small number, called an id or a symbol. Comparing two interned values is comparing two integers (instead of two strings), and storing them takes 4 bytes instead of 24 plus a heap piece. This is how most compilers handle identifiers. Gives up: one table lookup when you first meet a value, and a table that lives as long as the ids do.

4. Use an arena. An arena (also called a bump allocator or a region) takes one big block of memory, and hands out pieces by moving a pointer forward. Allocating is "add n to the pointer" (a few instructions). There is no free for individual objects: you free the whole arena at once. Gives up: freeing single objects early. If objects have very different lifetimes, an arena keeps memory longer than needed.

A fifth idea is smaller but very useful, and we measured it in Chapter 21: store small things inline. SmallVec<[u8; 16]> keeps up to 16 bytes inside the value itself, and only goes to the heap for longer data. Gives up: a bigger value, and a branch on every access.

 One allocation per object          Arena                         Inline (SmallVec)
 ┌───┐   ┌────┐                     ┌────────────────────────┐    ┌───────────────┐
 │ptr├──►│ A  │                     │ A │ B │ C │ D │  ...   │    │ len │ bytes … │
 ├───┤   └────┘                     └────────────────────────┘    └───────────────┘
 │ptr├──►┌────┐   (spread out)       ▲ next free pointer moves      (no pointer to chase)
 └───┘   │ B  │                       forward; free = drop all
         └────┘

Build it (the "how")

We start from v0 and change one thing at a time. The output of every version was compared with the original (same top 5 words and counts).

v0 → v1: stream, reuse a buffer, clone only the first time (1.6x)

Three changes, because they depend on each other: do not build Vec<String>, reuse one String buffer, and look up by &str so we only allocate a key when a word is new.

fn count_stream(text: &str) -> HashMap<String, usize> {
    let mut m: HashMap<String, usize> = HashMap::new();
    let mut buf = String::new();                         // reused for every word
    for raw in text.split_whitespace() {
        buf.clear();
        for c in raw.chars() {
            if c.is_alphanumeric() { for l in c.to_lowercase() { buf.push(l); } }
        }
        if buf.is_empty() { continue; }
        if let Some(n) = m.get_mut(buf.as_str()) { *n += 1; }   // found: no allocation
        else { m.insert(buf.clone(), 1); }                        // new word: one allocation
    }
    m
}

Why get_mut and then insert, and not entry? The entry API takes the key by value, so you must have already allocated it. Looking up with a borrowed &str first avoids that. (Another option is the raw_entry API, which is not stable. The two-step version looks up the word twice when it is new, but a new word is rare, so we do not mind.)

Result: 772 ms to 486 ms (1.6x), and peak memory from 300.6 MB to 33.6 MB (about 9x less). The allocations went from about 3 per word to about 26 in total (one per distinct word, plus the map). Notice the time did not fall as much as the memory did. We removed the allocator and the page faults, and what is left is the actual work of looking at characters, which we have not touched yet. Hold that thought.

v1 → v2: a cheaper hash function (almost nothing)

The standard HashMap uses SipHash, a hash function designed to be hard to attack with specially chosen keys (this matters for servers that take input from strangers). It is slower than simple hashes. The rustc-hash crate provides FxHash, the simple fast hash that the Rust compiler itself uses.

use rustc_hash::FxHashMap;
let mut m: FxHashMap<String, usize> = FxHashMap::default();

Result: 486 ms to 471 ms (3%). Almost nothing. Remember that in Chapter 16, hash_one and rustc_entry showed up in the profile at about 5% each, and our first guess was that the hash map was the main cost. This change confirms how small it is. On this input (26 short words, so the map is tiny and stays in L1), hashing a 5-byte word costs very little either way.

A word of care: FxHash is not safe against hostile input. An attacker who controls the keys can choose ones that all land in the same bucket, and slow your program to a crawl. Use it for internal data (compiler symbols, your own IDs), and keep SipHash where strangers choose the keys.

This is a good example of an optimization to skip. It was the first thing a typical programmer would try. It took a one-line change and gave 3%, because it attacked a cost that was not there.

v1 → v3: intern the words, keep the token list as ids (5.7x less memory than v0, with the list kept)

Sometimes you cannot stream: you need the token list afterward (to look at it again, to sort it, to pass it to the next stage of a compiler). Then the memory for the list matters. Instead of Vec<String> (24 bytes plus a heap piece, per token), keep Vec<u32>: one 4-byte id per token, plus a table of each distinct word once.

struct Interner { map: FxHashMap<Box<str>, u32>, names: Vec<Box<str>> }

impl Interner {
    fn intern(&mut self, s: &str) -> u32 {
        if let Some(&id) = self.map.get(s) { return id; }   // seen before: return its id
        let id = self.names.len() as u32;
        let b: Box<str> = s.into();
        self.names.push(b.clone());
        self.map.insert(b, id);
        id
    }
}
// tokens: Vec<u32>, with capacity reserved up front
// counting becomes:  counts[id] += 1   (a plain array, no hashing at all)

Result: time 499 ms (about the same as v1), peak memory 52.7 MB. Remember what a kept token list costs in the original design: 300 MB. Here, we keep all 5 million tokens, as u32 ids, in 20 MB, and the total peak is 52.7 MB (that includes the 38 MB of input text and ids). So v3 is 5.7 times less memory than v0 while still keeping the full token list, and about 1.5x faster than v0 too (499 ms against 772 ms). It is not faster than v1, because it still does the same slow work on each character.

Counting is now counts[id] += 1: a plain array index, with no hashing and no string comparison. The expensive lookup moved to the interning step. For a compiler, which will compare identifiers many times after the first lookup, that is a good trade: pay for the string once, then work with integers.

v2 → v4: look at the profile again (3.1x)

After v1 and v2, the program takes 471 ms for 5 million words: 94 ns per word. That is a lot for work that is "look at 6 characters". We follow our own rule from Part IV: profile again. The profile of v2:

    58.82%  wf::count_stream_fx            (the char loop is inlined into it)
     9.08%  core::char::ToLowercase::next
     6.57%  __memcmp_evex_movbe
     6.57%  wf::main
     5.80%  Iterator::try_fold
     5.10%  core::unicode::conversions::to_lower
     4.41%  __memmove_avx512_unaligned_erms
     1.21%  kernel (page faults)

The shape has completely changed. malloc, free, and the page-fault code are gone from the top. What is left is computing: decoding UTF-8 into chars, asking the Unicode tables "is this alphanumeric?" and "what is the lowercase of this?", and re-encoding each character into the String. For ASCII text, all of that is wasted generality. The page-fault count fell from 76,485 to 8,129.

This is a very important moment, and I want you to see it clearly. We removed the biggest cost (memory), and the profile now shows a different cost (work), which was hidden before. Performance work is a loop: fix the biggest thing, measure, find the new biggest thing. Without the loop, you stop at 1.6x and think you are done.

The new fix: when the input is ASCII, every character is one byte, and a 256-entry lookup table can map each byte to "its lowercase letter or digit, or 0 if we should drop it". We scan the bytes once, with no chars(), no Unicode tables, and no intermediate string.

const fn build_table() -> [u8; 256] {
    let mut t = [0u8; 256];
    let mut i = 0;
    while i < 256 {
        let b = i as u8;
        t[i] = if b.is_ascii_digit() || b.is_ascii_lowercase() { b }
               else if b.is_ascii_uppercase() { b + 32 } else { 0 };
        i += 1;
    }
    t
}
static TABLE: [u8; 256] = build_table();

fn count_bytes(text: &str) -> FxHashMap<Box<[u8]>, usize> {
    let mut m: FxHashMap<Box<[u8]>, usize> = FxHashMap::default();
    let mut buf: Vec<u8> = Vec::with_capacity(64);
    let mut flush = |buf: &mut Vec<u8>| {
        if buf.is_empty() { return; }
        if let Some(n) = m.get_mut(buf.as_slice()) { *n += 1; }
        else { m.insert(buf.as_slice().into(), 1); }
        buf.clear();
    };
    for &b in text.as_bytes() {
        if b.is_ascii_whitespace() || b == 0x0B { flush(&mut buf); }   // the vertical tab too (see Chapter 39)
        else { let l = TABLE[b as usize]; if l != 0 { buf.push(l); } }
    }
    flush(&mut buf);
    m
}

fn v4(text: &str) -> Vec<(String, usize)> {
    if !text.is_ascii() { return v2(text); }   // keep the correct Unicode path for other input
    /* ... */
}

Two honest notes, and a correction. First, v4 is a fast path for ASCII text. The original handled Unicode (it lowercases É to é, and keeps letters from other alphabets). The version above checks text.is_ascii() once, and falls back to the streaming Unicode version otherwise. Correction, added after Chapter 39: my first draft of this paragraph said that this "never gives a wrong answer", and that the output matched the original, because a checksum on our generated text matched. That was not true in general. A proper differential test (Chapter 39) found two differences. (1) The first version of the loop below used is_ascii_whitespace(), which does not count the vertical tab (U+000B) as whitespace, while the original's split_whitespace does: v4 was wrong on 0.79% of random ASCII inputs. The code now includes || b == 0x0B, and 2,000,000 random ASCII inputs show no difference. (2) The streaming versions (v1 to v3, and the Unicode fallback of v4) lowercase one char at a time, and the original lowercases the whole word, which matters for the Greek capital sigma (final sigma ς against σ): they differ from the original on 6.07% of random Unicode inputs from a small alphabet that includes it. This is a documented difference, not a fixed bug. For English text it does not matter. Decide for your own input, and test it.

Result: 471 ms to 151 ms (3.1x), and with the first step, 772 ms to 151 ms, 5.1x overall. That is 30 ns per word, from 154 ns.

Notice what gave the 3.1x here: not an allocator, not a data structure, but removing generality we did not need. This is the "cost of generality" from Chapter 21 again, in a different form: a Unicode-correct char loop pays for every language on Earth, to process text that is ASCII.

The arena and SmallVec, measured

We did not need an arena for the word counter, because streaming removed the allocations altogether. But our microbenchmark (Chapter 21) showed what it would give if we did need to keep 2 million small objects: 14.3 ns per object instead of 48.8 ns with Box, the same as a plain vector of values. In bumpalo it looks like this:

let bump = bumpalo::Bump::new();
let node: &mut Node = bump.alloc(Node { /* ... */ });   // a pointer bump, no malloc
// no individual free; when `bump` is dropped, everything is released at once

This is the standard tool for compilers, where the parse tree and the type information are built in one pass and all die together at the end. An arena also improves locality: nodes allocated one after another sit next to each other, which the prefetcher likes (Chapter 12), unlike Box nodes that were allocated at different times. Chapter 33 builds an AST this way.

SmallVec<[u8; 16]> gave 4.2 ns against 16.7 ns for Vec<u8>. It is a good fit when most values are small and a few are large (for example, identifiers, small lists of operands, or the children of a tree node).

Why it is this way (the "why")

Look at the scoreboard from the start of this Part, and sort the steps by what they gave:

StepGainWhat it attacked
v0 → v1: stream, reuse, clone once1.6x, 9x less memoryallocation count and memory footprint
v1 → v2: cheaper hash1.03xa cost that was only 5%
v1 → v3: intern idsabout the same speed, 5.7x less memory than v0 while keeping the listmemory of a kept list
v2 → v4: bytes and a table3.1xwork that the real problem did not need

Two lessons come out of this table.

Lesson 1: the order of the profile is the order of the work. The first profile said "allocation". We fixed it and gained 1.6x. The second profile said "Unicode character handling". We fixed that and gained 3.1x. If we had only looked at the first profile, we would have stopped at 1.6x. If we had looked at neither and guessed "the hash is slow", we would have gained 3%.

Lesson 2: removing work beats doing work faster. Every big gain here came from making something not happen: no Vec<String>, no temporary strings, no Unicode tables. A faster allocator (mimalloc) made allocation 23% cheaper. Removing allocation made it free. This is why the best performance advice is dull: do less.

Let me also tell you what the philosophy of an arena is, because it generalizes beyond memory. An arena is a bet on lifetimes: that many objects will be born around the same time and die around the same time. If the bet is right, you can manage them as a group. This is the same shape as the cache bets in Part III, applied to time instead of space. And the same trade applies: when the bet is wrong (one object in the arena should live for an hour while a million others die in a second), the arena wastes memory. Interning is a bet on repetition: that values repeat, so storing them once saves more than the lookup costs. When values never repeat, interning is pure overhead.

What would make these ideas wrong? Three things:

  • Correctness traps. Reusing a buffer means a bug can leak the last iteration's data into the next. An arena with references into it ties lifetimes together (Rust checks this for you, which makes it safer than in C, but it can make the code harder to write). The ASCII fast path is wrong for non-ASCII input unless you guard it.
  • A different workload. Our input has 26 distinct words. With millions of distinct words, the map and its misses (Chapter 12) dominate, and interning and the hash function matter much more. Always ask: is my test input like the real one?
  • The measurement is one machine. The ratios (1.6x, 3.1x) are about this CPU and this allocator. The direction should hold. The exact numbers will not.

Common mistakes

  • Applying every trick at once. You will not know which one worked, and one of them may have made things worse.
  • Optimizing the hash function first. Measure how much time the hash takes before you change it.
  • Using an arena for objects with very different lifetimes. You will hold memory longer than needed.
  • Interning data that never repeats.
  • Forgetting to guard a fast path. The ASCII-only version needs an is_ascii check or a fallback.
  • Stopping after the first win. Profile again.

Say it simply

There are four main ways to remove allocations: reuse a buffer, stream instead of collecting, intern repeated values into small ids, and use an arena for objects that die together. On the word counter, streaming and reuse gave 1.6x and 9x less memory, a faster hash gave 3%, interning kept the token list 5.7x smaller than the original, and after profiling again, an ASCII lookup table gave another 3.1x, for 5.1x overall. The first profile found the memory problem, the second found the work problem, and nearly every gain came from making something not happen.

Check yourself

Exercise 23.1 (Warm-up): Which idea? For each, pick reuse, stream, intern, arena, or inline: (a) a loop that builds a format! string each iteration and prints it; (b) a compiler that parses a file into a tree, analyzes it, then throws it all away; (c) a symbol table with the same variable name appearing 10,000 times; (d) a long list of small Vec<u32>s that mostly hold 3 elements.

Exercise 23.2 (Warm-up): The memory math. A Vec<String> of 5 million 6-character words uses about how many bytes if each String is 24 bytes and each heap piece is 32 bytes? How many bytes would a Vec<u32> of ids use? (Compare with the 300 MB we measured.)

Exercise 23.3 (Core): Reproduce the scoreboard. Implement v0 to v4 yourself, check that all of them give the same output, and measure them (median of 5 runs). Do your ratios match 1.6x, 1.03x, and 3.1x? Done when: a table with your times, your peak memory, and a one-line note on each step.

Exercise 23.4 (Core): Test the hash. Change the input to have 100,000 distinct words (generate them). Rerun v1 and v2. Does the cheaper hash matter now? Add the time of the map's cache misses to your explanation.

Exercise 23.5 (Core): Arena it. Write a version that keeps the token list as Vec<&str> where each token is a slice into a bumpalo arena (copy each word into the arena once). Compare time and memory with Vec<String> and with the interner.

Exercise 23.6 (Stretch): Unicode. Make the fast path handle UTF-8 input correctly for the Latin-1 supplement (like é) without falling back to the slow version for the whole text. How much of the speed do you keep?

Exercise 23.7 (Stretch): Design and defend. You are writing the lexer for a language compiler. The lexer produces a token stream that the parser reads exactly once. Would you collect the tokens into a Vec<Token>, or have the parser pull them one by one? What would change if the parser needs to look ahead three tokens, or backtrack?

What a strong answer includes: the memory and allocation cost of collecting, the idea of a small ring buffer for lookahead, the cost of backtracking over streamed data, and whether tokens carry strings (interning) or ids.


Chapter 24. What the Rust compiler does to your loop

In this chapter: You will learn what the compiler does with bounds checks, iterators, inlining, and release-profile settings, and measure what each one is worth. The surprise: the settings people reach for first (LTO, opt-level, unsafe) gave nearly nothing on our best code, and the one that mattered was a plain function call.

You need first: Chapters 13 and 23.

The problem first

Rust people argue a lot about zero-cost abstractions, about whether bounds checks are slow, and about whether to turn on LTO or codegen-units = 1. Many of these arguments are made with feelings and old benchmarks. Let us measure them on our own machine and our own code.

The model (the "what")

The compiler's pipeline in one picture. rustc turns your code into an intermediate form, hands it to LLVM (the optimizer shared by many languages), and LLVM turns it into machine code. The choices you control are in the profile (Cargo's [profile.release]) and in a few attributes. The knobs that matter:

SettingWhat it doesCost
opt-level (0 to 3, s, z)How hard LLVM optimizes. 3 is the usual release default.Compile time
lto ("link-time optimization")Lets the optimizer see across crate boundaries, so it can inline library code into yours. "fat" looks at everything. "thin" is cheaper.Much more compile time
codegen-unitsSplits a crate into pieces that compile in parallel. Fewer pieces (1) lets the optimizer see more at once.Slower compiles with fewer units
panic = "abort"A panic ends the program, with no unwinding. Smaller code, fewer cleanup paths.You lose catch_unwind and unwinding behavior
-C target-cpu=nativeAllow every instruction this CPU has (Chapter 19).The binary may not run on older CPUs
debug = trueKeeps debug info for profilers. Does not change the machine code.Bigger file

Inlining. To inline a function means to copy its body into the caller, so there is no call. A call costs a few instructions and a jump, but more importantly it blocks other optimizations: the optimizer cannot move code across a call boundary or see what the function does with its arguments. Small functions are almost always inlined automatically. The hints are #[inline] (a suggestion), #[inline(always)] (a strong request), and #[inline(never)] (a ban, which we used in Part IV to make functions visible in profiles).

Bounds checks. When you write v[i], Rust checks that i < v.len() and panics if not. That check is a compare and a branch. The compiler removes it when it can prove the index is in range (for example in for i in 0..v.len()). When it cannot prove it, the check stays.

Iterators. v.iter().map(...).sum() looks like it makes objects and calls closures. Rust promises these are zero-cost abstractions: after inlining, they compile to the same loop you would write by hand.

Build it (the "how")

Experiment 1: bounds checks, iterators, and unsafe

All timings are nanoseconds per element, best of 9, on 1 million u32s.

Loopns per element
for i in 0..v.len() { s += v[i] }0.158
v.iter().map(...).sum() (iterator)0.158
for i in 0..n { s += v[i] } with n not known to the compiler0.173
the same, after let v = &v[..n];0.158
  • Index loop and iterator: identical, 0.158. This is what zero-cost means. (I did not look at the machine code, but the equal times say the compiler made equivalent loops.)
  • A loop with an unknown limit n: 0.173, which is 9% slower. The compiler cannot prove n <= v.len(), so it must check on every step, and the check may also keep it from using wide (SIMD) instructions (I did not read the machine code to confirm). This is a real, but small, cost.
  • The fix is not unsafe. Writing let v = &v[..n] checks once, up front, and then the compiler knows the length. Back to 0.158. Hoisting a check out of the loop is the safe way to remove it.

Now a harder case, where the checks cannot be removed by the compiler: a gather, where the indexes come from data (data[idx[i]]). Here idx values could be anything.

Loop (1 million random indexes into 32 KiB)ns per element
data[i] with a bounds check0.362
get_unchecked(i) (unsafe, no check)0.355
fixed-size array, index & 81910.635
  • The check costs 2%. 0.362 against 0.355. That is the price of a compare and a branch that the CPU predicts almost perfectly (Chapter 13: a predictable branch is nearly free). Writing unsafe code to save 2% is almost never a good trade. And an incorrect get_unchecked is undefined behavior, which is a much worse bug than a panic.
  • The masked version was slower, 0.635, which I did not expect (it should have removed the check). I did not read the machine code, so I do not know why. A guess is that the compiler turned it into a different, worse loop. It is a good warning: a trick that should be faster is not always faster. Measure it.

So, the common belief that "bounds checks are slow" was, here, about 2% in the worst case, and 0% in the common case.

Experiment 2: inlining a small function

Count the vowels in 1 million bytes. The test is a tiny helper function, called once per byte:

Loopns per byte
helper function with #[inline(never)]1.089
helper function with #[inline(always)]0.727

The call version is 50% slower (1.089 against 0.727). A function call per byte costs real time, and it also stops the compiler from combining the work of neighboring iterations. This one is large because the helper is tiny: the call overhead is a big share of the work. (Normally, the compiler would inline a function this small on its own. I forced the call with #[inline(never)] to measure what it costs. If you use #[inline(never)] in a hot loop to make profiles easier to read, as we did in Part IV, you pay this. For a function called a few times and running for 500 ms, it is nothing. For a tiny function in an inner loop, it is a lot.)

Experiment 3: the release profile on the real program

On the v4 word counter (151 ms), I changed the build settings with environment variables and measured the median of 5 runs:

BuildTime
default release (opt-level = 3)157.0 ms
opt-level = 2158.4 ms
lto = "fat", codegen-units = 1157.2 ms
the above + panic = "abort"156.5 ms
-C target-cpu=native177.3 ms
opt-level = 1176.4 ms

Almost nothing helped. The default is already at the floor. Fat LTO, one codegen unit, and panic = "abort" all gave under 1%. Only opt-level = 1 (turning the optimizer down) was clearly worse, at 12% slower. And target-cpu=native was 13% slower. I ran each of these once (median of 5 runs), and did not repeat the native build, so I cannot tell if this is a real effect or noise. A possible reason would be that the compiler chose different, less suitable instructions or code layout for this loop, but I did not look. Exercise 24.3 asks you to find out.

Why did LTO do nothing for v4? Because the hot loop is a tight piece of our own code. There are few calls into other crates for LTO to inline, and the hash map code is already generic code that gets compiled into our crate. LTO helps most when your hot path goes through many function calls across crates. To see that, try the original v0 with the same setting: 772 ms became 677 ms, a 12% gain, because v0 spends its time in library calls (to_lowercase, String::from_iter, hash map code) that LTO can now inline and optimize together.

So the pattern is: compiler settings are worth the most on code that has not been fixed yet, and the least on code that has. That should not surprise you. A fix in the algorithm and in the data (Chapters 21 to 23) removed the opportunity for the settings to matter.

Why it is this way (the "why")

Why is the compiler so good that it makes iterators and index loops identical, but also so bad that a masked index is slower than a checked one? Because an optimizer is a pile of pattern-matching rules, applied in some order. When your code matches a rule, you get a big win. When it almost matches, you get nothing, or something worse. The result is not smooth. This is why performance on the same hardware can change by 2x when you move a line of code, update the compiler, or rename a variable (Mytkowicz's 2009 paper showed related effects on layout, Chapter 1).

The practical conclusion is a rule: write the clear, safe version first. Measure. Only then, if a profile points at a loop, look at what the compiler did (cargo asm, --emit asm, or Compiler Explorer) and change the code to help it: hoist a check, remove a branch, split a chain, provide a fixed size. This is much better than guessing, and much better than unsafe.

There is also a point about trust. The Rust abstractions are zero-cost in the cases we measured, so you can use iterators, closures, and bounds-checked indexing in hot code without fear. The cost that is not zero is the one the compiler cannot see: allocation, memory layout, and the choice of algorithm. That is why Parts III and V, not this chapter, gave the big wins.

What would make this wrong? A different compiler version (new LLVM passes can improve or break a loop), a different CPU, or a loop the compiler does not handle well. Inlining decisions can also change code size, and in a program with large hot code, a bigger binary can mean more instruction-cache misses (the "frontend bound" bucket of Chapter 19). Our test loops are tiny, so this does not show up here.

Common mistakes

  • Reaching for unsafe to remove bounds checks. Hoist the check, or restructure, and measure first. It was 2% here.
  • Turning on every build flag as a ritual. LTO and codegen-units = 1 gave under 1% on our fixed code.
  • Assuming target-cpu=native is always faster. We saw 13% slower, once.
  • Leaving #[inline(never)] in hot code after profiling.
  • Assuming a trick is faster without measuring. The masked index was 75% slower.
  • Ignoring the compile-time cost. Fat LTO with one codegen unit can multiply build time.

Say it simply

The compiler removes iterator and bounds-check overhead in simple loops, so index loops and iterators ran at the same speed here. A bounds check that cannot be removed cost about 2%, and unsafe code is not worth that. An unknown loop limit cost 9% until I checked it once, up front. A forced function call in an inner loop was 50% slower. On our already-fixed word counter, LTO and other build settings gave under 1%, but on the original version, LTO gave 12%. Fix the algorithm and the data first. Settings matter only afterward.

Check yourself

Exercise 24.1 (Warm-up): Cost of a check. A loop does 1 ns of work per element and a bounds check costs 0.02 ns. What is the overhead in percent? What if the work is 0.1 ns?

Exercise 24.2 (Warm-up): Which loop? For each, say whether the compiler can remove the bounds check, and why: (a) for i in 0..v.len() { v[i] }; (b) for x in &v { x }; (c) for i in 0..n { v[i] } with n a parameter; (d) for &i in &idx { v[i] }.

Exercise 24.3 (Core): Find out about native. Build the word counter v4 with and without -C target-cpu=native, 10 times each, and report the median and spread. Is it really slower? Use --emit asm (or cargo asm) to compare the hot loop. What differs? Done when: a table of 10 runs each and a description of one difference in the machine code.

Exercise 24.4 (Core): Hoist it. Find a loop in your own code with an unknown limit and bounds-checked indexing. Measure it, then hoist the check with a slice. How much did you gain?

Exercise 24.5 (Core): The LTO sweep. Build v0 and v4 with lto = false, "thin", and "fat", with codegen-units 16 and 1. Make a table of 6 builds for each program, with time and build time. Where does LTO pay, and where does it not?

Exercise 24.6 (Stretch): The masked index mystery. Find out why data[i & 8191] on a fixed-size array was slower than data[i] on a slice. Read the machine code and check each guess (vectorization, a different load, an extra instruction).

Exercise 24.7 (Stretch): Design and defend. Your team wants to turn on lto = "fat", codegen-units = 1, and panic = "abort" for every service, because "it is free speed". Write a half-page analysis: what would you measure to decide, what are the downsides, and for which programs would you expect a gain?

What a strong answer includes: a benchmark on real services, the build-time cost, losing unwinding (and catch_unwind users), the larger gains on code that calls across crates, and the small gains on tight loops.


Part V wrap-up

What you can now do:

  • Break the cost of an allocation into five parts, and say which part dominates in your program.
  • Measure the cost of a malloc, of a fresh page, and of a one-allocation-per-object layout on your machine.
  • Use DHAT to find where allocations come from, how many bytes and blocks each place makes, and the peak.
  • Remove allocations by reuse, streaming, interning, arenas, and inline storage, and say what each one gives up.
  • Profile again after every fix, and notice when the bottleneck has moved.
  • Measure what bounds checks, iterators, inlining, and release settings are worth, instead of guessing.

The word counter, start to finish:

v0v4
Time (5M words, without making the text)772 ms151 ms
Per word154 ns30 ns
Peak memory300.6 MB33.7 MB
Allocations (1M words, DHAT)3,038,526 blocks46 blocks
Page faults (5M words)76,485about 8,000 (v2; v4 not counted)

Mini project: Make something fast and prove it (6 to 10 hours). Take a program of your own, or the word counter with a different job (for example, count the longest word per line, or find all distinct words and sort them). Then:

  1. Write the performance question, a baseline, and a hypothesis (Parts I and IV).
  2. Profile it with two kinds of evidence (Part IV) and run DHAT (Chapter 22).
  3. Make three changes, one at a time, each from this Part (reuse, stream, intern, arena, inline, or a data-layout change from Part III). Measure after each one (median of at least 5 runs, with the spread).
  4. Profile again after your last change. What is the new top cost?
  5. Verify that the output of the final version equals the original on at least three different inputs, including one unlike your test data.
  6. Write the scoreboard table: change, gain, memory, and what it gave up.

Done when: a report with the scoreboard, the two profiles (before and after), the correctness check, and a paragraph that names the one change that you would not do again, and why.

Write-up prompt: Half a page. Which of your guesses about where the time went was wrong, and how did you find out?

Teach it back: In 5 sentences, explain to a friend why making a program use less memory made it faster even though each individual malloc is cheap.

Reading list for Part V:

  • Emery D. Berger, Benjamin G. Zorn, and Kathryn S. McKinley, "Reconsidering Custom Memory Allocation", OOPSLA 2002. A careful study that found most custom allocators no better than a good general one, with regions as the main exception. Read the abstract and the section on regions.
  • Daan Leijen, Benjamin Zorn, and Leonardo de Moura, "Mimalloc: Free List Sharding in Action", Microsoft Research tech report MSR-TR-2019-18 (2019), also presented at APLAS 2019. The design of the allocator we used.
  • Emery D. Berger, Kathryn S. McKinley, Robert D. Blumofe, and Paul R. Wilson, "Hoard: A Scalable Memory Allocator for Multithreaded Applications", ASPLOS 2000. The classic paper on allocator scalability. It leads into Part VI.
  • Nicholas Nethercote and others, The Rust Performance Book. A practical collection of tips on allocation, hashing, build settings, and profiling in Rust (not freshly checked for this edition).
  • The documentation of the dhat, bumpalo, smallvec, rustc-hash, and mimalloc crates.

Verification notes for Part V: All programs and tables were run in this session on the 1-core Xeon VM, with rustc 1.91.1 for the word counter and the allocation, arena, and SmallVec tests, and rustc 1.75 for the small bounds-check and inlining programs. The time tables use the median of 5 runs of the whole job without the fake-text step. All versions produced identical output on this input (compared by checksum). The dhat totals, the program points, and the 19-block vector are real output from dhat 0.3.3, read from its JSON file. The Berger, Zorn, and McKinley paper (abstract, venue, year), the mimalloc report (title, authors, number, year), and the Hoard reference (venue, year, authors) were checked against the search results in this session. Not verified: the glibc small-size cache as the cause of the 13.6 to 27 ns step (my understanding of glibc, not checked here), the cause of the masked-index slowdown, the cause of the slow target-cpu=native build (run once), the page-fault count of v4 (not measured), and the Rust Performance Book reference. The dhat viewer, cargo asm, and every exercise were not run. The 26-word vocabulary of the test input makes the hash map unusually small, which understates the cost of hashing and cache misses on real text.


Part VI. Many cores, shared state, and wide instructions

After this Part, you will be able to explain why adding threads can make a program slower, find and fix the three classic multi-core problems (false sharing, contention, and remote memory), and use wide SIMD instructions on purpose instead of by luck.

Read this first: an honest limit of this Part. Everything in this guidebook so far was measured, by me, on one machine: a cloud VM with one CPU core. Most of this Part is about several cores working at the same time, and a one-core machine cannot show that. So this Part is different from the others, and I will be strict about it:

  • Measured here, with real numbers: the cost of atomic operations and locks when only one thread uses them (Chapter 26), the cost of starting threads and of rayon's task splitting (Chapter 29), the correctness of a parallel word counter (Chapter 29), and all of SIMD (Chapter 28), because SIMD happens inside one core.
  • Run here, but it cannot show the effect: the false-sharing experiment (Chapter 25). I ran it. It shows no difference between the slow layout and the fast layout, and that result is itself a lesson.
  • Not run here: anything that needs real parallel speed-up, contention between cores, NUMA nodes, or perf c2c. For these I give you complete programs (they compile and run correctly on this machine), the theory, and what to expect, clearly labeled as an expectation and not a measurement. Chapter 27 shows the real error message that perf c2c gave in this VM.

Wherever a result comes from the literature or from my expectation, I will say so with a box like this:

Not run here. (what, and why)

The mini project at the end of this Part asks you to run these programs on a multi-core Linux machine and compare with the expectations. That turns this Part from reading into an experiment.


Chapter 25. Cache coherence and false sharing

In this chapter: You will learn what happens when two cores both hold a copy of the same memory, why a different variable in the same cache line can slow your threads down, and how to fix it with padding. I ran the classic false-sharing test here, and I will explain why it showed nothing.

You need first: Chapters 8 and 9.

The problem first

You have 4 threads. Each one counts something and keeps its own counter. No two threads ever touch the same variable. There are no locks. There is nothing to wait for. You expect 4 times the speed.

You get less speed than one thread.

Nothing in the source code explains it. The cause is in the hardware: the counters sit next to each other in memory, inside one 64-byte cache line, and the cores are fighting over that line.

The model (the "what")

Every core has its own cache

In Chapter 8, we had one CPU and a ladder of caches. A modern chip has several cores, and each core has its own private L1 and L2 caches (the L3 is usually shared). That brings in a new problem: if two cores both keep a copy of the same cache line, and one of them changes it, the other core's copy is now stale (out of date). Reading a stale value would break every program. So the hardware must make sure that all cores agree on what each line contains.

A set of rules that keeps copies consistent is a cache coherence protocol. Coherence means "everyone agrees on what the data is". All of this happens in hardware, with no help from your program, and at the unit of one cache line (64 bytes), not one variable.

The MESI states

The most famous protocol gives every line, in every core's cache, one of four states, named by their first letters (MESI):

StateMeaning
ModifiedThis core has the only copy, and it has changed it. Memory is out of date.
ExclusiveThis core has the only copy, and it has not changed it.
SharedSeveral cores may have a copy. Nobody has changed it. Read-only.
InvalidThis copy is not usable.

(Real chips use extended versions of this idea, with more states. The four-state version is enough to understand the cost.) The key rule is: to write to a line, a core must first be the only one that has it. So before a write, the core sends a message to all other cores: "throw away your copy of this line". Their copies become Invalid. This is called an invalidation.

Now follow two cores that both want to write the same line:

 time  Core A                         Core B
  1    writes x   → line is Modified in A's cache
  2                                   writes y (same line!)  → asks for the line
  3    line is sent to B, A's copy → Invalid.   B's copy → Modified
  4    writes x again → asks for the line back
  5                                   line is sent to A, B's copy → Invalid
  ...  the line "ping-pongs" between the two cores, once per write

Each transfer of the line between cores costs far more than a normal cache hit. The exact cost depends on the chip: it is the time of a trip across the chip (or between chips), and is typically tens of nanoseconds or more. (I did not measure it. The experiment that would is in the Build it section, and it needs two cores.) Compare it with the 1.7 ns of an L1 hit in Chapter 8: a bounce can be 10 to 100 times the cost of a normal access.

True sharing and false sharing

  • True sharing: two cores really use the same variable. The bounce is needed for correctness. Example: two threads adding to the same counter.
  • False sharing: two cores use different variables that happen to live in the same cache line. The bounce is not needed for correctness, but the hardware cannot tell: it only sees "someone wrote to line 17". It bounces the line anyway.

This is the cache-line rule of Chapter 9 turned against you. Bringing in the neighbors was a gift when one core used them. When two cores write to them, it is a tax.

 one cache line, 64 bytes:
 ┌────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┐
 │ cnt[0] │ cnt[1] │ cnt[2] │ cnt[3] │  ...   │        │        │        │
 └────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘
   thread 0   thread 1   thread 2   thread 3      ← four threads, four "separate" counters,
   writes     writes     writes     writes          ONE line. Every write invalidates the other three.

The fix is padding: put each thread's data in its own line, by making each item at least 64 bytes and aligned to 64. In Rust:

#[repr(align(64))]
struct Padded(AtomicU64);          // size 64, align 64: one per cache line

I checked what this does: size_of::<AtomicU64>() is 8, and size_of::<Padded>() is 64 with align_of 64. The crossbeam-utils crate has a ready-made type, CachePadded<T>. I checked its size in this session: size_of::<CachePadded<AtomicU64>>() is 128, with alignment 128. It pads to 128 bytes, not 64, on this platform. A reason (I know it from the crate's documentation, and did not verify) is that some Intel chips fetch cache lines in pairs (the adjacent-line prefetch that we met in Chapter 9, where our stride test showed a 128-byte effect) so 64 bytes of padding may not be enough to avoid sharing the pair.

Build it (the "how")

Here is the complete false-sharing test. Each thread increments its own counter, 20 million times. In the first version the counters are in a plain array (8 bytes apart, so 8 of them share a line). In the second they are padded to 64 bytes.

use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;

#[repr(align(64))]
struct Padded(AtomicU64);

fn run_unpadded(threads: usize, iters: u64) -> f64 {
    let counters: Vec<AtomicU64> = (0..threads).map(|_| AtomicU64::new(0)).collect(); // 8 bytes apart
    let t = Instant::now();
    std::thread::scope(|s| {
        for c in &counters {
            s.spawn(move || { for _ in 0..iters { c.fetch_add(1, Ordering::Relaxed); } });
        }
    });
    t.elapsed().as_secs_f64() * 1000.0
}

fn run_padded(threads: usize, iters: u64) -> f64 {
    let counters: Vec<Padded> = (0..threads).map(|_| Padded(AtomicU64::new(0))).collect(); // 64 bytes apart
    let t = Instant::now();
    std::thread::scope(|s| {
        for c in &counters {
            s.spawn(move || { for _ in 0..iters { c.0.fetch_add(1, Ordering::Relaxed); } });
        }
    });
    t.elapsed().as_secs_f64() * 1000.0
}

(std::thread::scope starts threads that are guaranteed to end before the function returns, so they can borrow local data. We use fetch_add with Relaxed ordering, which Chapter 26 explains.)

I ran it here, with 1, 2, and 4 threads (best of 5, milliseconds):

cores available to this program: Ok(1)
size_of AtomicU64 = 8, size_of Padded = 64, align_of Padded = 64

each thread does 20000000 increments of its OWN counter (best of 5, ms)
 threads   adjacent (8 B)    padded (64 B)
       1            128.2            128.3
       2            254.7            254.8
       4            511.5            509.9

Read this carefully, because the result is the opposite of what the chapter promises, and the reason is the lesson.

There is no difference between the two layouts. Padding changed nothing: 128.2 against 128.3 ms for one thread, 254.7 against 254.8 for two, and 511.5 against 509.9 for four. The time also grows exactly in step with the number of threads (128, 255, 511 ms): four threads take four times as long as one. That is not parallelism. That is one core doing the work of each thread in turn.

The explanation: available_parallelism says 1. The operating system runs the four threads one after another on a single core, switching between them. At any moment, only one thread is running, so there is only one core's cache holding the line. No other core has a copy to invalidate. There is no bouncing, so there is no false sharing, and padding cannot help.

This is not a failure of the experiment. It is a precise demonstration of what false sharing is: a problem of simultaneous writes from different cores. It also shows that you cannot test parallel performance on a machine without parallel hardware. Tests on a one-core laptop VM, a container limited to one CPU, or a CI runner with one vCPU will tell you nothing about false sharing or contention. If your team's benchmarks for threaded code run in such an environment, they may pass while the real servers have the problem. Always check available_parallelism() and lscpu before you believe a multi-thread result.

Not run here. What I expect on a machine with at least 4 cores (a hypothesis to check, not a measurement): the padded times stay near 128 ms for any number of threads up to the core count, because each core works alone on its own line. The adjacent times get worse as threads are added, and for 4 threads the program may take several times longer than for 1 thread, even though each thread has only its own counter. The size of the effect varies a lot by chip. Published work reports false-sharing slowdowns of up to an order of magnitude (Bolosky and Scott, 1993; Liu and Berger, 2011, who report fixing programs with "catastrophic" false sharing for up to 9x gains). Exercise 25.3 asks you to measure it.

How to find false sharing in a real program

False sharing is hard to see in source code, because the shared line comes from memory layout, not from anything you wrote. Here is a checklist of places it likes to hide:

  1. Arrays or vectors of per-thread data (Vec<AtomicU64>, Vec<Stats>, [Counter; N]) where element i belongs to thread i. This is the classic one.
  2. Struct fields written by different threads (struct Shared { reader_pos: AtomicUsize, writer_pos: AtomicUsize }). A ring buffer with the read and write positions in one line is a famous case.
  3. Small objects allocated by different threads that the allocator placed in neighboring memory. (A reason that thread-aware allocators exist: Chapter 21 mentioned Hoard.)
  4. A lock and the data it protects in the same line, when other threads spin on the lock while the owner writes the data.

How to confirm: (a) the padding test: pad the suspect data to a line, and re-measure. If the time drops, you found it. (b) perf c2c (Chapter 27), which counts the exact event: a core hitting a line that another core has modified. (c) A scaling plot: if the time increases when you add threads to code that should be independent, suspect false sharing first.

Why it is this way (the "why")

Why does the hardware work at the level of whole lines, and not single variables? Because tracking sharing per byte would need huge amounts of bookkeeping. The cache's whole design (Chapter 9) is built around the line: one tag, one state, one transfer. So coherence rides on the same unit. The granularity of the cache is the granularity of the conflict. This is a trade-off you cannot escape: the cache line is what makes sequential access fast (Chapter 9), and it is what makes unrelated writes collide.

The deeper principle: performance of shared data depends on where it lives, not just on what it is. Two variables that are logically independent can be physically coupled. This is true at every level: the line at the cache level, the page at the virtual memory level (a page of memory shared between processes), and the node at the NUMA level (Chapter 27). In each, a hidden unit of sharing is bigger than the thing you think you are sharing.

Padding has a cost, too: memory. If you pad each of a million small objects to 64 bytes, you use 8 to 16 times more memory (and, from Chapter 11, more cache), which can make single-thread performance worse. So pad only data that different threads write, and only when you have evidence. A better design is often to avoid sharing: give each thread its own copy, work on it privately, and combine at the end (Chapter 29). Padding treats the symptom. Not sharing removes the disease.

What would make this model wrong? Some chips handle sharing better than others (a shared L2 between neighbor cores, or faster cross-core links), and the penalty varies a lot between chips and between two cores of the same chip (it is higher between cores on different sockets). Hardware may also batch writes in a store buffer, which hides some of the cost for writes that do not need an immediate answer. So the effect depends on the machine. The rule (do not let different cores write to one line) is safe. The size of the effect must be measured.

Common mistakes

  • Testing parallel code on a one-core machine. You will see no effect, and then believe the code is fine.
  • Padding everything. Pad only what different threads write.
  • Padding to 64 and not checking the platform. Some libraries pad to 128.
  • Looking for false sharing in code when it lives in layout.
  • Assuming "no locks" means "no contention". Hardware has its own locks: line ownership.
  • Confusing false sharing with true sharing. The fix is different: for true sharing, you must change the algorithm.

Say it simply

Each core has its own caches, so the hardware keeps copies consistent by line: before a core writes a line, all other cores must drop their copies. If two cores write different variables in the same 64-byte line, the line bounces between them on every write. That is false sharing, and the fix is to put each core's data in its own line with padding or better, to avoid sharing at all. I ran the test on a one-core VM, and padding made no difference, because with one core there is nothing to bounce. That tells you that parallel performance cannot be tested without parallel hardware.

Check yourself

Exercise 25.1 (Warm-up): Same line or not? An array [AtomicU64; 8] starts at a 64-byte-aligned address. Which elements share a line? Now the array starts at an address that is 32 bytes past a 64-byte boundary. Which elements share a line now?

Exercise 25.2 (Warm-up): Which kind of sharing? For each, say true or false sharing: (a) two threads add to one AtomicU64; (b) thread A writes stats.hits and thread B writes stats.misses, which are neighbor fields; (c) thread A reads a config value that no one writes, while thread B writes a counter in the same line as the config.

Exercise 25.3 (Core): Measure it on real cores. On a machine with at least 4 cores, run the program above with 1, 2, 4, and 8 threads (up to the number of cores). Fill in the two columns. Then run with taskset -c 0 ./falseshare (all threads on one core) and compare it with the unpinned run. Done when: a table with both columns for each thread count, the ratio of adjacent to padded, and a note on how many cores and which CPU you used. Compare it with the one-core table in this chapter.

Exercise 25.4 (Core): Find the padding you need. Try padding sizes of 8, 16, 32, 64, 128, and 256 bytes (use #[repr(align(N))]). Where does the slowdown disappear? Does 64 suffice on your CPU, or is 128 better?

Exercise 25.5 (Core): Ring buffer. Write a single-producer, single-consumer ring buffer with head (written by the consumer) and tail (written by the producer). Measure the throughput with both in one struct, and with each padded to its own line. (A cache-line-aware layout is a standard technique here.)

Exercise 25.6 (Stretch): Allocator-made false sharing. Make two threads each allocate a small struct (16 bytes) and then update it in a loop. Is it possible, with the default allocator, that the two structs end up in the same line? Print the addresses to check, and measure with a different allocator.

Exercise 25.7 (Stretch): Design and defend. You are designing a metrics system. 32 threads each record counts for 100 different named counters, a million times a second. Compare three designs: (a) one shared array of atomics, (b) one array per thread with padding, merged on a timer, and (c) a thread-local buffer flushed in batches. Which would you build first, and what would you measure?

What a strong answer includes: contention on the shared array (true and false sharing), the memory cost of padded per-thread arrays, the freshness trade-off of merging on a timer, and a plan to measure scaling from 1 to N threads.


Chapter 26. Atomics, locks, and contention

In this chapter: You will learn what an atomic operation and a lock really cost, even with nobody else around. You will see that an atomic add is 21 times slower than a normal add on this machine, that an Arc::clone costs 15 times as much as an Rc::clone, and that a RwLock is not cheaper than a Mutex when only one thread is using it. You will also learn the memory-ordering rules in plain words.

You need first: Chapter 25.

The problem first

A program shares a counter between threads. The easy, safe choices in Rust are Arc<Mutex<u64>> or AtomicU64. Which is cheaper? How much cheaper than a plain u64? And does the memory-ordering argument (Relaxed, SeqCst) matter for speed, or is it only for experts arguing about correctness?

Let us measure the price list first: one thread, no competition. This is the minimum that any use of these tools will cost. Contention (many threads at once) only adds to it.

The model (the "what")

What "atomic" means

An operation is atomic if, to every other thread, it either has not happened yet or has completely happened. Nobody can see it half done. A plain counter += 1 on shared data is not atomic: it is three steps (read, add, write), and two threads can both read the old value, both add 1, and both write, losing one increment. This kind of bug is a data race. (Safe Rust does not let you write one.)

An atomic type, such as AtomicU64, gives operations that happen indivisibly: load, store, fetch_add (add and return the old value), swap, and compare_exchange (set a new value only if the current value is what I expect: the building block of lock-free code).

On x86-64, the hardware does this with a lock prefix on instructions like add and compare-and-exchange. A locked instruction makes the core take exclusive ownership of the line (Chapter 25's M state), do the operation, and not let any other core in between. It also acts as a full memory barrier (below), so the CPU must finish its earlier stores before going on.

A lock

A lock (in Rust, Mutex<T>) makes sure only one thread at a time runs a piece of code (the critical section). It is built from atomics: to take the lock, a thread atomically changes a flag from "free" to "held". If the lock is held, the thread waits: it may spin (keep checking in a tight loop) for a moment, and then park (ask the operating system to put it to sleep, to be woken when the lock is released). Parking costs far more than spinning, because it needs a system call and a context switch.

Memory ordering, in plain words

Here is the part that confuses everyone. Modern CPUs and compilers reorder memory operations to go faster. Within one thread, you never notice. Between threads, you can. Memory ordering is the set of rules for what reordering is allowed, chosen per atomic operation. Rust (like C++) has five levels. The three you need to know:

  • Relaxed: "Make this one operation atomic. I promise nothing about the order of anything else." Good for counters where only the final total matters.
  • Release / Acquire: a pair used for handing data from one thread to another. A thread that releases a flag guarantees that everything it wrote before the release is visible to any thread that then acquires that same flag and sees the new value. Think of a mail slot: you put the letter in (the data) and then raise the flag (release). Someone who sees the flag raised (acquire) is guaranteed to find the letter.
  • SeqCst (sequentially consistent): the strongest. All threads agree on one single order of all SeqCst operations. Easiest to reason about, and the default choice if you are unsure. The cost is that the CPU must do extra work to enforce it.

On x86, the hardware is already fairly strict (a model called TSO, total store order), and ordinary loads act as acquire and ordinary stores act as release without any extra instruction. The only thing x86 normally reorders is a store followed by a load. So on x86: Relaxed and Acquire/Release loads and stores are plain movs, but a SeqCst store needs a special instruction (an xchg, which is a locked operation) or a fence. Other chips (Arm, for example) are more relaxed, and need extra instructions in more places. This is a reason to write code with the right ordering and not the one that happens to work on your x86 laptop.

Build it (the "how")

Here is the price list, measured on this machine with one thread and nobody else around (best of 7 runs, nanoseconds per operation). Each loop ran 20 million operations (5 million for the lock tests).

Operationns
plain u64 += 1 (through black_box)0.40
Cell<u64> set(get()+1)0.38
RefCell<u64> *borrow_mut() += 10.35
AtomicU64::fetch_add, Relaxed8.63
AtomicU64::fetch_add, SeqCst8.62
AtomicU64::load, Relaxed0.35
AtomicU64::store, Relaxed0.70
AtomicU64::store, SeqCst9.38
AtomicU64::compare_exchange (succeeds)11.44
Mutex<u64> lock, add, unlock19.44
RwLock<u64> read lock, read, unlock20.62
RwLock<u64> write lock, add, unlock18.04
Rc::clone + drop1.41
Arc::clone + drop21.08

Go through the price list, because every row teaches something.

An atomic read-modify-write is 21 times a plain add (8.63 against 0.40 ns), even with no other thread anywhere. That is the cost of the lock prefix: the core must lock the line and drain its pending stores. About 8 ns is roughly 20 to 30 cycles if the clock is near 3 GHz (I do not know the real clock of this VM). (I did not read the machine code, but the equal cost of Relaxed and SeqCst for fetch_add, 8.63 against 8.62, fits what I said: on x86, every atomic read-modify-write is already a full locked instruction, and the ordering argument cannot make it cheaper.)

An atomic load is free (0.35 ns, the same as a plain read), and a Relaxed store is nearly free (0.70 ns). The ordering does not matter here: they are plain mov instructions on x86.

A SeqCst store is 13 times as expensive as a Relaxed store (9.38 against 0.70 ns). That is the one place where the ordering choice has a big, measurable price on x86: the store has to be a locked instruction, to give the "one total order" promise. If you only need to publish data to another thread, Release is enough, and costs no more than the plain store. I did not measure Release separately here. The expectation from the instruction set is that it matches Relaxed.

A successful compare_exchange costs 11.4 ns, a little more than fetch_add, which is also why lock-free algorithms that retry in a loop are not "free": each attempt costs about a locked instruction, and under contention many attempts fail.

A Mutex costs about 19.4 ns for one lock and unlock with nobody else wanting it: about 2.3 times the fetch_add. That is the price of two atomic operations (lock and unlock) plus some bookkeeping. So the first lesson for performance: for a single shared counter, an atomic is about half the price of a mutex, even without contention.

RwLock read costs 20.6 ns, no cheaper than a Mutex (19.4 ns). A reader-writer lock exists to let many readers go at the same time, but each reader still has to do an atomic update to a shared reader count. With one thread, there is no reader concurrency to profit from, so you pay the extra bookkeeping. And under many readers, that shared count is a contended cache line: all readers write to it (Chapter 25's bouncing line), so it can scale poorly. Do not assume RwLock is faster than Mutex.

Arc::clone costs 21 ns, and Rc::clone costs 1.4 ns: a 15x difference for the same idea (bump a reference count). Rc uses a plain add. Arc uses an atomic add so that it is safe across threads. If your hot path clones Arcs in a loop (passing handles to many small tasks, for example), you are paying a locked instruction every time, and under many threads, every clone writes to the same refcount line, which makes it worse. Fixes: borrow (&T) instead of cloning, clone once per thread and not per item, or use Rc where there are no threads at all.

What happens with more threads (on this machine)

Now the one experiment of this chapter that does involve several threads. 40 million increments of one shared counter, split across 1, 2, 4, and 8 threads, with an atomic and with a mutex:

40000000 increments of ONE shared counter, split across N threads (best of 5, ms)
 threads         atomic          Mutex
       1          253.9          615.7
       2          253.6          615.6
       4          253.7          619.2
       8          254.8          619.5

The time does not change with the number of threads: 254 ms for the atomic, 616 ms for the mutex, always. This is another one-core effect. The threads take turns on a single core, so there is never any real contention: while one thread runs, no other thread is trying to take the lock at the same moment. (It would have been possible for the operating system to switch threads while one holds the lock, which would show as waiting. In 5 runs of each, I did not see any slowdown, so the switches rarely landed inside a critical section.)

What this does show is the single-thread price per operation in a hot loop: 6.3 ns per atomic increment, and 15.4 ns per mutex increment: the mutex is 2.4 times the atomic. It matches the price list.

Not run here. On a machine with several cores, I expect this experiment to look completely different (hypothesis): the time should rise as threads are added, because all threads fight for ownership of the same line (true sharing, Chapter 25), and the mutex version should rise much faster, because blocked threads will park and wake. It is possible for 8 threads on a shared counter to be several times slower than 1. The program in this chapter runs unchanged on any machine, and Exercise 26.3 asks you to measure it.

And one more price, from the same run: starting and joining a thread that does nothing costs 14.5 µs. That is 14,500 ns, about the same as 1,700 atomic increments. Chapter 29 uses this number.

Why it is this way (the "why")

Why does an uncontended atomic cost 21 times a plain add? Because it is a promise to the rest of the machine: "this happens in one piece, in a total order with every other locked operation". Keeping that promise forces the core to stop reordering, empty its store buffer, and take the line in exclusive state. A plain add makes none of those promises, and so can be freely reordered, batched, and kept in a register (that is why it takes about 0.4 ns, roughly one cycle: the compiler can keep the counter in a register). The price list is the price of guarantees.

This leads to the central rule of concurrent performance: the cheapest synchronization is none. Every shared variable that multiple threads write is a place where the hardware must serialize them. So the first design question is not "which lock?", but "can the threads avoid sharing this?" The usual answers, in order of preference:

  1. Don't share. Each thread has its own data (a thread-local counter, its own hash map), and the results are combined once at the end. This is what Chapter 29's parallel word counter does.
  2. Share read-only data. Many threads reading one table, with nobody writing, needs no synchronization and causes no bouncing (lines in the S state, Chapter 25).
  3. Share less often. Batch updates: each thread accumulates 1,000 increments locally, then adds them to the shared counter in one atomic operation. That cuts the atomic cost by 1,000.
  4. Use an atomic for a single shared word, and pick the weakest ordering that is correct.
  5. Use a lock for compound data. Keep the critical section short, and do no I/O or heavy work inside.
  6. Lock-free structures only when a profile shows locks are the bottleneck, because they are hard to get right.

There is a general model behind this, which you already know: Amdahl's law (Chapter 1). If the share of work inside a lock is s, then no matter how many cores you add, the speed-up cannot exceed 1/s. A critical section that is 10% of the run caps the speed-up at 10 times, even with 64 cores. And contention makes it worse than the law says, because waiting itself costs.

What would make this wrong? Memory ordering is subtle. Using Relaxed where you need Acquire and Release can give a bug that never shows on x86 and appears on Arm. So the rule is: when unsure, use SeqCst (the slow, safe default), and only weaken it when you can explain exactly why the weaker ordering is correct, and (in this order) a profile shows it matters. The price list here is also x86-specific: on Arm, the ordering costs differ.

Common mistakes

  • Using SeqCst everywhere for speed reasons. It is the right default for correctness. It costs about 13x for stores on x86. Weaken it only with a proof.
  • Using Relaxed for data hand-off. It gives no ordering of the data. Use Release and Acquire.
  • Assuming RwLock is faster than Mutex. Measure it, with your read share.
  • Cloning Arc in a hot loop. Each clone is a locked instruction on a shared line.
  • Holding a lock across slow work. All other threads wait.
  • Testing locking cost on one core. You measure the best case.
  • Believing that atomic means free. It is a locked instruction.

Say it simply

An atomic read-modify-write on x86 is a locked instruction that cost 8.6 ns here, 21 times a plain add, even with one thread. Loads are free, relaxed stores nearly free, and a SeqCst store costs 13 times a relaxed one. A mutex costs about 19 ns, a RwLock read is no cheaper, and Arc::clone is 15 times Rc::clone. Memory ordering says what other threads may see and when, and Release and Acquire are the pair for handing data over. The cheapest synchronization is none: avoid sharing, then share less often, then use the weakest correct tool.

Check yourself

Exercise 26.1 (Warm-up): Price check. A function takes 50 ns of real work and then does one fetch_add on a shared counter. About what share of its time is the atomic (using this chapter's price)? What if it batches 100 items per atomic?

Exercise 26.2 (Warm-up): Which ordering? For each, name the weakest ordering that is correct: (a) a thread counts events, and the total is read after all threads have joined; (b) a thread fills a buffer, then sets ready = true, and another thread waits for ready and reads the buffer; (c) a global "stop" flag that threads check each loop (no data is passed).

Exercise 26.3 (Core): Measure the contention. Run oversub.rs on a machine with at least 4 cores. Fill the same table (1, 2, 4, 8 threads, atomic and mutex). Where does the time start to rise? Compare with the flat table here, and write 5 sentences about the difference. Done when: your table, the number of cores, and the ratio of 8 threads to 1 thread for both columns.

Exercise 26.4 (Core): Reproduce the price list. Run the single-thread price list on your machine. Which rows differ most from this chapter's? Try on an Arm machine, if you can, and compare SeqCst stores with Relaxed stores.

Exercise 26.5 (Core): Batch it. Change the shared-counter test so each thread adds to a local variable, and only adds to the shared atomic every 1,000 increments (and at the end). Measure on 1 and on several cores. How much faster is it?

Exercise 26.6 (Stretch): The Arc tax. Write a loop that clones an Arc<Config> per item and passes it to a function, and a version that passes &Config. Measure with 1 thread and with many threads. How does the gap change with the thread count?

Exercise 26.7 (Stretch): Design and defend. A cache of 1 million entries is read by 16 threads, and updated rarely (once a second). Compare Mutex<HashMap>, RwLock<HashMap>, and a design that swaps in a whole new immutable map (for example, behind an Arc that readers clone once per batch). Which would you pick, and what would you measure?

What a strong answer includes: the contention on the lock's own line even for readers, the cost of Arc::clone per lookup, the idea that rare writes allow a copy-on-write swap, the cost of building a new map, and scaling measurements from 1 to 16 threads.


Chapter 27. NUMA and finding contention with perf c2c

In this chapter: You will learn what NUMA is, why memory can be "near" or "far" on big machines, and how the kernel decides where a page lives. You will learn the perf c2c tool, which finds false sharing by counting the exact hardware event. I could not run either on this VM, and I show the real error message that proves it.

You need first: Chapters 10, 25, and 26.

The problem first

You have a server with two CPU sockets. A parallel job runs 40% slower than the benchmark said it should. There is no false sharing. The locks are fine. The data fits the caches. What is left?

On big machines, not all memory is equally near. Your threads may be reading data that lives on the other socket.

The model (the "what")

NUMA

A machine with more than one CPU socket, or a chip built from several pieces (chiplets), usually has more than one memory controller. Each controller has its own RAM sticks attached. A core reaches the RAM attached to its own controller quickly (local memory) and the RAM attached to another controller more slowly (remote memory), through a link between the sockets. This design is called NUMA, for non-uniform memory access: the cost of an access is not uniform, it depends on which core asks and which RAM holds the data.

   Socket 0                                Socket 1
 ┌─────────────┐                        ┌─────────────┐
 │ cores 0..15 │                        │ cores 16..31│
 │  caches     │◄──── inter-socket ────►│  caches     │
 └──────┬──────┘        link            └──────┬──────┘
        │ fast                                  │ fast
   ┌────┴─────┐                            ┌────┴─────┐
   │ RAM  (node 0) │       slower ──►      │ RAM  (node 1) │
   └──────────┘                            └──────────┘

Each socket with its RAM is a NUMA node. As a rough picture, from the literature and from experience of others (not measured here), a remote access can have noticeably higher latency than a local one and may have lower bandwidth, with the gap varying by machine. Check yours with the tools below.

First touch

How does the kernel decide which node holds a page? The usual Linux policy is first touch: the page is placed on the node of the core that first writes to it (the first page fault, Chapter 15). This has a big consequence. Look at this program shape:

 main thread:   allocates a 10 GB array and fills it with zeros   → ALL pages land on node 0
 worker threads (on both sockets): read the array in parallel     → half of them read remote memory

The workers on node 1 are slower for the whole run, because the data was placed by whoever initialized it, not by whoever uses it. The fix is simple: initialize the data in parallel, in the same split that the later work uses, so each page is first touched by the thread that will use it. Here is a program for that (I compiled and ran it here to check it is correct; on a one-node machine it can only show that it works):

use rayon::prelude::*;

let n = 256usize << 20;
let mut a = vec![0u8; n];                       // zeroed memory is not really mapped yet
a.par_chunks_mut(1 << 20).for_each(|c| {        // each worker first-touches its OWN chunks
    for x in c { *x = 1; }
});
let total: u64 = a.par_chunks(1 << 20)          // and later reads the same chunks
    .map(|c| c.iter().map(|&x| x as u64).sum::<u64>()).sum();

Here, on this machine, it printed:

threads: 1
init by one thread   :   160.5 ms     then parallel read:    79.2 ms
init by all workers  :   189.9 ms     then parallel read:    80.2 ms

With one thread and one node, the two ways of initializing are the same (the 160 against 190 ms difference is noise and the cost of the chunked loop; I did not investigate it). The program is correct, and it is the right shape for a NUMA machine. But the effect it is designed to fix cannot appear here.

Not run here. On a two-socket machine, I would expect (a hypothesis) the parallel read of the array that was initialized by one thread to be slower than the read of the array initialized by all workers, and the gap to be larger for memory-bound reads (like this sum) than for compute-heavy work. Exercise 27.3 asks you to measure it.

What this VM does have, which I checked with lscpu:

Thread(s) per core:    1
Core(s) per socket:    1
Socket(s):             1
NUMA node(s):          1
NUMA node0 CPU(s):     0

One node, one core. The numactl tool, which is used to see and control NUMA placement, is not installed here. On your machine:

lscpu | grep -i numa            # how many nodes, and which CPUs belong to each
numactl --hardware              # nodes, their memory size, and the "distance" between them
numactl --cpunodebind=0 --membind=0 ./program     # run on node 0's cores, with node 0's memory
numactl --cpunodebind=0 --membind=1 ./program     # deliberately remote: the worst case
taskset -c 0-7 ./program        # restrict a program to cores 0..7

The two numactl runs, local against deliberately remote, are the quickest experiment to learn how much NUMA costs on your machine. If they differ little, NUMA is not your problem.

perf c2c: counting the bouncing

In Chapter 25, I listed three ways to confirm false sharing. The best is perf c2c, where c2c stands for cache to cache. It uses special hardware sampling to catch loads that hit a line modified in another core's cache. That event is called HITM (for "hit, modified"). It is exactly the signature of line bouncing. The workflow is:

perf c2c record ./program        # sample memory loads, with the data address of each
perf c2c report                  # group by cache line, and rank the lines by how many HITM events they got

The report ranks cache lines. For each line it shows how many HITM events it received, which offsets inside the line were accessed (for example, byte 0 and byte 8), and by which threads and code locations. When the offsets of one line are accessed by different threads, and each thread uses its own offset, that line is false sharing. When different threads use the same offset, it is true sharing. This is the exact distinction that Chapter 25 said is hard to see otherwise.

And now the real result of trying it here:

$ perf c2c record -o /tmp/c2c.data ./falseshare
failed: memory events not supported

This VM does not give the CPU's memory-sampling events to programs, so perf c2c cannot even start. (This is the same family of limits as the missing cycles counter in Chapter 15.) Because I could not run it, I will not show a made-up report. What I can tell you is what to look for: the top line of the report should be a cache line with a large HITM count, with two or more different byte offsets inside it, written by different threads. That line is your suspect.

Not run here. perf c2c needs a CPU and a kernel that support precise memory-load sampling, which is normally available on bare-metal Intel and AMD machines with recent kernels, and often not in VMs. Check with perf c2c record true.

If you cannot use perf c2c, the padding test from Chapter 25 is the fallback: pad the suspected data, and see if the time drops. It needs no special hardware, and you already have the code.

Why it is this way (the "why")

Why does NUMA exist? Because of physics and money. A single memory controller can only be attached to so many RAM sticks, and only supply so much bandwidth to so many cores. To build a machine with 64 or 128 cores and terabytes of RAM, vendors split it into pieces, each with its own controller. Each piece is fast to itself. Reaching across costs a trip over a link. The result is exactly the same kind of trade-off as the cache ladder (Chapter 8): you can have big, fast, or uniform. Pick two. Mainframes of the past chose uniform. Modern servers choose big and fast, and leave the non-uniformity to you.

The philosophy is the same one that runs through Part III and this Part: hardware hides its structure until you hit it. A page, a line, a cache set, a node: each is a hidden unit in which things are close or far. Each one is invisible in the source code and visible in the performance. For NUMA, the practical rule is: place data where it will be used. For false sharing: keep what different cores write apart. For caches: keep what you use together. In all three, the question is "what is near what?"

Another connection: the first-touch policy is a bet on lifetimes of ownership, like the arena in Chapter 23. The system bets that "who first wrote this is who will use it". When the bet is wrong (a main thread initializes, workers use), you pay. Parallel initialization makes your program match the system's bet.

What would make this wrong? Modern kernels can migrate pages between nodes ("automatic NUMA balancing") to fix bad placements at runtime, which can help, or can add its own overhead and noise. Some cloud VMs hide the NUMA structure from the guest, so lscpu may show one node even when the host has two. And on chips built with chiplets, "NUMA" may exist inside one socket. So: check the topology of the machine you are really running on, and measure local against remote.

Common mistakes

  • Initializing data on one thread and using it on many. First touch placed it all on one node.
  • Benchmarking on one socket and deploying on two. The effect appears only in deployment.
  • Assuming a VM shows the real topology.
  • Pinning threads without pinning memory. Cores on node 1, data on node 0.
  • Trying perf c2c on a VM and giving up. Use the padding test instead.
  • Tuning for NUMA before measuring local against remote.

Say it simply

On big machines, memory is near or far: each socket has its own RAM, and reaching the other socket's RAM is slower. Linux places a page on the node of the thread that first writes it, so a program that initializes data on one thread and uses it on all of them pays for remote reads. Initialize in parallel, with the same split as the later work. The tool perf c2c finds false sharing by counting loads that hit a line modified in another core's cache. I could not run either on this one-node, one-core VM, and perf c2c printed "memory events not supported".

Check yourself

Exercise 27.1 (Warm-up): Where does it live? A thread on node 0 allocates a 4 GB array and fills it. Eight worker threads, four on each node, then each read their own quarter. Which workers read local memory, and which remote? How would you change the program?

Exercise 27.2 (Warm-up): Read the symptoms. A parallel job scales well up to 16 threads (one socket) and then barely improves from 16 to 32 (two sockets). List three possible causes, one of which is NUMA.

Exercise 27.3 (Core): Local against remote. On a machine with at least two NUMA nodes, run a memory-bound job (a big sum) with numactl --cpunodebind=0 --membind=0 and then with --membind=1. Report both times and the ratio. Then run the first-touch program in this chapter. Done when: a table with local and remote times, the machine's numactl --hardware output, and 3 sentences about the first-touch result.

Exercise 27.4 (Core): Use perf c2c. On a machine where it works, run perf c2c record and perf c2c report on the false-sharing program with 4 threads, adjacent counters. Find the cache line, the offsets, and the threads. Then pad the counters and run it again. What happens to the HITM counts?

Exercise 27.5 (Stretch): Topology map. Write a small program that reads /sys/devices/system/node and /sys/devices/system/cpu to print which CPUs belong to which node, and which CPUs share an L2 or L3 cache. Compare the output with lscpu.

Exercise 27.6 (Stretch): Design and defend. Your service runs on a two-socket machine with 64 cores. A request handler allocates a buffer, fills it, and processes it on the same thread. Another design has one thread fill all the buffers, then hands them to workers. Which one suffers more from NUMA effects, and what would you measure?

What a strong answer includes: first-touch placement, the cost of remote memory for the handoff design, the option of keeping each request's data on one thread, and measurements with numactl and per-node counters.


Chapter 28. SIMD and auto-vectorization

In this chapter: You will learn how one instruction can work on 16, 32, or 64 bytes at once. This chapter has real measurements on this machine, because SIMD happens inside one core. You will see a loop go 26 times faster with hand-written wide instructions, learn why the compiler did not do it for you, and meet a tiny missing instruction that cost a factor of three.

You need first: Chapters 13 and 19.

The problem first

In Chapter 13, we made a sum 7 times faster by splitting it into independent chains. In Chapter 19, we saw a machine with a roof of 10.6 GFLOP/s for a default build and about 24 for a native build, and we said the difference was "SIMD". We never opened that box. Now we do, because one core has a form of parallelism that does not need threads at all: doing the same operation on many values with one instruction.

The model (the "what")

Lanes and registers

A normal ("scalar") instruction works on one number. A SIMD instruction works on a group of numbers at once. SIMD stands for single instruction, multiple data. The data lives in a wide register, and each number occupies a slot, called a lane.

 scalar add:         3 + 4 = 7                  (one lane)

 SIMD add, 4 lanes:  [ 1  2  3  4 ]
                   + [ 10 20 30 40 ]
                   = [ 11 22 33 44 ]            (one instruction, four additions)

On x86-64, the register width has grown over the years. Each generation is a vector extension:

ExtensionRegister widthf32 lanesf64 lanesu8 lanesAlways available on x86-64?
SSE2128 bits (16 bytes)4216Yes (part of the baseline)
AVX2256 bits (32 bytes)8432No (most chips since about 2013)
AVX-512512 bits (64 bytes)16864No (only some chips)

Three facts about this table are very important:

  1. By default, rustc builds for the baseline x86-64: SSE2 only. Even if your CPU has AVX-512, the default binary uses 128-bit registers, because it must run on any x86-64 chip.
  2. You can ask for more: -C target-cpu=native (everything this machine has; the binary may not run on older CPUs), or specific features like -C target-feature=+avx2,+fma. Or you can use runtime detection (below) to choose at run time.
  3. This is exactly why Chapter 19's two builds had roofs 2.2 times apart.

A related instruction is FMA, fused multiply-add: a × b + c in one instruction, with one rounding. It does two floating-point operations at once, which is why peak "FLOP/s" numbers count 2 per FMA lane.

Auto-vectorization

The compiler can turn a scalar loop into a SIMD loop by itself. This is called auto-vectorization. It works when the compiler can prove that doing several iterations at once gives the same answer. The usual obstacles:

  • A loop-carried dependency: each iteration needs the result of the last (x = x * 3 + 1). Lanes cannot run at once.
  • Floating-point reductions: a sum of floats is a chain. Adding in a different order changes the rounding, so the compiler is not allowed to reorder it (Chapter 13). Integer sums can be reordered freely.
  • Unknown aliasing: if two pointers might point to the same memory, writing through one can change what the other reads. (Rust's slices help here: &mut guarantees no aliasing, which gives the compiler more freedom than C often has.)
  • Control flow and complex access patterns: branches inside the loop, or gathers (a[idx[i]]).
  • Bounds checks and unknown lengths (Chapter 24).

When it succeeds, you get the speed for free. When it fails, you get a scalar loop, silently. The only way to know is to look at the machine code (or measure).

Build it (the "how")

Experiment 1: count the newlines in a block of text

A loop everyone writes. Count the bytes equal to \n in 64 KiB of data (about 5% of the bytes are newlines). Four versions:

// 1. scalar loop
fn count_scalar_loop(t: &[u8]) -> u64 { let mut c = 0; for &b in t { if b == b'\n' { c += 1; } } c }

// 2. iterator
fn count_iter(t: &[u8]) -> u64 { t.iter().filter(|&&b| b == b'\n').count() as u64 }

// 3. SSE2 intrinsics: 16 bytes per step
unsafe {
    let needle = _mm_set1_epi8(b'\n' as i8);          // 16 copies of '\n'
    for ch in t.chunks_exact(16) {
        let v  = _mm_loadu_si128(ch.as_ptr() as *const __m128i);   // load 16 bytes
        let eq = _mm_cmpeq_epi8(v, needle);                        // 0xFF where equal, else 0
        total += (_mm_movemask_epi8(eq) as u32).count_ones() as u64; // 1 bit per byte → count them
    }
}

// 4. the same with AVX2: 32 bytes per step, chosen at run time
#[target_feature(enable = "avx2,popcnt")]
unsafe fn count_avx2_inner(t: &[u8]) -> u64 { /* _mm256_* versions of the same three steps */ }
// if is_x86_feature_detected!("avx2") && is_x86_feature_detected!("popcnt") { unsafe { ... } } else { sse2 version }

(The unsafe is needed because intrinsics are not safe to call by default. The runtime check with is_x86_feature_detected! is what makes the call sound: never run an AVX2 instruction on a chip that lacks it. I made each version return the same count (3,341 newlines), and asserted it in the program.)

Results, nanoseconds per byte (best of 15), default build:

Versionns / bytevs scalar
scalar loop with if0.44611x
iterator filter().count()0.44411x
SSE2 intrinsics, 16 B/step0.09924.5x
AVX2 intrinsics, 32 B/step0.04899.1x
AVX2 intrinsics, with popcnt enabled0.016826.6x

Several things here deserve a long look.

The iterator and the loop are the same speed (0.446 and 0.444): both compile to the same code, again. And I checked the machine code of the default build: both contain the vector instructions pcmpeqb and paddq. So the compiler did vectorize them, with SSE2. And yet they are 4.5 times slower than my hand-written SSE2 version, which uses the same width. Why? I did not read the full machine code, so I do not know for sure. My reading: the auto-vectorized code compares 16 bytes at a time, but then has to widen each byte result to 64 bits (the counter is a u64) and add them up with paddq and punpcklbw. Widening costs many instructions per vector. My version uses movemask and a popcount, which collapses 16 compare results into one small integer in a couple of instructions. The lesson: auto-vectorized does not mean well vectorized. The way the result is accumulated matters.

The AVX2 version with popcnt is 3 times faster than the AVX2 version without it (0.0168 against 0.0489 ns). The only difference is one word in an attribute: #[target_feature(enable = "avx2,popcnt")] against "avx2". Why? The count_ones() call in Rust counts the 1-bits in an integer. There is a single CPU instruction for it, popcnt. But popcnt is not part of the baseline x86-64 instruction set. So in the default build, the compiler cannot use it, and replaces count_ones with a sequence of about a dozen shifts, masks, and adds. I checked the generated assembly: the default build contains 0 popcnt instructions, and the native build contains 18. So a function that "uses AVX2" can still be wasting its time on the instructions it did not ask for. When you enable one feature, check which other features your hot instructions need.

Now the same program, built with -C target-cpu=native (on this machine, that allows AVX-512 and popcnt, among others):

Versiondefault buildnative build
scalar loop0.44610.1027
iterator0.44410.1021
SSE2 intrinsics0.09920.0239
AVX2 intrinsics0.04890.0131

Look at the first row. The same scalar source code is 4.3 times faster in the native build (0.1027 against 0.4461), without a single change to the code. The compiler used the wider registers and popcnt by itself. And the best version, AVX2 intrinsics in the native build, takes 0.0131 ns per byte, which is 34 times faster than the default scalar loop: about 76 GB/s of text scanned, on one core. (That is a little above the L1 and L2 sequential-sum bandwidth we measured in Chapter 8 for this machine, 66 to 70 GB/s. The data here, 64 KiB, fits in the cache. I did not look into why the scan beats the sum.)

Experiment 2: the dot product of two f32 arrays

Multiply two arrays of 16,384 f32 numbers element by element and add the products. Three versions:

Versionns / element (default)ns / element (native)
plain loop (one dependency chain)0.69180.6917
8 accumulators in an array (compiler vectorizes)0.12070.0962
AVX2 + FMA by hand, 4 vectors of 8 lanes0.09270.0863
  • The plain loop is identical in both builds: 0.69. The compiler cannot vectorize a float sum (it would change the result), so no instruction set helps. It is one chain of dependent additions, limited by the latency of an add. This is the same effect as Chapter 13, now with f32.
  • Eight accumulators let the compiler vectorize: 5.7 times faster in the default build (SSE2), 7.2 times in the native build. I split the work myself, so I changed the answer's rounding. The three results were 6150.189, 6150.190, and 6150.187: the same number up to the last digits, but not equal. That is the price of speed here, and it is why the compiler will not do it for you. You must decide that you accept different rounding.
  • Hand-written AVX2 + FMA is only 1.3 times faster than the 8-accumulator loop (0.0927 against 0.1207). So for this loop, a clear loop with the right structure gets most of the benefit, and the intrinsics get the last 25%, at the price of unsafe code and run-time feature detection. In the native build, the compiler's version is within 11% of mine (0.0962 against 0.0863).

How to see what the compiler did

Do not guess. Look.

rustc -C opt-level=3 --emit asm file.rs -o file.s       # or:  cargo rustc --release -- --emit asm
grep -E "pcmpeqb|paddq|mulps|addps|vfmadd" file.s       # vector instructions

Vector instructions have recognizable names. In SSE: names ending in ps (packed single: four f32), pd (packed double), and pcmpeqb, paddq, pmovmskb (packed integer operations). In AVX: the same names with a v in front (vaddps, vfmadd231ps). Scalar instructions end in ss (scalar single) or sd (scalar double): addss is a plain one-lane add. In our default-build check, the dot-product plain loop contained only mulss and addss (scalar: no vectorization), and the 8-accumulator version contained mulps and addps (packed: vectorized). A quick look at the instruction suffixes tells you which loop you got. Compiler Explorer (godbolt.org) does the same in a browser, and highlights each source line.

Portable SIMD in Rust (std::simd) offers a way to write vector code without per-architecture intrinsics. As far as I know, it is still only available on the nightly compiler, but check the current state before you rely on this.

Why it is this way (the "why")

Why does SIMD exist at all? Because of a fact from Chapter 13: a core has a lot of execution hardware, and a scalar instruction uses only a little of it. Making a core wider (more instructions per cycle) is hard, because the machinery that finds independent instructions grows quickly in cost. Making each instruction wider (more data per instruction) is cheap: the control logic is shared by all the lanes. SIMD is the cheapest way to get more work per cycle, if your data allows it. This is "doing the same thing to many things", which many programs do all the time (images, audio, text scanning, matrix math, parsing, hashing, compression, and databases).

It also shows the same lesson as every Part of this guidebook: hardware likes regular work. The things that make SIMD work are the same things that make caches and prefetchers work: the same operation, over contiguous data, with no surprises. If your data is stored as AoS (Chapter 11), the fields you want are not next to each other and SIMD cannot load them in one go. SoA gives SIMD its contiguous lanes. So the layout decision from Chapter 11 and the vectorization decision are one decision.

There is a philosophical trade-off in how you get SIMD:

ApproachEffortPortabilityControl
Write plain loops and rely on auto-vectorizationlowesthighlow (it may silently fail)
Restructure the loop to help the compiler (accumulators, fixed chunks, no branches)mediumhighmedium
Use target-cpu or target-feature flagslowlow (binary tied to a CPU level)medium
Intrinsics with run-time detectionhighmediumhighest

The right order is from the top: write the clear loop, check whether it was vectorized, and restructure only if it was not. Reach for intrinsics last. They give the highest speed and the highest maintenance cost: they are unsafe, they are specific to one architecture (x86 intrinsics do not run on Arm), and each wider extension needs a separate version.

What would make this wrong? Three things we saw or know:

  • Wider is not always faster. On some chips, heavy AVX-512 use lowers the clock speed. In Chapter 19, the native build's L1 roof dropped from 40 to 24 GFLOP/s at high intensity, and in Chapter 24 the native build of the word counter was 13% slower; I did not explain either, and this is a candidate. I did not test it.
  • Memory-bound loops do not speed up. SIMD makes the arithmetic cheaper. If the loop waits for memory (Chapter 7: the multi-accumulator trick did nothing at large sizes), more lanes change nothing. Our test data lived in the cache on purpose.
  • Run-time detection has a cost and a risk. The check is cheap, but if it is wrong, or you forget it, the program crashes with an illegal instruction.

Common mistakes

  • Assuming the compiler vectorized. Check the assembly.
  • Writing AVX2 intrinsics and forgetting the other features they need (here popcnt). A factor of three, lost.
  • Calling intrinsics without a run-time feature check. Illegal instruction on older CPUs.
  • Expecting SIMD to speed up a float reduction automatically. Split into accumulators, and accept a different rounding.
  • Using SIMD on memory-bound loops.
  • Shipping target-cpu=native binaries to other machines.
  • Using intrinsics before trying a clearer loop. The compiler got within 25% on the dot product.

Say it simply

SIMD instructions work on many values at once: 16 bytes with SSE2, which every x86-64 chip has, 32 with AVX2, and 64 with AVX-512. A default Rust build uses SSE2 only, so enabling the right target features can speed up the same code. On one core, my newline counter went from 0.446 to 0.0168 ns per byte, 26 times faster, with hand-written AVX2 and the popcnt feature enabled, and 34 times faster with a native build. Float sums do not vectorize by themselves, because the order changes the rounding. Always read the assembly to see what you really got.

Check yourself

Exercise 28.1 (Warm-up): Lanes. How many u16 values fit in a 256-bit AVX2 register? How many f64 in a 512-bit register? If one add instruction takes 1 cycle for either width, how much more work per cycle does AVX2 give than SSE2?

Exercise 28.2 (Warm-up): Read the suffix. Which of these is a scalar instruction, and which is packed? addss, addps, vaddpd, mulsd, vfmadd231ps.

Exercise 28.3 (Core): Reproduce the table. Build and run the newline counter on your machine, in the default and the native builds. Check the assembly for popcnt. Fill in both tables. How do your ratios compare with 26x and 4.3x? Done when: two tables, and a sentence on which result surprised you.

Exercise 28.4 (Core): The missing feature. Change the AVX2 version to enable only avx2 (no popcnt). Confirm that it gets slow. Then try enabling bmi2 or lzcnt in a different test. Find another instruction whose absence in the default build costs time.

Exercise 28.5 (Core): Help the compiler. Write a function that finds the maximum of a &[f32] with a plain loop. Is it vectorized? (Check.) Write a version with 8 independent maxima. Measure. Why does max of floats behave differently from sum, if at all? Hint: think about what NaN does to the ordering.

Exercise 28.6 (Stretch): Fast ASCII check. Write a function that tells if a byte slice is all ASCII (no byte has the top bit set). Write a scalar version, a version over 8-byte words (u64), and an SSE2 version using _mm_movemask_epi8. Compare. Then compare with <[u8]>::is_ascii, from the standard library.

Exercise 28.7 (Stretch): Design and defend. You are building a text search tool. The inner loop finds the next newline or a given byte. Compare these designs: plain loop, standard library memchr-style function, and your own AVX2 with runtime detection. Which would you choose for a tool that runs on your own laptop, and which for a tool that you ship to users? What would you measure?

What a strong answer includes: the speed of each design on realistic input (not only random bytes), the effect of text where matches are rare against frequent, the portability and testing cost of unsafe intrinsics, run-time feature detection, and why a tested library function is usually the best first choice.


Chapter 29. Threads and data parallelism

In this chapter: You will learn the model that tells you how much you can gain from parallelism (work and span), and the costs that eat into the gain. I measured the overhead of threads and of rayon on this one-core machine, which is the part of the ledger a one-core machine can measure. And we build a parallel version of the word counter that is correct and has no shared state.

You need first: Chapters 25 and 26.

The problem first

Everyone's first thought when a program is slow is "use all the cores". But Chapter 25 showed that threads can fight each other, and Chapter 26 priced the tools they use to coordinate. Before writing a parallel program, you need the model (how much can I gain at most?) and the price (how much do I pay for splitting the work?).

The model (the "what")

Work and span

Draw a computation as a graph of small tasks, where an arrow means "this task needs the result of that one". Two numbers describe it:

  • Work (T₁): the total time if one core ran everything, one task after another.
  • Span (T∞, also called the critical path): the time along the longest chain of dependent tasks. Even with infinitely many cores, you cannot finish faster than this.

The parallelism of the computation is T₁ / T∞: how many cores it can keep busy at most. With P cores, the time is at best

        T_P  ≥  max( T₁ / P ,  T∞ )

Both limits are intuitive: you cannot go faster than "the work divided among P cores", and you cannot go faster than the longest chain. A sum of N numbers has work N and a span of about log₂ N (add in pairs, then pairs of pairs), so its parallelism is huge: plenty of room. A computation where each step needs the last (a chain) has span equal to work: parallelism 1. No cores will help.

This is Amdahl's law (Chapter 1) in a more precise dress. If a fraction s of the work is serial, then the speed-up is at most 1/s, however many cores you have. And there is a counter-view, Gustafson's law: if you make the problem bigger as you add cores (a bigger image, a longer file), the serial part stays fixed while the parallel part grows, and the speed-up can grow with the cores. Both are right, for different questions: Amdahl for "how fast can I make this job", Gustafson for "how big a job can I do in this time".

The costs that eat the gain

Splitting work costs something. At least four things:

  1. Starting and ending the parallel work. Spawning a thread, or putting a task in a queue. (I measured this below.)
  2. Splitting and combining the data and results.
  3. Waiting for the slowest part. If you cut the work into 4 pieces and one takes twice as long, the whole thing takes as long as that one. This is load imbalance.
  4. Sharing: contention, bouncing lines, remote memory (Chapters 25 to 27).

The size of a piece of work, below which the cost of splitting is bigger than the gain, is the grain size. A grain that is too small drowns in overhead. One that is too large leaves cores idle and unbalances the load.

Work stealing and rayon

Starting an operating-system thread per task is too slow (see the numbers below), so libraries keep a pool of worker threads that live for the whole program, and give them tasks through queues. Rayon is the standard Rust library for this. Its model: you write par_iter() where you would write iter(), and rayon splits the data recursively into smaller pieces (down to a minimum size) and hands them to the pool. The scheduling uses work stealing: each worker has its own queue of tasks; when a worker runs out, it steals a task from another worker's queue. This keeps all cores busy even when tasks have different sizes, with little contention, because most of the time a worker only touches its own queue.

Build it (the "how")

Here is what I could measure: the overhead side. This machine has 1 core, and rayon's pool therefore has 1 thread (I printed it). So every measurement below is "parallel code, with no parallel hardware": it shows what you pay for the parallel machinery, and not what you gain. The gain is the part I cannot show.

The price of a thread

spawn + join of an empty thread: 14.5 us each

14.5 µs to start and finish a thread that does nothing. Compare: one atomic increment costs 8.6 ns (Chapter 26). A thread costs the same as about 1,700 atomic increments, or about 36,000 plain additions (at 0.4 ns). So: if a thread's job takes less than around a millisecond, spawning costs more than a percent of it; and a job under 15 µs is slower spawned than not.

The price of rayon, on a sum

I summed arrays of u64 with a sequential loop, with rayon's par_iter(), with rayon and a minimum piece size (with_min_len), and with four threads started by hand (thread::scope). Best of 15. Microseconds.

Array sizesequentialpar_iter()with_min_len(100)with_min_len(10,000)with_min_len(1,000,000)4 manual threads
1,0000.16.5 (+4,504%)6.7n/an/a55.7 (+39,392%)
100,00011.817.5 (+49%)20.6 (+75%)19.8 (+68%)n/a73.3 (+523%)
10,000,0002,7663,753 (+36%)2,779 (+0%)2,776 (+0%)2,825 (+2%)3,194 (+15%)

Read this table. It is the cost side of parallelism, in numbers.

  • For 1,000 numbers, the sequential sum takes 0.1 µs (100 ns). Rayon takes 6.5 µs: 65 times slower. Four manual threads take 55.7 µs: 557 times slower (4 spawns × 14.5 µs ≈ 58 µs matches). The work is smaller than the cost of starting any parallel work. Parallelizing it is a loss, on any number of cores. Never parallelize work that is smaller than the overhead.
  • For 100,000 numbers, the sequential sum takes 11.8 µs. Rayon costs 17.5 µs, still 49% worse. With enough cores, rayon would split it, and at some core count it would win (not measured). On one core, no.
  • For 10 million numbers, the sum takes 2.77 ms. The cost of rayon is small relative to that, if pieces are large enough: with a minimum piece size of 100 or 10,000, there is no measurable overhead (+0%). With the default setting, rayon was 36% slower (3,753 against 2,766 µs). I did not investigate why: the default policy for deciding how finely to split is rayon's, and I did not read it. A natural guess is that it created many more small tasks than were useful when there is only one thread, but that is a guess. The practical point is that the grain size is a tuning knob, and it matters.
  • Four manual threads at 10 million numbers: +15%, which is the cost of 4 spawns (about 58 µs) plus the splitting, and which looks bigger than 58 µs: 3,194 against 2,766 µs is 428 µs. The rest I did not explain. The threads are time-sharing one core, so some of that may be context switching.

So the overhead side is measured: a task costs microseconds, a thread costs about 15 µs, and the work in each piece should be much larger than that. A good rule: aim for each task to take at least 100 µs to 1 ms, so that the overhead is under a percent or so.

A parallel word counter with no shared state

Now the design that Chapter 26 recommended: don't share. The word counter v4 from Chapter 23 counts words in a text, and all work is independent per word except the final counts. So:

  1. Cut the text into chunks, at whitespace (so no word is split in two).
  2. Count each chunk with its own hash map, with no sharing at all (this is v4's count_bytes).
  3. Merge the small maps. There are only 26 distinct words, so the merge costs almost nothing.
fn chunk_bounds(text: &str, parts: usize) -> Vec<(usize, usize)> {
    let b = text.as_bytes();
    let mut cuts = vec![0usize];
    for i in 1..parts {
        let mut p = text.len() * i / parts;
        while p < b.len() && !b[p].is_ascii_whitespace() { p += 1; }   // move to the next whitespace
        if p > *cuts.last().unwrap() { cuts.push(p); }
    }
    cuts.push(text.len());
    cuts.windows(2).map(|w| (w[0], w[1])).collect()
}

fn v5(text: &str) -> Vec<(String, usize)> {
    use rayon::prelude::*;
    if !text.is_ascii() { return v2(text); }
    let parts = rayon::current_num_threads() * 4;     // a few more chunks than threads, to balance load
    let maps: Vec<FxHashMap<Box<[u8]>, usize>> = chunk_bounds(text, parts)
        .into_par_iter()
        .map(|(a, z)| count_bytes(&text[a..z]))       // each chunk: its own private map
        .collect();
    let mut total: FxHashMap<Box<[u8]>, usize> = FxHashMap::default();
    for m in maps { for (k, c) in m { *total.entry(k).or_insert(0) += c; } }
    top(total.into_iter().map(|(k, c)| (String::from_utf8(k.into_vec()).unwrap(), c)))
}

Design choices, and why:

  • No locks, no atomics, no sharing. Every worker writes only its own map. There is nothing to bounce.
  • More chunks than threads (4 × threads): if one chunk takes longer, the others can steal work, which balances the load. Too many chunks would cost the grain-size overhead we just measured. Each chunk here is about 1/4 of the text per thread: large (milliseconds), so the overhead is negligible.
  • The merge is serial. It is the s in Amdahl's law: a small serial part. Here it is tiny, because the number of distinct words is small. With millions of distinct words, the merge would be a real serial cost, and you would merge in parallel too (a tree of merges), or partition by hash so each worker owns a part of the key space.

What I verified here: correctness. The output was identical to v0's and v4's (checksum 6001abbd) with 1 thread, 4 threads, and 16 threads (I forced the thread count with RAYON_NUM_THREADS, on this one core), including with up to 64 chunks, which tests that no word is cut in two by a chunk boundary.

What I could measure: the overhead. v4 took 167.2 ms and v5 took 165.3 ms (median of 5 runs; the machine ran about 10% slower for these runs than in Chapter 23, where v4 took 151 ms, which I did not explain). So the parallel machinery costs nothing measurable here: its cost is lost in the run-to-run noise.

Not run here. What I expect on a machine with P cores (a hypothesis): the counting phase should scale close to P, up to the memory bandwidth limit, since v4 reads the text once (Chapter 8's bandwidth). With 8 cores, I would expect somewhere around 5 to 7 times, because making the fake text (3% of the time, not parallelized here) and the merge are serial, and reading memory can saturate. Exercise 29.4 asks you to measure it, and to compute the Amdahl limit from the serial fraction.

Why it is this way (the "why")

Why is the price of a thread so high (14.5 µs) while the price of a rayon task is only a few µs? Because a thread is an operating-system object: starting it means a system call, the kernel allocating a stack and a descriptor, the scheduler adding it to a queue, and later tearing it all down. A rayon task is just a closure in a queue, which an already-running worker picks up. The first is "hire and train a new employee for each errand". The second is "put the errand in the inbox of someone who is already at their desk". The whole design of thread pools, async runtimes, and work-stealing schedulers is about reusing the expensive thing.

Why is it so important to avoid sharing? Because of everything in this Part, in one sentence: every shared write is a place where the hardware must serialize, and serialization is the opposite of parallelism. The speed-up of a parallel program is limited by its serial parts (Amdahl), and by the cost of its sharing (bouncing lines, contended locks, remote memory). The programs that scale are the ones that share almost nothing: shared-nothing design. This is the same idea as an arena (Chapter 23): get independence, and the machine rewards you.

And a deeper point about measurement. Everything in this chapter was measured on the wrong machine, on purpose, to show what each machine can and cannot tell you. A one-core machine measured the costs of parallelism and could not measure its gains. A real parallel machine measures the gains and can hide costs in the noise. Neither is the whole truth. Good performance engineering knows which question each experiment can answer.

What would make the model wrong? Real parallel speed-ups are limited by more than Amdahl: memory bandwidth (all cores share it; Chapter 8 showed one core already reaching about 10 GB/s from main memory, and the chip's total is limited), the shared L3, turbo frequency (a chip runs each core slower when many are busy), and the operating system's scheduling. So the simple model gives an upper bound, and the real number is below it.

Common mistakes

  • Parallelizing small work. Compare the work with the 15 µs thread cost and the few µs task cost.
  • Using more threads than cores for CPU-bound work. On this machine, 8 threads took the same total time as 1, so you gain nothing.
  • Spawning a thread per task. Use a pool.
  • Sharing a mutable map between workers. Give each its own and merge.
  • Assuming a speed-up from timing on a machine with few cores.
  • Ignoring the serial part. It sets the ceiling.
  • Cutting data in the middle of an item (a word, a record). Cut at boundaries.

Say it simply

A parallel computation has work (the total time on one core) and span (the longest chain of dependent steps), and the best time on P cores is the larger of work divided by P and the span. Splitting costs something: starting a thread took 14.5 µs here, about 1,700 atomic increments, and a rayon task cost a few microseconds, so each piece of work should take far longer than that. On one core, parallel code can only show its overhead, never its gain. The best parallel designs share nothing: each worker keeps its own data and the results are merged at the end, as in our parallel word counter, which gave identical output with 1, 4, and 16 threads.

Check yourself

Exercise 29.1 (Warm-up): Work and span. A computation has total work 100 ms. The longest chain of dependent steps takes 20 ms. What is its parallelism? What is the best possible time on 4 cores? On 16 cores? On infinitely many?

Exercise 29.2 (Warm-up): Is it worth it? A function takes 50 µs per call. Thread spawning costs 15 µs and a rayon task costs 3 µs. Would you parallelize one call with 4 threads? What if the function takes 5 ms? What if you call it 10,000 times?

Exercise 29.3 (Core): Measure the overhead. Run the thread and rayon overhead tests on your machine. Fill in the table above (use your own core count too). At what array size does par_iter() start to win on your machine? Done when: a table of your times, the crossover size, and the core count.

Exercise 29.4 (Core): Scale the word counter. Run v5 with RAYON_NUM_THREADS set to 1, 2, 4, 8 (up to the number of cores). Plot the speed-up. Compute the serial fraction from the largest run, using Amdahl's law, and compare with the share of make_text and the merge. Done when: a plot, a computed serial fraction, and a sentence on what limits your scaling.

Exercise 29.5 (Core): Break the chunks. Remove the whitespace adjustment from chunk_bounds (cut in the middle of words). Do the counts change? How many words are wrong? Why does this bug depend on the number of threads?

Exercise 29.6 (Stretch): The merge. Make the input have 1 million distinct words, and time the serial merge. Then write a version that partitions words by hash into P buckets, so each worker owns one bucket and no merge is needed. Which is faster, and when?

Exercise 29.7 (Stretch): Design and defend. You have a parser for a file format where each record depends on the one before it (state carries over). A teammate wants to parallelize it with 8 threads. What is the span of the computation? What can you do to find a split point so that you can parallelize at least part of it?

What a strong answer includes: the span equal to the work for a pure chain, techniques such as finding record boundaries by scanning (as chunk_bounds does), speculative parsing from a guessed state with a fix-up, and splitting into independent files or blocks if the format allows it.


Part VI wrap-up

What you can now do:

  • Explain cache coherence and the MESI states, and tell true sharing from false sharing.
  • Know why you cannot test parallel performance on a one-core machine, and check available_parallelism() before you trust a multi-thread result.
  • Quote the price of an atomic operation, a lock, an Arc::clone, and a thread, and use those prices to design.
  • Explain the memory-ordering levels in plain words, and say why SeqCst is the safe default and Release and Acquire are the pair for handing over data.
  • Explain NUMA and first touch, and use numactl to measure local against remote.
  • Use perf c2c to find false sharing, and the padding test when you cannot.
  • Read SIMD instruction names, check whether a loop was vectorized, and write an intrinsic version with run-time detection.
  • Use the work and span model to bound a parallel speed-up, and pick a grain size.
  • Design a shared-nothing parallel program, and check it for correctness.

Mini project: The multi-core lab (4 to 6 hours, needs a multi-core Linux machine). This Part left several results as expectations. Close them.

  1. Check the machine: lscpu, nproc, numactl --hardware, and the output of perf c2c record true. Write the topology on one line.
  2. Run the false-sharing program (Chapter 25) with 1, 2, 4, and 8 threads, both layouts. Compare with the hypothesis ("padded stays flat; adjacent gets worse").
  3. Run the contention program (Chapter 26) with 1, 2, 4, and 8 threads. Compare with the one-core table in this guidebook.
  4. If you have 2 NUMA nodes, run the first-touch program and the numactl --membind experiment (Chapter 27).
  5. Run perf c2c on the false-sharing program, if it works. Find the line and the offsets.
  6. Run the parallel word counter v5 with 1, 2, 4, and 8 threads. Plot the speed-up and compute the serial fraction.
  7. Run the SIMD experiments (Chapter 28) in default and native builds, and compare with the tables here.

Done when: a one-page lab report with a table for each step, with your measured numbers next to this guidebook's expectations (for steps 2, 3, 4, and 6) or measurements (for steps 7). For each expectation, mark confirmed, partly, or wrong, and give your best explanation for any that were wrong.

Write-up prompt: Half a page. Which of my expectations failed on your machine, and what does it tell you about the difference between a model and a measurement?

Teach it back: In 5 sentences, explain to a friend why two threads that each write their own variable can be slower than one thread, and how to fix it.

Reading list for Part VI:

  • William J. Bolosky and Michael L. Scott, "False Sharing and Its Effect on Shared Memory Performance", USENIX Symposium on Experiences with Distributed and Multiprocessor Systems (SEDMS IV), 1993 (also Microsoft Research report MSR-TR-93-01). The classic paper on defining and measuring false sharing.
  • Tongping Liu and Emery D. Berger, "SHERIFF: Precise Detection and Automatic Mitigation of False Sharing", OOPSLA 2011. A tool to find and fix false sharing, reporting gains up to 9x on programs with catastrophic false sharing.
  • Mara Bos, Rust Atomics and Locks, O'Reilly, 2023. The most practical book on atomics, memory ordering, and building locks in Rust (not freshly checked for this edition).
  • Maurice Herlihy and Nir Shavit, The Art of Multiprocessor Programming. The theory of locks, lock-free structures, and consistency.
  • Gene M. Amdahl, "Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities", AFIPS 1967, and John L. Gustafson, "Reevaluating Amdahl's Law", Communications of the ACM, 1988 (neither freshly checked). The two views of parallel speed-up.
  • Intel, Intrinsics Guide (online) and the Intel optimization manual, for the exact instruction names and latencies. Agner Fog's instruction tables for measured numbers per chip.
  • The documentation of rayon, crossbeam-utils, and std::arch.

Verification notes for Part VI: Run in this session on the 1-core, 1-NUMA-node Xeon VM (rustc 1.91.1 for the rayon, crossbeam, and word-counter programs; rustc 1.75 for the standalone programs): the price list of atomics, locks, Rc and Arc (single thread); the false-sharing test (which showed no difference, as explained); the shared-counter test with 1 to 8 threads (flat, as explained); the thread spawn cost; all the SIMD tables, including the assembly checks for popcnt (0 in the default build, 18 in the native build) and vector instruction names; the rayon and manual-thread overhead table; the correctness of the parallel word counter (identical output with 1, 4, and 16 threads); the crossbeam_utils::CachePadded size (128); the first-touch program (runs correctly, one node only); and the failure message of perf c2c ("memory events not supported"). Not run here, and given as expectations or code only: every multi-core speed-up or slow-down (false sharing, contention, scaling of the parallel word counter), NUMA effects, numactl (not installed), and perf c2c reports. Checked against sources in this session: the Bolosky and Scott paper (title, authors, venue, year, report number) and the Sheriff paper (title, authors, OOPSLA 2011, the "up to 9x" claim). Not verified: the MESI description (my knowledge), the claim that CachePadded pads to 128 for the adjacent-line prefetcher (from my memory of the crate's documentation), the exact reason the auto-vectorized newline counter is 4.5x slower than the hand-written SSE2 version (my reading, no full machine-code check), the cause of the 36% slowdown of rayon's default splitting on one thread, the statement that std::simd is still nightly-only, and the Bos, Herlihy and Shavit, Amdahl, and Gustafson references. The run-to-run machine drift (v4 at 151 ms in Chapter 23 and 167 ms here) is unexplained.


Part VII. Applying it: four real systems

After this Part, you will be able to sit down with a matrix routine, a parser, an interpreter, or a compiler's tree, and say which of the ideas from Parts I to VI apply, in what order, and how far to go.

Parts I to VI taught tools and ideas one at a time. This Part puts them to work on four kinds of program, chosen because they are what systems programmers build: numeric kernels, parsers, interpreters and virtual machines, and compiler data structures. Every chapter has the same shape: build the obvious version, measure it, fix the biggest problem, measure again, and then compare with the best result I could get, so that you know when to stop.

All programs in this Part ran on the same 1-core Xeon VM, built with rustc 1.91.1 in release mode, unless I say otherwise. Every program was checked for correctness against a reference before it was timed (I say how in each chapter).

One correction before we start. In Chapter 19, I measured a "compute roof" of about 10.6 GFLOP/s (default build) and 24 GFLOP/s (native build) for this machine. That was wrong as a statement about the machine. In Chapter 30, a tuned library multiplies matrices on the same core at about 131 GFLOP/s in f32. My Chapter 19 kernel was limited by something other than the arithmetic units, so what I called the roof was the roof of that kernel. I have added a correction box to Chapter 19. The lesson is the same one as in Chapter 19 itself: a roof that is too low is a bug in your experiment. I had one, and I did not catch it until I had something better to compare with. This is why every chapter in this Part compares with a strong reference.


Chapter 30. Matrix multiplication, from naive to blocked

In this chapter: You will multiply two matrices five different ways, and watch the speed go from 0.6 to 37 GFLOP/s with changes that never touch the math. You will see which of Part III's ideas matter (loop order, cache blocking, register tiling, packing), which do not (tiling alone, a power-of-two width), and how far you still are from a real library at 131 GFLOP/s.

You need first: Parts III and V, especially Chapters 8, 9, 11, 13, and 28.

The problem first

Matrix multiplication is the most studied loop in computing. Neural networks (the MNIST network and transformer that people build from scratch), graphics, physics, and linear solvers all spend most of their time in it. It is also a beautiful test case: the code is three nested loops, anyone can write it, and the gap between the first version and the best one is more than 200 times.

Here it is, for two 1024 × 1024 matrices of f32 numbers (that is 2.15 billion floating-point operations of work):

textbook triple loop :  3,560 ms     (0.60 GFLOP/s)
tuned library        :     16 ms     (131  GFLOP/s)

Same answer. Same machine. One core. A factor of 218.

The model (the "what")

The problem

To multiply A (n × n) by B (n × n) into C, each element is a dot product:

C[i][j] = sum over k of  A[i][k] * B[k][j]

That is n³ multiply-adds, or 2 n³ floating-point operations (flops). For n = 1024: 2.15 billion. The work is fixed. Nothing in this chapter changes it. What changes is how the data moves.

Arithmetic intensity, again

In Chapter 19, we met arithmetic intensity: flops per byte moved from memory. Let us compute it for this problem.

  • The best case. Each matrix has n² numbers. If we could read A and B once and write C once, we would move 3 n² numbers, which is 12 n² bytes for f32. The intensity would be 2 n³ / 12 n² = n / 6 flops per byte. For n = 1024 that is about 170 flops per byte. That is far above the knee of any machine (about 4 to 10 flops per byte in Chapter 19). Matrix multiplication can be compute-bound. Its data can be reused enough.
  • The naive case. If the loops fetch data again and again from far away (each use of a number costs a trip to memory), the intensity is tiny, and the loop becomes memory-bound.

So the whole game is reuse: how many times can we use a number after it has been brought close to the CPU, before it has to go away? Every number in A is used n times (once for each column of B). Every number in B is used n times. If we could keep what we are using in the fastest memory long enough to use it n times, the problem would be solved.

The memory ladder meets the loop nest

This is where Part III pays off. The ladder (Chapter 8) has rungs of different sizes: registers (a few hundred bytes), L1 (tens of KiB), L2 (about 2 MiB here), L3, and memory. A big matrix multiplication fits on none of the top rungs: three 1024 × 1024 f32 matrices are 12 MiB. The fix, used by every serious implementation, is blocking (also called tiling): cut the matrices into small blocks, and multiply the blocks, so that the blocks being worked on fit in a fast rung. One level of blocking for each rung of the ladder.

 whole matrices (12 MiB)       →  too big for L2
   └─ big panels               →  sized to fit L2   (cache blocking)
        └─ small panels         →  sized to fit L1   (packing)
             └─ register tile   →  4 x 16 numbers of C held in vector registers, for the whole k loop

This four-layer structure is the standard one in the high-performance libraries (often called the Goto algorithm, after Kazushige Goto, and used in BLIS; see the reading list. I know it from the literature and did not re-derive it here).

Loop order matters

Rust stores matrices row by row (row-major: element (r, c) is at r * n + c). The three loops can be written in six different orders, and all of them compute the same sums. They are not equally fast, because the innermost loop decides how the memory is walked:

OrderWhat the inner loop walks
i-j-k (textbook)a row of A (stride 1, good) and a column of B (stride n, bad: Chapter 9's 16x)
i-k-ja row of B and a row of C (both stride 1, good), and one fixed number of A

In i-k-j, the inner loop is C[i][j] += a_ik * B[k][j] for all j: the same operation on consecutive numbers, with no reduction chain. That is exactly what SIMD (Chapter 28) and the prefetcher (Chapter 12) like.

Build it (the "how")

Five versions. All matrices are n × n, f32, row-major. I checked every version's output against the textbook one (all versions that add in the same order gave identical results, bit for bit; the library, which adds in a different order, differs in the 5th to 6th digit, as Chapter 28 predicted).

// 1. textbook i-j-k: the inner loop walks down a COLUMN of b (stride n)
for i in 0..n { for j in 0..n {
    let mut s = 0.0f32;
    for k in 0..n { s += a[i * n + k] * b[k * n + j]; }
    c[i * n + j] = s;
} }

// 2. loop interchange, i-k-j: the inner loop walks along a ROW of b and c
for i in 0..n {
    let crow = &mut c[i * n..(i + 1) * n];
    for k in 0..n {
        let aik = a[i * n + k];
        let brow = &b[k * n..(k + 1) * n];
        for j in 0..n { crow[j] += aik * brow[j]; }
    }
}

// 3. cache blocking: the same i-k-j loops, in tiles of bs x bs x bs
for ii in (0..n).step_by(bs) { for kk in (0..n).step_by(bs) { for jj in (0..n).step_by(bs) {
    /* the i-k-j loops above, but only over the tile */
} } }

// 4. register tile: keep a 4 x 16 block of C in local variables for the whole k loop
let mut acc = [[0.0f32; 16]; 4];
for k in 0..n {
    let brow = &b[k * n + j..k * n + j + 16];
    for r in 0..4 { let av = a[(i + r) * n + k]; for x in 0..16 { acc[r][x] += av * brow[x]; } }
}
/* then write acc back into c */

// 4b. register tile + a packed copy of the B panel + blocking over k
//     for each block of k (256 values) and each 16-column strip of B:
//         copy B[kk..kk+256][j..j+16] into one small CONTIGUOUS buffer (16 KiB: fits in L1)
//         for every 4-row tile of A: run the register-tile loop over the 256 values of k, reading B from the buffer

(The full programs are about 150 lines. Version 4b is the idea of the packed panel from the Goto structure, in about 25 lines, using plain Rust and no unsafe, and letting the compiler turn the inner for x in 0..16 loop into vector instructions.)

Results at n = 1024 (best of 2 or 3 runs, default build):

VersionmsGFLOP/svs textbook
1. i-j-k textbook3,559.80.601x
2. i-k-j loop interchange194.311.0518.3x
3. blocked, tiles of 32222.69.6516x
3. blocked, tiles of 64181.911.8119.6x
3. blocked, tiles of 128167.512.8221.3x
4. 4 × 16 register tile198 to 20410.5 to 10.817.5 to 18x
4b. register tile + packed B + k blocks104.720.5234x
5. matrixmultiply crate (sgemm)16.3 to 18.6115 to 131190 to 218x

And the same at n = 512 (default build), where everything fits better in the cache:

VersionmsGFLOP/s
1. i-j-k144.51.86
2. i-k-j20.413.16
3. blocked, 32 / 64 / 12827.4 / 21.8 / 19.09.79 / 12.30 / 14.12
4. register tile14.718.26
4b. packed10.725.02
5. matrixmultiply2.0131.4

And with -C target-cpu=native at n = 1024, for the versions I recorded:

Versiondefault buildnative build
1. i-j-k0.60 GFLOP/s0.56
2. i-k-j11.0512.90
4. register tile10.86.91
4b. packed20.5237.17

(The library gave the same speed in both builds, 131.9 against 131.4 at n = 512, because it chooses its instructions at run time, as in Chapter 28.)

Now read the tables slowly. They hold five lessons, and two of them go against what you may have been told.

Lesson 1: loop order alone gave 18x. Going from i-j-k to i-k-j changed nothing but the order of two lines, and made the program 18 times faster at n = 1024 (and 7 times at n = 512). The inner loop went from a stride-n walk (one useful number per cache line, Chapter 9) to a stride-1 walk that the compiler could vectorize. This is the cheapest and biggest win in the chapter. Nothing is smarter about it than "walk memory in the order it is stored."

Notice the textbook version gets worse as n grows: 1.86 GFLOP/s at n = 512 and 0.60 at n = 1024. At n = 1024, each step of the inner loop jumps 4,096 bytes, a power of two, and B is 4 MiB, which is bigger than this machine's 2 MiB L2. I did not separate these causes for the textbook loop.

Lesson 2: tiling alone barely helped. The blocked versions (3) were between 14% slower and 14% faster than plain i-k-j, depending on the tile size. This is a common surprise. Blocking is famous, but it only pays when the data you reuse would otherwise fall out of cache between uses. In i-k-j, each row of B is streamed once per row of A, and a row of C stays in L1 while it is used. On this machine, at these sizes, that was already decent. The best tile size (128) was the one with the longest inner loops, so my reading is that the tile was not acting mainly as a cache fix, but as a way to reduce loop overhead (I did not test this). Blocking is not a magic word. It must be matched to a measured problem. The popular advice "block your loops" would have given us nothing here.

Lesson 3: a power-of-two width was not the problem. In Chapter 9, a stride of exactly 4,096 bytes made 13 lines fight for one cache set. Here, the register-tile version at n = 1024 (a width of 4,096 bytes) was slower than at n = 512, and Chapter 9 was the obvious suspect. I tested it: the same version at n = 1024 took 212.2 ms, and at n = 1040 (not a power of two) 204.4 ms: only 4% better. In the native build, 1040 was 6% worse (304.1 ms against 286.2 ms). So the cache-set conflict was not the main cause. The slowdown at 1024 came from something else: most likely, B no longer fits in the L2 (4 MiB against 2 MiB), so every pass over it comes from the L3. The remedy has the right shape: version 4b.

Lesson 4: packing and blocking over k worked, a lot. Version 4b ran in 104.7 ms against 198 to 204 for plain register tiling: 1.9 times faster, and in the native build 5.4 times faster (57.8 ms against 310.7 ms). The idea: before using a small strip of B many times, copy it into a small contiguous buffer that fits in L1 (16 KiB here). Then every use is an L1 hit, with no stride and no page jumping. The copy costs a little. The reuse (it is used for every 4-row tile of A, 256 times) pays for it many times over. This is the packing of the Goto structure, and it is the first time in this chapter that the cache-blocking idea gave a real gain, because it was combined with a register tile that has enough arithmetic per byte to benefit from reuse.

Note also an oddity I cannot explain: in the native build, the unpacked register tile was slower than in the default build (310.7 against 198.3 ms), while the packed version was 1.8 times faster. Wide vectors made the memory-bound version worse and the compute-bound one better. I did not investigate, and it echoes the unexplained "native is slower" result in Chapter 24.

Lesson 5: the stopping rule says "keep going". The best version I wrote reached 37 GFLOP/s (native, packed). A mature library reached 131. We are at 28% of what this single core can clearly do. Here the roofline gives a stopping rule that tells us not to stop: there is a 3.5x gap, and it is not in the memory system any more (the library has the same memory to deal with). It is in the inner kernel: the library uses a hand-tuned micro-kernel with explicit FMA (fused multiply-add, Chapter 28) instructions and a bigger register tile, packs A too, and picks block sizes from the cache sizes. Those are the standard ingredients (my understanding from the literature; I did not read the library's source here). Getting the last 3.5x by hand in Rust is a real project, and it needs intrinsics and unsafe code. Exercise 30.7 is a start.

And a sanity check on the peak. 131 GFLOP/s in f32 on one core. If this core has two 512-bit FMA units and runs near 3 GHz, then its peak is 2 units × 16 lanes × 2 flops × 3 GHz, about 190 GFLOP/s, so the library is at about 70% of the peak. (I did not verify the number of FMA units or the clock, so treat this as a rough check, not a measurement.) Either way, it is clear that the "compute roof" in Chapter 19 was far too low.

Why it is this way (the "why")

Why do the three loops need all this? Because of a simple fact with deep consequences: 2 n³ flops on 3 n² data means a lot of reuse is available, and exploiting reuse is entirely a matter of the order in which you do the work. The flops do not care about the order. The memory system cares enormously. Matrix multiplication is the clearest example in computing of an idea we met in Part III: performance is mostly about moving data, and the way to fewer moves is to do the work in an order that reuses what is close.

This also shows how a hierarchy of problems works. We solved four problems, one for each rung of the ladder, and each solution had the same shape: choose a block that fits this rung, and loop over blocks outside it. It is the same idea at four scales. This is a pattern to remember: when a problem has levels, an algorithm that is recursive in structure and matches each level to a block size does well at all of them. (There are even cache-oblivious algorithms that do this by recursion without knowing the sizes; see the reading list.)

There is a lesson in professional humility, too. A mature library is 3.5 times faster than my best attempt, after I spent a good deal of effort. For matrix multiplication, in production, you should use a library (a BLAS, the matrixmultiply crate, faer, or ndarray with a BLAS backend). The reason to learn this chapter is not to replace the library. It is to know what the library is doing, to tell whether your own loops (which are rarely a plain matrix multiplication, but often have the same shape) have the reuse problem, and to know the questions to ask when a loop is slow.

What would make this model wrong? Three things we saw:

  • The sizes of the problem and of the machine. Tile sizes that suit a 2 MiB L2 do not suit a 256 KiB L2. A good library chooses them from the machine, or tunes them.
  • Compilers can be surprising. The unpacked register tile was 1.8 times slower in the native build, and I have no explanation. A change that helps one build can hurt another.
  • Floating-point order. Our versions summed in the same order and gave bit-identical results. The library sums in a different order. If your tests demand bit-identical results across versions, a faster summation order may not be allowed.

Common mistakes

  • Writing the textbook loop and accepting it. It cost 18x here.
  • Blocking without measuring. Tiling alone gave about nothing.
  • Optimizing before checking for a stronger reference. My Chapter 19 roof was wrong because I had nothing to compare with.
  • Assuming a power-of-two size is the problem. Test with a nearby size first.
  • Rewriting a library routine for production. Use the library, and use the knowledge to feed it well.
  • Forgetting correctness checks. Compare the output with a simple reference, every time.

Say it simply

Matrix multiplication does 2n³ flops on only 3n² numbers, so the data can be reused many times, and speed is all about doing the work in an order that reuses what is close. Changing the loop order made my code 18 times faster, tiling alone gave nothing, and copying a panel of B into a small buffer plus a register tile gave another 1.9 to 5.4 times. The best I wrote reached 37 GFLOP/s, and a library reached 131, so there is still a 3.5x gap, which tells me the roof I measured in Chapter 19 was too low. Use the library in production, and use this knowledge to understand it.

Check yourself

Exercise 30.1 (Warm-up): Count the flops. How many flops does an n × n matrix multiplication take for n = 512? At 131 GFLOP/s, how long should it take? Compare with the 2.0 ms we measured.

Exercise 30.2 (Warm-up): Intensity. If you do the best-case data movement (read A and B once, write C once, in f32), what is the intensity for n = 64? For n = 1024? Which size is more likely to be compute-bound, and why?

Exercise 30.3 (Core): Reproduce the ladder. Implement versions 1 to 4b and the library call. Check that each result matches the textbook one. Time them at n = 256, 512, and 1024 (best of 3). Does your ladder have the same steps as the tables here? Which step gave the least on your machine? Done when: a table of your numbers and one sentence on each lesson that did or did not hold.

Exercise 30.4 (Core): Test the stride. Run the textbook version at n = 1024 and at n = 1040 and n = 1056. Does the textbook loop depend on the power-of-two width, even though the register tile did not? What does it say about Chapter 9's cache-set conflict?

Exercise 30.5 (Core): Tune the tile. In version 4b, try register tiles of 2 × 16, 4 × 16, 6 × 16, and 8 × 16, and panel depths KC of 64, 128, 256, and 512. Make a table. Which combination is best? Why does the best KC depend on your L1 size?

Exercise 30.6 (Stretch): Cache-oblivious. Write a recursive version that splits the largest of i, j, k in half until the pieces are small (say, 32), then runs the simple loops. Compare with the blocked version at n = 1024. Why does a recursion adapt to all the levels of the ladder at once?

Exercise 30.7 (Stretch): Close part of the gap. Rewrite the inner 4 × 16 kernel with AVX2 and FMA intrinsics (Chapter 28), with run-time detection. How close to 131 GFLOP/s do you get? What else would the library be doing?

Exercise 30.8 (Stretch): Design and defend. You are writing a neural network from scratch in Rust, and 90% of the time goes to matrix multiplications of a few shapes (for example 784 × 128 times 128 × batch). Would you write your own, call matrixmultiply, or link a BLAS? What would you measure first, and what would make you change your mind?

What a strong answer includes: the sizes of your real matrices (small and skinny ones behave differently from big square ones), the cost of an extra dependency against the speed gain, the profile showing that matrix multiplication really is the hot spot, the floating-point order issue, and a reference measurement against the best library.


Chapter 31. Parsers

In this chapter: You will build four JSON parsers and see a 2.4x gap between the slowest and fastest (70 to 166 MB/s), caused mostly by how many allocations they make, not by how clever the parsing is. You will compare with serde_json, and with a simple byte scan that runs at 2 GB/s, which shows how far a parser still is from the speed of just reading the bytes.

You need first: Chapters 15, 21 to 23.

The problem first

A parser reads text and builds a structure. Compilers, configuration loaders, network servers, and data tools all do it. Parser speed is measured in megabytes per second (MB/s), and the usual first parser runs at tens of MB/s while the disk and the network may deliver hundreds or thousands. When parsing is the bottleneck, a team ends up buying more machines to do work that a better parser would do on one.

So: what makes a parser slow, and how fast can it be?

The model (the "what")

A parser does three kinds of work, and each has a different cost:

  1. Scanning: reading the bytes, one at a time or in groups, to find the structure (where a string ends, where a number ends, what the next token is). Cost: roughly instructions per byte, plus branch mispredictions (Chapter 13) when the input has an irregular pattern.
  2. Building: making the output (a tree of values, strings, vectors). Cost: allocations (Chapter 21) and memory traffic.
  3. Converting: turning text into numbers ("3.14" into an f64), or escapes into characters. Cost: arithmetic per digit, and sometimes a lot of it.

There is also validation (checking that the input is legal) which a strict parser must do, and a fast, sloppy one may skip. Comparing parsers without noting how much they validate is not fair. I will point it out where it matters.

A useful reference number is the speed of light: how fast can you merely read the data? If a scan of the bytes runs at 2 GB/s, a parser at 100 MB/s spends 95% of its time on things other than reading. Those things are what we are going to find.

Build it (the "how")

The test input is 22.0 MB of JSON: an array of 150,000 records, each like this (I generated them with a fixed seed):

{"id": 41, "name": "user_8421", "score": 733.19, "active": true,
 "tags": ["alpha", "beta3", "g"], "note": "line \"7\" ok", "parent": null}

Each record has numbers, strings, a boolean, an array of three strings, one string with escaped quotes, and a null. To measure allocations, I wrote a tiny counting allocator: a global allocator that counts every call to alloc and realloc and then forwards to the system allocator (about 8 lines). It is the cheapest allocation profiler there is, and it needs no tools:

struct Counting;
static ALLOCS: AtomicU64 = AtomicU64::new(0);
unsafe impl GlobalAlloc for Counting {
    unsafe fn alloc(&self, l: Layout) -> *mut u8 { ALLOCS.fetch_add(1, Ordering::Relaxed); System.alloc(l) }
    unsafe fn dealloc(&self, p: *mut u8, l: Layout) { System.dealloc(p, l) }
    unsafe fn realloc(&self, p: *mut u8, l: Layout, n: usize) -> *mut u8 { ALLOCS.fetch_add(1, Ordering::Relaxed); System.realloc(p, l, n) }
}
#[global_allocator]
static A: Counting = Counting;

Every parser returns a tree of values, and I sum all the numbers in the tree after parsing, so the work cannot be thrown away (Chapter 1) and so that I can check that all parsers read the same data. The four sums were identical: 11324895736.1.

Four versions:

  • A. chars + owned Strings. The slow, natural first version: turn the text into a Vec<char> (4 bytes per character), walk it, and build an owned String for every string and number token.
  • B. bytes + owned Strings. The same, but walking the bytes of the &str directly, and building strings from byte slices.
  • C. bytes + borrowed strings + integer fast path. The value type holds Cow<'a, str>: a string that is borrowed from the input (no copy) when it has no escapes, and owned only when an escape forces a copy. Integers are parsed by a short loop of digit arithmetic, and only fractions or exponents go through the general str::parse::<f64>. Vectors start with a small capacity guess (8 for objects, 4 for arrays).
  • D. serde_json::Value. The standard library of the Rust ecosystem, as a reference.
  • E. scan only. Not a parser: count the " bytes with iter().filter().count(). This is the speed of light.

Results (best of 3 to 5 runs):

VersionTimeSpeedAllocations
A. chars + owned Strings314.7 ms69.9 MB/s2,848,504
B. bytes + owned Strings266.5 ms82.5 MB/s2,548,501
C. bytes + borrowed + int fast path132.2 ms166.3 MB/s750,017
D. serde_json::Value307.6 ms71.5 MB/s2,100,019
E. scan only (not a parser)10.5 ms2,083.6 MB/s0

Look at the allocation counts first. A makes 19 allocations per record (2,848,504 / 150,000). C makes 5 per record, 3.8 times fewer, and is 2.4 times faster. The two numbers move together. We saw this in Part V on the word counter, and it is the same story: in a parser that builds a tree, the allocator is the main cost, and every allocation you remove is a gain.

Go through the rows.

A to B: 1.18x from walking bytes instead of chars. Version A first copies the whole 22 MB text into a Vec<char> of 88 MB (4 bytes each), then walks it. B skips both. It is only a modest gain, because the number of allocations barely changed (2.85 to 2.55 million): this is a scanning improvement, and the allocations still dominate. Use bytes, and check that the input is valid UTF-8 once, instead of decoding every character.

B to C: 2.0x, from fewer allocations and cheaper numbers. This is the big one, and it has three parts, in my judgment (I did not measure them separately): borrowed strings (no allocation for names, tags, and keys, which are nearly all the strings), the integer fast path (no to_string and parse for each id), and starting vectors with a capacity. The remaining 750,000 allocations are about 5 per record. I did not break them down. The likely ones are the vector for each object and each array, and the owned string for the one field per record that contains escapes.

D: serde_json was no faster than my first version. At 71.5 MB/s, the standard JSON library, parsing into its dynamic Value type, ran about as fast as my A and B, and 2.3 times slower than C. This is not a fair comparison, and I want to be careful: serde_json is a complete, strict, correct parser, and mine is not. It validates the grammar exactly, checks the UTF-8 of every string, reports errors with positions, handles all escapes and number formats, and its default Value stores objects in a sorted map. My parser C accepts some invalid JSON, panics on bad input, and handles only a few escapes. What this row tells you is only that a dynamic Value tree is expensive to build (2.1 million allocations here), for anyone, however good. The way serde_json is usually fast is not the Value route: it is deserializing straight into your own struct, with no intermediate tree. I did not measure that here. It is the next experiment (Exercise 31.4).

E: the speed of light is 20 times higher. A plain scan of the bytes ran at 2.08 GB/s. Even our best parser (C) is 12.5 times below that. That is the room that remains, and it is not small. It is the room where simdjson-style parsers live. They use SIMD (Chapter 28) to find the structure of the JSON (the quotes, braces, and commas) 32 or 64 bytes at a time, and they report gigabytes per second (Langdale and Lemire, in the reading list; I know their result from the paper's title and abstract, and I did not run it here).

Profile it again

Following our rule from Part V, I profiled the fastest version (C) with perf:

    24.21%  PRef::value                        (the main parsing loop)
     8.80%  sum_nums_ref                       (our check: walking the tree)
     7.34%  PRef::string
     7.09%  [kernel] do_user_addr_fault        (page faults)
     6.92%  libc  unlink_chunk                 (the allocator, freeing)
     6.53%  drop_in_place<VRef>                (freeing the tree)
     4.95%  libc  _int_malloc                  (the allocator, allocating)
     3.07%  [kernel] __rcu_read_unlock
     2.73%  [kernel] __pte_offset_map_lock

(This profile also includes the time to build the test text itself, because the profiled run generates the input and then parses it 8 times, so the shares are blurred. Still, the shape is clear.)

The parsing itself (value and string) is about 31% of the time. The rest is memory management: the allocator (the two entries listed, unlink_chunk and _int_malloc) at about 12%, dropping the tree at 6.5%, and kernel time for page faults at about 13% (the lines starting with [kernel]). So about one third of the fastest parser's time is the cost of building and tearing down the tree. That is the cost of choosing to make a tree at all. The value type is 32 bytes, and an object entry (key and value) is 56 bytes (I printed size_of), so the 22 MB of text becomes a good deal more memory as a tree.

Why it is this way (the "why")

Why does a parser that builds a tree spend so much time on memory? Because a tree has a different shape from the text. The text is a sequence of bytes, read once, in order: ideal for the prefetcher (Chapter 12). The tree is a web of separate heap pieces (vectors, strings, boxes) that must each be created, linked, later walked, and later freed. The input has no pointers. The output is made of pointers. Building the output is a different, and harder, memory problem than reading the input. The speed of light (2 GB/s) is the speed of the easy part.

This tells you where the large gains are, in order:

  1. Don't build what you don't need. If you only need three fields out of 20, or you process records one at a time, a streaming parser or a typed deserializer that fills your own struct skips the tree entirely. This is the biggest lever and has nothing to do with the parsing algorithm.
  2. Borrow, do not copy. Strings that point into the input (zero-copy) need no allocation. The price: the tree cannot outlive the input, and Rust's lifetimes make you say so (VRef<'a>). This is a compile-time check on exactly the thing that would be a use-after-free bug in C, which is a nice example of the type system helping performance work.
  3. Pre-size the containers. A Vec that grows by doubling copies its data again and again (Chapter 22: 19 allocations, 64% of the bytes).
  4. Make the common case fast and the rare case correct. The integer fast path handles the commonest numbers with a few multiplications, and only unusual numbers pay for the general path. This is the same "one guarded fast path" idea as the ASCII table in Chapter 23. Daniel Lemire's work on number parsing (reading list) is a deep study of exactly this.
  5. Only then, make the scanning faster: bytes instead of chars, branch-free tests, table lookups, and, at the far end, SIMD.

There is a design principle behind this list: a parser is a data transformation, and the cost lives in the shape of the output. The same holds for a compiler's front end (Chapter 33), where the output of parsing is the AST. The faster approach there, too, is to make the output flat and compact.

What would make this wrong? Our input is regular: every record has the same shape, which makes the branches very predictable (Chapter 13). Real data is messier, with more mispredictions, and the ranking of the techniques can change. Our parser also has no error handling. A real one needs error paths, and those are cheap if done with Result and ? on the cold path, but I did not measure them. And speeds on a 22 MB file that fits in the L3 are not speeds on a 20 GB file that streams from disk, where the I/O dominates.

Common mistakes

  • Comparing parsers that do different amounts of validation.
  • Building a tree when you only need a few fields.
  • Using Vec<char> or .chars() where bytes will do.
  • Allocating a String for every token. Borrow with &str or Cow.
  • Using the general number parser for every number.
  • Forgetting to size Vecs.
  • Measuring parse speed in a loop on a file already in cache when the real input comes from a disk or the network.

Say it simply

A parser reads text and builds a structure, and the cost is in three places: scanning the bytes, building the output, and converting numbers. My four JSON parsers ran from 70 to 166 MB/s, and the allocation counts (19 against 5 per record) explained almost all of the gap. A plain byte scan ran at 2 GB/s, so even the best parser spends most of its time on tree building and memory management. The biggest wins are not building a tree you do not need, borrowing strings from the input, and giving the common case a fast path.

Check yourself

Exercise 31.1 (Warm-up): Per record. Version A made 2,848,504 allocations for 150,000 records. How many per record? If one allocation and free costs 14 ns (Chapter 21), what share of A's 314.7 ms is that, at most?

Exercise 31.2 (Warm-up): Which gain? For each change, say which of the three kinds of work (scanning, building, converting) it speeds up: (a) Vec<char> to bytes; (b) Cow<str> instead of String; (c) an integer fast path; (d) Vec::with_capacity.

Exercise 31.3 (Core): Reproduce the table. Implement a byte-based, borrowed-string parser for the JSON subset above (or use mine as a model). Add a counting allocator. Check that your tree gives the same number sum as serde_json::Value. Fill in the table for your machine. Done when: a table with time, MB/s, and allocations for at least three versions, and the speed-of-light scan on the same input.

Exercise 31.4 (Core): Skip the tree. Use serde_json::from_str::<Vec<Record>> with a #[derive(Deserialize)] struct that has only id and score (ignoring the other fields). Compare with the Value route. How fast is it, and how many allocations?

Exercise 31.5 (Core): The cost of validation. Add UTF-8 validation of every string in the borrowed parser, then full number-grammar checks. How much slower does each make it?

Exercise 31.6 (Stretch): Find the structure with SIMD. Write a function using SSE2 or AVX2 (Chapter 28) that finds the positions of all " bytes in a buffer 16 or 32 bytes at a time, returning a bit mask per block. Compare with the scalar scan at 2 GB/s. This is the first step of a simdjson-style parser.

Exercise 31.7 (Stretch): Design and defend. A service receives 50,000 JSON requests per second, each about 2 KB. It needs 4 fields from each. Compare three designs: serde_json::Value, a typed struct with Deserialize, and a hand-written zero-copy scanner. Which would you pick first, and what would make you move to the next?

What a strong answer includes: the allocations per request, the share of the CPU budget for parsing, validation requirements for untrusted input, the maintenance cost of a hand-written parser, and a profile of the real service before deciding.


Chapter 32. Interpreters and VMs

In this chapter: You will run the same tiny program through nine different interpreter designs, and see the speed go from 166 ns per loop iteration to 6.4 ns, against 0.9 ns for the native loop. You will learn which ideas matter most (how variables are found, how many instructions each loop iteration takes), which the folk wisdom overrates, and why one result came out differently every time I rebuilt the program.

You need first: Chapters 12, 13, 24.

The problem first

An interpreter is a program that runs another program. If you write a language (a scripting language, a query engine, a Lox from Crafting Interpreters), you write one. Its speed decides how much your language can do in a second. And its design has a few well-known forks: walk the syntax tree directly, or compile to a bytecode first? A stack machine or a register machine? How do you look up a variable?

I will answer them with one small program, run through every design, and with numbers.

The program, in a tiny language with integers only:

s = 0; i = 0;
while i < n {
    s = s + (i * i) % 7;
    i = i + 1;
}
result = s

with n = 20,000,000. Every design must give the answer 40,000,001 (I asserted it in the program, so a wrong interpreter cannot appear in the table).

The model (the "what")

Tree walking

A tree-walking interpreter keeps the program as its syntax tree, and runs it by recursion: to evaluate a + b, evaluate a, evaluate b, then add. It is the simplest design, and the one most of us write first. Its costs: a function call per node, a pointer chase per node (Chapter 11), and a match on the node kind per node.

Bytecode

A bytecode interpreter first compiles the tree into a flat list of simple instructions, and then runs the list in a loop:

loop {
    match code[pc] {            // fetch and decode the instruction
        Op::Add => { ... }      // execute it
        ...
    }
}

The loop is the dispatch loop: dispatch means finding the code for the next instruction. A match on an enum is usually compiled to a jump table (an array of code addresses, indexed by the opcode) and one indirect branch (a jump whose target is read from memory), which is the kind of branch the predictor finds hardest (Chapter 13).

Bytecode comes in two main flavors:

  • A stack machine keeps temporaries on a stack. s + (i*i) % 7 becomes: push s, push i, push i, multiply, push 7, mod, add. Instructions are small (no operand names), and the compiler is easy. But a lot of instructions just push and pop.
  • A register machine names its operands: mul t, i, i. The instructions are bigger, but there are far fewer of them. The Lua language switched from a stack to a register design in version 5.0, and the Java and .NET virtual machines remained stack-based (my recollection; I did not verify it here).

Where variables live

Every interpreter must find the value of i when it meets the name i. The designs:

  • By name at run time: a hash map from the name (a String) to the value. Simple, and the first thing most people write. Each use of a variable costs a hash of the name and a table lookup.
  • By slot: before running, a pass (a resolver) assigns every variable a numbered slot in an array, and replaces the name with the number. A use is then an array index. This is what the resolver chapter of Crafting Interpreters sets up.

Build it (the "how")

Nine designs. I ran the whole program 7 times, and the table reports the median of the 7 runs, with the min and max, in nanoseconds per loop iteration:

DesignmedianminmaxInstructions per iteration
native Rust loop (the reference)0.920.840.98n/a
register VM, unchecked indexing (unsafe)6.406.126.707
register VM + fused compare and branch, loop test at the bottom7.376.877.825
register VM + fused compare and branch, immediates8.458.119.156
register VM, plain10.5610.0011.317
register VM, safe, register numbers masked with & 710.7210.1410.927
stack VM36.6835.1138.3917
tree walker, variables in slots40.1338.8541.21n/a
tree walker, variables in a HashMap<String, i64>165.74161.85172.57n/a

The sizes: an Op of the stack VM is 16 bytes, an instruction of the register VM is 8 bytes, and an AST Expr node is 32 bytes. Read the table in this order.

Variables in a HashMap: 4.1 times slower than variables in slots. The two tree walkers do the same work, except for how they handle variables: the slot version indexes an array, and the hash-map version hashes the name on every use, and (in my code) also clones the name String on every assignment, which is an allocation. So the gap is a hash-map cost plus an allocation cost, and I did not separate them. 165.7 against 40.1 ns per iteration. That is the single biggest gap among the designs in the table, and it comes from one choice, in an interpreter where nothing else is slow. Each iteration of our loop uses a variable 8 times (6 reads, 2 writes), and each use hashes a string with SipHash. If your first interpreter keeps variables in a hash map by name, which almost every first interpreter does, a resolver pass that turns names into slots is likely your best first optimization, and the reason is not subtle: it removes work that scales with the number of variable uses.

Tree walker in slots against a stack VM: about the same (40.1 against 36.7 ns). This goes against folk wisdom ("bytecode is faster than tree walking"). It is faster here by only 9%, because our stack VM executes 17 instructions per iteration, and each one is a trip around the dispatch loop with pushes and pops. A tree walker with slots does the same work with a few recursive calls. Switching from tree to bytecode does not by itself make a program fast. The number of dispatches does.

Stack VM to register VM: 3.5 times faster. The same work, but 7 instructions per iteration and not 17, a reduction of 59%. The time fell almost in the same proportion as the instruction count: 36.7 ns for 17 instructions is 2.16 ns each, and 10.56 ns for 7 is 1.51 ns each. The cost of an interpreter is close to (number of instructions executed) × (cost of one dispatch). There is a research paper on exactly this comparison (Shi, Casey, Ertl, and Gregg, ACM TACO 2008). They report that a register machine with a plain switch dispatch removed more than 46% of the executed instructions and was on average 1.48 times faster than the corresponding stack machine on one of their processors. My toy program shows a bigger effect (59% fewer instructions, 3.5x), because it is nothing but small operations. Real programs have bigger instructions (calls, allocations, property lookups) that dilute the effect.

More fusing helps a little. Combining a compare and a branch into one instruction, and using immediate constants (the 7 and 1 written into the instruction instead of loaded from a register), brought it to 6 instructions and 8.45 ns, a 20% gain. Turning the loop upside down (testing at the bottom, so that the unconditional jump disappears) reached 5 instructions and 7.37 ns, another 13%. These are the classic superinstructions and loop inversion (the second is a standard compiler technique, not specific to VMs). Each gain is small, and each is real, and the ladder of 17, 7, 6, 5 instructions ends at 36.7, 10.6, 8.45, 7.37 ns.

Bounds checks were large here: 1.65x. Removing every bounds check with unsafe indexing (get_unchecked) took the register VM from 10.56 to 6.40 ns. In Chapter 24, a bounds check cost 2%. Here it was 38% of the time. The difference: in Chapter 24, each check protected a load that cost 0.36 ns and was hidden by the CPU's overlap. Here each VM instruction does only about 1.5 ns of work and has several checks (the instruction fetch code[pc] and three register accesses). When the work per check is small, the check is a big share. How much a check costs depends on how much work is next to it.

But the safe fix did not work. When I masked each register number with & 7, so that the compiler can prove it is within the 8-element array without unsafe, the time did not change (10.72 ns against 10.56). So the register-index checks were not what cost the time. The remaining suspect is the check on code[pc] (the instruction fetch), and the unsafe version also removed that. I did not test it. It is a good exercise, and a good example of a safe change that "should" have worked and did not (Chapter 24's masked index also failed).

The best VM is still 7 times slower than the native loop (6.40 or 7.37 against 0.92 ns). That is the price of interpretation: every iteration is 5 to 7 dispatches. Closing that gap needs a just-in-time compiler or compiling to native code, which is another subject. For most languages, an interpreter 5 to 10 times slower than native code is a normal and acceptable place to be.

One more result, and a warning

I wrote the fused instructions first, and ran the program for the first time. Then I added one more function (the masked version) to the same file and rebuilt. The row "register VM + fused compare and branch, immediates" went from 11.88 ns in the first build to 8.45 ns in the second (median of 7). That is a 40% change in the same source code for the same VM function, only because something else in the file changed. The most likely cause is a difference in how the compiler laid out the machine code (where the dispatch loop landed in memory, how the jump table was aligned), and I did not confirm it. This is the effect of Chapter 1 and Chapter 4 (Mytkowicz and co-authors, 2009) once more, in a context where it is easy to miss: tiny interpreters are very sensitive to code layout and compiler luck. The consequence for you: when you compare two interpreter designs by a difference of 10 to 20%, rebuild a few times with other changes in the file, and report medians. And do not trust a single run. The 5-instruction against 6-instruction ordering above is stable in the 7 runs (their ranges do not overlap), but the 40% swing is a warning that small differences are not safe.

Why it is this way (the "why")

Why is the dispatch loop the heart of an interpreter's speed? Because every instruction pays a fixed cost to be fetched, decoded, and routed, whatever it does. In our VM, an instruction like add does 1 ns of real work and costs about 1.5 ns in all. The rest is overhead. If the work per instruction were large (a string concatenation, a hash lookup, a function call), the overhead would be small and the design would not matter much. If the work is small (an integer add), the dispatch dominates. This is why an interpreter's speed depends so much on the language: a language with big instructions (like many scripting languages, with high-level operations on strings and lists) is far less sensitive to dispatch design than one with small instructions (like numeric code).

The deeper principle is one we saw in Part III: the machine likes regular, predictable work. Dispatch uses an indirect branch, the hardest kind to predict. The classic worry was that one shared indirect branch at the top of the loop has a target that depends on the previous instruction, so the predictor does badly, and the classic fix, threaded code (each instruction ends with its own indirect jump to the next one), spreads the branches over many places so each is more predictable. This was an important result in the early 2000s (Ertl and Gregg, in the reading list). Later studies (Rohou, Swamy, and Seznec, CGO 2015) found that recent CPUs predict a single shared dispatch branch very well, which reduces the benefit of threading. I could not test this, because the hardware counters for branch misses are not available in this VM (Chapter 15), and our loop is a tiny, perfectly repeating program that any predictor learns. A real program with a more varied instruction mix would stress the predictor more. Rust on stable does not (as far as I know) give you a guaranteed way to write threaded code, because it lacks computed goto and guaranteed tail calls. Check the current state before you rely on that.

The philosophical point is about where work is done. The tree walker decides things while running: "what does this name mean, what type is this, which operation is this". The compiled forms decide them before running: names become slots, operations become opcodes, constants become immediates. Every decision moved from run time to compile time is paid once, not once per iteration. The ladder in our table is that idea at work: the resolver moves name lookup to compile time (4.1x), the register compiler moves temporaries to compile time (3.5x), fusing moves common pairs to compile time (1.2x). A JIT compiler is the end of the road: move everything to compile time, down to machine code. Adrian Sampson's essay on flattening ASTs (Chapter 33) notes the same thing from another side: a flat array of small nodes with numeric references is already a bytecode.

What would make this wrong? Our program is a tight arithmetic loop. A real program spends time in function calls, objects, strings, and garbage collection, which our toy does not have. In such programs, the dispatch cost shrinks as a share, and the costs of calls, allocation, and data representation (how a "value" is stored: a tagged union? a pointer?) grow. The ranking of designs can change. And nothing here covers the cost of compiling to bytecode, which matters for short programs.

Common mistakes

  • Keeping variables in a hash map by name. Resolve to slots first.
  • Believing "bytecode is faster" without counting instructions. A bad bytecode can lose to a good tree walker.
  • Measuring one build and one run. Layout changed one result by 40%.
  • Ignoring instruction count. It is the main cost driver in a small-instruction VM.
  • Removing bounds checks with unsafe without measuring. It gave 1.65x here, but the safe attempt did nothing, and the gain depends on the work per check.
  • Benchmarking a loop that is too regular and concluding the branch predictor never matters.

Say it simply

The same loop ran from 166 ns per iteration (a tree walker with variables in a hash map) to 6.4 ns (a register VM with unchecked indexing), against 0.9 ns native. Resolving names to slots made the tree walker 4.1 times faster, a register machine was 3.5 times faster than a stack machine because it ran 7 instructions per iteration and not 17, and a bytecode is not automatically faster than a tree. An interpreter's cost is close to the number of instructions it executes times the cost of one dispatch, so make the instructions fewer, and move work from run time to compile time.

Check yourself

Exercise 32.1 (Warm-up): Count the dispatches. Write the stack bytecode for x = (a + b) * (c - d). Then write the register bytecode. How many instructions each? If a dispatch costs 2 ns, how much faster is the register version per execution?

Exercise 32.2 (Warm-up): Cost model. Our register VM runs 7 instructions at about 1.5 ns each. An instruction that does a hash-map lookup costs 30 ns of real work. What share of that instruction is dispatch? What does that say about which VM design matters for a language with expensive instructions?

Exercise 32.3 (Core): Reproduce the ladder. Implement the tree walker with a HashMap and with slots, a stack VM, and a register VM for the loop above. Check the answer. Time each (median of 7 runs). Do you get the same ordering? How close are your ratios to 4.1x and 3.5x? Done when: a table with median and range, and one sentence on any result that differs.

Exercise 32.4 (Core): The fetch check. In the register VM, remove only the bounds check on code[pc] (use get_unchecked) and keep the register checks. Then do the reverse. Which one explains the 1.65x? Can you find a safe way to remove the instruction-fetch check (for example, an end-of-code sentinel and a pointer or an iterator instead of an index)?

Exercise 32.5 (Core): Superinstructions. Find the most common pairs of instructions in your stack VM's trace (count them), and add fused instructions for the top three pairs. How many instructions per iteration now? How much faster?

Exercise 32.6 (Stretch): Add function calls. Add call and return to the register VM, and run a recursive fib(30). Compare with a tree walker. What is the cost of a call, in dispatches and in nanoseconds?

Exercise 32.7 (Stretch): The layout lottery. Build the VM benchmark ten times, each time with a different unused function added to the file, and record the time of the plain register VM. How wide is the spread? Can you find the cause by comparing the assembly of the dispatch loop (--emit asm) in a fast and a slow build?

Exercise 32.8 (Stretch): Design and defend. You are writing a scripting language whose programs mostly loop over lists and call library functions written in Rust, such as sort, map lookup, and string search. Would you spend your first week on a register VM, or on making the library calls fast? What would you measure first?

What a strong answer includes: a profile of typical scripts showing where time goes, the idea that expensive instructions dilute dispatch cost, the cost of crossing between the VM and the native functions, and a baseline against a similar language.


Chapter 33. Compiler data structures: ASTs, interning, and arenas

In this chapter: You will build the same 2.28-million-node expression tree four ways, and measure building, evaluating, and freeing it. A flat tree with 4-byte ids builds 3.5 times faster than a Box tree, evaluates 2.2 times faster, frees 64 times faster, and uses 2.3 times less memory. You will also see why interning names is worth it, and why a plain loop can replace recursion.

You need first: Chapters 11, 21, 23.

The problem first

A compiler or an interpreter turns text into a tree (the abstract syntax tree, the AST) and then walks that tree many times: to check types, to simplify, to generate code. The tree can have millions of nodes. The usual first design is a Rust enum with Box for children:

enum Expr { Num(i64), Var(String), Add(Box<Expr>, Box<Expr>), Mul(Box<Expr>, Box<Expr>) }

It is natural, and the borrow checker likes it. And in Chapter 11 we saw what Box does to a loop over many items: 3x slower in order, 19x slower when shuffled. A compiler's AST is exactly a large web of boxes.

The model (the "what")

Three ideas, all from earlier chapters, now applied to one structure.

1. Interning. Identifiers repeat. x may appear 10,000 times in a file. Instead of 10,000 Strings (24 bytes plus a heap block each), keep one table of distinct names, and let every use hold a small number: a symbol (Sym(u32)). Comparing two names is comparing two integers. A table Vec<Value> indexed by the symbol replaces a hash map keyed by string.

2. The flat arena (also called an index arena or a flattened AST). Instead of Box, put all nodes in one Vec<Node>, and let a node refer to its children by index (u32). Adrian Sampson calls this data structure flattening in an essay (in the reading list). The two pieces, in his words, are: keep nodes in a contiguous array, and let nodes refer to each other by array index. The benefits, as he lists them (I read the essay for this chapter): references are 32 bits instead of 64, so references take half the space; the data caches work better; and the whole tree dies together, so there are no individual frees. In Rust, you wrap the index in a small type so that it cannot be confused with other integers: struct ExprRef(u32).

3. The bump arena. The other kind of arena allocates each node separately, but from a big block by moving a pointer (Chapter 23's bumpalo), and nodes refer to each other by ordinary references (&'a Expr<'a>). Easier to write than indices, since you keep the plain-reference syntax, but references are 64 bits.

Build it (the "how")

The same tree is built four ways. I made every version take the same random decisions in the same order, so that all four trees are identical (the evaluation result is the same number in every run, -9107210502808600576, which I checked). The tree has 2,281,087 nodes. Leaves are numbers or variables (one third are variables, chosen from 1,000 distinct names), and internal nodes are Add or Mul. Evaluation is a recursive walk that returns an integer (with wrapping arithmetic).

  • A. Box tree, variable names are Strings, environment is a HashMap<String, i64>.
  • A2. Box tree, variable names are interned Sym(u32), environment is a Vec<i64> indexed by the symbol.
  • B. index arena: Vec<Node>, children are u32 ids, symbols for names.
  • C. bump arena: nodes in a bumpalo::Bump, children are &Expr references, symbols for names.

Each version ran in its own process, 5 times. Median of 5:

Versionbuildevaldrop (free)memory
A. Box + String names104.1 ms51.8 ms70.0 ms81.3 MB
A2. Box + interned symbols82.9 ms37.5 ms52.5 ms69.7 MB
B. index arena30.1 ms23.3 ms1.1 ms35.0 MB
C. bump arena (references)42.1 ms33.9 ms1.5 ms52.3 MB

The node sizes, from size_of: Expr with Box is 24 bytes, the arena Node is 16 bytes, and the bump-arena node is 24 bytes. From the memory used, that is about 35.6 bytes per node for A (the Strings and the box headers add up), 30.6 for A2, 15.4 for B, and 22.9 for C.

Go through the table with the three ideas.

Interning (A to A2): 20% faster to build, 28% faster to evaluate, 14% less memory. The only change: names became numbers. Building no longer allocates a String for each variable node (about 380,000 of them: roughly half the nodes are leaves, and a third of the leaves are variables), and evaluation no longer hashes a string for each variable use, but indexes an array. Variable lookups by name are the same cost as in Chapter 32: a hash and a probe, against an index. This is a small structural change for a clear gain, and it is nearly free in code: one table.

The index arena (A2 to B): 2.8x faster to build, 1.6x to evaluate, 48x to free, 2x less memory. Everything changed because all the Box allocations disappeared. Building is a push into a vector. Evaluating walks nodes that sit next to each other in memory (the prefetcher, Chapter 12, can follow), and each node is 16 bytes instead of 24 plus an allocator header, so four nodes fit in a cache line instead of two. And freeing took 1.1 ms against 52.5 ms: dropping a Vec<Node> is one free call, while dropping a Box tree is 2.28 million calls (and 2.28 million pointer-chasing visits), and we saw in Part V that the free of millions of small objects is a real cost. The 64x total between A and B on free is the "free everything at once" payoff of Chapter 23, with no special arena library.

The bump arena (C) is good, but not as good as the index arena. It frees in 1.5 ms and builds in 42 ms (2x faster than A2), but evaluates 45% slower (33.9 against 23.3 ms) and uses 50% more memory (52.3 against 35.0 MB) than B. The reason is mainly the size of a node: a reference is 8 bytes and an index is 4, so C's nodes are 24 bytes and B's are 16. Smaller nodes mean more nodes per cache line, and less to read. (I did not test whether the allocation order in bumpalo also matters. It allocates downward inside a chunk, as far as I know, which would reverse the memory order of nodes. I did not check.) This is exactly Sampson's observation that 32-bit references save half the space of 64-bit ones, now measured.

A bonus: evaluate with a plain loop. In the index arena, I built the nodes in post-order: a node is pushed after both of its children. So every child has a smaller index than its parent. That means you do not need recursion to evaluate: one forward loop over the vector works, because when you reach a node, its children's values are already computed and stored in a side array:

fn eval_b_iter(a: &ArenaB, env: &[i64], vals: &mut Vec<i64>) -> i64 {
    vals.clear();
    for node in &a.nodes {
        let v = match *node {
            NB::Num(n) => n,
            NB::Var(s) => env[s.0 as usize],
            NB::Add(x, y) => vals[x as usize].wrapping_add(vals[y as usize]),
            NB::Mul(x, y) => vals[x as usize].wrapping_mul(vals[y as usize]),
        };
        vals.push(v);
    }
    *vals.last().unwrap()
}

Time: 17.8 ms, against 23.3 ms for the recursive version: another 1.3x, with no call stack, no recursion, and no risk of a stack overflow on a very deep tree. (Median of 5 runs, a separate run from the table above.) The walk is now a sequential scan of an array, the access pattern the hardware likes best. Any pass that goes bottom-up (evaluation, type inference of expressions, constant folding) can use it. Passes that go top-down need the nodes in the opposite order, or an explicit stack.

The code, in short

An index arena and an interner are small. Here are the heart of both:

#[derive(Clone, Copy)] struct Sym(u32);                 // an interned name
#[derive(Clone, Copy)] struct ExprRef(u32);             // a node id: not the same type as a Sym, so you cannot mix them up

#[derive(Clone, Copy)]
enum Node { Num(i64), Var(Sym), Add(ExprRef, ExprRef), Mul(ExprRef, ExprRef) }

struct Ast { nodes: Vec<Node> }
impl Ast {
    fn add(&mut self, n: Node) -> ExprRef { self.nodes.push(n); ExprRef(self.nodes.len() as u32 - 1) }
    fn get(&self, r: ExprRef) -> Node { self.nodes[r.0 as usize] }
}

struct Interner { map: FxHashMap<Box<str>, Sym>, names: Vec<Box<str>> }
impl Interner {
    fn intern(&mut self, s: &str) -> Sym {
        if let Some(&id) = self.map.get(s) { return id; }
        let id = Sym(self.names.len() as u32);
        let b: Box<str> = s.into();
        self.names.push(b.clone());
        self.map.insert(b, id);
        id
    }
    fn name(&self, s: Sym) -> &str { &self.names[s.0 as usize] }
}

Why it is this way (the "why")

Why do compilers (and, as far as I know, rustc itself) use arenas and interning? Because of the shape of the lifetimes (Chapter 23). An AST is built in one phase and lives until the compiler is done with it, and then everything dies at once. Identifiers are created once and compared millions of times. When objects are born together, die together, and refer to each other a lot, an arena fits like a glove, and individual Boxes are paying (in time and memory) for a flexibility (free one node early) that the program never uses. (That rustc interns types and uses arenas is my understanding from reading about it; I did not check the compiler source for this guidebook.)

There is another reason that has nothing to do with speed. A flat arena of Copy nodes with integer ids is easier to program in Rust, which is itself a performance feature if it lets you write more passes and optimize them. With Box or with references, trees fight the borrow checker whenever you want a parent pointer, a graph (sharing a node in two places), or to mutate a tree while holding a reference into it. With indices, a "reference" is just a number, so none of that is a problem. Sampson notes this as an ergonomic advantage; I agree from the point of view of how little friction there is.

The flip side, honestly: indices give up some safety. Rust no longer checks that an ExprRef is valid, or that it belongs to this arena and not another. A wrong index is a logic bug or a panic, not a compile error. Newtype wrappers help (you cannot confuse a Sym with an ExprRef), and some crates add generation counters or one marker type per arena to catch mixups. And you cannot free a single node, or reuse its slot, without extra work (a free list). For a compiler, this is rarely an issue. For a long-running server that builds and discards trees continuously, an arena needs to be reset per request.

The bigger principle: change the representation, not the algorithm. The evaluation function in A and in B does exactly the same work. The speed-up came from the way the data is laid out: contiguous nodes, small ids, one allocation. This is the main message of Parts III and V, applied to the structure that every compiler has. If you take only one habit from this guidebook for the systems you build, make it this one: when a profile shows pointer chasing or allocation in a tree or graph, ask whether the same structure can live in a flat array with small integer ids.

What would make this wrong? Four things:

  • A smaller tree. For 10,000 nodes, everything fits in the L2, and the differences shrink. Our 2.28 million nodes (35 to 81 MB) do not fit.
  • A tree that is edited a lot. Inserting and removing nodes in the middle of a flat array is awkward. Transformations that rewrite the tree often build a new arena, or use free lists.
  • Not post-order. The loop evaluation trick needs children before parents. A parser that builds top-down gives a different order, and the order should be understood.
  • My test is one traversal. A compiler makes many passes, with different access patterns, and some of them (top-down, or following links between non-adjacent nodes) benefit less.

Common mistakes

  • Box everywhere, then profiling the allocator. Start from the arena.
  • Interning data that does not repeat.
  • Using usize or plain u32 for all ids. Use newtypes so that you cannot mix them.
  • Building a tree in an order that makes the traversal order a random walk through memory.
  • Forgetting that an arena frees nothing until it is dropped. Reset it at a sensible boundary.
  • Assuming that arena references (&'a T) and index arenas cost the same. The size of a reference showed up as 50% more memory.

Say it simply

A compiler's AST is a large tree of nodes that are born together, die together, and refer to each other, so it fits an arena. Storing the nodes in one Vec with 4-byte ids instead of boxes built our 2.28-million-node tree 3.5 times faster than the Box version with String names, evaluated it 2.2 times faster, freed it 64 times faster, and used 2.3 times less memory. Interning names alone gave about 20 to 28%. A bump arena with references was nearly as good, but its 8-byte references made nodes 50% bigger. If nodes are stored in post-order, you can evaluate with a plain loop, another 1.3x faster.

Check yourself

Exercise 33.1 (Warm-up): The sizes. An Expr node is 24 bytes with Box, 16 with a u32 index, and 24 with a reference. How many nodes of each fit in a 64-byte cache line? How many cache lines does a 2.28-million-node tree use in each case?

Exercise 33.2 (Warm-up): Can I use a loop? For each pass, say whether a single forward loop over a post-order arena works: (a) evaluating the tree; (b) computing the depth of each node; (c) printing the tree as indented text; (d) propagating the type of the root down to the leaves.

Exercise 33.3 (Core): Reproduce the table. Build the four layouts on your machine, with the same tree in each. Check the results agree. Measure build, eval, drop, and memory (median of 5). Do you see the same order? Which of the four ratios is closest to ours? Done when: your own four-row table, and one sentence on which column surprised you.

Exercise 33.4 (Core): Intern or not. Make a version of A2 with 50% of the leaves being variables with distinct names (no repetition). Does interning still help? What does this say about when to intern?

Exercise 33.5 (Core): Shuffle the arena. Take the index arena, and randomly permute the order of the nodes (rewriting the ids to match). Evaluate with the recursive version. How much of the 23.3 ms advantage over A2 survives? What does this say about layout against size?

Exercise 33.6 (Stretch): Type-safe ids. Make the arena generic: Arena<T> with Id<T>(u32, PhantomData<T>), so that an Id<Expr> cannot be used in an Arena<Type>. Does it cost anything at run time? (Check the machine code, or time it.)

Exercise 33.7 (Stretch): Hash-consing. Add hash-consing to the arena: before pushing a new node, look it up in a hash table, and reuse an existing identical node. How many nodes does a typical expression tree save? What does it do to equality checks between subtrees?

Exercise 33.8 (Stretch): Design and defend. You are writing a compiler front end. Pick a representation for the AST, for types, and for identifiers, and explain each choice in two sentences, using what you have measured. What would make you change your mind about each one?

What a strong answer includes: an index or bump arena for the AST (why), interning for identifiers, hash-consing or interning for types (so equality is an id compare), the lifetimes of each, the passes that need bottom-up or top-down order, and how you would measure to confirm the choice.


Part VII wrap-up

What you can now do:

  • Explain matrix multiplication as a problem of reuse across the memory ladder, and say which of loop order, tiling, register tiles, and packing helps, and which does not, and by how much.
  • Use a strong library as a reference and as a stopping rule, and notice when your own roof is too low.
  • Tell which cost dominates in a parser (scanning, building, or converting), count allocations with a tiny counting allocator, and pick the right lever.
  • Compare interpreter designs by the number of instructions they execute and the cost of one dispatch, and know what a resolver pass buys you.
  • Choose between Box, an index arena, and a bump arena for a compiler's tree, and use interning and post-order loops.
  • Spot, and report honestly, a result that changes when only the code layout changes.

Mini project: One system, three ideas (8 to 12 hours). Pick one of the four systems (a matrix routine, a parser for a format you care about, a small interpreter, or a compiler front end with an AST), or use one of your own. Then:

  1. Write a correct, plain version, and a reference to check against (a library, or a simple slow version that is obviously right).
  2. Measure it with the harness of Part I or Criterion (Part II), on at least three input sizes or shapes. Write down the baseline and your hypothesis.
  3. Profile it (Part IV), with at least two kinds of evidence. Count allocations if it builds data.
  4. Apply three of the ideas from Parts III, V, and VII, one at a time. Measure after each (median of at least 5 runs).
  5. Compare your best version with the strongest reference you can find (a mature library, or the best result you can estimate from the roofline). Say how far you are, and what the remaining gap is made of.
  6. Rebuild after adding an unrelated function to the file, and re-measure. Did anything move by more than 10%?

Done when: a report with the scoreboard table (change, gain, what it gave up), the profile before and after, the gap to the reference, and one paragraph on the idea that did not work for you and your best guess why.

Write-up prompt: Half a page. Which result changed your mind about what is "obviously" fast?

Teach it back: In 5 sentences, explain to a friend why changing a tree from Box nodes to a flat array of nodes with 4-byte indexes can make a compiler several times faster, even though the algorithm does not change.

Reading list for Part VII:

  • Kazushige Goto and Robert A. van de Geijn, "Anatomy of High-Performance Matrix Multiplication", ACM Transactions on Mathematical Software, 2008. The standard account of the blocking and packing structure. (Not freshly checked for this edition.)
  • Field G. Van Zee and Robert A. van de Geijn, "BLIS: A Framework for Rapidly Instantiating BLAS Functionality", ACM TOMS, 2015. The framework built on that structure. (Not freshly checked.)
  • Harald Prokop, "Cache-Oblivious Algorithms", MIT master's thesis, 1999, and Frigo, Leiserson, Prokop, and Ramachandran, "Cache-Oblivious Algorithms", FOCS 1999. The recursive approach that adapts to every cache level. (Neither freshly checked.)
  • Geoff Langdale and Daniel Lemire, "Parsing Gigabytes of JSON per Second", The VLDB Journal, 2019, and Daniel Lemire, "Number Parsing at a Gigabyte per Second", Software: Practice and Experience, 2021. SIMD structure finding and fast number conversion. (Not freshly checked.)
  • Yunhe Shi, Kevin Casey, M. Anton Ertl, and David Gregg, "Virtual Machine Showdown: Stack Versus Registers", ACM Transactions on Architecture and Code Optimization, 4(4), Article 21, 2008. The comparison discussed in Chapter 32 (title, authors, venue, year, and the 46% and 1.48x results checked against the paper's abstract in this session).
  • M. Anton Ertl and David Gregg, "The Structure and Performance of Efficient Interpreters", Journal of Instruction-Level Parallelism, 2003, and Erven Rohou, Bharath Narasimha Swamy, and André Seznec, "Branch Prediction and the Performance of Interpreters: Don't Trust Folklore", CGO 2015. The dispatch-branch story. (Neither freshly checked.)
  • Adrian Sampson, "Flattening ASTs (and Other Compiler Data Structures)", a blog post at cs.cornell.edu/~asampson (checked in this session: the description of flattening, the 32-bit references and the shared lifetime argument).
  • Robert Nystrom, Crafting Interpreters, 2021. The tree-walking and bytecode interpreters, with a resolver chapter. (Not freshly checked.)

Verification notes for Part VII: All programs in this Part were compiled and run in this session on the 1-core Xeon VM with rustc 1.91.1 (release mode; the native-build columns used -C target-cpu=native). Correctness checks: every matrix version was compared with the textbook result (max difference 0 for same-order versions, about 2e-5 to 6e-4 for the library, which sums in a different order); all four JSON parsers returned the same number sum (11324895736.1); every interpreter design asserted the same answer (40000001); all four tree layouts returned the same evaluation result. Timing method: matrix times are best of 2 to 4 runs, parser times are best of 3 to 5, interpreter rows are the median of 7 runs of one binary, and tree-layout rows are the median of 5 runs, each in a separate process. Checked against sources in this session: the Shi et al. paper's venue, year, article number, and its 46% and 1.48x results; Sampson's flattening essay (the 32-bit reference and arena-lifetime points). Not verified: the Goto, BLIS, cache-oblivious, Langdale and Lemire, Ertl and Gregg, Rohou et al., and Nystrom references (from my own knowledge); the structure of the Goto algorithm beyond what the experiments show; the library's internal micro-kernel design; the number of FMA units and the clock of this CPU (used in one rough peak estimate); that rustc uses arenas and interning; that Rust lacks guaranteed tail calls on stable; and the causes of these unexplained results: the slow unpacked register tile in the native build, the power-of-two result at n = 1024 (only a partial test), the 40% build-to-build change in one interpreter row, why masking the register numbers did not help, and why the bump arena evaluates slower than the index arena (my explanation, node size, was not isolated). Known limitation: the parsers in Chapter 31 are not conforming JSON parsers (they accept some invalid input and panic on bad input), so the comparison with serde_json measures tree-building cost, not parser quality. Not run: hardware counters (branch misses, cache misses), a typed serde deserialization, SIMD structure scanning, a JIT, and every exercise.


Part VIII. Inside the tools

After this Part, you will not treat any measuring tool as a black box. You will know what Criterion computes, what the hardware counters are, what Valgrind simulates, and what "a cycle" really is, and you will have checked each of them yourself.

We have used these tools for seven Parts. Each time, we read their output and made decisions from it. That is only safe if you know what is behind the number. This Part opens each tool, and the method is the same every time: I recompute the tool's own answer by an independent route. If my small program and the tool agree, the tool is demystified. If they disagree, we have learned something. In this Part, almost everything agreed, and the places where it did not are the most interesting.

The same limit as before applies: this is a 1-core VM with no hardware counters. Chapter 35 is about those counters, and I will show you exactly where my access to them ends.


Chapter 34. Inside Criterion

In this chapter: You will see exactly what Criterion computes, by recomputing its numbers from its own raw files. Every estimate (the slope, the mean, the median, the confidence interval, the outlier fences) matched. And you will use the same files to check an assumption Criterion makes, that samples are independent. On this machine, it does not hold.

You need first: Chapters 5 to 7.

The problem first

In Chapter 6 we said Criterion decides "improved", "regressed", or "no change" with a p-value and a 1% noise threshold. We took its word for it, apart from the rule itself, which I read in the source code. But the report also says things like "time: [12.024 µs 12.046 µs 12.070 µs]", and we never asked how those three numbers are computed, from what data. And one result in Chapter 7 nagged: a benchmark of unchanged code was flagged as "regressed". Was that a Criterion bug, or something about the data?

All the data is on disk. Criterion saves every sample of every benchmark in target/criterion/<name>/new/sample.json, and its estimates in estimates.json. So we can check.

The model (the "what")

Here is what Criterion does, step by step, from the source code of version 0.8.2 (I read routine.rs, lib.rs, analysis/mod.rs, analysis/compare.rs, and the stats module for this chapter).

Step 1: choose the batch sizes. After the warm-up, Criterion knows roughly how long one call takes (met, the warm-up mean execution time). In the default linear sampling mode, it plans n samples (100 by default) whose sizes grow in a straight line: sample a runs a × d iterations, for a = 1, 2, ..., n. The step d is chosen so that the whole plan takes about the measurement time:

total_runs = n (n + 1) / 2                      (for n = 100: 5,050)
d = ceil( target_time / met / total_runs )
iterations in sample a = a × d

In the source, the comment says exactly this: "Solve: [d + 2·d + 3·d + ... + n·d] · met = m_ns". For our benchmark in Chapter 7 (sum/one_acc/100000, a 2 s target and about 12.1 µs per call), that gives d = 33, and the saved file confirms the plan: iters = 33, 66, 99, ... 3300, 100 samples, 166,650 iterations in total. (There is also a flat mode, where every sample has the same number of iterations, for slow benchmarks, and a quick mode that stops early.)

Step 2: measure. For each planned size, run that many iterations in a batch and time the whole batch (as in our harness of Chapter 4). The result is a pair (iters, elapsed time) per sample.

Step 3: estimate the time per iteration. Criterion computes several estimates from the 100 pairs:

  • mean and median of the per-iteration time of each sample (elapsed / iters);
  • standard deviation and median absolute deviation of the same numbers;
  • the slope: a regression of elapsed time against iterations. The code (stats/bivariate/regression.rs) is:
let xy = dot(xs, ys);        // sum of  iters × time
let x2 = dot(xs, xs);        // sum of  iters × iters
Slope(xy / x2)

That is the least-squares line forced through the origin: time ≈ slope × iters. The slope is what Criterion prints in the middle of time: [low mid high] in linear mode.

Why a regression and not just a mean? The time of a sample is fixed_cost + iters × per_iteration_cost. The fixed cost (reading the clock twice, entering the loop) is the same for a sample of 33 iterations and one of 3,300. With a mean of time/iters, small samples carry a large share of that fixed cost. A regression uses all the sizes, and because it is forced through the origin, the large samples carry far more weight (the slope is Σ x·y / Σ x², so each point counts in proportion to its x). That tends to push the fixed cost out. This is my reading of the design, and I tested it below.

Step 4: confidence intervals by bootstrap. To get the range around each estimate, Criterion resamples: it draws 100 pairs with replacement from the 100 it has, recomputes the estimate, and repeats 100,000 times by default. The spread of those 100,000 estimates gives the 95% interval (the 2.5th and 97.5th percentiles). This is the bootstrap of Chapter 5.

Step 5: outliers. The classification of Chapter 6, with the Tukey fences at 1.5 and 3 times the interquartile range, applied to the per-iteration times. (Criterion reports them but, in the default analysis, does not remove them.)

Step 6: compare with the last run. For a benchmark with a saved earlier run, Criterion computes a t statistic between the per-iteration times of the new and the old sample, and finds its p-value by a mixed bootstrap: it pools the two samples, draws two resamples of the right sizes from the pool, and computes the t statistic of each pair. If the old and new runs came from the same distribution, the observed t should look like those resampled ones. The share of resampled t values at least as extreme as the real one is the p-value. Then the verdict rule of Chapter 6 applies (p below 0.05, and the whole confidence interval of the change beyond ±1%).

Build it (the "how")

I wrote a 40-line Python script that reads the raw sample.json of the benchmark from Chapter 7 (sum/one_acc/100000) and recomputes everything. Here is how it compares:

QuantityMy recomputationCriterion's file
slope (Σxy / Σx²)12,115.59 ns12,115.59 ns
mean of time/iters12,106.91 ns12,106.91 ns
median of time/iters12,068.85 ns12,068.85 ns
95% interval of the slope (20,000 resamples of mine, against its 100,000)[12,072.42, 12,163.31][12,072.94, 12,163.68]
Tukey fences (low severe, low mild, high mild, high severe)11,642.4, 11,833.4, 12,342.9, 12,533.911,637.7, 11,830.3, 12,343.6, 12,536.1

The slope, the mean, and the median match to the last digit. The bootstrap interval matches to 0.01% (the small difference is expected: it is a random resampling, with a different number of draws). The outlier fences match to about 0.04%, and the small difference is probably in how the quartile is defined. (I did not read that part of the code.) Nothing in the chapter 5 report is mysterious any more: it is a regression through the origin, a bootstrap, and Tukey's fences.

Does the fixed cost really matter?

I tested the reason for the regression. For the same data, I also fitted a line with a free intercept: the slope came out at 12,116.81 ns per iteration (against 12,115.59), and the intercept at −2,713 ns. A negative fixed cost is not physical. It is just noise, much smaller than the 12 µs × 1,650 average iterations (20 ms) of a sample. So for a function this slow, the fixed cost is invisible, and the mean, the median, and the slope agree within 0.4%.

For a very fast function, I ran Criterion on black_box(1u64) and on black_box(x) + 1, where each call takes about 0.3 ns. The per-sample batch sizes were enormous (615,074 to 61,507,400 iterations), so the fixed cost was again negligible: the fitted intercepts were +772 µs and −294 µs (noise, of either sign, on samples lasting hundreds of milliseconds), and the slopes were 0.318 and 0.336 ns. The slope and mean agreed within 5% (0.318 against 0.333). And 0.318 ns agrees with the 0.36 ns we measured with our own harness in Chapter 4, for the cost of an empty closure plus black_box.

So the regression is a safety net for benchmarks where the fixed cost matters (a function of a few hundred nanoseconds measured with short batches). On the benchmarks of this guidebook, it did not matter. I was not able to show a case where it did.

Reproducing the verdict of Chapter 7

Now the false alarm. Criterion kept the 100 samples of the baseline (before) and of the new run for sum/one_acc/1000. I recomputed from those files:

base: mean 107.93 ns, standard deviation 1.48 ns
new:  mean 114.37 ns, standard deviation 10.70 ns
change in the mean: +5.96%     Welch t statistic: 5.96
p-value from a pooled bootstrap of t, 20,000 resamples: 0.0000 (fewer than 1 in 20,000)
95% bootstrap interval of the change in the mean: [+4.20%, +8.13%]  -> whole interval above +1%: yes

My re-implementation gives the same verdict as Criterion: regressed. The test is working as designed. So where does the false alarm come from? Look at the second line. The standard deviation of the new run (10.70 ns) is seven times that of the baseline (1.48 ns). During the new run the machine was noisier, and the test saw "two different samples" and said so. It cannot tell why they differ: the code, or the machine.

This points at the assumption behind the bootstrap: the 100 samples must be independent draws from a stable distribution. Let me test it. I computed the lag-1 autocorrelation of each run's 100 per-iteration times: the correlation between each sample and the next one. For independent samples it should be near 0; if the machine drifts slowly, it is near 1.

Runlag-1 autocorrelation
one_acc/1000, baseline run+0.39
one_acc/1000, new run+0.77
one_acc/100000+0.12
sum 100k u64 (Chapter 5)+0.46
multi_acc/4000000+0.93

For the 4-million-element benchmark, the correlation is +0.93, and the mean of the first 10 samples was 1.06 ms against 0.96 ms for the last 10: a drift of 9% inside one run. The samples are not independent. Neighbors are strongly alike. That breaks the bootstrap's assumption, and its effect is predictable: with correlated samples, the effective number of independent observations is much smaller than 100, so the confidence intervals are too narrow and the p-values too small. A wobble that is real, and in no way caused by your code, is treated as certain. That is the mechanism behind the false alarms of Chapters 6 and 7. It is not a bug in Criterion. It is a property of measuring on a machine that drifts: the statistics are right if their assumptions are, and on this machine one of them is not.

Why it is this way (the "why")

Why did Criterion's authors choose a bootstrap and not a formula? Because timing data is not normally distributed (it is right-skewed, Chapter 2) and the bootstrap makes no assumption about the shape. It asks only that the samples be independent and alike. That is a reasonable bet on a quiet machine, and a weaker one on a shared, drifting one.

Why a linear plan of sizes (d, 2d, ...) and not the same size every time? Because of the regression: you need different sizes to separate a fixed cost from a per-iteration cost, in the same way you need two different points to draw a line. The flat mode gives up that separation for simplicity. This is a good example of a design choice that is invisible in the output and affects its meaning.

Now, what do we do about drift? A few ideas, in order of cost:

  1. Know your wobble (Chapter 6, Exercise 6.3): run the unchanged code several times, and treat changes smaller than that as noise. That does not need any new tool.
  2. Interleave the old and new code: A, B, A, B, ... in one process. Drift then hits both equally, so it cancels out of the difference. (This is Exercise 4.6.) Criterion does not do this by default, and its comparison across two separate runs is exposed to drift. Some tools do interleave.
  3. Use a quantity without drift as a gate: instruction counts from a simulator (Chapter 20), which have almost no noise. They do not see time, but they see work, and they never drift.
  4. Raise the bar: a larger noise threshold (--noise-threshold), or require the same verdict in several repeats, before blocking a change. Chapter 38 builds a gate this way.

The philosophical lesson is about trusting a tool's confidence. Criterion's interval says "95% confident". The truth is: 95% confident if the samples are independent. When you can test the assumption (and here, with two lines of arithmetic on a file Criterion already saves, you can), do. A tool's confidence is only ever conditional confidence.

What would make this chapter wrong? I read Criterion 0.8.2 only for the parts I name (sampling plan, slope, comparison, and outlier fences), and I reproduced the numbers of the main estimates. I did not read all the code of the bootstrap or the t statistic, and my p-value comes from my own implementation of the same idea, which agreed with Criterion's verdict, not from a line-by-line check. The autocorrelation numbers are from five benchmarks on one machine.

Common mistakes

  • Treating the confidence interval as unconditional. It assumes independent samples. Check the autocorrelation.
  • Comparing two runs made minutes apart on a drifting machine and attributing the difference to the code.
  • Ignoring the standard deviation of the new run. A 7x change in spread is a sign the machine changed.
  • Reading estimates.json and never looking at sample.json. The raw data is the evidence.
  • Assuming the regression slope is always better than the mean. On our data, they agreed within 0.4%.

Say it simply

Criterion plans 100 samples with sizes d, 2d, ... 100d, times each batch, and estimates the time per iteration as a least-squares slope through the origin, with the mean, median, and spread alongside, and bootstrap intervals from 100,000 resamples. I recomputed its slope, mean, median, interval, and outlier fences from its saved raw data and matched every one. Its comparison is a t statistic with a pooled bootstrap p-value, which I reproduced too. All of this assumes the samples are independent, and on this machine the correlation between neighboring samples reached 0.93, so the intervals are too narrow and false alarms follow.

Check yourself

Exercise 34.1 (Warm-up): The plan. A benchmark has a warm-up mean time of 500 ns per call, a measurement time of 3 s, and 100 samples. Compute d, the iteration counts of the first and last sample, and the total number of iterations.

Exercise 34.2 (Warm-up): Slope by hand. Three samples have (iters, time) equal to (10, 130), (20, 250), (30, 380) ns. Compute the slope through the origin (Σxy / Σx²). Compare with the mean of time/iters.

Exercise 34.3 (Core): Recompute it yourself. Run a Criterion benchmark on your machine. Write a script (any language) that reads sample.json and estimates.json, recomputes the slope, mean, and median, and compares. Then recompute a bootstrap interval with 10,000 resamples. Done when: a table like the one above for your benchmark, with the differences.

Exercise 34.4 (Core): Test independence. For five of your benchmarks, compute the lag-1 autocorrelation of the per-iteration times from sample.json. Plot time against the sample number for the worst one. Is there a trend or a cycle?

Exercise 34.5 (Core): Make a fixed cost visible. Benchmark a function that takes about 50 ns, with --sample-size 10 and a very short measurement time, so that the batches are small. Fit a line with an intercept. Is the intercept now visible? Does the slope differ from the mean of time/iters?

Exercise 34.6 (Stretch): Interleaved comparison. Write a benchmark that runs the old and the new function alternately (A, B, A, B, ...) inside one process, many times, and reports the ratio of the medians. Run it against the unchanged function against itself 20 times. How often does it flag a difference? Compare with Criterion's two-run comparison on the same pair.

Exercise 34.7 (Stretch): Design and defend. Your team's benchmark CI has 3% false alarms per benchmark and 80 benchmarks, so almost every pull request has a "regression". Propose a design that cuts false alarms without hiding real 5% regressions. What would you measure to check it works?

What a strong answer includes: the arithmetic of many comparisons at a fixed false-alarm rate, repeating a flagged benchmark before reporting, interleaved A/B runs, instruction counts as a first gate, a dedicated quiet runner, and an experiment that injects known regressions of 2%, 5%, and 10% to measure the detection rate.


Chapter 35. Inside perf and the hardware counters

In this chapter: You will learn what a hardware performance counter is, how perf talks to the kernel (one system call), and what the CPU does when it samples. I call that system call myself from Rust, count 16,384 page faults for 16,384 pages, and show you the exact error the kernel gives for a hardware event that this VM does not have.

You need first: Chapters 15 and 19.

The problem first

Throughout Part IV, perf told us things like "76,485 page faults" and showed <not supported> for cycles and instructions. We treated perf as a program. But perf is a thin command-line layer over a facility of the Linux kernel, and over a facility of the CPU. When a counter is missing, which layer is it missing from? And what is a counter?

The model (the "what")

The performance monitoring unit

Every modern CPU core has a performance monitoring unit (PMU): a small piece of hardware that counts events as they happen inside the core. Some are about work: instructions retired (finished and made official), cycles. Some are about waiting: cache misses at each level, branch mispredictions, TLB misses, stalled cycles (cycles in which nothing could proceed). A PMU has a few counters: registers that add one each time a chosen event happens. A typical core has a small, fixed number of programmable counters (often 4 to 8 per core; the number differs by chip, and I did not check this chip) which you point at an event of your choice, and a few fixed counters that always count one particular event (instructions, cycles, reference cycles).

Two ways to use a counter:

  • Counting mode (perf stat): start the counter at zero, run the program, read the total at the end. No interruption of the program at all, so the cost is almost nothing.
  • Sampling mode (perf record): set the counter so that it overflows after every N events (say, every 1,000,000 cycles). When it overflows, the CPU raises an interrupt, the kernel records where the program was (the instruction pointer, and the stack), and restarts the counter. After a million such interrupts, you have a statistical picture of where the events happened. This is how perf record -e cycles works: it is a poll driven by the event itself, and not by a timer.

What happens if you ask for more events than counters? The kernel multiplexes: it turns events on and off in rotation, and scales the counts up to estimate the total. perf stat shows a percentage next to a scaled count (the share of the time that event was really counted). Scaled counts are estimates, and they are unreliable for short programs.

Counting events has some limits you should know:

  • Skid. The interrupt takes time to arrive, so the instruction pointer you record can be a few instructions after the one that caused the event. Some CPUs have a hardware feature (Intel's PEBS is one; AMD has IBS) that records the exact instruction. I know of these from the literature, and I could not test them.
  • Counts are of what the hardware does, not what you meant. Speculatively executed instructions that were thrown away (Chapter 13) can still count for some events.
  • Events are specific to a CPU family. The same name (like cache-misses) can map to different underlying events on different chips. Raw event codes are listed in the vendor's manuals.

The kernel's side: one system call

All of this reaches your program through one Linux system call, perf_event_open. You fill in a structure that says what you want (an event type and a code, counting or sampling, which process and CPU), and the call returns a file descriptor. Then:

  • read(fd) returns the counter's current value (counting mode);
  • ioctl(fd, ENABLE/DISABLE/RESET) starts, stops, and zeroes it;
  • mmap on the descriptor gives a ring buffer into which the kernel writes samples (sampling mode).

The perf command is a convenient front end that builds these requests, reads the results, and symbolizes addresses. samply (Chapter 17) and many Rust libraries use the same call. Nothing about it is secret, and you can call it from your own code.

The kernel provides several kinds of event sources (each called a PMU in the kernel's own sense): the CPU's hardware counters, a software PMU for events the kernel counts itself (page faults, context switches, a software clock), tracepoints inside the kernel, and others. A machine lists them in /sys/bus/event_source/devices/.

Build it (the "how")

First, what does this machine offer?

$ ls /sys/bus/event_source/devices/
breakpoint  msr  power  software  tracepoint  uprobe
$ perf list hw
(prints nothing)

There is a software PMU, a tracepoint PMU, and a few others (msr, power, breakpoint, uprobe). There is no cpu PMU. On a bare-metal Intel or AMD Linux machine, there would be one, and it is the one that carries cycles, instructions, cache-misses, and the rest. And in /proc/cpuinfo, the CPU flags include hypervisor (this is a guest in a virtual machine) and none of arch_perfmon (the flag that advertises the architectural PMU). So the hypervisor does not give this guest a PMU.

Now I call perf_event_open myself, from Rust, with the perf-event2 crate (a small wrapper over the system call). The program opens a counter, enables it, touches memory, disables it, and reads it:

use perf_event::events::{Hardware, Software};
use perf_event::Builder;

fn touch(mb: usize) -> u64 {
    let mut v: Vec<u8> = Vec::with_capacity(mb << 20);
    unsafe { v.set_len(mb << 20); }
    let mut i = 0; let mut s = 0u64;
    while i < v.len() { v[i] = 1; s += v[i] as u64; i += 4096; }   // one byte in every 4 KiB page
    std::hint::black_box(&v); s
}

let mut c = Builder::new(Software::PAGE_FAULTS).build()?;   // this is the perf_event_open call
c.enable()?;
touch(64);
c.disable()?;
println!("{}", c.read()?);

Result:

page-faults        counted while touching 64 MiB: 16384
task-clock (ns)    counted while touching 64 MiB: 23178980
context-switches   counted while touching 64 MiB: 0
cycles             perf_event_open failed: No such file or directory (os error 2)
instructions       perf_event_open failed: No such file or directory (os error 2)
cache-misses       perf_event_open failed: No such file or directory (os error 2)

Look at the numbers.

16,384 page faults for 64 MiB. 64 MiB is 67,108,864 bytes, and 4 KiB is 4,096 bytes, so that is exactly 16,384 pages. The program touched one byte in each page, and the kernel counted exactly one fault per page. This confirms the model of Chapters 10 and 21: first touch of a page means one fault, no more, no less. (In Chapter 21, the cost was 1.7 µs each in this VM. Here, 23.18 ms of task time over 16,384 faults is about 1.4 µs per fault, which includes the loop itself.) The counter is the kernel's own count, with no sampling and no error.

0 context switches: the program was never interrupted by the scheduler in that time. A tidy confirmation that the machine was quiet.

The hardware events fail with "No such file or directory" (ENOENT). The kernel's answer to "give me a cpu-cycles event" is "there is no such event": no PMU has registered that event type. This is the same thing that perf stat printed as <not supported> in Chapter 15, and now we can see where it comes from: the kernel has no hardware PMU for this CPU, because the hypervisor does not expose one to the guest. (perf c2c in Chapter 27 failed with "memory events not supported", the same family of limit.) Also, the msr PMU exists (it exposes a few model-specific registers), but it does not offer the APERF and MPERF events that would give the real clock ratio: perf stat -e msr/aperf/ printed "unknown term 'aperf' for pmu 'msr'". We will measure the clock by another route in Chapter 37.

Why do software events (page faults, task clock) work while hardware ones do not? Because the kernel counts software events itself, in its own code, so it does not need the CPU's help. And it is why perf record -e cpu-clock worked in Part IV: the "sampling" was driven by a kernel software timer, not by a counter overflow. A timer-driven sample can only land when the kernel is able to interrupt, and it samples time, not events. That is a good approximation to "where does the program spend time", and not a way to ask "where do the cache misses happen".

Why it is this way (the "why")

Why does the design put one system call between your program and the CPU's counters? Because the counters are a shared, privileged resource. There are only a handful per core, they could reveal information about other programs (a side-channel risk: the counts depend on what the other program did to the cache), and they must be saved and restored when the scheduler moves your program from one core to another. The kernel does that work. perf_event_paranoid (Chapter 15) is the knob that says how much a normal user may see.

And why do virtual machines often hide the PMU? Because the host would have to virtualize the counters: save and restore them on every switch between guests, and keep one guest's events from leaking into another. It is possible (some clouds offer it, often as an option on some instance types), but it costs the host work and has security implications, so it is often off. This is not a limit of this VM alone. It is a very common state of affairs for cloud benchmarks. If you are profiling for performance in the cloud and you need cycles, instructions, or cache-miss counts, check first that your instance type gives them.

There is a philosophical point about what a counter is. A counter is the hardware's own bookkeeping, kept for free as a side effect of doing the work. That is why it is so cheap, and so exact in the counting mode: nobody has to add code to the program. In exchange, you can only ask about the things the hardware chose to count, under the names the vendor chose. This is the opposite trade from instrumentation (Chapter 18), where you can ask about anything you wrote a line for, at a cost per call. A counter is an observation of the machine. A timer in your code is an observation of your program. You often need both: the first says what the machine did, the second says which of your lines it did it for.

What would make my account wrong? I know the structure of the PMU (fixed and programmable counters, overflow interrupts, multiplexing, skid, PEBS, and IBS) from the literature and the kernel documentation, and I could not run a hardware counter here to check any of it. The things I did verify are: the system call's interface (I called it), the software counters (16,384 faults for 16,384 pages), the absence of a cpu PMU, and the ENOENT result. The statement that virtualized PMUs are often disabled is from my general knowledge, not tested here.

Common mistakes

  • Assuming perf stat -e cycles works everywhere. Check on your instance.
  • Reading a multiplexed count as exact. Look for the percentage.
  • Sampling with a timer and describing it as "cache-miss hot spots". A timer samples time.
  • Forgetting skid. The instruction blamed may be a few before or after the true one.
  • Comparing event counts across CPU families as though names meant the same thing.
  • Giving up when counters are missing. Use software events, simulation (Chapter 20), or instrumentation.

Say it simply

A performance counter is a register in the CPU that adds one each time a chosen event happens, either read at the end (counting) or set to interrupt every N events (sampling). Programs reach them through one Linux system call, perf_event_open, which returns a file descriptor. I called it from Rust: software counters worked, and counted exactly 16,384 page faults for 16,384 pages touched, while every hardware event failed with "No such file or directory", because this virtual machine has no hardware PMU. Without counters, you still have software events, simulation, and instrumentation.

Check yourself

Exercise 35.1 (Warm-up): Counting or sampling? For each, say counting or sampling: (a) "How many instructions did the whole program execute?" (b) "Which function causes the most branch mispredictions?" (c) "What is the IPC of the whole run?"

Exercise 35.2 (Warm-up): Multiplexing. A core has 4 programmable counters. You ask perf stat for 8 events. What happens? What percentage would you expect to see next to each count?

Exercise 35.3 (Core): Call it yourself. Write the page-fault counter program above (with perf-event2, or by calling the system call through libc). Touch 64 MiB, then 128 MiB. Do the counts double? Now touch the memory twice in a row. What does the second pass count? Done when: three numbers (first touch of 64 MiB, of 128 MiB, second pass), and one sentence of explanation each.

Exercise 35.4 (Core): Check your machine. Run ls /sys/bus/event_source/devices/, perf list hw, and grep -c arch_perfmon /proc/cpuinfo. Does your machine have a cpu PMU? If so, extend the program to count Hardware::CPU_CYCLES and INSTRUCTIONS for a loop of 1 billion additions, and compute the instructions per cycle.

Exercise 35.5 (Core): IPC of Part III. On a machine with counters, count instructions and cycles for the sorted and the random versions of the branch test in Chapter 13, and for the sequential and random access in Chapter 12. Which has the lowest IPC? What does that say about what limits it?

Exercise 35.6 (Stretch): Skid. Write a loop with one slow instruction (an integer divide), record with perf record -e cycles, and use perf annotate to see which instruction gets the samples. Is it the divide, or the one after? Repeat with :pp or :ppp precision modifiers, if your CPU supports them.

Exercise 35.7 (Stretch): Design and defend. Your company runs all benchmarks on cloud VMs without PMUs. List three ways to still get the information that the hardware counters would give, what each one cannot see, and what you would buy or change if you needed the counters badly.

What a strong answer includes: simulation for instruction and cache behavior (and its limits), instrumentation for exact counts, a bare-metal instance type or a dedicated machine for counter work, software events, and the idea that counter-based and time-based evidence answer different questions.


Chapter 36. Inside Valgrind

In this chapter: You will learn what Valgrind does to your program, and what Cachegrind's "cache" really is, by writing the cache yourself. A 20-line simulator and Cachegrind agreed on six different access patterns, within 7 misses. You will also measure what the translation costs: 5 times for the framework alone, and 25 to 30 times with a cache or call simulation.

You need first: Chapters 9 and 20.

The problem first

In Chapter 20, Cachegrind gave us counts of instructions and of "cache misses" for the word counter. We said these misses come from a "simple model", and listed what it leaves out. But what is the model? If the cache in Cachegrind is just a few dozen lines of logic, then everything it says follows from that logic, and you can reason about it. If it is something complicated, you should be more careful.

The model (the "what")

Dynamic binary instrumentation

Valgrind is a tool for running a program under a microscope, without recompiling it. It works on the machine code of the finished program. The method is called dynamic binary instrumentation (DBI):

  1. Valgrind loads your program into its own process, and starts to run it on a synthetic CPU, not on the real one directly.
  2. It takes your program's machine code one basic block at a time (a straight run of instructions up to the next jump) and translates it into an intermediate representation, an easy-to-handle code of simple operations.
  3. A tool (Cachegrind, Callgrind, Memcheck, DHAT, and others) is then allowed to insert extra operations into that representation: "count this instruction", "record this load address", "check this memory is valid".
  4. Valgrind translates the result back to machine code, stores it in a code cache, and runs it. The next time the same block is needed, it reuses the translated version.

Because it works on machine code and not source code, Valgrind needs no changes to the program, and it sees everything, including library code. The price is the translation work and the extra operations, which is why programs run slower under it.

Cachegrind, the simulated cache

Cachegrind's tool inserts, for every instruction and every memory access, a call that updates a cache simulator. The simulator is a model of one or two levels of cache. Here is the whole idea, in the terms of Chapter 9:

  • A cache is described by three numbers: its size, its associativity (ways per set), and its line size. For example, 32 KiB, 8-way, 64-byte lines has 32768 / (8 × 64) = 64 sets.
  • For each memory access, the simulator finds the set (from the address), looks for the line in the set's ways, and either records a hit, or records a miss and puts the line in the set, evicting the least recently used line if the set is full. (This policy is called LRU.)

That is it. It simulates a first-level instruction cache (I1), a first-level data cache (D1), and one unified last-level cache (LL). It does not model a hardware prefetcher, a TLB, a store buffer, or out-of-order overlap, which is what we said in Chapter 20.

Build it (the "how")

If that description is complete, I can write my own simulator and get the same answers. Here is mine, in Python, for the same 32 KiB, 8-way, 64-byte cache:

from collections import OrderedDict
class Cache:
    def __init__(s, size, ways, line):
        s.sets = size // (ways * line); s.ways = ways; s.line = line
        s.data = [OrderedDict() for _ in range(s.sets)]; s.miss = 0
    def access(s, addr):
        ln = addr // s.line; st = s.data[ln % s.sets]
        if ln in st: st.move_to_end(ln); return          # hit: now the most recently used
        s.miss += 1; st[ln] = 1                            # miss: bring the line in
        if len(st) > s.ways: st.popitem(last=False)        # set full: evict the least recently used

And a tiny Rust program (cgt) that makes controlled memory reads, so that I know exactly what addresses it touches:

  • seq: read 1 MiB, byte by byte, in order.
  • conflict k gap: read k addresses that are gap bytes apart, in order, 1,000 times over.

I ran the Rust program under Cachegrind with the cache explicitly set (--D1=32768,8,64), and subtracted the misses of a run that does nothing (process start-up, which is constant). I fed my simulator the address sequence the program generates. Result:

Access patternMy simulator (misses)Cachegrind (read misses, minus baseline)
sequential, 1 MiB16,38416,385
8 lines, 4096 bytes apart815
9 lines, 4096 bytes apart9,0009,007
16 lines, 4096 bytes apart16,00016,007
16 lines, 4160 bytes apart1623
64 lines, 4160 bytes apart6471

They agree, within 7 misses in every row. (I did not trace the constant extra 7 misses. They show up in every row, so it is probably some fixed cost of my test program.) Cachegrind's cache is exactly the simple LRU set-associative cache described above. Nothing hides in it. Read the rows with the eyes of Chapter 9:

  • Sequential, 1 MiB: 1 MiB / 64 B = 16,384 lines, each missed once, and every other byte of the line hits. The simulator and the tool both give 16,384.
  • 8 lines 4096 apart: all eight map to the same set (4096 is a multiple of 64 sets × 64 bytes), but the set has 8 ways, so all 8 fit. Only the 8 first-touch (cold) misses happen, and then every repeat hits.
  • 9 lines 4096 apart: one more line than the set can hold. With LRU and a cyclic access pattern (1, 2, ... 9, 1, 2, ...), each access evicts the line that will be needed next, so every access misses: 9 lines × 1,000 rounds = 9,000 misses. This is the "associativity cliff" of Chapter 9 (where we saw the jump at 12 or 13 lines on the real 12-way L1), reproduced exactly in a simulator, with a different number of ways. A set that is one line too small is not slightly worse. It is entirely thrashed.
  • 16 lines 4096 apart: the same, 16,000 misses.
  • 16 or 64 lines, 4160 apart: 4160 is 4096 plus one line, so consecutive lines fall in different sets. Only cold misses (16, 64): the working set (1 KiB or 4 KiB) fits easily.

So Cachegrind is not mysterious. And this opens a superpower: because it is a simulator, you can change the machine. --D1=8192,2,64 is an 8 KiB, 2-way L1. --LL= sets the last level. You can ask "how would this program behave on a CPU with a smaller cache?" without having one. Exercise 36.5 uses this.

What the translation costs

Valgrind's overhead, on the 5-million-word run of the word counter (v4, native time 0.20 s), wall-clock:

How it was runTimeSlowdown
native0.20 s1x
valgrind --tool=none (translate and run, add nothing)0.97 s5x
valgrind --tool=cachegrind --cache-sim=yes4.96 s25x
valgrind --tool=callgrind5.94 s30x

The first row after native is revealing. --tool=none adds no instrumentation at all, and is still 5 times slower. That 5x is the price of the framework: translating the code, and running it through a synthetic CPU. The tools then cost about 5 to 6 times more again. That matches the "20 to 50 times slower" rule of Chapter 20, with some room to spare.

(A warning from the measuring side: my first attempt used a 1-million-word run of 0.046 s, and the numbers were meaningless, because the fixed start-up cost of Valgrind (about 0.4 s for --tool=none) dominated. In the very first attempt, one tool failed at once because I passed it another tool's flag, and the table showed a slowdown of 0x. I only noticed because it was too good to be true.) Always check the exit status of a tool you time, and time a run long enough that start-up does not dominate. It is the lesson of Chapter 1 once more: a number that is too good (here, a simulator that appears to take 0.002 s) is a sign that the thing did not run.

Why it is this way (the "why")

Why does Cachegrind use such a simple cache? Because a simulator can only be as detailed as someone has time to write and run, and every detail slows it. A prefetcher model, a TLB, and an out-of-order core model would each cost time and would have to be right for the specific chip, which the vendors do not publish in full detail. The designers chose a model that is simple, cheap, and understood, so that the answers are repeatable and explainable. The result has a name in modeling: it is a first-order model. It captures the strongest effect (the size and the associativity of the cache) and leaves out the second-order ones.

That is precisely why we found it useful and why we found limits. It was useful in Chapter 20 to count instructions and to expose wasted work. And its limits were exactly the things it leaves out (page faults, the TLB, the prefetcher), which Part III showed to be large for some programs. Know what your model leaves out, because the effects it leaves out are the ones that will surprise you.

There is also a deeper idea about tools like this: a tool that changes the program to observe it can also be asked to change the world around it. Valgrind is a machine that you can turn the knobs of. Real hardware is a fixed machine, with counters you cannot change. When the question is "what if the cache were smaller", only the simulator can answer. When the question is "how long does it take", only the real machine can. The two are complementary, and wise engineers use each for what it is good at.

What would make this chapter wrong? I verified Cachegrind against my own simulator on six simple patterns. That shows that the cache logic is as described, for D1 read misses with these parameters. It does not show that Cachegrind's instruction counts, write-miss counts, or LL behavior match a more complex program, and I did not test those. My description of the DBI machinery (basic blocks, an intermediate representation, a code cache) comes from Valgrind's documented design and the 2007 paper (reading list), and I did not inspect Valgrind's code. And the slowdown ratios are for one program on one machine.

Common mistakes

  • Timing a short run under Valgrind. Start-up dominates.
  • Not checking that the tool actually ran. A suspiciously fast result usually means it did not.
  • Treating simulated misses as real misses. They are model misses, with LRU and no prefetcher.
  • Using the default cache configuration without noting it. Say which sizes you simulated.
  • Running multi-threaded timing-sensitive programs under Valgrind and trusting the timing. (As far as I know, Valgrind runs threads one at a time. I did not test it.)
  • Expecting a simulator to see page faults or the TLB.

Say it simply

Valgrind runs your program on a synthetic CPU by translating its machine code a block at a time and letting a tool insert extra operations. Cachegrind's tool feeds every memory access to a simple LRU, set-associative cache simulator. I wrote that simulator in 20 lines, and it matched Cachegrind within 7 misses on six access patterns, including the cliff where 9 lines in an 8-way set make every access miss. The framework alone slowed the word counter 5 times, and the tools made it 25 to 30 times slower. Because it is a simulator, you can change the cache to ask "what if".

Check yourself

Exercise 36.1 (Warm-up): Sets. A cache is 64 KiB, 4-way, with 64-byte lines. How many sets? Which set does address 0x1F40 map to? Which addresses 4096, 8192, and 16384 bytes later map to the same set?

Exercise 36.2 (Warm-up): Predict the misses. In the 4-way cache above, a loop reads 5 lines that map to one set, in order, 100 times. How many misses with LRU? What if it reads only 4 lines?

Exercise 36.3 (Core): Reproduce the table. Write the simulator in a language you like. Write a small program that reads memory in a known pattern. Run it under valgrind --tool=cachegrind --D1=32768,8,64 ..., and compare. Subtract a baseline run. Done when: a table with at least four patterns, both columns, and the differences.

Exercise 36.4 (Core): Break the model. Find an access pattern where your simulator and the real hardware disagree strongly. (Hint: use a pattern the real prefetcher handles well, like a long sequential scan over a big array. Time it for real, and compare with the simulated miss count.) What does the simulator leave out?

Exercise 36.5 (Core): Change the machine. Use Cachegrind to run the row-by-row and column-by-column matrix sums of Chapter 9 (n = 1024) with D1 sizes 8 KiB, 32 KiB, 128 KiB. At what size does the column walk stop thrashing? Compare with what you computed by hand.

Exercise 36.6 (Stretch): Measure the overhead. For three programs of different kinds (a tight loop, an allocation-heavy program, a program that waits on a file), measure the slowdown under --tool=none, cachegrind, and callgrind, with runs long enough that start-up is below 5%. Which program slows down the most, and why?

Exercise 36.7 (Stretch): Add a policy. Extend your simulator with a different replacement policy (random, or FIFO) and compare its misses with LRU on the 9-line cyclic pattern. What happens, and why does LRU do worst on this pattern?

Exercise 36.8 (Stretch): Design and defend. A teammate says: "Cachegrind said our change cut misses by 30%, so it will be 30% faster." Write a reply that explains what the 30% does and does not tell you, and what you would run next.

What a strong answer includes: simulated misses against real time, the missing prefetcher and TLB, the cost of a miss differing by level, the need to measure real time with a quiet machine, and the use of the simulation as evidence of less work, not of speed.


Chapter 37. The silicon contract: frequency, cycles, and what "a cycle" means

In this chapter: You will measure this CPU's real clock speed with a chain of dependent instructions, and find that it is about 3.3 GHz while the machine says 2.1. You will convert the guidebook's nanosecond numbers into cycles, find three of my earlier estimates wrong, and learn what the contract between software and hardware does and does not promise.

You need first: Chapters 13 and 35.

The problem first

All through this guidebook, I gave times in nanoseconds, and sometimes converted to cycles with "if the clock really ran at 2.1 GHz". The 2.1 GHz came from lscpu. In Chapter 13, I said a branch misprediction cost "roughly 17 cycles" with a warning that the true clock might be higher. In Chapter 28, I wondered whether heavy AVX-512 code lowers the clock, which might explain two "native build is slower" results. I never measured the clock, because the usual way (reading a hardware cycle counter) is not available in this VM (Chapter 35).

There is another way. It needs one idea, and it works on any machine.

The model (the "what")

What is a cycle?

A CPU is driven by a clock: a signal that ticks at a fixed rate, and every step of the hardware happens on a tick. One tick is a cycle. At 3 GHz, a cycle lasts 1/3 of a nanosecond. But which clock?

  • The core clock is the clock the core is really running at right now. It changes. To save power and heat, a chip lowers its clock when idle, and when it is busy it can raise it above its labeled speed (turbo, up to some power and temperature limit). The hardware cycle counter that perf stat -e cycles reads counts core clock cycles.
  • The time-stamp counter (TSC) is a counter that ticks at a constant rate, whatever the core clock does. On modern x86 chips (the CPU flags constant_tsc and nonstop_tsc say so), the TSC rate is the nominal frequency of the chip. You read it with the rdtsc instruction (in Rust, core::arch::x86_64::_rdtsc). It is a good, cheap clock for time. It is not a count of core cycles.

Two clocks, two meanings. When someone says "this loop takes 12 cycles", you must ask whether they mean core cycles or TSC ticks. They can differ by a factor of two on a turbo chip.

The latency of an instruction is a number of core cycles

Now the idea. Some instructions have a fixed, well known latency in core cycles (Chapter 13): an integer add takes 1 cycle to produce its result, an integer imul takes 3 on recent Intel and AMD cores. If you run a chain of such instructions, each needing the result of the one before, then the chain runs at exactly one instruction per latency. The time per instruction is then latency / clock. Measure the time and you know the clock:

clock = latency_in_cycles / time_per_instruction

This works on any machine, in any VM, with no counters at all. It needs the latency from the manual (Agner Fog's tables, the vendor's optimization manual), and it needs the chain to be truly dependent. As we are about to see, the second condition can fail in a surprising way.

The contract

All of this is a question about the contract between software and hardware. The contract is the instruction set architecture (ISA): what each instruction means. It guarantees correctness: an add adds, on every chip, today and in ten years. It guarantees nothing about speed. How many cycles an instruction takes, how big the caches are, how fast the clock runs, how many instructions can run at once: all of that belongs to the microarchitecture (the specific design of a chip), and none of it is promised. It changes from chip to chip, and, with turbo and power limits, from minute to minute on one chip.

In the contract (the ISA)Not in the contract (the microarchitecture)
What add, imul, popcnt computeHow many cycles they take
That popcnt exists, if the chip says so (CPUID)Whether it is fast
The memory model (what other cores may see)Cache sizes and latencies
That a load returns the last stored valuePrefetching, branch prediction, out-of-order depth
The meaning of rdtscThe core clock, the turbo, the power limit

This is why this guidebook has measured everything instead of quoting a table. Every row on the right is an empirical question.

Build it (the "how")

The program uses core::arch::asm! (stable Rust) to write the chains exactly. Each loop iteration is 16 copies of one instruction, all dependent, with a sub and a jnz for the loop:

macro_rules! chain16 {
    ($name:ident, $ins:literal) => {
        #[inline(never)]
        fn $name(mut n: u64) -> u64 {
            let mut x: u64 = 1;
            unsafe { asm!("2:", $ins, /* ...16 times... */ $ins,
                "sub {n}, 1", "jnz 2b", x = inout(reg) x, n = inout(reg) n, options(nostack)); }
            x
        }
    };
}
chain16!(chain_imul, "imul {x}, {x}");          // x = x * x : each needs the previous result
// and a register-to-register add: "add {x}, {y}" with y = 3 in another register

First result, and a mistake of mine. I started with add {x}, 3 (add an immediate constant), a natural way to write a chain. It gave 0.0656 ns per add, which would mean a clock of 15.3 GHz. That is impossible for any CPU on Earth. My first version of the program printed "15.7 GHz" and I would have reported it, had I not stopped at the number. The chain was not running at one add per cycle. Dividing, 0.0656 ns at 3.28 GHz is 0.22 cycles per add: about 4.5 adds per cycle, even though each depends on the last. The CPU did something clever: it appears to fold a chain of adds of a small constant into one operation before executing (a kind of shortcut at the stage where instructions are renamed). I believe this is a feature of recent Intel cores, but I did not verify it. The lesson for the method is clear: a dependent chain is not always a latency measurement. Sanity-check the answer, and if it is impossible, the experiment is wrong, not the physics. This is the same lesson as the 121 ns "loop" of Chapter 1, and the too-low roof of Chapter 19.

The fix: use add {x}, {y} with a register operand, which cannot be folded the same way. Results:

TSC ticks per nanosecond:       2.1000 GHz
dependent add reg,reg  : 0.3047 ns each  -> 3.282 GHz if it takes 1 cycle
dependent imul reg,reg : 0.9360 ns each  -> 3.205 GHz if it takes 3 cycles
core clock / TSC rate = 1.563

Two independent chains, with two different latencies from the manual (1 and 3 cycles), give 3.28 and 3.21 GHz. They agree within 2.4%, which is a good check that the method works: if I had the latencies wrong, they would not agree. So this core runs at about 3.2 to 3.3 GHz. The TSC, and lscpu, say 2.1 GHz (the TSC measured against the wall clock came out at 2.1000, a constant). So the core ran about 55% faster than its labeled speed: turbo.

The latencies, in cycles, at 3.28 GHz (best of 7):

Dependent instructionnscycles
add reg, imm (folded chain)0.06560.22
add reg, reg0.30471.00
imul0.93603.07
addsd (f64 add)0.68692.25
mulsd (f64 multiply)1.38054.53

The first two define the clock, so they are 1 and 3 by construction. But the last two are new information: the f64 add takes about 2.25 cycles and the multiply about 4.5. (Round numbers would be 2 and 4 or 3 and 4. The fractions show how much noise a few percent of clock wander adds.) This explains the 0.635 ns per element for the one-accumulator sum in Chapter 13: 0.635 ns at 3.28 GHz is about 2.1 cycles: exactly one add latency. At the time, I wrote that I "did not look up this chip's floating-point add latency, so I won't convert to cycles". Now I can, from a measurement.

Converting the guidebook's numbers

With the clock at 3.28 GHz, here are the key numbers of Part III in cycles:

Measurementnscycles at 3.28 GHz
L1 pointer-chase step (Chapter 8)1.735.7
a step at the L2 level (1 MiB chase)6.9523
branch misprediction (Chapter 13)8.327
main memory, dependent access137450

These conversions correct one of my earlier statements. In Chapter 13 I wrote that a misprediction "would be roughly 17 cycles if the clock really ran at 2.1 GHz", and that "the true clock could be higher, which would mean more cycles". It was: the cost is about 27 cycles. (And the common figures for a misprediction penalty on recent Intel cores are in the range of 15 to 20 cycles, plus whatever refill time: 27 is on the high side, which may be a sign that this VM or this chip has costs I did not isolate. I did not check.) I have corrected the sentence in Chapter 13, and the machine description at the start of the guidebook. The L1 step of 5.7 cycles is in line with the usual 4 to 5 cycle load-to-use latency, and the 450 cycles for memory shows what "100+ ns" means to a CPU that can execute several instructions per cycle: well over a thousand instructions' worth of waiting.

Does the clock move?

I measured the clock in 60 windows of 25 ms each, with the add chain:

clock over 60 windows of 25 ms (GHz): min 2.795  median 3.052  max 3.320

The clock is not constant: it moved between 2.80 and 3.32 GHz, a range of 17% (a median of 3.05 and an earlier single estimate of 3.28 differ by 7%). This is the machine's wobble from Chapter 2, seen from the hardware side: part of the 5% drift we saw in Criterion, and of the 8.7x spread in a micro-benchmark, can come from the core running at a different speed from one moment to the next. A frequency change moves every nanosecond measurement by the same factor, and no amount of repeated sampling within one frequency removes it.

Test of an old hypothesis: does AVX-512 lower the clock?

In Chapters 19, 24, and 28, three results had a common suspect: a native build (which uses AVX-512 on this chip) was slower, or the roof dropped, in ways I could not explain. One hypothesis: heavy 512-bit instructions make some CPUs lower their clock for a while. I tested it directly: measure the clock with the add chain, run 400 ms of 512-bit FMA instructions on eight independent chains, and then measure the clock again, in ten windows of 5 ms.

clock before the burst:            3.331 GHz
ten 5 ms windows right after it:   3.33 3.35 3.34 3.32 3.33 3.33 3.35 3.34 3.20 3.18

I saw no lasting drop. The clock right after the burst was the same as before, 3.33 GHz, and only the last two windows were lower (3.20 and 3.18, about 4% below), within the 2.8 to 3.3 range we saw at random. So on this VM, I found no evidence that AVX-512 work lowers the clock after the work is over. This test has an important limit: I could only measure the clock after the burst, because one core cannot run the burst and the measurement at the same time, and any slowdown that ends as soon as the 512-bit work ends would be invisible. So this weakens the hypothesis, and does not rule it out. The three unexplained results stay unexplained.

Why it is this way (the "why")

Why is the contract so narrow? Because a narrow contract is what lets hardware improve. If the ISA promised that imul takes 3 cycles, a new design that does it in 2 would break the contract. By promising only meaning, the vendors keep the freedom to change the cache sizes, the pipeline, the clock, and the number of units on every generation, without breaking a single program. The price of that freedom, for people who care about speed, is that speed is not portable. A program tuned for one chip is not guaranteed to be fast on the next. That is why the guidebook's rules are about shapes (use all of the cache line, make loads independent, avoid unpredictable branches) and why the numbers always come with a machine.

Why does the clock move at all? Because a chip is limited by heat and power, not only by design. A chip can run all its transistors at full speed only for a short time before it gets too hot, or it exceeds its power budget. So the control logic raises the clock when it has headroom (few cores busy, low temperature) and lowers it when it does not. That makes the same program run at different speeds depending on what else is happening on the chip and on the machine (and, in a VM, possibly on other tenants of the host; I did not test that). It is one reason why time is a noisy measure of work, and why Chapter 20's instruction counts, and Chapter 35's cycle counts (when you can get them), are steadier: counts of work do not change when the clock changes.

This brings back the question of what to measure. There are three kinds of quantity:

KindExampleChanges with the clock?Where you get it
Timenanosecondsyesany machine
Workinstructions, memory accessesnosimulator, or hardware counters
Cyclescore cyclesno (cycles are the clock's own unit)hardware counter, or a calibrated chain

If your goal is to compare two versions of code, work and cycles are less noisy than time, but they only tell you about the machine's effort. If your goal is the user's experience, time is the truth, noise and all. Know which one you are quoting, and say so.

The philosophical heart of the chapter: a measurement tool that gives an impossible answer is not broken, it is telling you the model behind it is wrong. The 15 GHz was the CPU quietly doing something I had not accounted for. Chasing the contradiction taught me something about the hardware (immediate-add folding) that no manual page in my head had. The contradiction is the discovery. Treat every "that can't be right" as an invitation.

What would make this wrong? The latency values (1 for add, 3 for imul) come from my knowledge of recent Intel cores, and I did not look them up for this chip. The agreement between the two chains is a good sign (a wrong latency would have broken it) but not a proof. The clock windows of 25 ms may miss faster changes. The folded-add explanation is a guess. And everything is for one VM, whose host controls the real frequency.

Common mistakes

  • Taking the clock from lscpu or from the model name. It is the nominal frequency. Measure.
  • Confusing TSC ticks with core cycles.
  • Believing a chain is a latency chain without checking. Immediate adds were folded.
  • Reporting an impossible number. Stop and find the cause.
  • Converting nanoseconds to cycles at the wrong clock. I did, three times, in earlier chapters.
  • Assuming that code tuned on one chip is fast on another. Speed is not in the contract.
  • Comparing runs taken at different clock speeds.

Say it simply

A cycle is one tick of the core clock, which is not the constant TSC and which moves with turbo and power limits. You can measure the clock on any machine by timing a chain of dependent instructions of known latency: an integer add takes 1 cycle and an imul 3, and two chains gave 3.28 and 3.21 GHz, while the machine claims 2.1. At that clock, a dependent f64 add takes about 2.25 cycles, a branch misprediction about 27 cycles, and a main-memory access about 450. The instruction set only promises what instructions mean, never how fast they are, so speed is always an empirical question.

Check yourself

Exercise 37.1 (Warm-up): Cycles from nanoseconds. At 3.28 GHz, how many cycles is 137 ns? 1.73 ns? 0.3 ns? At 2.1 GHz, how many would they be? Which is the right clock for this machine?

Exercise 37.2 (Warm-up): Predict. An integer add has a latency of 1 cycle. A chain of 1,000 dependent adds runs on a core at 4 GHz. How long does it take? If the chain instead has 4 independent chains interleaved, and the core can do 4 adds per cycle, how long does it take for the same total number of adds?

Exercise 37.3 (Core): Measure your clock. Write the dependent-add chain with asm! (or find another way to make a dependent chain in your language). Compute the clock. Then do it with imul and compare. Compare with lscpu and with your CPU's marketing frequency. Done when: two clock estimates, their difference in percent, and the nominal frequency.

Exercise 37.4 (Core): The folded chain. Reproduce the 0.22 cycles per add result with an immediate operand. Then try add reg, 1, add reg, 1000, add reg, 100000, and lea reg, [reg + 3]. Which are folded? Is there a pattern?

Exercise 37.5 (Core): Latencies in cycles. Measure the latency, in cycles, of integer div, popcnt, lea, and a load that depends on the previous load (an L1 pointer chase). Compare with Agner Fog's tables for your CPU.

Exercise 37.6 (Stretch): The drifting clock. Record the clock every 10 ms for a minute, while the machine is idle, and again while you compile a large project in another process. Plot both. How much does the clock move, and how long does it take to change?

Exercise 37.7 (Stretch): Test the AVX-512 hypothesis better. On a machine with AVX-512, measure the throughput of a scalar loop on one core while a second thread on a different core runs heavy 512-bit code, and compare with the same loop while the second thread runs scalar code. Does the clock of the first core change? (On many CPUs, the frequency limit depends on how many cores are busy and which instructions they run.)

Exercise 37.8 (Stretch): Design and defend. Your benchmark suite runs on a laptop and on a server, and results are compared across them in a shared dashboard. Explain, using this chapter, why that comparison can mislead, and what you would record with every result so that a number is interpretable.

What a strong answer includes: clock speed and turbo behavior, the CPU model and microcode, the compiler and flags, whether SMT or other tenants were present, the use of work or cycle counts alongside times, and the idea that results should be compared within a machine and ratios, not absolute values, across machines.


Part VIII wrap-up

What you can now do:

  • Say exactly what Criterion computes (the sampling plan, the regression through the origin, the bootstrap, Tukey's fences, and the t-test with a pooled bootstrap), and recompute it from its saved files.
  • Test whether Criterion's independence assumption holds on your machine, and explain why false alarms follow when it does not.
  • Describe a performance counter, counting and sampling mode, multiplexing, and skid, and call perf_event_open yourself.
  • Check whether your machine has a PMU, and know what to do when it does not.
  • Explain how Valgrind translates and instruments a program, write a cache simulator that matches Cachegrind, and use Cachegrind to ask "what if" about the cache.
  • Measure your CPU's real clock with a dependent chain, convert nanoseconds to cycles, and distinguish core cycles from TSC ticks.
  • State what the instruction set promises and what it does not.

Mini project: The calibrated machine (4 to 6 hours). Write a small tool, machine-report, that runs on any Linux x86-64 machine and prints a one-page report of everything this guidebook taught you to measure instead of assume:

  1. The nominal clock (TSC rate), the measured core clock (two chains), their ratio, and the clock's range over 60 windows.
  2. The latency, in cycles, of add, imul, f64 add, f64 multiply, and an L1 load.
  3. The memory ladder (Chapter 8): the sizes of L1, L2, and L3 where the steps appear, and the latency of each in ns and in cycles.
  4. The cache line size (Chapter 9), and the first-level TLB reach (Chapter 10).
  5. The cost of a branch misprediction in ns and cycles (Chapter 13).
  6. Whether hardware counters are available (a perf_event_open for cycles), and whether perf_event_paranoid allows them.
  7. The wobble of the machine: the spread of 30 repeats of one fixed benchmark, and the lag-1 autocorrelation of their per-iteration times.

Done when: the tool runs in under two minutes, prints a table, and you have run it on at least two different machines (for example your laptop and a cloud VM), with a paragraph on what differs and what that means for comparing benchmarks between them.

Write-up prompt: Half a page. Which number on your machine differed most from what you would have assumed?

Teach it back: In 5 sentences, explain to a friend why a CPU's labeled speed in GHz is not the speed it runs at, and how you can measure the real one with nothing but a loop.

Reading list for Part VIII:

  • The Criterion.rs source code and user guide (the analysis process page), and the changelog of the version you use. The best way to know what it computes is to read routine.rs, stats/bivariate/regression.rs, and analysis/compare.rs, as I did for Chapter 34.
  • Bradley Efron and Robert J. Tibshirani, An Introduction to the Bootstrap, 1993. The standard book on the method Criterion uses. (Not freshly checked.)
  • Brendan Gregg, Systems Performance (2nd edition, 2020), the chapters on CPUs and on observability tools, and the perf examples on his website. (Not freshly checked.)
  • The Linux kernel documentation and the manual page perf_event_open(2). The authoritative description of the system call used in Chapter 35.
  • Intel, 64 and IA-32 Architectures Software Developer's Manual, volume 3, the chapter on performance monitoring, and Agner Fog, Instruction tables and The microarchitecture of Intel, AMD and VIA CPUs, for latencies and the behavior of instruction chains. (Not freshly checked.)
  • Nicholas Nethercote and Julian Seward, "Valgrind: A Framework for Heavyweight Dynamic Binary Instrumentation", PLDI 2007, and the Valgrind user manual (Cachegrind and Callgrind chapters). (From my own knowledge, not freshly checked.)
  • Todd Mytkowicz, Amer Diwan, Matthias Hauswirth, and Peter F. Sweeney, "Producing Wrong Data Without Doing Anything Obviously Wrong!", ASPLOS 2009. The paper of Part I, which this Part has been quietly illustrating: every tool here measures something, and the something depends on details you did not choose.

Verification notes for Part VIII: Run in this session on the 1-core Xeon VM: the recomputation of Criterion's slope, mean, median, interval, and fences from its saved files (all matched as shown), the source reading of Criterion 0.8.2 (lib.rs iteration counts, routine.rs, regression.rs, compare.rs), the re-implementation of its comparison on the saved samples of sum/one_acc/1000, the lag-1 autocorrelations of five saved benchmarks, two near-empty Criterion benchmarks, the listing of event sources and CPU flags, the perf_event_open calls from Rust (software events counted; hardware events failed with ENOENT), the cache simulator against Cachegrind on six patterns (with an explicit cache configuration), the Valgrind slowdown timings on a 5-million-word run, the clock measurements (TSC rate, add and imul chains, instruction latencies, 60 clock windows, and the AVX-512 burst test). Not verified: the structure of the PMU in general and the facts about PEBS, IBS, multiplexing, and typical counter counts (from the literature, not testable here); that virtual machines often hide the PMU; Valgrind's internal design beyond what the six-pattern comparison shows; the latencies of 1 and 3 cycles for add and imul on this chip; the explanation for the folded immediate chain; the typical size of a branch misprediction penalty; the explanation for the 7-miss constant offset between my simulator and Cachegrind; the Valgrind run on a very short program (my first timing attempt was invalid, as explained). Limits: the autocorrelation figures come from five benchmarks on one drifting VM; the clock estimates come from a single VM whose host controls the frequency; I could not measure the clock during an AVX-512 burst; everything about hardware counters beyond the system call's interface is from documentation, not from this machine. Corrections made elsewhere because of this Part: the machine description at the start of the guidebook, and two sentences in Chapter 13 (the branch misprediction cost in cycles, and the floating-point add latency).


Part IX. Production practice

After this Part, you will be able to keep a program fast after you have made it fast, change code for speed without breaking it, and help other people do the same, in a pull request, a workshop, or on a stage.

Parts I to VIII were about finding out: how to measure, find, explain, and fix. This Part is about what happens next. A speed-up that is lost in three months was not worth much. A speed-up that broke the program was worth less than nothing. And knowledge that stays in one head helps one person.

This Part is also the one where this guidebook turns on itself. In Chapter 38, I build a performance gate and measure its false-alarm rate. In Chapter 39, I run a proper test against my own optimized word counter from Part V, and it finds two bugs that I shipped. I corrected the text of Part V, and I show you the whole story, because the way a real engineer works with errors is the most useful thing I can teach in the last chapters.


Chapter 38. Performance as a maintained contract

In this chapter: You will learn to treat speed as something a project promises and checks, like correctness. I measured a set of gate designs against regressions I injected on purpose: a naive gate fired falsely on 13% of unchanged runs, and a better design caught a real 5% regression 98% of the time with 0% false alarms. You will also see why instruction counts and allocation counts make strong, exact gates, and one thing instruction counts can never see.

You need first: Chapters 2, 6, 20, and 34.

The problem first

A correct program can get slow without a single test failing. A well-meaning change adds a clone here, a lock there, a Vec that was reserved and now isn't. Each one costs 1% or 3%. No review catches it, because each looks harmless. Six months later, the program is 40% slower than it was, and nobody knows which change did it. This is called performance creep, or death by a thousand cuts.

The fix is the same as for bugs: catch it at the commit that causes it, while it is cheap to understand and fix. That needs a gate: an automatic check in the pull request, or in the nightly build, that says "this change made something slower".

But a gate is a measuring instrument, and Part I taught us what happens when measuring on a noisy machine. The two ways a gate can fail:

  • A false alarm: it fires when nothing got slower. People learn to ignore it, and then it protects nothing.
  • A miss: it stays silent when something got slower. It gives false comfort.

Every gate trades one against the other. The job is to find a design with few of both.

The model (the "what")

What to guard, and with what

There are three families of quantities, with very different noise (Chapter 37's table):

FamilyExamplesNoiseWhat it can seeWhat it cannot see
Resources and countsnumber of allocations, peak memory, binary size, number of syscallsnone: exactly repeatablewaste you can countslowness not caused by those counts
Workinstructions executed (Callgrind or Cachegrind, Chapter 20), simulated missesalmost nonemore work being donethe same work done more slowly (cache misses, branch mispredictions, page faults)
Timewall-clock from a benchmarkhigh (Parts I, II, 34, 37)everything the user seesnothing, but it cannot tell you why, and it is noisy

The strongest design uses all three, in layers: exact gates first (they never lie and cost almost nothing), timing gates for what the exact ones cannot see, and a long-term history for slow creep.

A gate is a hypothesis test

"Did this change make it slower?" is a statistical question. A gate has:

  • a false alarm rate (the share of unchanged runs on which it fires), written α;
  • a detection rate (the share of runs with a real slowdown of a given size on which it fires), also called its power.

The two pull against each other. A sensitive gate (fire above +1%) detects small slowdowns and also false-alarms often. A relaxed gate (fire above +5%) is quiet, and misses small slowdowns. There is no setting that is both sensitive and quiet. The only way out is a better measurement, one with less noise, so that the two distributions are further apart. That is what the designs in the next section do.

And one piece of arithmetic that is easy to forget. If your suite has K benchmarks and each has a false-alarm rate of α, the chance that at least one fires on a change that did nothing is 1 − (1 − α)^K. For K = 80 benchmarks:

False alarm rate α per benchmarkChance of at least one false alarm per pull request
13%99.999%
5%98%
2%80%
0.5%33%
0.1%7.7%

(This assumes the benchmarks fail independently. In practice they do not: a noisy minute on the runner hits many benchmarks at once, so failures cluster and the real chance of at least one is a little lower, though the failures that do happen come in groups.) The table says why a team with 80 benchmarks and a 5% gate has a CI that "always has a regression": it nearly always does, falsely, and people stop looking.

Build it (the "how")

I built the experiment of Chapter 6 in reverse: instead of waiting for noise to fool a gate, I injected regressions of known size into a function and measured how each gate design reacts.

The function under test sums 100,000 u64 (about 14.9 µs per call here). The "regressed" version adds extra dependent add instructions at the end (0.35 ns each), sized to add about 2%, 5%, and 10% to the time. (The nominal sizes are a little larger than the effects I measured, because the extra adds overlap partly with the loop. The measured median changes were +1.8%, +4.1%, and +8.2% in the best design. I call them 2%, 5%, and 10% below.) For each of 4 true sizes (0%, 2%, 5%, 10%), I ran 60 independent trials of each gate. In each trial, the gate measures the baseline and the new code (50 samples of 80 calls each, median per call) and decides.

Two ways to organize the measurement:

  • Separate runs: measure the baseline completely, then the new code completely, like two CI jobs one after the other.
  • Interleaved runs: alternate a batch of baseline, a batch of new code, a batch of baseline, ... in one process, so that slow drift (Chapter 37: the clock moved 17%) hits both equally and cancels out of the difference.

First, what the gates see. The measured change in each trial (median, minimum, and maximum over 60 trials), for the separate and the interleaved designs:

True changeSeparate runs: median, min, maxInterleaved runs: median, min, max
0%−0.0%, −6.8%, +23.0%−0.1%, −3.8%, +2.3%
+2%+2.1%, −1.2%, +8.2%+1.8%, +0.7%, +9.5%
+5%+4.9%, +1.3%, +12.0%+4.1%, +2.2%, +9.7%
+10%+9.2%, −9.9%, +38.4%+8.2%, −7.5%, +23.3%

Look at the first row. On unchanged code, one separate-runs trial reported +23.0%. One trial of the 10% case reported −9.9%: the code was made 10% slower and the measurement said it got 10% faster. These are the heavy tails of Chapter 2, hitting a gate. A single noisy trial is enough to fire, or to hide a real slowdown. Interleaving tightens the 0% case (−3.8% to +2.3%, against −6.8% to +23.0%), but it does not remove the rare large outliers at higher sizes (+23.3% and −7.5% at the true 10%).

Now what each gate does. Share of trials in which the gate fires:

Gate designtrue 0% (unchanged)true +2%true +5%true +10%
separate runs, fire if > +1%13%93%100%95%
separate runs, fire if > +3%5%22%98%93%
separate runs, fire if > +5%3%7%43%93%
interleaved, fire if > +1%2%98%100%97%
interleaved, fire if > +3%0%2%98%97%
separate, > +3% in TWO runs in a row0%3%97%87%

(With 60 trials, a measured 0% means the true false-alarm rate is probably below about 5%. The last row uses 30 pairs of trials, so it is less precise.)

Read it as a design problem.

The naive gate is bad. "Run the baseline, run the new code, fire above +1%" false-alarms on 13% of unchanged runs. With 80 benchmarks it would fire on every pull request. Raising the threshold to 5% quiets it (3% false alarms) and makes it blind: it catches a real 5% regression only 43% of the time, and a 2% regression 7%.

Interleaving is the single biggest improvement. The same threshold (+1%) with interleaved runs has a 2% false-alarm rate (against 13%) and detects a 2% regression 98% of the time (against 93%). Better on both axes at once, because the measurement itself is better. Interleaved at +3% had no false alarms in 60 trials and caught the 5% regression 98% of the time. This is the strongest result of the section: a better measurement beats a cleverer threshold. (It is the A/B design I suggested in Chapters 4 and 34, now measured.)

Repeat before you fail is a cheap second option. A gate that needs the same verdict twice in a row (separate runs, above 3% both times) had 0% false alarms and caught the 5% case 97% of the time. The cost: twice the measurement time, and the real 10% case dropped to 87% (a single missed run fails the pair). You can get this design with no change to your benchmark code: re-run the failing benchmark once before reporting.

No gate here caught everything. Even at a true 10% regression, the gates fired in only 87% to 97% of trials. In the other trials, noise hid it. A gate is a net with holes. That is a reason for the layers.

The exact gates

Now the gates with no noise at all.

An allocation count. Part V found that the number of allocations is a strong predictor of speed, and a count is exactly repeatable. Here is the cheapest possible performance regression test: use the counting allocator of Chapter 31 and assert on the count. I ran a function that should not allocate and a "regressed" version that collects into a Vec:

allocations in 1000 calls: ok version = 0, regressed version = 8000

Zero against 8,000, with no noise, and no need to run for a long time. As a unit test:

#[test]
fn count_digits_does_not_allocate() {
    let text = "abc123def456 ghi789".repeat(100);
    let before = ALLOCS.load(Ordering::Relaxed);
    std::hint::black_box(count_digits(std::hint::black_box(&text)));
    assert_eq!(ALLOCS.load(Ordering::Relaxed) - before, 0, "count_digits started allocating");
}

(One catch: the allocation counter is global to the process, and Rust runs tests in parallel threads, so other tests' allocations would be counted. Run these tests with --test-threads=1, or use a counter per thread.)

An instruction count. With Cachegrind (Chapter 20) I ran a program 7 times under each of 3 injected regressions of different sizes. The count is exactly repeatable: the baseline gave 3,581,742 instructions in two separate runs. The injected regressions gave 3,731,732, 3,971,734, and 4,361,741 instructions (+4.2%, +10.9%, and +21.8%). A gate on instruction counts, even a tight one (fail above +0.5%), would catch every one, with no false alarms at all, and in a fraction of the time of a timing gate with enough samples.

But note something. These percentages are instruction percentages, not time percentages: three instructions per extra add, and the time effect is much smaller, because these adds overlap with other work. In the same run, the wall-clock times of the four versions were 3.39, 3.42, 3.40, and 3.61 ms (including about 1 ms of process start-up), barely different. An instruction gate detects more work. It does not tell you how much slower the program will be. It would flag a harmless 4% increase in instructions that costs nothing.

And the gate's blind spot, which no instruction count can fix. A regression that does the same work in a worse order:

VersionInstructions (Cachegrind)Wall time
gather with sequential indexes134,201,63419.69 ms
gather with shuffled indexes (same code, same data)134,201,64837.42 ms

The instruction counts differ by 14 out of 134 million (0.00001%). The time is 90% worse. This is the whole of Part III in one row: it is memory that decides the speed, and instruction counts cannot see it. An instruction gate would pass this regression without a murmur. Only a timing gate (or hardware counters, or a simulation of the cache) can see locality. This is why you cannot use only exact gates.

A policy, in layers

Putting it together, a defensible design for a project like the ones in this guidebook:

  1. On every pull request, blocking: exact gates. Allocation counts for the hot functions that must not allocate (as unit tests), and instruction counts of a small set of benchmarks (a fixed threshold, say +2%, set from your own measured noise of zero).
  2. On every pull request, informational (a comment, not a block): an interleaved timing comparison for the benchmarks that the change touches, reported as a ratio with an interval and the provenance (Chapter 7), and re-run once before it is shown as a regression.
  3. Nightly: the full suite, on a quiet, dedicated machine, stored with the commit, the machine, and the compiler version, so you can draw the history.
  4. Weekly or on a schedule: look at the history for slow creep: compare not with the previous commit but with the best result of the last several weeks (a ratchet). A series of 1% regressions each below the gate threshold adds up, and only the history shows it.
  5. When something is flagged: find the commit with a bisect: run the benchmark on the middle commit, then the middle of the half where it changed, and so on (git bisect run automates it). This needs every commit to be buildable and the benchmark to be runnable at any commit, which is a requirement to put on the project from the start.

The history in step 3 is where real teams have found the next step of sophistication. David Daly and co-authors at MongoDB (ICPE 2020, in the reading list) describe how their performance CI moved from manual inspection of graphs, to threshold-based detection, to change point detection (an algorithm that finds the points in a time series where the level changes), with a human reviewing the candidates. They report that it dramatically reduced their false positive rate and made the process more timely. (I read the abstract of that paper for this chapter. I did not run change point detection here.)

Why it is this way (the "why")

Why is a gate so hard to get right? Because it asks a statistical question of a physical system that drifts. The tools of statistics (a threshold, a p-value) assume a stable thing being measured with independent noise. A real machine has slow drift (Chapter 37), correlated noise (Chapter 34), and heavy tails (a trial that read +23% on unchanged code). A gate that ignores this is a gate that cries wolf.

Interleaving works because of a simple idea: if you cannot remove the noise, make it affect both sides equally and compare the sides. This is the oldest trick in experimental science (a control group measured at the same time as the treatment group). Instead of asking "is this run's number bigger than yesterday's number?" (which mixes the change with a day's worth of drift), you ask "in this minute, was the new code slower than the old code?" The question is narrower, and the answer is cleaner.

The deeper principle is that a performance claim is only as strong as the weakest link in its measurement, and the cheap exact measurements (counts) are much stronger than the expensive noisy ones (time), within what they can see. Use the exact ones as the foundation and the noisy ones as the roof.

There is also an organizational point, which I think is the most important one. A gate is a social object. People respond to a gate that cries wolf by ignoring or disabling it. A gate with few false alarms builds trust, and trust is what makes a team fix regressions instead of arguing with the tool. That is why this chapter prefers a design that is quiet and a little blind (0% false alarms, 98% detection at 5%) over one that is sensitive and wrong (13% false alarms). A missed 2% regression is caught later by the history. A gate that is ignored is never caught.

What would make this wrong? My experiment injected a simple, fixed-size slowdown into one function on one drifting VM. Real regressions are of all sizes and shapes, and many benchmarks would have different noise. The 60 trials give the false-alarm rate with a wide error (a measured 0% is "probably below 5%"). I tested a few designs, not all. And the instruction-count gate here was applied to a toy: with a real program, small instruction changes from the compiler version or from library updates can cause "regressions" that are not the project's own doing, so exact gates need an occasional re-baselining.

Common mistakes

  • Gating on one timing run. The heavy tail will fire it.
  • Comparing against a baseline measured earlier, on another machine or on another day.
  • Choosing the threshold without measuring your own noise.
  • Ignoring the arithmetic of many benchmarks. 1 − (1 − α)^K.
  • Using only instruction counts and missing locality regressions (a 90% slowdown with a 0.00001% change in instructions).
  • Never looking at the history, and so missing slow creep.
  • Letting a flaky gate stay. Fix it or turn it off. A bad gate teaches people to ignore the good ones.
  • Not being able to bisect. Make benchmarks runnable at every commit.

Say it simply

A gate checks a change for slowdowns, and every gate trades false alarms against misses. I injected known slowdowns and measured: a naive gate (separate runs, fire above 1%) false-alarmed on 13% of unchanged runs, while interleaving the old and new code in one process caught a 5% slowdown 98% of the time with 0% false alarms. Allocation counts and instruction counts are noise-free and make good first gates, but an instruction count missed a 90% slowdown caused only by memory order. Use exact gates first, careful timing second, and the history for slow creep.

Check yourself

Exercise 38.1 (Warm-up): The arithmetic. A suite has 200 benchmarks and each has a 1% false-alarm rate. What is the chance of at least one false alarm on an unchanged pull request? What false-alarm rate per benchmark would you need for a 5% chance?

Exercise 38.2 (Warm-up): Which gate sees it? For each regression, say which of allocation count, instruction count, and timing can see it: (a) a new clone() in a hot loop; (b) a struct field reordering that adds padding; (c) the same loop with the iteration order changed to column-major; (d) a 5% higher instruction count from a different compiler version, with no change in time.

Exercise 38.3 (Core): Build the interleaved gate. Write a function compare(old, new) that alternates batches of the two functions, 50 rounds, and returns the ratio of the medians. Test it by comparing a function with itself 100 times. What range of ratios do you get? What threshold gives under 1% false alarms? Done when: a histogram of the 100 ratios, and a threshold with its measured false alarm rate.

Exercise 38.4 (Core): Measure the power. Inject known slowdowns (extra work of 1%, 2%, 5%) into a function of your own, and measure the detection rate of your gate over 60 trials each. At what size does it detect 90% of the time?

Exercise 38.5 (Core): The allocation test. Add a counting allocator to a project you have, and write allocation-count tests for three hot functions. Then make a small change that adds one allocation to one of them. Does the test fail? How long does the whole test take to run?

Exercise 38.6 (Stretch): The creep experiment. Simulate 20 commits that each add 1% to a benchmark, with timing noise of 2%. Does a gate that compares with the previous commit (fire above 3%) ever fire? Does a gate that compares with the best result of the last 10 commits? What about a simple trend line over the history?

Exercise 38.7 (Stretch): Change points. Take a series of 200 nightly results with one real change of 3% in the middle and noise of 1.5%. Implement a simple change point finder (for example, the split that maximizes the difference between the means before and after, divided by the standard error). Does it find the change? How wrong is its position when the change is 1%?

Exercise 38.8 (Stretch): Design and defend. You maintain a library with 400 benchmarks and a CI with shared 2-vCPU runners. Contributors complain that the performance check "always fails". Write a one-page plan: what you would change first, what you would stop measuring in the pull request, and how you would know the plan worked.

What a strong answer includes: the arithmetic of false alarms, moving blocking checks to exact counts, interleaved or repeated timing for a few relevant benchmarks only, a dedicated nightly runner, history and change point detection for creep, and a measurement of the gate's own false-alarm and detection rates before and after.


Chapter 39. From profile to safe change

In this chapter: You will learn how to change code for speed without changing what it does. I ran a proper differential test against the fast word counter from Part V, and it found two bugs I had shipped: the fast version was wrong on 0.79% of random ASCII inputs (the vertical tab), and the streaming versions were wrong on 6.07% of random Unicode inputs (the Greek final sigma). Each was invisible to the checksum I used before. I also corrected the text of Part V.

You need first: Chapters 23, 24, and 38.

The problem first

In Part V, I made the word counter 5.1 times faster. To check it, I compared a checksum of the output of every version with the original on our generated text, and they all matched. I wrote: "with the same output". I believed it.

That claim was false. Not on our input, where it was true. On other input, where it was not. I did not know, because I had tested the optimization with the data that the optimization was designed for. The checksum could not fail on that data.

This is the oldest trap in optimization. An optimization is a change of behavior that is supposed to be invisible. The test for "invisible" must be able to see the cases the optimization was built to ignore.

The model (the "what")

The safe-optimization loop

Every safe performance change follows the loop of Chapter 1, with one more step in the middle:

 1. MEASURE        a baseline, on a workload you wrote down first      (Parts I, II)
 2. PROFILE        find the top cost, with two kinds of evidence       (Part IV)
 3. WRITE A CHECK  that can fail: compare with the slow, obviously
                   correct version on many inputs, including ones
                   the optimization was designed to ignore             (this chapter)
 4. CHANGE         one thing
 5. RUN THE CHECK  and measure again
 6. GUARD          keep the check and a performance gate in the repo   (Chapter 38)

Step 3 comes before the change, so that you cannot write the test to fit the code.

The oracle and differential testing

An oracle is a trusted answer to compare with. For an optimization, the best oracle is the code before the optimization: it is slow, but it is right (or at least, it is the behavior you are promising to keep). Differential testing runs the old and the new code on the same inputs and compares the outputs. Any difference is a bug in one of them.

The inputs are the key. A good differential test uses:

  • Random inputs from a generator that covers the whole input space, including strange corners (empty, very long, all one character, every possible byte).
  • Inputs aimed at each assumption the optimization makes. The ASCII fast path assumes ASCII: test non-ASCII, and test ASCII control characters. The chunked parallel counter assumes it can cut at whitespace: test inputs with no whitespace at all.
  • A shrinker: when a test fails on a long random input, find the shortest failing input, so you can understand it. The simplest shrinker keeps the shortest failure seen so far.

This is the idea of property-based testing (the proptest and quickcheck crates in Rust do it with smart shrinking, and cargo fuzz does it with coverage guidance). I wrote the simple version by hand, because it is 30 lines and needs nothing.

Build it (the "how")

The oracle is v0 (the original, slow word counter). The code under test is v4 (the fast byte-and-table version). The test generates 200,000 random strings of 0 to 40 ASCII characters (letters, digits, spaces, punctuation, and any ASCII byte at all with a 8% chance, so control characters appear), runs both, and compares the sorted top-5 word counts:

fn diff_test(n_cases: usize) {
    let mut fails = 0; let mut shortest: Option<String> = None;
    for _ in 0..n_cases {
        let t = random_ascii_string();                 // includes control characters
        if v0(&t) != v4(&t) {
            fails += 1;
            if shortest.as_ref().map_or(true, |s| t.len() < s.len()) { shortest = Some(t); }  // keep the shortest failure
        }
    }
    println!("{} of {} inputs disagree", fails, n_cases);
    if let Some(t) = shortest { println!("shortest failing input: {:?}", t); }
}

Result:

200000 random ASCII inputs: 1578 where v0 and v4 disagree (0.79%)
shortest failing input: "g\u{b}r"
  v0 says [("g", 1), ("r", 1)]
  v4 says [("gr", 1)]

The fast version is wrong on 0.79% of random ASCII inputs, and the shortest failure is three characters: g, then the character U+000B, then r. U+000B is the vertical tab, an ASCII control character. Rust's char::is_whitespace (used by split_whitespace in the original) counts it as whitespace, so v0 splits the text into two words, g and r. My fast version used u8::is_ascii_whitespace, which counts the space, tab, newline, form feed, and carriage return, but not the vertical tab. So v4 treated it as a non-letter to be dropped, and glued the two words into gr.

Two nearly identical-sounding functions, and one character of difference. Neither my reading nor my checksum caught it. The test caught it in 200,000 random cases, and shrank it to three characters in the same run.

The fix is one condition:

if b.is_ascii_whitespace() || b == 0x0B { flush(&mut buf); }   // char::is_whitespace also counts the vertical tab

Re-run on 2,000,000 random inputs: 0 disagreements. And the speed? v4 ran in 135.2 ms on the 5-million-word job (median of 5 runs), the same as before within the machine's noise (earlier: 151 and 167 ms on different days). The fix cost nothing measurable. (It is worth saying why: a correct and an incorrect version of this loop differ by one comparison per byte, which is not what the 30 ns per word is made of.)

The second bug

The streaming versions of Part V (v1, v2, v3) lowercase one character at a time (c.to_lowercase()), while the original lowercases the whole word at once (str::to_lowercase). For almost all text these are identical. I tested them against each other on random Unicode input from a small alphabet (letters with accents, a Chinese character, spaces, and a few special letters):

200000 random Unicode inputs: v0 and v2 disagree on 12149 (6.07%)
shortest failing input: "cΣ"
  v0 says [("cς", 1)]
  v2 says [("cσ", 1)]

The capital Greek letter sigma (Σ, U+03A3) has two lowercase forms: σ in the middle of a word and ς, the final sigma, at the end of a word. The standard library's str::to_lowercase knows this rule and picks the right one by looking at the context of the whole word. A single character's char::to_lowercase cannot see the context, and always gives σ. So all my streaming versions (and the Unicode fallback in v4) produce a different lowercase word for any Greek text with a final sigma. 6.07% of inputs here, because I put Σ in the alphabet on purpose.

Now notice how the correctness of my own Part V claim changes. I wrote that every version gave the same output, and I checked on the 26 words of our generated English text. That was true for that text. It is not true for all text: v1 to v3 and the Unicode fallback of v4 are not equivalent to v0 on text with a final sigma, and, before the fix, v4 was not equivalent on text with a vertical tab. I have corrected the paragraph in Chapter 23, the code there, and the introduction of Part V. The corrected statement is: the versions give the same output on ASCII text without a vertical tab (after the fix, on any ASCII text), and differ from the original on Unicode text with a final sigma. Whether that difference matters is a requirements question, not a testing question: for counting English words, it does not. For a Greek-language search tool, it is a bug. You decide, and you write it down.

I did not fix the sigma difference in the code, because the right fix depends on the requirement (call str::to_lowercase on the cleaned word when it is non-ASCII, at the cost of an allocation on that path, is the simple one). I left it as a documented difference. A documented difference is not a bug. An undocumented one is.

Why it is this way (the "why")

Why did the checksum pass? Because a test is only as good as the inputs it uses, and I used the very input the optimization was made for. The generated text had 26 words made of ASCII letters, with capitals and spaces. No control characters, no Greek. The optimization (ASCII only, a table of 256 bytes) was built for that text. Testing it on that text is like checking that a key opens the lock it was cut from.

It also shows what is dangerous about equivalent-looking standard-library functions. is_ascii_whitespace and is_whitespace look the same and differ in two places for ASCII (the vertical tab, and, for characters above 127, the many Unicode spaces). to_lowercase on a string and on a char look the same and differ in context. These are exactly the places where an optimization that "just uses the byte version" becomes subtly wrong. The more specialized the code, the more its edge cases differ from the general code's.

The philosophy: every optimization is a small theorem: "for all inputs in the set S, the new code gives the same result as the old code." S is the set the optimization assumes. The job of a test is to attack S: to find an input in S where the theorem is false, and to find inputs outside S that the code might receive anyway. Writing S down (in a comment, or in an assert!) is half of the safety. Mine was "ASCII text", and I had not even written that down until the is_ascii check, and I had not thought about what ASCII covers.

Other practices that belong to this chapter:

  • Keep the slow version as the oracle, in the repository, behind #[cfg(test)]. It is documentation that cannot go out of date: the best statement of what the fast code must do.
  • One optimization per commit, with the measurement in the commit message. If a bug shows up, git bisect finds the commit, and the commit explains what was meant.
  • Be most careful with unsafe. Chapter 32 removed bounds checks with unsafe for 1.65x. The risk is a bug that does not fail a test but corrupts memory. Isolate unsafe in small functions with a comment that says what must be true, test with a tool that checks for undefined behavior (Miri, which interprets Rust and checks every access, and sanitizers), and take the speed only when the profile says it matters. (I did not run Miri here.)
  • Floating-point reordering is a behavior change. Chapter 28's split sum changed the last digits. Decide on a tolerance, and test with it. Never use exact equality for results that can legally differ.
  • Write the performance gate and the correctness test together (Chapter 38): the same inputs can serve both.

What would make this chapter wrong? My differential test generated small strings (up to 40 characters) from a particular distribution. It found two bugs. It cannot show there are no others: 2 million cases with 0 disagreements for the ASCII path is good evidence, not proof. A coverage-guided fuzzer, run for hours, explores more. And the oracle can itself be wrong: v0 is the original, not necessarily the correct one: it treats the digit string 1e5 as one word and counts non-English text by Rust's rules, which may or may not be what the user wants.

Common mistakes

  • Testing an optimization with the data it was designed for.
  • Comparing a checksum on one input. It can only fail if the input can make it.
  • Believing two similar standard functions behave the same. Read their documentation for the differences.
  • Not writing down the assumed input set.
  • Fixing the optimization and not adding the failing case as a permanent test. The shortest failing input is a free regression test. Keep it.
  • Treating a documented difference as a bug, or a silent one as acceptable.
  • Using unsafe for a speed-up without a tool that checks it.
  • Measuring after the fix but not before the optimization. Without both, you cannot say the gain survived the fix.

Say it simply

An optimization is supposed to change the speed and nothing else, so write a check that can fail before you change anything: compare the fast code with the slow, trusted version on many random inputs, including ones the optimization was designed to ignore, and shrink any failure to its shortest form. My own fast word counter, which passed a checksum, disagreed with the original on 0.79% of random ASCII inputs (the vertical tab was not treated as whitespace), and my streaming versions disagreed on 6.07% of random Unicode inputs (the final sigma needs the whole word to lowercase). I fixed the first with one condition, documented the second, and corrected Part V.

Check yourself

Exercise 39.1 (Warm-up): Which inputs? An optimization assumes "the numbers in the input are non-negative and fit in 32 bits". List five inputs you would add to a differential test, including at least two that violate the assumption.

Exercise 39.2 (Warm-up): Spot the difference. Name one input where s.to_lowercase() and s.chars().map(|c| c.to_ascii_lowercase()).collect::<String>() give different results. Name one where split_whitespace() and split(' ') differ.

Exercise 39.3 (Core): Rerun the test. Implement v0 and the byte-based v4 (the version before the fix), write the differential test, and find the vertical tab bug yourself. Then fix it, and run 2 million cases. Add the shortest failing input as a unit test. Done when: the failure rate before the fix, the shortest failing input, the rate after the fix, and the unit test.

Exercise 39.4 (Core): Find another. Extend the generator to include bytes above 127 (as invalid UTF-8 can't be a &str, use all of Latin-1 as chars). Does the is_ascii fallback in v4 route them correctly? Compare v4 against v0 on this input. Where do they disagree, and is each difference a bug or a documentation item?

Exercise 39.5 (Core): A property test. Use the proptest crate to express "for all strings, v0(s) == v4(s)", and let it shrink the failure for you. Compare its shrunken failing input with mine (g\u{b}r).

Exercise 39.6 (Stretch): Fix the sigma. Make the streaming version equal to the original on Greek text, with the smallest slowdown you can. Measure the cost on English text and on Greek text. Where would you put the fast path check?

Exercise 39.7 (Stretch): Fuzz the parallel counter. Test the parallel word counter of Chapter 29 against v4 with random thread counts (RAYON_NUM_THREADS) and random inputs, including long inputs with no whitespace, and inputs where every character is whitespace. Does the chunking ever cut a word?

Exercise 39.8 (Stretch): Design and defend. A teammate has a 30% faster JSON parser that "passes all the tests". Write the list of questions you would ask before merging it, and the three checks you would run.

What a strong answer includes: a differential test against the old parser on a corpus of real and random files, the assumptions of each fast path and a test that violates them, error and edge cases (invalid input, deep nesting, huge numbers), a performance gate to hold the speed-up, and the idea that "passes the tests" says nothing about cases the tests do not contain.


Chapter 40. Contribute and teach

In this chapter: You will learn how to turn what you now know into a contribution that a maintainer will merge, and into a talk, a workshop, or a guide that other people can learn from. There are no new measurements here. The chapter is about evidence, honesty, and clear explanation, using this guidebook's own mistakes as examples.

You need first: Everything, but especially Chapters 1, 38, and 39.

The problem first

You have found a real performance problem in an open-source project, and you have a fix. You open a pull request that says: "Made parse 2x faster." What happens next?

Often, nothing, or an argument. The maintainer does not know your machine. They do not know whether the benchmark represents real use. They worry about readability, binary size, and the next release. A "2x faster" with no evidence is a request to trust you. A pull request that arrives with evidence is a request to check you, which is much easier to say yes to.

The same is true of teaching. A talk that says "caches are important" teaches nothing. One that shows a loop going from 3.6 seconds to 16 milliseconds with the same math teaches something, and one that also shows your own wrong guess teaches more.

The model (the "what")

A performance contribution is a small experiment report

A maintainer decides on a performance pull request with a few questions. Answer each in advance, in the description:

The maintainer asksYou answer with
What is the problem, for whom?A real use case or issue, and the workload (the five blanks, Chapter 1)
How do I know it is real?A minimal benchmark that anyone can run, with the command
Where does the time go?A profile or a flamegraph, and the one thing the change targets (Part IV)
Is the gain real?Before and after on the same machine, interleaved or repeated, with the spread (Chapters 34, 38)
Is it correct?A differential or property test, and what inputs it covers (Chapter 39)
What does it cost?Code complexity, binary size, compile time, memory, and any case that got slower
Will it stay?A benchmark or test added to the repository, if the project has the place for it

The template I would use, filled in with our word counter as the example:

## Make word counting 5x faster on ASCII input

**Problem.** `count_words` on a 5-million-word text takes 772 ms and allocates 515 MB
(profile attached: 60% in `tokenize`, 87% of all allocation).

**Change.** (1) Stream the words instead of building a Vec<String>. (2) Reuse one buffer.
(3) For ASCII input, scan bytes with a 256-entry table instead of decoding chars.

**Evidence.**
| | before | after |
| time (median of 5, 5M words) | 772 ms | 135 ms |
| peak memory | 300.6 MB | 33.8 MB |
| allocations (1M words, DHAT) | 3,038,526 | 46 |
Machine: 1-core Xeon VM, rustc 1.91.1, Linux. Command: `cargo bench --bench words`.
The numbers move about 10% between runs on this machine; the improvement is far larger.

**Correctness.** Differential test against the old implementation on 2,000,000 random ASCII
inputs: 0 differences. Known difference on Unicode: final sigma (documented in the docs, see test).

**Costs.** +30 lines. Non-ASCII input takes the old slower path. No new dependencies.

Notice what the template does not contain: "blazingly fast", an unexplained bar chart, or a claim without a command. And notice that it contains the limits (the 10% machine wobble, the Unicode difference). Maintainers trust the people who tell them what could go wrong.

Smaller, separate, reversible

Maintainers like pull requests that are easy to review and easy to undo. For performance work, that usually means:

  • A benchmark PR first. Add the benchmark and the baseline numbers. It is uncontroversial, it makes the later PR checkable, and it often gets merged fast.
  • Then one change per PR, each with its own measurement. If the third change turns out to be wrong, it can be reverted without losing the first two.
  • No unrelated clean-up in the same PR.

Reading someone else's benchmark

The skills of this guidebook work in the other direction, too. When you read a claim (a blog post, a README table, a pull request), ask:

  1. What is the workload, and is it like real use? (Chapter 3)
  2. Was the work kept alive? Could the compiler have removed it? (Chapter 1)
  3. What was the machine, the compiler version, the flags? Was it a release build?
  4. How many runs, and what is the spread? Is the difference larger than the noise? (Chapters 2, 34)
  5. Were both sides measured on the same machine at the same time? (Chapter 38)
  6. Are the inputs favorable to the change? (Chapter 39)
  7. Is there a stronger reference it should be compared with? (Chapter 30: a "roof" is only a roof if no better result exists)
  8. What does it cost: memory, code size, compile time, other cases?
  9. Does the explanation match the evidence, or is it a story laid over a number? (Chapter 14)
  10. Has anything been corrected since?

Apply the same ten questions to your own work before you publish it. This guidebook failed some of them, and I will name the failures, because it is the most honest way to show what the questions are for.

The corrections in this guidebook

WhereWhat I got wrongHow I found outChapter that corrected it
Chapter 19The "compute roof" of 10.6 and 24 GFLOP/s was the roof of my kernel, not the machineA tuned library reached 131 GFLOP/s on the same core (a stronger reference)30 (correction box in 19)
Chapters 13, 8, and the machine descriptionCycles computed from the nominal 2.1 GHz, when the core ran near 3.3 GHzMeasured the clock with a dependent chain37
Chapter 23Claimed all versions gave "the same output", from a checksum on ASCII input; the fast version mishandled the vertical tab, and the streaming versions the final sigmaA differential test39
Chapter 6 and 7Treated the false alarm as noise "somewhere"; did not test the independence assumptionRead Criterion's saved samples34
Chapter 1 and 4An unexplained 47% slowdown of identical codeNot solved. It is an exerciseopen

I put this table here on purpose. The point is not to confess. It is to show that every one of these was found by one of the habits in this guidebook (a stronger reference, an independent measurement of a hidden quantity, a test that can fail, reading the raw data), and that publishing the correction is part of the work. A guide that never corrects itself is a guide that never checks itself.

Teaching what you know

Understanding something well enough to teach it is a test of understanding, and the one you started this guidebook with. Here is how the pieces fit.

Four sizes of teaching, each from the same material:

SizeAudienceGoalShape
5-minute explanationa colleague at a deskone idea, understooda question, a picture, a number, the one-sentence rule
45-minute workshop10 to 30 people with laptopsthey do the experimenta prediction exercise, a guided run, a break-it exercise, a discussion
Talk to 500+ peoplea conference hallone story they remember, and one thing they will trya surprising number, a wrong guess, the explanation, a call to action
Written guideanyone, any timethey can learn alonethis guidebook

The material for the first three is in the Teaching kit after this Part. The principles behind it:

  1. Start with a number that surprises. "The same loop, the same math, 3.6 seconds against 16 milliseconds." People remember a number they did not expect.
  2. Make them guess first. A prediction costs nothing and changes how they listen (Chapter 1: write the hypothesis before you measure). If you teach a room of people, ask for a show of hands: "Which is faster, rows or columns?"
  3. Show the wrong turn. The story "I thought it was the hash map; it was the allocator" teaches more than "it is the allocator", because the audience has the same instinct and needs to see it fail. Your own mistakes are your best material. (This guidebook's wrong first guess about the hash map, and the 15.7 GHz clock, are better lessons than the right answers.)
  4. One idea per slide, and the evidence on the slide. The number, the machine, the unit. If you cannot say what the number means in one sentence, it does not belong on a slide.
  5. Plan for the demo to fail. Live demos on shared projectors fail. Record the output ahead of time and have it ready, and say so: "This is from a run I did this morning." A recorded real result is better than a live broken one. Never fake output.
  6. Say what you did not check. A line like "I could not run this on real hardware" costs you nothing and earns trust, and it is the habit of this whole guidebook.
  7. End with something they can do on Monday. One command, one question to ask of their own code.
  8. Prepare for the hard questions (the kit lists ten), and be willing to say "I don't know, I would measure it by..." That answer is a good answer.

Build it (the "how")

There is nothing to compile in this chapter, but there is something to do, and it is the most useful exercise in the guidebook: write one real contribution and one real explanation, and let someone else try to break them.

For the contribution: pick a small open-source Rust project you use. Find a place with a plausible performance problem (an issue, or a profile of your own use), and carry out the whole loop of this guidebook: baseline, profile, differential test, change, evidence table, gate. Write the description with the template above. If the project is not ready for your change, send the benchmark and the profile as an issue instead. Maintainers value a well-evidenced report even without a patch.

For the explanation: take one chapter you found hard, and explain it to a person who has not read it, in five minutes, using one number and one picture. Record where they get lost. That is where the guidebook is weak, and where yours will be.

Why it is this way (the "why")

Why does evidence matter this much in a social setting? Because performance claims are easy to make and hard to check, and everyone knows it. The history of the field is full of benchmarks that were wrong, and maintainers have been burned. A contributor who shows the work (the workload, the machine, the spread, the test, the cost) is saying "I have already tried to break this, and here is how." That is the strongest thing you can say to a stranger who must maintain your code for years.

Why teach? Beyond helping others, there are two selfish reasons. Teaching exposes the gaps in your own understanding faster than anything else: you will find, when someone asks "why", that you had memorized a rule and not a reason. And explaining in simple words forces you to separate what you know from what you have only heard. A concept you cannot say in elementary-level English is a concept you do not yet own. That is the test this guidebook was written to pass.

The deepest reason is this: performance engineering is a discipline of honesty. Measure, and report what you measured, including the spread, the limits, and the parts you could not check. Everything in this guidebook (the five blanks, the baseline, the false-alarm rate, the "not run here" boxes, the corrections) is one habit applied in different places: tell the truth about what you know and how you know it. A person who has that habit will make fast programs, will be trusted by maintainers, and will teach well. Skills change with each generation of hardware. The habit does not.

What would make this chapter wrong? Every project has its own culture. Some want small pull requests, some want a design discussion first, some do not accept performance changes without a benchmark in their own CI. Read the project's contribution guide, and ask a maintainer before spending a week. And teaching styles differ: the structure above is one that has worked for me, and a good teacher you know may do it differently.

Common mistakes

  • "2x faster" with no workload, machine, or command.
  • A pull request that changes ten things.
  • Hiding the downsides. Maintainers find them anyway, and then stop trusting the rest.
  • Not adding the benchmark. The next contributor cannot check you.
  • A talk of conclusions. Show the guess and the wrong turn.
  • A live demo with no recorded fallback.
  • Never correcting a published claim.
  • Teaching only what you have heard. If you did not measure it, say so.

Say it simply

A performance contribution should read like a small experiment report: the problem and workload, a profile, a before-and-after table with the machine and the spread, a test showing the result is unchanged, and the costs. Send the benchmark first, and change one thing per pull request. Teach by starting with a surprising number, asking for a guess, showing the wrong turn, and ending with one thing to try. Name what you did not check, and correct what you got wrong: this guidebook corrected itself in five places, each found by one of its own habits.

Check yourself

Exercise 40.1 (Warm-up): Critique a claim. Apply the ten questions to this sentence from a README: "Our parser is 3x faster than the competition (benchmarked on a MacBook, 100 iterations)." List the five questions you would ask first.

Exercise 40.2 (Warm-up): Rewrite. Rewrite this pull request title and first line: "Optimize hot path. This makes things much faster." Include the problem, the evidence, and a limit.

Exercise 40.3 (Core): Write the evidence table. Take an optimization you made in this guidebook's exercises (or any of your own). Fill in the template of this chapter: problem, change, evidence table with machine and command, correctness check, costs. Done when: a one-page description that a stranger could run and check.

Exercise 40.4 (Core): Break each other's work. Exchange your description with a friend. Each of you tries to reproduce the other's table on your own machine. Where do the numbers differ, and why? Write down what the description should have said.

Exercise 40.5 (Core): The 5-minute explanation. Explain one idea of this guidebook (for example, why walking a matrix by columns is 16 times slower) to someone who has not read it, using one number and one picture, in five minutes. Record where they get lost.

Exercise 40.6 (Stretch): A real contribution. Find a real performance issue in an open-source Rust project, reproduce it with a minimal benchmark, and either submit the benchmark and profile as an issue, or a patch with the evidence table. Report what the maintainers answered.

Exercise 40.7 (Stretch): Run the workshop. Run the 45-minute workshop from the teaching kit with at least three people. Which exercise took longest? Which prediction did most people get wrong? Improve the material from what you saw.

Exercise 40.8 (Stretch): Design and defend. You are asked to give a 40-minute talk to 500 engineers on "Rust performance". You may show only three numbers. Which three, in what order, and what is the story that connects them?

What a strong answer includes: a surprising first number (such as the matrix walk or the layout change), a wrong guess and its correction (such as the hash map against the allocator), a final number that gives the audience something to act on, and an honest statement of a limit.


Part IX wrap-up

What you can now do:

  • Treat performance as a promise that a project checks: exact gates for allocations and instructions, careful interleaved timing, and a history for slow creep.
  • Compute the false-alarm rate of a suite, and design a gate with a measured false-alarm and detection rate.
  • Change code for speed with a check that can fail: an oracle, differential tests aimed at the optimization's assumptions, and a shrinker.
  • Write a performance contribution as a small, checkable experiment report.
  • Read other people's benchmarks with ten questions, and apply them to your own.
  • Teach the material at four sizes, and say what you did not check.

Mini project: Ship one real improvement (10 to 16 hours). Choose a real piece of Rust code (an open-source crate you use, or your own project of more than 500 lines). Carry out the whole discipline of this guidebook, and produce one pull request or one report that a maintainer could merge:

  1. Measure: a baseline on a written-down workload, with a counting allocator for allocations and a Criterion or interleaved benchmark for time.
  2. Profile: two kinds of evidence, with the top cost named.
  3. Check: a differential test against the old code, aimed at the assumptions of your change, and a shrinker.
  4. Change: one thing at a time, with a measurement after each.
  5. Guard: an allocation or instruction test, and a timing check with your measured false-alarm rate.
  6. Report: the evidence template of Chapter 40, with the machine, the spread, the correctness check, the costs, and at least one thing you could not verify.
  7. Review: give it to another person and ask them to break it.

Done when: the description reproduces on a second machine within the stated spread, the differential test has run on at least a million cases, and the report lists at least one honest limit.

Write-up prompt: Half a page. What did your own test find that you would not have found by reading the code?

Teach it back: In 5 sentences, explain to a friend why a speed-up that "passes all the tests" can still be wrong, and what kind of test would have caught it.

Reading list for Part IX:

  • David Daly, William Brown, Henrik Ingo, Jim O'Leary, and David Bradford, "The Use of Change Point Detection to Identify Software Performance Regressions in a Continuous Integration System", ACM/SPEC ICPE 2020, Edmonton (checked in this session: title, authors, venue, year, and the abstract, which describes the move from manual inspection to thresholds to change point detection).
  • David Daly, "Creating a Virtuous Cycle in Performance Testing at MongoDB", ACM/SPEC ICPE 2021 (checked: title, author, venue, year). How a performance CI is organized in practice.
  • Edd Barrett, Carl Friedrich Bolz-Tereick, Rebecca Killick, Sarah Mount, and Laurence Tratt, "Virtual Machine Warmup Blows Hot and Cold", arXiv 1602.00602, 2017 (checked: title, authors, year; I know it was published at OOPSLA 2017 but did not check this). An empirical study of how benchmarks of language VMs fail to reach a steady state, with changepoint analysis.
  • Andy Georges, Dries Buytaert, and Lieven Eeckhout, "Statistically Rigorous Java Performance Evaluation", OOPSLA 2007. The classic argument for measuring with confidence intervals and not single numbers. (Not freshly checked.)
  • The documentation of proptest, quickcheck, cargo fuzz, and Miri, for the tools of Chapter 39. (Not freshly checked.)
  • The Rust Performance Book, by Nicholas Nethercote and contributors, and the contribution guide of any project you work on. (Not freshly checked.)
  • Mytkowicz and co-authors, "Producing Wrong Data Without Doing Anything Obviously Wrong!", ASPLOS 2009: the paper of Chapter 1, and still the best reason for everything in this Part.

Verification notes for Part IX: Run in this session on the 1-core Xeon VM: the gate experiment (4 true sizes times 60 trials, separate and interleaved designs, thresholds and the two-in-a-row rule, built with rustc 1.91.1); the Cachegrind instruction counts for the baseline and three injected regressions, and for the sequential and shuffled gather (with wall-clock times, best of 7); the counting-allocator comparison (0 against 8,000 allocations in 1,000 calls); the differential tests of the word counter (200,000 and then 2,000,000 random ASCII inputs, and 200,000 random Unicode inputs from a small alphabet), the fix of the vertical-tab bug, and the re-measurement of the fixed fast version (135.2 ms, median of 5). Checked against sources in this session: the Daly et al. ICPE 2020 paper (title, authors, venue, year, and what its abstract says), the Daly ICPE 2021 paper (title, venue), and the Barrett et al. warmup paper (title, authors, arXiv number, year). Not verified: the Georges, Buytaert, and Eeckhout reference; the existence and details of the tools proptest, quickcheck, cargo fuzz, and Miri beyond my knowledge of them (none was run here); that the Barrett paper appeared at OOPSLA 2017; the claim that a "fix" for the sigma difference would be the simple one I describe (not implemented); the Rust Performance Book reference. Limits: the gate experiment used one function, one fixed-size injected slowdown, one drifting VM, and 60 trials per case (a measured 0% means "probably below 5%"); the instruction-count example is a toy; the differential tests cover small random strings, and 0 disagreements is evidence and not proof. Known open item: the unexplained 47% slowdown of Chapter 4. Corrections made elsewhere because of this Part: the text of Chapter 23 (the claim of identical output, the code of the whitespace test) and the introduction of Part V.


Capstone projects

These eleven projects test the whole guidebook. Each one needs ideas from at least three different Parts, and none of them comes with a recipe. You get a goal, requirements, and constraints. You design the solution, build it, measure it, and defend it.

How to use them. Do not do them in one sitting, and do not do all of them. Pick by what you want to be able to do:

  • Build a real system fast: projects 1, 2, 3.
  • Build tools that other people use: projects 4, 7.
  • Find out why something is slow: projects 5, 6.
  • Design and defend: projects 8, 9.
  • Teach and contribute: projects 10, 11.

Every project ends with a write-up of half a page to two pages. The write-up is part of the work, because explaining the trade-offs (the philosophy layer) is what proves you understand them. In every project, keep the habits of this guidebook: write the question and the hypothesis first, check correctness against a reference, measure with a method you can defend, and say what you did not verify.

Coverage. Every Part of the guidebook is used by at least two projects. Difficulty and rough time are marked on each one.

#ProjectKindParts usedLevel
1A fast parser, provenbuildI, II, IV, V, VII, IXHard, 2 to 3 weekends
2A bytecode VM, built up in stepsbuildI, III, IV, VII, VIII, IXHard, 2 to 3 weekends
3Matrix multiplication against a librarybuildI, III, IV, VI, VII, VIIIVery hard, 3 weekends
4machine-report: your machine, measuredtoolI, III, VI, VIIIMedium, 1 to 2 weekends
5The slow service: a root-cause investigationinvestigateI, III, IV, V, VIMedium, 1 to 2 weekends
6The layout lotteryinvestigate and reproduceI, II, III, IV, VIIIHard, 1 to 2 weekends
7A performance gate you can trusttool and productionI, II, IV, V, VIII, IXHard, 2 weekends
8A compiler front end: layout and allocationdesign and defendIII, V, VII, IXHard, 2 weekends
9A parallel log aggregatordesign and defendIII, IV, VI, IXHard, 2 weekends (needs a multi-core machine)
10Teach it: workshop and talkteachallMedium, 1 to 2 weekends
11The performance audit: a real contributionmasteryallVery hard, 3 to 4 weekends

Capstone 1: A fast parser, proven

What you make: A parser for a text format you choose (a JSON subset, CSV with quoting, a log-line format, or the configuration format of a tool you use), and a written proof that it is correct and fast.

Why it matters: Parsers are on the critical path of compilers, servers, and data tools, and this project exercises scanning, allocation, and number conversion together (Chapter 31).

Ideas you will use: Measuring honestly (I), Criterion and groups (II), flamegraphs and allocation profiles (IV), arenas, borrowing, and with_capacity (V), parsers and speed of light (VII), differential tests and gates (IX).

Requirements

  1. A plain, obviously correct parser first (it becomes your oracle).
  2. A fast parser that returns the same result on every valid input, and a defined behavior (error, not panic) on invalid input.
  3. A workload of at least three inputs of different shapes and sizes, written down before you measure (Chapter 3).
  4. A "speed of light" number: how fast can a bare scan of the same bytes run on your machine?
  5. At least three measured improvements, each with its time, its MB/s, and its allocation count (use a counting allocator).
  6. A differential test against the oracle on at least one million random inputs, with a shrinker, aimed at the assumptions of each fast path.

Constraints: No parsing crates in the fast version (you may use one as a reference, as serde_json was in Chapter 31). Every claim must come from a run you did. Release builds only.

Done when:

  • A table like the one in Chapter 31: time, MB/s, allocations, for every version, and the speed-of-light scan.
  • A profile of your fastest version, with a note on what is left (parsing, tree building, allocator, page faults).
  • The differential test has run on 1,000,000 cases with 0 disagreements, and one bug it found along the way is described.
  • You can say how far you are from the speed of light, and why.

Make it great (stretch): Parse straight into a typed struct without building a tree. Add a SIMD structure scanner (Chapter 28). Add an allocation-count test and an instruction-count gate (Chapter 38).

Write-up: Which change gave the most? Which one did you expect to give the most and did not? What assumption did your differential test attack, and what did it find?

What a strong result looks like:

  • Correctness evidence that could have failed, with a bug that it did find.
  • Gains explained by allocation counts and profiles, not by guesses.
  • An honest statement of the gap to the speed of light.
  • A note on what changes for messy, real-world input.

Capstone 2: A bytecode VM, built up in steps

What you make: An interpreter for a small language (integers, variables, while, if, and functions) in four versions: a tree walker with variables in a hash map, the same with resolved slots, a stack VM, and a register VM.

Why it matters: This is the structure of most language implementations, and the gaps between the versions are large and measurable (Chapter 32).

Ideas you will use: Measuring (I), branch prediction and dependency chains (III), profiling (IV), interpreters (VII), cycles and the cost of a dispatch (VIII), a gate and a differential test (IX).

Requirements

  1. Four implementations that return the same answer on every program, checked by a differential test on at least 10,000 random programs (generate random expression trees and loops).
  2. At least three benchmark programs: a tight arithmetic loop, a loop with function calls (recursive fib), and one that you design to stress something different.
  3. A table in nanoseconds per loop iteration and per bytecode instruction, and cycles per instruction using the clock you measured (Chapter 37).
  4. The number of instructions executed per iteration for each bytecode design, and a check that time follows instruction count.
  5. At least two optimizations beyond the four versions (a fused instruction, a loop inversion, a constant folding pass, an unchecked access with a justification).
  6. Rebuild the whole benchmark with a different unrelated function added, and report how much each number moved (Chapter 32's layout warning).

Constraints: Safe Rust for the versions you compare. If you use unsafe, isolate it and test it (Chapter 39).

Done when:

  • The table, with medians of at least 7 runs and the ranges.
  • A comparison with the native Rust version of each program.
  • One result that surprised you, with your best explanation and what you did to test it.

Make it great (stretch): Add a closure-compilation version (compile the AST into Rust closures). Add function calls with a call stack. Compare your numbers with a well-known small interpreter.

Write-up: Which design decision gave the biggest gain, and why is that the one a typical first interpreter gets wrong?

What a strong result looks like:

  • The ladder of gains with the cost model "instructions × cost of dispatch" tested, not assumed.
  • A layout-sensitivity measurement, reported honestly.
  • A differential test that really attacks the compiler passes.
  • A clear statement of which of your results depend on the toy programs.

Capstone 3: Matrix multiplication against a library

What you make: A single-threaded f32 matrix multiplication in Rust that gets as close as you can to a tuned library, with a record of every step.

Why it matters: It is the cleanest problem in which all the memory and SIMD ideas meet, and it gives you a strong reference to measure against (Chapter 30).

Ideas you will use: Measuring (I), the memory ladder, cache lines, and layout (III), the roofline and profiling (IV), SIMD and target features (VI), matrix multiplication (VII), cycles and the real clock (VIII).

Requirements

  1. The textbook version, the loop-interchanged version, and at least two blocked or tiled versions, each checked against the textbook result.
  2. A register-tiled kernel with packed panels (Chapter 30's version 4b) and a measurement of what packing gained.
  3. The measured peak of your machine for f32, from the library (so that your "roof" is real), and your clock from Chapter 37, so that you can say what fraction of the theoretical peak you reach.
  4. A kernel with explicit AVX2 and FMA instructions and run-time feature detection, in a function with #[target_feature], with a safe wrapper.
  5. A tuning table of register tile sizes and panel depths, with times.
  6. Results at sizes 256, 512, 1024, and 1040 (to test the power-of-two question), in a default and a native build.

Constraints: No linear algebra crate in your own kernels (use one only as the reference). Report a floating-point tolerance, and test with it.

Done when:

  • A table of GFLOP/s for every version and size, with the library as the last row.
  • Your best result as a percentage of the library's, and of the theoretical peak (with the assumptions stated).
  • A short account of which of Chapter 30's five lessons held on your machine and which did not.

Make it great (stretch): Pack A as well. Add prefetching. Add a second level of blocking for the L3. Parallelize over blocks with rayon, and plot the scaling (needs several cores).

Write-up: Where is your remaining gap to the library, in your best explanation, and how would you test that explanation?

What a strong result looks like:

  • Every step is measured, including steps that did not help.
  • The reference defines the roof, not a guess.
  • The gap is explained in terms of the memory ladder and the instruction mix.
  • Correctness is checked with the floating-point order in mind.

Capstone 4: machine-report, your machine measured

What you make: A command-line tool that prints a one-page report of what your machine really does, from measurements: the clock, instruction latencies, the memory ladder, cache line size, TLB reach, branch misprediction cost, counter availability, and the machine's own noise.

Why it matters: It turns the guidebook's habit, measure instead of assume, into a tool that you can run on any machine before you trust a benchmark there (Chapter 37 and the Part VIII mini project).

Ideas you will use: Measuring (I), the machine (III), SIMD and atomics prices (VI), clocks, counters, and cycles (VIII).

Requirements

  1. The core clock from two dependent chains of different latencies, the TSC rate, and the range of the clock over 60 windows.
  2. The latency in cycles of integer add, multiply, floating-point add and multiply, and an L1 load.
  3. The latency staircase and the bandwidth staircase, with the detected sizes of L1, L2, and L3.
  4. The cache line size from a stride test, and the first-level TLB size from a packed-against-spread test.
  5. The cost of an atomic read-modify-write, a mutex, and an Arc::clone (Chapter 26).
  6. The cost of one branch misprediction in ns and cycles.
  7. Whether perf_event_open works for hardware events, and the value of perf_event_paranoid.
  8. The machine's own wobble: 30 repeats of a fixed benchmark and the lag-1 autocorrelation of their times.

Constraints: One binary, under two minutes, no root needed (report what needs it). Detect and report an impossible value instead of printing it (Chapter 37's 15 GHz).

Done when:

  • It runs on at least two different machines, and you wrote a paragraph about what differs.
  • Every number has a sanity check next to it (for example, two clock estimates that agree within 5%).

Make it great (stretch): Detect the associativity of L1 from a set-conflict test. Add NUMA topology from /sys. Output JSON for a dashboard.

Write-up: Which number on your machine differed most from what you would have assumed? What does that mean for a benchmark you ran there last year?

What a strong result looks like:

  • Robust methods (best of N, medians) and explicit uncertainty.
  • Impossible values caught.
  • A useful comparison between two real machines.

Capstone 5: The slow service, a root-cause investigation

What you make: A written root-cause analysis of a slow program that someone else (or you, in advance, in secret) prepared.

Why it matters: Most performance work is finding out what is slow, with evidence, in code you did not write.

Ideas you will use: Measuring (I), the machine (III), profiling with several kinds of evidence (IV), allocation and page faults (V), contention (VI).

Setup. Write, or ask a friend to write, a log analyzer in Rust of 200 to 400 lines: it reads a large generated log, parses lines, groups by a key, keeps per-key statistics in a shared structure, and prints the top 10. Plant at least three performance problems from this guidebook, without telling the investigator: for example, a String allocation per token (Part V), a HashMap keyed by String that is cloned on every lookup (Chapter 22), a Vec that grows without with_capacity, a column-major walk of a table (Chapter 9), or a Mutex held across a slow call (Chapter 26). If you do it alone, plant them, then wait a week before you investigate.

Requirements

  1. A baseline with the five blanks filled, on a written-down workload.
  2. A written hypothesis for the top cost before profiling.
  3. At least three kinds of evidence: a flamegraph or perf profile, an allocation profile (counting allocator or DHAT), and one of: event counts, a timeline, or Cachegrind.
  4. For each problem found: the evidence, the mechanism (the why), the fix, and the measured gain.
  5. A profile again after each fix, and a note when the top cost moved.
  6. A list of the problems you did not find or fix, and the most you could still gain (Amdahl's law).

Constraints: Do not read the source for hints until you have a profile. Release builds with debug info.

Done when:

  • Every planted problem is found, or you can say why you missed it.
  • The final version is at least 3x faster, with identical output, checked by a differential test.

Make it great (stretch): Compare with a faster-allocator-only fix and say what it would have bought. Add a performance gate (Chapter 38) that would have caught each planted problem.

Write-up: Which of your first hypotheses was wrong, and what in the evidence told you?

What a strong result looks like:

  • Evidence before conclusions, from at least two independent kinds.
  • The order of fixes follows the profiles.
  • A candid account of what the profile could not tell.

Capstone 6: The layout lottery

What you make: A controlled experiment that reproduces and explains the "same source, different speed" effects of this guidebook: the 47% slower second closure (Chapter 4) and the 40% swing in an interpreter row (Chapter 32).

Why it matters: The effect is real, it is easy to miss, and almost nobody checks for it. It was the subject of the paper in Chapter 1 (Mytkowicz and co-authors), and the guidebook left two instances unexplained.

Ideas you will use: Measuring and noise (I), Criterion and its assumptions (II), the machine and the branch predictor (III), perf and Callgrind (IV), the instruction-level view and clocks (VIII).

Requirements

  1. Reproduce one of the two effects on your machine (or find a new instance), with a benchmark that holds the source of the measured function constant.
  2. Build the program at least 20 times, each with a different unrelated change (an unused function of a different size, a different order of functions, an environment variable of different length at run time), and record the measured time of the unchanged function in each build.
  3. Report the distribution of those 20 numbers: median, range, and a plot or histogram.
  4. Find at least one mechanism with evidence: compare the machine code (--emit asm) of the function in a fast and a slow build, check the alignment of the hot loop, and test the hypothesis by forcing the alignment (for example with -C llvm-args=-align-all-nofallthru-blocks=5 or by inserting padding, if your compiler supports it).
  5. Propose a measurement procedure that is robust to the effect, and test that it is.

Constraints: Everything must be reproducible from a script. If you find no mechanism, report what you excluded.

Done when:

  • A table of 20 builds with the spread.
  • Either a confirmed mechanism with a test that could have failed, or an honest list of what you ruled out.
  • A recommended procedure (for example, report the median over several builds) and a measurement of how much it narrows the spread.

Make it great (stretch): Try other machines or compilers. Test whether -C target-cpu=native changes the spread. Build a tool that builds N variants and reports the layout spread of any benchmark.

Write-up: Why does a 5% difference measured on one build deserve less trust than it looks?

What a strong result looks like:

  • A real distribution, not one number.
  • A hypothesis tested by an intervention.
  • A practical rule, with its own measurement.

Capstone 7: A performance gate you can trust

What you make: A command-line tool and a CI recipe that compare two versions of a Rust project and report regressions with a measured false-alarm rate and detection rate.

Why it matters: A gate that cries wolf gets ignored. Chapter 38 showed the shape of the problem, and this project turns it into a tool.

Ideas you will use: Measuring (I), Criterion's statistics (II), Callgrind and counting (IV), allocation counting (V), what the tools assume (VIII), gates and history (IX).

Requirements

  1. Three kinds of check: an allocation-count test, an instruction-count comparison (with Callgrind or Cachegrind), and an interleaved timing comparison that returns the ratio of medians with a bootstrap interval.
  2. A repeat-before-fail rule for timing.
  3. A measurement of the tool's own false-alarm rate (the same code against itself, at least 100 trials) and detection rate (injected slowdowns of 1%, 2%, 5%, 10%, at least 60 trials each).
  4. Output that includes the provenance of Chapter 7: compiler version, machine, commit, and command.
  5. Storage of results in a file per commit, and a command that prints the history of one benchmark with the best value of the last N commits (a ratchet).
  6. A test that the tool catches a locality-only regression that the instruction check misses (Chapter 38's gather).

Constraints: No network service needed: files and a shell script. The tool must work on a noisy machine and must say when a result is too noisy to decide.

Done when:

  • A table of false-alarm and detection rates for each check, measured.
  • A demonstration on a real project: an injected regression caught by the right check, and a harmless change passing.
  • A section of the README that explains in plain words what a "fire" means.

Make it great (stretch): Add change point detection on the history (Chapter 38's reading). Add a bisect helper that finds the commit for a flagged benchmark.

Write-up: What false-alarm rate did you accept, and why? Which check would you block merges on, and which would you only report?

What a strong result looks like:

  • Measured rates, not intentions.
  • Layers, with exact gates first.
  • Honest limits (noise floor, benchmarks it cannot judge).

Capstone 8: A compiler front end, layout and allocation

What you make: The front end of a small compiler (lexer, parser, AST, symbol table, and one analysis pass) in two designs, measured, and a written decision between them.

Why it matters: The structure of data in a compiler decides most of its speed, and the choice is made early and is hard to change (Chapter 33).

Ideas you will use: Layout and boxes (III), arenas and interning (V), compiler data structures (VII), differential tests and gates (IX).

Requirements

  1. A small language with variables, arithmetic, let, if, and functions, at least 20 constructs in total.
  2. Design A: Box-based AST with String identifiers. Design B: index arena with interned symbols (and, optionally, design C with a bump arena).
  3. A generator of large programs (at least 500,000 nodes) with a fixed seed, and three passes: parse, resolve names, and a bottom-up analysis (constant folding or type check).
  4. A table of build, each pass, and drop time, memory, and nodes per second for every design, in median of 5.
  5. A post-order loop version of the bottom-up pass (Chapter 33), and a recursive one.
  6. A differential test that both designs give identical analysis results on 100,000 random programs.
  7. A written decision for a compiler that you would ship, with the two measurements that would change your mind.

Constraints: No compiler framework crates. Report memory per node.

Done when:

  • The table, the decision, and the differential test.
  • A measured answer to "what does the arena give up?" (for example, the cost of editing the tree).

Make it great (stretch): Add hash-consing of types. Add incremental re-analysis after an edit. Compare with a real parser generator's output.

Write-up: Which assumption of an arena design would break first as the language grows?

What a strong result looks like:

  • Measured differences in each phase, not only in the total.
  • A decision with conditions under which it flips.
  • Ergonomics weighed honestly alongside speed.

Capstone 9: A parallel log aggregator

What you make: A program that reads a large log and aggregates statistics by key, in a sequential version, a shared-state parallel version, and a shared-nothing parallel version, with a scaling study.

Why it matters: This is the typical shape of data-parallel work, and it exposes contention, false sharing, and NUMA directly (Part VI).

Ideas you will use: Memory and layout (III), profiling parallel programs (IV), atomics, false sharing, NUMA, SIMD, and thread pools (VI), gates and honest limits (IX).

Requirements (needs a machine with at least 4 cores; if you have only one, do the single-core variant below)

  1. A sequential baseline with the speed-of-light scan of the same bytes.
  2. A shared-state version (one Mutex<HashMap>), a sharded version (an array of locks selected by hash), and a shared-nothing version (per-thread maps merged at the end).
  3. Scaling curves for 1 to the number of cores, with the speed-up, the efficiency, and the serial fraction computed from Amdahl's law.
  4. A false-sharing experiment: per-thread counters adjacent and padded (Chapter 25), on your hardware.
  5. A grain-size study: chunk sizes from 10 KB to 100 MB, and the point where overhead appears.
  6. If you have a multi-socket machine: first-touch placement and numactl local against remote runs. If you have perf c2c, use it on the adjacent-counters case.
  7. A profile that shows where the time goes in each version, and the hypothesis you wrote first.

Single-core variant: Do items 1, 2, and 5, and compute (do not measure) what Amdahl's law would give; mark every multi-core result "not run" as this guidebook does, and run the programs on someone's multi-core machine to fill the gaps.

Constraints: Output must be identical in all versions (check with a differential test, including inputs where a chunk edge would cut a record).

Done when:

  • A table and a plot of scaling for every version.
  • Each deviation from the ideal speed-up explained by evidence.
  • A recommendation for which design to ship, with the conditions that would change it.

Make it great (stretch): Parallelize the merge. Use SIMD to split lines. Compare with a different thread pool or with async I/O.

Write-up: Which assumption about parallel hardware failed on your machine?

What a strong result looks like:

  • Real scaling data with the hardware named.
  • Shared state measured, not just warned against.
  • The serial fraction found and discussed.

Capstone 10: Teach it, a workshop and a talk

What you make: A 45-minute hands-on workshop and a 40-minute talk on one topic of this guidebook, delivered to real people, with feedback.

Why it matters: Teaching is the test of understanding, and the reason this guidebook exists: you should be able to give a talk to 500 people. (Chapter 40 and the teaching kit.)

Ideas you will use: All Parts. Pick one core topic (for example the memory ladder, profiling, or allocation) and reach into at least three Parts.

Requirements

  1. A one-sentence promise: what the audience will be able to do afterward.
  2. A 5-minute version, tested on one person who had not read the guidebook, with notes on where they got lost.
  3. A workshop with at least three exercises (prediction, guided measurement, break-it), starter code that compiles, and an answer sheet.
  4. A talk outline with one surprising number, one wrong guess shown, a recorded fallback for every live demo, and one thing the audience can do on Monday.
  5. Real numbers from your own machine on every slide, with the machine named, and one slide that says what you did not verify.
  6. A list of the ten hardest questions you expect and your answers (use the teaching kit's list as a start, and add your own).
  7. Delivery to at least five people (workshop) and a recorded or live run of the talk to at least ten, with written feedback.

Constraints: No fabricated output or slide numbers you did not measure. Plain words: every technical term defined on first use.

Done when:

  • Materials another person could use to teach it.
  • The feedback summary and what you changed.

Make it great (stretch): Submit the talk to a meetup or a conference. Record it, and publish the materials with a correction log.

Write-up: What did the audience misunderstand, and what does that say about how you understood it?

What a strong result looks like:

  • A clear arc, with a wrong turn shown.
  • Exercises that people could do and get stuck on in useful ways.
  • Evidence of change after feedback.

Capstone 11: The performance audit, a real contribution

What you make: A performance audit of a real open-source Rust crate or application (at least 2,000 lines), with a prioritized report and at least one contribution (a benchmark, a patch, or a well-evidenced issue) sent to its maintainers.

Why it matters: This is the whole discipline in one project: measure, find, explain, fix, guard, and communicate (Chapters 38 to 40).

Ideas you will use: All Parts.

Requirements

  1. Choose the project, read its contribution guide, and write down the workloads that represent real use (from its issues, examples, or your own use).
  2. A baseline with provenance (machine, compiler, flags, commit, command) and Criterion or interleaved benchmarks, with the project's own benchmarks if it has them.
  3. A profile with at least two kinds of evidence, a flamegraph, an allocation profile, and (if available) counters or Cachegrind.
  4. A ranked list of the findings, each with the share of time (with the margin of error, Chapter 15), the mechanism, a proposed fix, and the most it could gain by Amdahl's law.
  5. At least one fix implemented, with a differential test against the old behavior, an evidence table (the template of Chapter 40), and a guard (an allocation or instruction test, or a benchmark in the repository).
  6. A message to the maintainers that follows their conventions, and a record of their answer.
  7. A list of everything you could not verify, and a correction note for anything you got wrong along the way.

Constraints: Do not claim a result you did not measure. Do not change behavior silently. If the maintainers decline, say so, and summarize why.

Done when:

  • The audit report, the contribution (merged or not), and the maintainers' response.
  • A one-page lessons note: what you would do differently on the next audit.

Make it great (stretch): Land three contributions. Add the project's first performance gate (Chapter 38). Give a talk about the audit (project 10).

Write-up: What is the single most valuable thing you found, and how sure are you? What would you check next?

What a strong result looks like:

  • Evidence that a stranger can reproduce.
  • A fix that preserved behavior, with a test that could have failed.
  • Honest limits, and a correction note.

Teaching kit

Material for the first three sizes of teaching in Chapter 40. Every number below comes from this guidebook, measured on its 1-core Xeon VM (nominal 2.1 GHz, core about 3.3 GHz, rustc 1.75 or 1.91). Put your own machine's numbers on your own slides, and say which machine they are from.

A. The 5-minute explanation: "Why is walking a matrix by columns 16 times slower?"

The promise: After five minutes, your listener can predict which of two loops is faster, and say why in one sentence.

1. The hook (30 seconds). "Same matrix, same additions. One loop takes 9 milliseconds. The other takes 153. Sixteen times slower, with the same math. Which one, and why?" (Numbers from Chapter 9: 9.42 ms and 152.79 ms, a 4096 by 4096 matrix of 4-byte numbers.)

2. Ask for a guess (30 seconds). "Rows first, or columns first? Hands up." Most people guess right. The why is the lesson.

3. The one picture (2 minutes). Draw memory as a long strip, cut into boxes of 64 bytes. "The CPU does not read a number. It reads a whole box. Sixty-four bytes. That is sixteen 4-byte numbers." Draw a row walk: one box gives sixteen useful numbers. Draw a column walk: one box gives one useful number and fifteen wasted ones, and by the time you come back for the next number in that box, the box is gone.

4. The rule (1 minute). "Count the boxes you touch, not the numbers. The row walk touches 1 box per 16 numbers. The column walk touches 16 boxes per 16 numbers." Name the term once: cache line.

5. The limit and the Monday task (1 minute). "The exact size changes with the machine. I measured this on one cloud machine. On yours, the ratio will differ, and the direction will not." The task: "Find a loop in your own code that walks a table. Check which index changes fastest in memory."

Check for understanding: Ask them to explain it to the next person. If they cannot, the picture was not clear enough.

B. The 45-minute workshop: "Measure, find, fix"

Audience: 10 to 30 people with a laptop each, Rust installed. Promise: by the end, each person has measured a surprising gap, found where a program's time goes, and made one fix with a number to show for it.

Preparation (do it a week before). Run every program on the workshop machine type. Save the outputs to a file. Send people a starter repository that already compiles (no one should wait for downloads: vendor the crates), with three programs: the matrix walk (Chapter 9), the pointer chase (Chapter 8), and the word counter (Chapter 14).

MinutesActivityWhat people do
0 to 5HookSee the 3,560 ms against 16 ms matrix numbers (Chapter 30). "By the end you will know why."
5 to 12Exercise 1: predict, then measureEach person writes down, on paper, which of rows or columns is faster and by how much. Then runs the matrix walk. Compare. (Exercise 9.4.)
12 to 22Exercise 2: find your own ladderRun the pointer chase, plot or list the staircase, and mark where the jumps are. Compare with lscpu. (Exercise 8.4.) Discuss why the L3 may disagree with its report.
22 to 27Break and questions
27 to 40Exercise 3: profile and fixRun the word counter (v0). Record with perf or samply. Find the top cost. Apply one fix (remove the clone, or stream the words), and measure again. (Chapters 14 to 23.)
40 to 45Wrap-up"What did you guess wrong?" Collect answers. Give the Monday task: profile one of your own programs and find the top cost.

Things that go wrong (and what to do).

  • No perf on a laptop or a VM. Use samply, or the instrumentation crate (Chapter 18), and say that this is a real constraint of many machines (Chapter 35).
  • Numbers differ from the slide. Good: say "the shape is what matters", and ask what differs.
  • A person gets a speed-up that is too good. Ask: did the compiler delete the work? (Chapter 1.)
  • Noisy results. Show the spread, and repeat three times (Chapter 2).
  • People finish early. Give the stretch exercise: make the matrix walk fast with loop interchange and measure (Chapter 30).

Answer sheet (for you). Rows beat columns by about 10 to 20 times on a large matrix (16.2 times here). The staircase has flat parts at the cache sizes. For the word counter, the allocator and string handling dominate: removing the key clone and the per-word String gives a large gain (1.6 times here for the first fix of Chapter 23).

C. The talk to 500 people: "The same loop, 200 times apart"

Length: 40 minutes, plus 10 minutes of questions. Promise: the audience will remember one thing, measure before you guess, and will try one thing on Monday.

MinSlide / beatContent, and where the number comes from
0 to 3The number."3,560 milliseconds against 16. Same math, same machine, one core." (Chapter 30: the textbook matrix multiplication against a library.) Promise: "By the end, you will know why."
3 to 8My wrong guess.A program counts words. "The hash map is slow, everyone knows." Show the plan. Then the profile: the hash map was 24.5% of the time, and tokenize 60.6%. (Chapter 16.) The audience sees their own instinct fail.
8 to 13Measuring honestly.The deleted loop: "200 million loop steps in 121 nanoseconds." Impossible. The compiler removed the work. (Chapter 1: 121 ns against 417 ms.) One slide: the five blanks.
13 to 20The ladder.Show the staircase from the pointer chase (Chapter 8: 1.7 ns up to 137 ns). Live demo with a recorded fallback. "Memory is not a box. It is a ladder." Then the matrix: 64-byte lines, 16 numbers, one useful.
20 to 28Find it, fix it, find it again.The flamegraph (Chapter 16). 3.04 allocations per word (Chapter 22). The fixes: 772 to 486 ms, then a second profile showing a new top cost, then 151 ms. (Chapter 23.) "Fix the biggest thing, then look again." Show the hash function fix gave 3%.
28 to 33My bugs."I shipped three mistakes in this material, and each was found by a habit." The 15.7 GHz clock (Chapter 37), the wrong roof (Chapter 19, 30), the vertical tab in the fast version (0.79% wrong, Chapter 39). This is the slide people remember.
33 to 37Machines and cores.One slide: "On one core I could not show you false sharing. Here is what I expect on yours, and how to check." (Chapter 25: the honest null result.)
37 to 40Monday.One command and one question: "Profile one program. Find the top cost. Write your guess first." Give the link to the materials and the correction log.

Slides discipline. One idea per slide. The number, the unit, the machine, and the source on the slide. No chart without an axis label. No claim you did not measure. One slide says what you did not check.

Demos. Every live demo has a recorded copy of a real run on the same machine, in a file you can show in one keystroke. Say "this is from a run this morning". Never fake output.

If you may show only three numbers (Exercise 40.8): (1) 3,560 ms against 16 ms (the surprise). (2) 772 ms to 151 ms on the word counter, after the profile contradicted the first guess (the method). (3) 0.79% (the fast version that passed the checksum was wrong on one random input in 126: why a test must be able to fail).

D. The ten hardest questions, with answers from this guidebook

  1. "Why not just use a faster allocator?" It helps: mimalloc made the original word counter 1.3 times faster for three lines of code. But removing the allocations made it 5 times faster, and then the allocator did not matter. Allocate less first. (Chapter 21.)

  2. "Aren't Rust's bounds checks slow?" It depends on the work next to the check. In a gather they cost about 2%. In a tiny VM instruction (about 1.5 ns of work) removing all checks with unsafe gave 1.65 times, and the safe masking trick did not help. Measure in your loop. (Chapters 24 and 32.)

  3. "Should I use unsafe for speed?" Only with a profile that shows the check matters, a tool that checks it (Miri, sanitizers), a small isolated function, and a comment that says what must be true. The safe fix (hoist the check) often gives most of the gain. (Chapters 24 and 39.)

  4. "How do I know my benchmark isn't lying?" Test it on a known cost; keep the work alive with black_box; look at the spread, not one number; check that the result is possible (121 ns for 200 million steps is not). (Chapters 1 to 4.)

  5. "Does this hold on my machine, or on ARM?" The shapes hold (cache lines, ladders, dependent chains), the numbers do not (line size, clock, latencies). Measure the machine first. (Chapters 8, 37.)

  6. "Why didn't LTO or codegen-units = 1 help?" On our already-fixed word counter, the hot loop was one tight piece of our own code, so there was little for LTO to inline: under 1%. On the original, LTO gave 12%. Fix the data and algorithm first. (Chapter 24.)

  7. "Is a cache line always 64 bytes?" It is on most x86-64 chips. Some chips use other sizes in places, and our stride test showed a 128-byte effect that I attribute (without proof) to a neighbor-line prefetch. Check your chip. (Chapter 9.)

  8. "Can I measure performance in CI?" Yes, with care. A naive gate false-alarmed on 13% of unchanged runs here. Interleaving old and new code caught a 5% regression 98% of the time with 0% false alarms. Add exact gates (allocation and instruction counts), repeat before failing, and keep a history. (Chapter 38.)

  9. "Why did adding threads make it slower?" Possible causes: false sharing (different variables in one 64-byte line), contention on a lock or an atomic (an atomic add cost 21 times a plain add even alone), too-small tasks (a thread costs 14.5 µs), or remote memory on a NUMA machine. Find out which with perf c2c, padding, and a scaling plot. (Chapters 25 to 29.)

  10. "What did you get wrong?" The table in Chapter 40: a compute roof that was the roof of my kernel; cycle counts from the nominal clock instead of the measured one; an "identical output" claim from a checksum that could not fail; a confidence interval I trusted without testing independence; and an unexplained 47% slowdown I could not solve. Showing this is not a weakness. It is the method.


Glossary

Every technical term used in the guidebook, in plain words, with the chapter where it is explained. Terms are in alphabetical order.

Allocator. The library that hands out pieces of heap memory (malloc and free). Chapter 21.

Amdahl's law. The most a program can speed up when you speed up one part: 1 / ((1 − p) + p/s). Chapters 1, 26, 29.

Amortize. To spread a fixed cost over many items, so each item pays little. Chapter 4.

Arena. A big block of memory from which objects are handed out by moving a pointer, and which is freed all at once. Also called a bump allocator or a region. Chapters 23, 33.

Arithmetic intensity. Floating-point operations done per byte moved from memory. Chapters 19, 30.

Associativity (ways). How many lines a cache set can hold. A 12-way cache holds 12 lines per set. Chapter 9.

Atomic. An operation that other threads see as either not started or finished. Chapter 26.

Auto-vectorization. The compiler turning a scalar loop into SIMD instructions by itself. Chapter 28.

Backend bound. In top-down analysis, slots lost because the core could not finish instructions (waiting for data or a free unit). Chapter 19.

Bandwidth. How much data can flow per second. Chapter 8.

Baseline. A saved result you compare a later run with. Chapter 7.

black_box. A function that tells the compiler to assume a value is used, so it cannot remove the work. Chapter 1.

Bootstrap. A way to get a confidence interval by resampling your own data many times. Chapters 5, 34.

Bounds check. The test that an index is inside an array before it is used. Chapter 24.

Branch. A place where the next instruction depends on a test (an if, a loop condition). Chapter 13.

Branch predictor. The hardware that guesses which way a branch will go. Chapter 13.

Bump allocator. See Arena.

Cache. A small, fast memory that keeps copies of recently used data. Chapter 8.

Cache coherence. The rules that keep copies of a line in different cores' caches consistent. Chapter 25.

Cache line. The 64-byte (on most x86-64 chips) chunk in which memory moves between levels. Chapter 9.

Change point. A point in a time series where the level changes. Chapter 38.

Compare-and-exchange (CAS). An atomic operation that sets a value only if it equals an expected one. Chapter 26.

Confidence interval. A range built by a method that captures the true value in a stated share (like 95%) of repeats of the experiment. Chapter 5.

Context switch. The operating system stopping one thread and running another on a core. Chapter 15.

Core. One processing unit of a CPU. Chapter 25.

Core clock. The clock a core is running at right now, which changes with turbo and power. Chapter 37.

Counting allocator. A global allocator that counts calls and forwards to the real one. Chapter 31.

CPU time. The time the CPU actually worked on your program, not counting waiting. Chapter 1.

Critical section. Code that only one thread may run at a time. Chapter 26.

Cycle. One tick of a clock. See Core clock and TSC. Chapter 37.

Data race. Two threads accessing the same memory without synchronization, at least one writing. Safe Rust does not allow it. Chapter 26.

Dependency chain. A series of operations where each needs the result of the last. Chapter 13.

DHAT. A heap profiler that records every allocation with its call stack. Chapter 22.

Differential testing. Running old and new code on the same inputs and comparing the outputs. Chapter 39.

Dispatch. Finding and starting the code for the next instruction in an interpreter. Chapter 32.

Distribution. The full picture of how a set of measurements is spread. Chapter 2.

Dynamic binary instrumentation (DBI). Changing a program's machine code while it runs, to observe it (what Valgrind does). Chapter 36.

False sharing. Two cores writing different variables that live in the same cache line, making the line bounce. Chapter 25.

First touch. The policy that a memory page lives on the NUMA node of the thread that first writes it. Chapter 27.

Flamegraph. A picture of merged stack samples in which a box's width is the share of time with that function on the stack. Chapter 16.

FMA (fused multiply-add). One instruction that computes a × b + c. Chapter 28.

Folded stacks. A text format with one line per distinct call stack and a count. Chapter 16.

Frame pointer. A register convention that lets a profiler walk the call stack quickly. Chapter 15.

Frequency scaling. A CPU changing its clock speed to save power or avoid heat. Chapter 37.

Frontend bound. In top-down analysis, slots lost because the core could not get instructions fast enough. Chapter 19.

Gate. An automatic check that blocks or flags a change that made something slower. Chapter 38.

Grain size. The size of a piece of parallel work. Too small drowns in overhead. Chapter 29.

Hardware counter. A register in the CPU that counts events (cycles, cache misses, and so on). Chapter 35.

Heap. The memory that the allocator manages. Chapter 21.

HITM. A load that hit a line modified in another core's cache. The signature of line bouncing. Chapter 27.

Hot/cold split. Keeping often-used fields together and rarely-used ones apart. Chapter 11.

Huge page. A 2 MiB (or 1 GiB) page, so one TLB entry covers more memory. Chapter 10.

Inclusive time. The share of samples in which a function was anywhere on the stack. Chapter 14.

Inlining. Copying a function's body into its caller. Chapter 24.

Instruction latency. The cycles from the start of an instruction to when its result is usable. Chapter 13.

Instruction throughput. How many instructions of a kind can start per cycle. Chapter 13.

Interleaving. Alternating batches of two versions in one process so that drift hits both equally. Chapters 4, 38.

Interning. Storing each distinct value once and referring to it by a small id. Chapter 23.

Interrupt. A signal that makes the CPU stop and handle an event. Chapter 2.

IPC. Instructions retired per cycle. Chapter 15.

L1, L2, L3. The levels of the cache, from smallest and fastest. Chapter 8.

Lane. One slot in a SIMD register. Chapter 28.

Latency. How long one thing takes from start to finish. Chapters 1, 8.

Lock. A tool that lets only one thread at a time into a critical section (a Mutex). Chapter 26.

Locality. The data you use is close in time (temporal) or in space (spatial). Chapter 9.

LTO. Link-time optimization: letting the optimizer see across crates. Chapter 24.

MAD. Median absolute deviation, a spread measure that ignores outliers. Chapter 2.

Median. The middle value of a sorted set. Chapter 2.

Memory ordering. The rules for what reordering of memory operations other threads may see. Chapter 26.

MESI. A cache coherence protocol with four line states: Modified, Exclusive, Shared, Invalid. Chapter 25.

Memory-level parallelism (MLP). Many memory requests in flight at once. Chapter 12.

Monotonic clock. A clock that never goes backwards (Instant). Chapter 2.

Multiplexing. The kernel rotating events over too few counters and scaling the counts. Chapter 35.

Noise. Changes in a measured time that are not caused by your code. Chapter 2.

NUMA. Non-uniform memory access: memory is near or far depending on the core. Chapter 27.

Observer effect. Measuring changes what you measure. Chapter 14.

Oracle. A trusted answer to compare with (often the old, slow code). Chapter 39.

Out-of-order execution. The CPU running instructions in the order their inputs are ready, not the program order. Chapter 13.

Outlier. A sample far from the others. Chapter 6.

p95, p99. The value below which 95% (or 99%) of samples fall. Chapter 2.

Padding. Extra bytes added to align data or to keep it in its own cache line. Chapters 11, 25.

Page. The unit of virtual memory, usually 4 KiB. Chapter 10.

Page fault. The kernel handling the first touch of a page. Chapters 15, 21.

Page walk. The CPU reading the page tables to translate an address. Chapter 10.

Percentile. See p95. Chapter 2.

perf. The Linux profiler. Chapter 15.

Pipeline. The assembly-line structure of a CPU core. Chapter 13.

PMU. The performance monitoring unit: the part of a CPU that holds the counters. Chapter 35.

Prefetcher. Hardware that fetches data before you ask, by spotting a pattern. Chapter 12.

Profile. A record of where a program spends time or memory. Chapter 14.

Register machine / stack machine. Bytecode designs that name operands in registers, or keep temporaries on a stack. Chapter 32.

Register tile. A small block of results held in registers during a loop. Chapter 30.

Regression (statistical). Fitting a line to data. Criterion's slope is a regression through the origin. Chapter 34.

Regression (performance). A change that made the program slower. Chapter 38.

Resolver. A pass that replaces names by slots before running. Chapter 32.

Retiring. An instruction being finished and made official. Chapters 13, 19.

Roofline. A model where speed is the lower of a compute ceiling and bandwidth times intensity. Chapter 19.

Sampling. Looking at the program at fixed intervals and recording what it does. Chapters 14, 15.

Set. A group of cache lines among which an address can live. Chapter 9.

SIMD. One instruction acting on many values at once. Chapter 28.

Skid. A sample landing a few instructions after the event that caused it. Chapter 35.

Slope. Criterion's estimate of time per iteration. Chapter 34.

SoA / AoS. Struct of arrays and array of structs: two layouts for many records. Chapter 11.

Span. The longest chain of dependent steps in a computation. Chapter 29.

Speculative execution. Running instructions before knowing they are needed. Chapter 13.

Stack (call stack). The chain of function calls that led to the current point. Chapter 14.

Symbol. A small integer standing for an interned name. Chapter 23.

TLB. The cache of address translations. Chapter 10.

Throughput. How much work is done per unit of time. Chapter 1.

Tiling (blocking). Splitting a computation into blocks that fit a fast memory. Chapter 30.

Top-down method. Sorting every lost CPU slot into one of four causes. Chapter 19.

TSC. The time-stamp counter, ticking at a constant rate. Chapter 37.

Turbo. A core running above its labeled speed when there is headroom. Chapter 37.

Wall-clock time. Time as a stopwatch on the wall would show. Chapter 1.

Warm-up. Running code before measuring so that caches and clocks settle. Chapter 5.

Work. The total time if one core did everything. Chapter 29.

Work stealing. A scheduler where idle workers take tasks from busy workers' queues. Chapter 29.

Working set. The data actively used at one time. Chapter 8.

Write-allocate. Fetching a line before writing to it, if it is not in the cache. Chapter 9.

Zero-cost abstraction. An abstraction (such as an iterator) that compiles to the same code as the hand-written version. Chapter 24.


Reading map

Every paper, book, and document recommended in this guidebook, grouped by topic. A ✓ means I checked the title, authors, venue, and year (and, where noted, a claim from the abstract) against a source during this project. A ○ means it comes from my own knowledge and I did not check it. Check a ○ entry before you cite it.

Measuring and statistics

  • ✓ Todd Mytkowicz, Amer Diwan, Matthias Hauswirth, and Peter F. Sweeney, "Producing Wrong Data Without Doing Anything Obviously Wrong!", ASPLOS 2009, pp. 265 to 276. Start here. Tiny, innocent choices change conclusions.
  • ○ John Ousterhout, "Always Measure One Level Deeper", Communications of the ACM, 2018.
  • ○ Andy Georges, Dries Buytaert, and Lieven Eeckhout, "Statistically Rigorous Java Performance Evaluation", OOPSLA 2007.
  • ○ Bradley Efron and Robert J. Tibshirani, An Introduction to the Bootstrap, 1993.
  • ○ John W. Tukey, Exploratory Data Analysis, 1977 (outlier fences).
  • Criterion.rs: the user guide and the source of the version you use (read routine.rs, stats/bivariate/regression.rs, analysis/compare.rs). Source of Chapter 34, read for this guidebook.

The machine: memory, caches, CPUs

  • ○ Ulrich Drepper, What Every Programmer Should Know About Memory, 2007.
  • ○ John L. Hennessy and David A. Patterson, Computer Architecture: A Quantitative Approach.
  • ○ Agner Fog, Optimizing software in C++, Instruction tables, and The microarchitecture of Intel, AMD and VIA CPUs (free, and the best source of per-chip latencies).
  • ○ Intel, 64 and IA-32 Architectures Optimization Reference Manual and Software Developer's Manual; Intel Intrinsics Guide.
  • ○ Brendan Gregg, Systems Performance (2nd edition, 2020).

Profiling and counters

  • ✓ Ahmad Yasin, "A Top-Down Method for Performance Analysis and Counters Architecture", IEEE ISPASS 2014.
  • ○ Samuel Williams, Andrew Waterman, and David Patterson, "Roofline: An Insightful Visual Performance Model for Multicore Architectures", Communications of the ACM, 2009.
  • ○ Brendan Gregg, "The Flame Graph", ACM Queue / Communications of the ACM, 2016.
  • ○ Nicholas Nethercote and Julian Seward, "Valgrind: A Framework for Heavyweight Dynamic Binary Instrumentation", PLDI 2007.
  • The Linux manual page perf_event_open(2), the perf wiki, and the documentation of inferno, samply, hotpath, and dhat.

Memory allocation

  • ✓ Emery D. Berger, Benjamin G. Zorn, and Kathryn S. McKinley, "Reconsidering Custom Memory Allocation", OOPSLA 2002.
  • ✓ Daan Leijen, Benjamin Zorn, and Leonardo de Moura, "Mimalloc: Free List Sharding in Action", Microsoft Research MSR-TR-2019-18, 2019 (also APLAS 2019).
  • ✓ Emery D. Berger, Kathryn S. McKinley, Robert D. Blumofe, and Paul R. Wilson, "Hoard: A Scalable Memory Allocator for Multithreaded Applications", ASPLOS 2000.
  • ○ The Rust Performance Book (Nicholas Nethercote and contributors).

Many cores, atomics, and SIMD

  • ✓ William J. Bolosky and Michael L. Scott, "False Sharing and Its Effect on Shared Memory Performance", USENIX SEDMS IV, 1993.
  • ✓ Tongping Liu and Emery D. Berger, "SHERIFF: Precise Detection and Automatic Mitigation of False Sharing", OOPSLA 2011.
  • ○ Mara Bos, Rust Atomics and Locks, O'Reilly, 2023.
  • ○ Maurice Herlihy and Nir Shavit, The Art of Multiprocessor Programming.
  • ○ Gene M. Amdahl, "Validity of the Single Processor Approach to Achieving Large Scale Computing Capabilities", AFIPS 1967; John L. Gustafson, "Reevaluating Amdahl's Law", Communications of the ACM, 1988.

Applied systems

  • ○ Kazushige Goto and Robert A. van de Geijn, "Anatomy of High-Performance Matrix Multiplication", ACM TOMS, 2008; Field G. Van Zee and Robert A. van de Geijn, "BLIS: A Framework for Rapidly Instantiating BLAS Functionality", ACM TOMS, 2015.
  • ○ Matteo Frigo, Charles E. Leiserson, Harald Prokop, and Sridhar Ramachandran, "Cache-Oblivious Algorithms", FOCS 1999.
  • ○ Geoff Langdale and Daniel Lemire, "Parsing Gigabytes of JSON per Second", The VLDB Journal, 2019; Daniel Lemire, "Number Parsing at a Gigabyte per Second", Software: Practice and Experience, 2021.
  • ✓ Yunhe Shi, Kevin Casey, M. Anton Ertl, and David Gregg, "Virtual Machine Showdown: Stack Versus Registers", ACM TACO 4(4), Article 21, 2008 (checked: a register machine with switch dispatch removed on average more than 46% of executed instructions, and was on average 1.48 times faster on one of their processors).
  • ○ M. Anton Ertl and David Gregg, "The Structure and Performance of Efficient Interpreters", Journal of Instruction-Level Parallelism, 2003; Erven Rohou, Bharath Narasimha Swamy, and André Seznec, "Branch Prediction and the Performance of Interpreters: Don't Trust Folklore", CGO 2015.
  • ✓ Adrian Sampson, "Flattening ASTs (and Other Compiler Data Structures)" (blog post; checked: the flat-array-with-indexes design, 32-bit references, the shared-lifetime argument).
  • ○ Robert Nystrom, Crafting Interpreters, 2021.

Production practice

  • ✓ David Daly, William Brown, Henrik Ingo, Jim O'Leary, and David Bradford, "The Use of Change Point Detection to Identify Software Performance Regressions in a Continuous Integration System", ACM/SPEC ICPE 2020 (checked: the abstract's account of moving from manual inspection to thresholds to change point detection, and the reported drop in false positives).
  • ✓ David Daly, "Creating a Virtuous Cycle in Performance Testing at MongoDB", ACM/SPEC ICPE 2021 (checked: title, author, venue).
  • ✓ Edd Barrett, Carl Friedrich Bolz-Tereick, Rebecca Killick, Sarah Mount, and Laurence Tratt, "Virtual Machine Warmup Blows Hot and Cold", arXiv 1602.00602, 2017 (checked: title, authors, and that it uses changepoint analysis to classify benchmark runs; I believe it appeared at OOPSLA 2017 but did not check).
  • Documentation of proptest, quickcheck, cargo fuzz, and Miri.

End of the guidebook. The corrections log is in Chapter 40, and every Part ends with its own verification notes. If you find a mistake in the numbers or the claims, treat it as the guidebook itself would: reproduce it, measure it, and write the correction down.