Topic 05

Pipelines, Branch Prediction and Speculation

Hardware

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.

Five instructions in a five-stage pipeline
clock ticks123456789instruction 1FDEMWinstruction 2FDEMWinstruction 3FDEMWinstruction 4FDEMWinstruction 5FDEMWtick 5: all five busyF = fetchD = decodeE = executeM = memoryW = write back

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.

The same 48 bytes, shuffled and sorted
shuffled18 of 48 guessed wrongsorted3 of 48 guessed wrongvalue ≥ 128: branch takennot takenpredictor guessed wrong

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.

Misconceptions
  • "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.
Why It Matters
  • 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.
RelatedCaches and locality the other half of why data order changes speed (Chapter 4)Memory models what reordering means when two threads watch (Chapter 11)Side channels leaks through timing, with no memory bug at all

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