20. Prefetching and branch prediction
A modern CPU spends its time guessing: which memory you will want next, and which way your next if will go. When your data makes the guesses easy, code flies. When it doesn’t, even the fastest chip waits.
- Explain what the hardware prefetcher does, and which access patterns it can follow.
- Explain pointer chasing, and why it defeats the prefetcher.
- Describe a pipeline, and why it forces the CPU to guess the outcome of an if.
- Predict which data makes branch prediction easy, and count a simple predictor’s mistakes.
Same data, different order
Here are two loops over the same million particles. The first goes in
order. The second visits them in a shuffled order, given by an index
array idx:
// in order
for (let i = 0; i < n; i++) {
s += x[i] + vx[i];
}
// shuffled
for (let i = 0; i < n; i++) {
const j = idx[i];
s += x[j] + vx[j];
}
Both read every particle exactly once, so they fetch essentially the
same cache lines (the shuffled one also reads idx). Chapter 19 says they should cost about the same. They don’t. This
book’s benchmark has a shuffled loop like the second one (random-x)
and an in-order loop over the same particles that does more work per
particle (update-xy reads four fields and writes two):
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 10.85 | 16.66 |
| packed Float64Array | 3.71 | 6.12 |
| struct of arrays | 3.26 | 3.80 |
Measured with the repo’s harness (engine/ts/packages/bench) on Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. One fresh process per case. Shared cloud VM: expect noise. Compare ratios, not absolute times.
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 57.48 | 69.04 |
| packed Float64Array | 7.83 | 8.58 |
| struct of arrays | 7.69 | 8.56 |
Measured with the repo’s harness (engine/ts/packages/bench) on Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. One fresh process per case. Shared cloud VM: expect noise. Compare ratios, not absolute times.
Less work, more time. Order matters, and the reason is that the CPU is quietly guessing what you will do next.
The prefetcher
A good warehouse clerk notices that you asked for box 10, then box 11, then box 12. Without waiting to be asked, they put boxes 13, 14 and 15 on the next van. By the time you want them, they’re already on your desk.
The prefetcher is hardware that watches which memory a program reads and fetches the lines it predicts will be needed next, before they are asked for. It follows simple patterns: forwards or backwards, through several arrays at once, with small or medium strides[1]. When it works, the trips to memory still happen, but they overlap with useful work instead of stalling it.
It also answers a question chapter 19 left open. If each line had cost a full trip to memory, around a hundred nanoseconds, the every-8th loop over a million lines would have needed about a tenth of a second. It usually takes far less, because the prefetcher was running ahead. A shuffled order gives it nothing to follow, so every miss is paid in full.
Try it. This reads every element of one big Float64Array exactly once,
first in order and then in a shuffled order:
const n = 1 << 22; // 4M doubles: 32 MB
const x = new Float64Array(n);
const inOrder = new Int32Array(n);
for (let i = 0; i < n; i++) {
inOrder[i] = i;
}
// shuffle a copy (Fisher-Yates)
const shuffled = inOrder.slice();
for (let i = n - 1; i > 0; i--) {
const r = Math.random() * (i + 1);
const j = Math.floor(r);
const t = shuffled[i];
shuffled[i] = shuffled[j];
shuffled[j] = t;
}
function sum(order) {
const t0 = performance.now();
let s = 0;
for (let i = 0; i < n; i++) {
s += x[order[i]];
}
return performance.now() - t0;
}
sum(inOrder);
sum(shuffled); // warm up
const a = sum(inOrder).toFixed(1);
const b = sum(shuffled).toFixed(1);
console.log('in order, ms:', a);
console.log('shuffled, ms:', b);Pointer chasing
Arrays of objects have a second problem, and the chart shows it: the
shuffled loop hurts objects far more than typed arrays. With typed
arrays, the CPU computes the address of x[j] from j straight away,
so it can send off several reads and wait for them all at once[3]. With objects, the address
of ps[j].x is stored inside memory: first load the pointer from the
array, then follow it.
Pointer chasing is following a chain of pointers, where each load needs the address produced by the load before it. The prefetcher can’t predict the addresses, and the CPU can’t start the next load early, so every step waits out the full latency. It is very common in data structures built from many small heap objects[2].
The linked list you summed in chapter 17 is the purest example. Each node tells you where the next one is, so you can only ever be at one node at a time.
The pipeline
A car factory doesn’t build one car from start to finish before starting the next. The line keeps every station busy: while one car gets its wheels, the next gets its doors, and the one behind it its frame.
A CPU does the same with instructions. Step through it:
A pipeline splits running an instruction into stages (fetch,
decode, execute, write back, and on real chips many more) and works on
several instructions at once, each at a different stage[4].
To keep it full at an if, the CPU predicts which way the branch will
go and carries on along that path before it knows. This is branch
prediction. If the guess was wrong, the work done on the wrong path is
thrown away[6].
What makes a branch easy to guess?
Branch predictors learn from history. Branches that almost always go one
way, like a loop’s i < n, are guessed right nearly every time. Branches
with no consistent pattern are where they go wrong[6].
Step through a toy predictor that always guesses “same as last time”:
for (const v of xs) {if (v < 50) s += v;}
Real predictors find far subtler patterns than this toy. But they share its blind spot: random data. A well-known experiment sums the elements below 50 of an array of random numbers. In compiled C++ on a modern CPU, sorting the array first made the loop several times faster, because the branch became predictable[5]. How much of this shows up in JavaScript depends on the machine code the engine generates, so measure before you rely on it.
Look at what a struct-of-arrays loop gives the hardware: an address that
is a simple function of i, so the prefetcher can run ahead; no
pointers to chase; and a loop branch that is taken every time but the
last. An array of objects asks for a pointer load before every field,
and the objects may be anywhere. That is why, in the charts at the top of
this chapter, shuffling cost the objects so much more time per element
than it cost the typed arrays. Viscosity’s rewrite turns
ps[i].x into x[i]: it replaces pointer chasing with arithmetic the
hardware can predict.
Your turn
A toy branch predictor guesses that each if will go the same way it went last time. Its very first guess is false. Write mispredicts(outs), where outs is an array of booleans (what the if actually did each time), that returns how many guesses were wrong.
function mispredicts(outs) {
// your code here
}
const xs = [12, 30, 71, 5, 88];
const outs = xs.map((v) => v < 50);
console.log(mispredicts(outs));Write findStride(xs), where xs is a list of addresses in the order they were read. If every gap between consecutive addresses is the same, return that gap (it may be negative: the reads go backwards). Otherwise, or if there are fewer than two addresses, return null: no pattern to follow.
function findStride(xs) {
// your code here
}
console.log(findStride([0, 8, 16, 24]));What you learned
- The prefetcher fetches lines before you ask, when your reads follow a simple pattern: in order, backwards, or a steady stride.
- Pointer chasing defeats it: each address is only known once the previous load arrives, so every miss is paid in full.
- A pipeline works on many instructions at once, like an assembly line.
- To keep it full, the CPU uses branch prediction: it guesses which
way each
ifgoes. Wrong guesses throw work away. - Predictable data (sorted, or rarely-taken checks) keeps guesses right. Random data doesn’t.
That’s the end of Day 5. Tomorrow: SIMD, how JavaScript stores numbers and objects, and AoS against SoA, measured.
Check your understanding
0/6- 1.
Which loop is the prefetcher most likely to help?
- 2.
Why is following
ps[j].xfor a randomjworse than readingx[j]from a Float64Array? - 3.
What is the main benefit of a pipeline?
- 4.
The toy predictor (“same as last time”, first guess false) sees: true, true, false, false. How many wrong guesses?
- 5.
What happens to instructions fetched after a branch that was mispredicted?
- 6.
Which change is most likely to make
if (v < 50)cheaper inside a loop over many random numbers?
References
- [1] Sergey Slotin. Prefetching, in Algorithms for Modern Hardware. algorithmica.org (open online book). en.algorithmica.org/hpc/cpu-cache/prefetching/ (checked 2026-10-31)
- [2] Sergey Slotin. Memory Latency, in Algorithms for Modern Hardware. algorithmica.org (open online book). en.algorithmica.org/hpc/cpu-cache/latency/ (checked 2026-10-31)
- [3] Sergey Slotin. Memory-Level Parallelism, in Algorithms for Modern Hardware. algorithmica.org (open online book). en.algorithmica.org/hpc/cpu-cache/mlp/ (checked 2026-10-31)
- [4] Sergey Slotin. Instruction-Level Parallelism, in Algorithms for Modern Hardware. algorithmica.org (open online book). en.algorithmica.org/hpc/pipelining/ (checked 2026-10-31)
- [5] Sergey Slotin. The Cost of Branching, in Algorithms for Modern Hardware. algorithmica.org (open online book). en.algorithmica.org/hpc/pipelining/branching/ (checked 2026-10-31)
- [6] Apple Inc. (2025). Optimize CPU performance with Instruments (WWDC25 session 308). Apple Developer. developer.apple.com/videos/play/wwdc2025/308/ (checked 2026-10-31)