Viscosity
Review
Part II · How machines run code · Day 5

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.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • 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):

In order: x += vx, y += vy
Median ns per element, n = 1,000,000 (shorter is faster)
array of objects: median 10.85 ns, p95 16.66 ns per elementarray of objects10.9packed Float64Array: median 3.71 ns, p95 6.12 ns per elementpacked Float64Array3.7 2.9× fasterstruct of arrays: median 3.26 ns, p95 3.80 ns per elementstruct of arrays3.3 3.3× faster
Table view
layoutmedian nsp95 ns
array of objects10.8516.66
packed Float64Array3.716.12
struct of arrays3.263.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.

Reading four fields and writing two, in order.
Shuffled: read x and vx
Median ns per element, n = 1,000,000 (shorter is faster)
array of objects: median 57.48 ns, p95 69.04 ns per elementarray of objects57.5packed Float64Array: median 7.83 ns, p95 8.58 ns per elementpacked Float64Array7.8 7.3× fasterstruct of arrays: median 7.69 ns, p95 8.56 ns per elementstruct of arrays7.7 7.5× faster
Table view
layoutmedian nsp95 ns
array of objects57.4869.04
packed Float64Array7.838.58
struct of arrays7.698.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.

Reading just two fields, in shuffled order: slower for every layout, and dramatically slower for objects.

Less work, more time. Order matters, and the reason is that the CPU is quietly guessing what you will do next.

The prefetcher

Intuition

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.

Step throughThe prefetcher spots a pattern
Ten cache lines. Reads are marked as hits or misses; lines the prefetcher has requested early are marked.walking an array0miss123456789read order: 0misshitfetched early, not read yet
A loop walks a big array that isn’t cached. The first read misses: a full trip to main memory.
1/6
Definition

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:

Try itSame reads, different 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.

Definition

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

Intuition

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:

Step throughAn assembly line for instructions
A pipeline grid: four stages down the side, eight clock ticks across, with instructions moving diagonally through itt1t2t3t4t5t6t7t8fetchdecodeexecutewritei1i1i1i1i2i2i2i2
Without a pipeline, each instruction goes through every stage before the next one starts. Two instructions take 8 ticks, and three of the four stages sit idle at any moment.
1/5
Definition

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”:

Step throughGuessing an if: unsorted vs sorted
for (const v of xs) {
if (v < 50) s += v;
}
A branch predictor guessing the outcome of v < 50 for unsorted and sorted valuesunsortedvalueguessactual123071588644793
This loop has an if inside. The CPU must guess which way it goes long before v < 50 is worked out. Our toy predictor guesses “same as last time”, starting with false.
1/5

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.

Under the hood

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

ExerciseCount the wrong guesses

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));
Extra practiceBe the prefetcher

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 if goes. 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. 1.

    Which loop is the prefetcher most likely to help?

  2. 2.

    Why is following ps[j].x for a random j worse than reading x[j] from a Float64Array?

  3. 3.

    What is the main benefit of a pipeline?

  4. 4.

    The toy predictor (“same as last time”, first guess false) sees: true, true, false, false. How many wrong guesses?

  5. 5.

    What happens to instructions fetched after a branch that was mispredicted?

  6. 6.

    Which change is most likely to make if (v < 50) cheaper inside a loop over many random numbers?

References

  1. [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. [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. [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. [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. [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. [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)