Pipelines, Branch Prediction and Speculation
A modern core does not finish one instruction before starting the next. It works on dozens at once, like an assembly line, and it guesses which way every branch will go so that the line never has to wait. When the guess is right the program flies. When it is wrong, the core throws the guessed work away and starts again.
That guess is why a loop over sorted data can run several times faster than the same loop over shuffled data, with exactly the same number of steps. And in 2018 it is how the industry learned that guessing leaves footprints an attacker can read. This topic follows the line, the guess and the footprints, and prices each.
The Assembly Line
Fetching an instruction, decoding it, executing it, touching memory and writing the result back are separate jobs done by separate circuits. So while one instruction executes, the next is being decoded and the one after that fetched, the way a car factory works on many cars at once, each at a different station. The figure shows five instructions in a five-stage pipeline: at the fifth tick every stage is busy, and five instructions finish in 9 ticks instead of 25.
Real cores go much further. Current designs have pipelines on the order of 15 to 20 stages, and they can fetch, decode and start several instructions in every tick, so a single core keeps dozens, often well over a hundred, instructions in flight at once. None of this is visible to the program, which sees instructions complete one after another in the order it wrote them.
The Stall
The line only flows if every instruction has what it needs. An instruction that needs a result not yet computed, or data not yet loaded from memory, cannot proceed, and in a simple pipeline everything behind it stops too. That is a stall.
Out-of-order execution is the core's answer. It looks ahead in the instruction stream for later instructions that do not depend on the stalled one and runs those in the meantime, then puts the results back in program order. That hides short stalls, a few ticks waiting for an arithmetic result or a nearby cache. It cannot hide a trip to main memory, which costs about a hundred nanoseconds on the latency ladder in Chapter 4, a few hundred ticks, far longer than the core can find independent work to fill.
Branches and the Predictor
At an if statement, or at the end of a loop, the core does not yet know which instruction comes next: the comparison that decides it is still somewhere in the pipeline. Waiting would empty the line on every branch. So the core predicts. A branch predictor remembers the recent history of each branch and guesses from it, and the core keeps fetching and executing along the guessed path.
Modern predictors are right well over 90 percent of the time on typical code, because most branches are regular: a loop branch goes the same way thousands of times, an error check almost never fires. When a guess is wrong, the core discards every instruction it started on the wrong path and refills the line from the right one. That flush costs on the order of 15 to 20 ticks on current cores, about the depth of the pipeline.
Why Sorted Data Runs Faster
Take a loop that adds up every byte whose value is at least 128. Over random bytes the branch is a coin flip, and no predictor can beat a coin. A simple model of a predictor, one that remembers whether the branch was taken recently, guessed wrong on 50.0 percent of a million random bytes. Sort the same bytes first and the branch goes one way for the first half and the other way for the rest. The same model then guessed wrong 3 times in the million, all at the switch.
Compiled, the difference is large. On the laptop this book was written on, that loop took about 3.6 nanoseconds per byte over shuffled data and about 0.4 over sorted data, roughly nine times faster for the same count of steps. This is the effect behind a famous 2012 Stack Overflow question about why processing a sorted array is faster. In CPython 3.15 the same loop took about 24 nanoseconds per byte shuffled and 15 sorted: the effect is still there, and the interpreter's own overhead shrinks it from nine times to about one and a half. It shows most in compiled code, vectorized libraries and database engines.
Speculation and the Spectre Lesson
Executing along a guessed path is called speculative execution. Its results are held back until the guess is confirmed, and thrown away if it was wrong, so the program's visible state is never affected. But the guessed path also loads data, and that data stays in the processor's cache after the results are discarded.
In January 2018 researchers disclosed Spectre, together with a related attack called Meltdown. They showed that an attacker could steer speculation to read a secret on a path that would never really run, and then recover the secret by timing which memory had become fast to read. Spectre affected processors from Intel, AMD and ARM, because speculation is how all fast cores go fast. It is a class of attacks, not a single bug: new variants kept appearing after 2018, and the hardware and software mitigations cost performance on some workloads.
The Price of Unpredictability in Your Code
The same algorithm on data in a predictable order runs faster than on shuffled data. Branch-heavy code over random input, such as parsing, hash lookups and tree walks with random keys, pays for mispredictions on top of its step count. That is one reason a flat scan over an array can beat a tree on small inputs, which Chapter 4 measures.
It is also one reason the constant that Big-O discards, in Chapter 1, differs between two O(n) loops. Two loops with the same class and the same step count can run nine times apart because one of them branches unpredictably, and nothing in the source code says which.
- "The CPU executes my instructions one at a time, in order." It keeps dozens in flight, executes them out of order, and runs ahead of branches it has not resolved. Only the final visible result is in program order for a single thread, and Chapter 11 shows what other threads can see.
- "An if statement is free." A predictable branch nearly is. An unpredictable one costs a pipeline flush of 15 to 20 ticks every time it is guessed wrong, which in a tight compiled loop can exceed the cost of the work inside it.
- "The sorted-array speed-up comes from the algorithm." The step count is identical. The difference is the branch predictor, which is why it disappears when the loop is rewritten without a branch and shrinks sharply in CPython.
- "Spectre was one vendor's bug and it was patched." It affected processors from every major vendor, because speculation is how they all go fast. The fixes are a mix of hardware changes and software mitigations with an ongoing cost, and new variants continued to appear after 2018.
- Process data in an order that keeps branches predictable when a hot compiled loop branches on its values. Sorting or grouping first can pay for itself.
- Measure compiled hot paths on realistic data, not sorted or synthetic data. A benchmark over ordered input hides the mispredictions production will pay for.
- Keep speculative-execution mitigations enabled on machines that run untrusted code. Shared cloud hosts and browsers are the systems they exist to protect.
- Blame the branch predictor last in Python. Interpreter overhead is larger, and the fix is vectorizing, the next topic, rather than reordering branches.
Knowledge Check
A five-stage pipeline runs five instructions that do not depend on each other. Roughly how many ticks do they take?
- 25, five stages for each of the five
- 9, one new instruction entering per tick
- 5, since all five run fully in parallel
- 1, because each stage takes a fifth of a tick
A compiled loop sums the bytes that are at least 128. Why does it run several times faster after the bytes are sorted?
- Sorting lets the loop skip the bytes below 128 entirely
- Sorted bytes fit in the processor cache, shuffled ones do not
- The compiler detects sorted input and removes the if statement
- The branch becomes predictable, so mispredictions nearly vanish
The same sorted-versus-shuffled experiment in CPython shows a much smaller gap than in compiled code. Why?
- The interpreter's own work per step dwarfs the flush cost
- CPython turns the branch predictor off while it is running
- CPython sorts every list internally before looping over it
- Python if statements compile to code with no branches in it
What did Spectre exploit?
- A buffer overflow in operating systems of the time
- Wrong-path results written back to program memory
- Cache contents left behind by discarded speculation
- A design flaw found only in one vendor's processors
Which loop in compiled code will suffer the most from branch mispredictions?
- A loop over a million items with no if statement inside it
- A loop that checks every item for an error that almost never occurs
- A search tree walked with random keys, branch per level
- A loop over sorted values that tests them against one threshold
You got correct