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

18. The CPU and its caches

Your processor can add two numbers faster than it can fetch one from memory. Small, fast caches sit in between to hide that gap, and whether your data is already in them decides how fast your code runs.

About 14 minutes, including the exercises and quiz
By the end you will be able to
  • Describe what a CPU does with each instruction, and what registers are for.
  • Explain why caches exist, and put L1, L2, L3 and main memory in order of size and speed.
  • Define latency and a cache miss, and estimate the cost of a few misses.
  • Read a benchmark chart and connect a slowdown to data outgrowing the caches.

The same loop, two speeds

Here’s a loop you have written many times, run over the particles from chapter 3. It adds up every mass:

let s = 0;
for (let i = 0; i < ps.length; i++) {
  s += ps[i].mass;
}

This book’s benchmark ran exactly this loop on a thousand particles and on a million. The work per particle is identical: one read, one add. Yet the time per particle was not.

Sum every mass: 1,000 particles
Median ns per element, n = 1,000 (shorter is faster)
array of objects: median 1.50 ns, p95 2.13 ns per elementarray of objects1.5struct of arrays: median 0.64 ns, p95 0.69 ns per elementstruct of arrays0.6 2.3× faster
Table view
layoutmedian nsp95 ns
array of objects1.502.13
struct of arrays0.640.69

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.

With a thousand particles, every element is cheap.
Sum every mass: 1,000,000 particles
Median ns per element, n = 1,000,000 (shorter is faster)
array of objects: median 8.94 ns, p95 10.81 ns per elementarray of objects8.9struct of arrays: median 1.13 ns, p95 1.66 ns per elementstruct of arrays1.1 7.9× faster
Table view
layoutmedian nsp95 ns
array of objects8.9410.81
struct of arrays1.131.66

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.

The same loop costs several times more per element when there are a million particles. Compare the array-of-objects bars.

Nothing about the code changed. Only the amount of data did. To see why, you need to meet the machine that runs the loop.

The CPU: a very fast, very simple worker

Intuition

Think of the processor as a worker at a desk who can only follow one tiny instruction at a time: “pick up the number at mailbox 16”, “add these two”, “add 8 to that address”. They are astonishingly quick at it. But they can only hold a handful of numbers in their hands at once.

Step through one turn of the loop. The instructions here are made up and simplified, but the shape is real.

Step throughFetch, decode, execute
load r1, [r2] ; r1 = xs[i]
add r0, r0, r1 ; s = s + r1
add r2, r2, 8 ; next element
A CPU with three stages, fetch, decode and execute, three registers, and memory holding xsCPUfetchdecodeexecuteREGISTERSr00r10r216saddressMEMORY16xs[0] = 2.524xs[1] = 432xs[2] = 1.5
The program is itself bytes in memory. First the CPU fetches the next instruction. Registers r0, r1 and r2 are its few tiny boxes of storage: r0 is our sum s, r2 holds the address of xs[0].
1/6
Definition

The CPU (central processing unit, or processor) runs a program by repeating three steps for each instruction: fetch it from memory, decode what it asks for, and execute it[3]. As it goes, it reads and changes registers and memory[1]. Registers are a few tiny storage slots inside the CPU itself, where it keeps the values it is working on right now. Using them costs almost nothing[2].

The warehouse problem

Adding two registers takes the CPU almost no time. Fetching a number from main memory takes a very long time by comparison. On the CPUs Apple describes, a read that has to go all the way to main memory is about fifty times slower than the fast path[1]. A rough 2021 table for ordinary hardware tells the same story: about 2 nanoseconds for the fastest cache, about 100 for main memory[2]. Exact figures differ from chip to chip. The gap of one to two orders of magnitude is what matters.

Intuition

Imagine all your stock is in a warehouse across town. A trip there takes an hour, so you keep a desk drawer for things you’re using right now, a shelf in your office for this week’s work, and a storeroom down the hall. Each place is bigger and further away than the last. Every time you need something, you look in the drawer first.

Latency ladder: registers, L1, L2, L3 and main memory. Each level is bigger and slower than the one above. Rough figures: L1 about 2 ns, L2 about 5, L3 about 20, main memory about 100 nanoseconds.registersin your hand≈ freeholds a handful of valuesL1 cachedesk drawer≈ 2 nsholds ~10 KBL2 cacheoffice shelf≈ 5 nsholds ~100 KBL3 cachestoreroom≈ 20 nsholds ~1 MB per coremain memorywarehouse≈ 100 nsholds gigabytesbar length on a log scale: each step is a multiple
Each level is bigger and slower than the one above. The memory ladder. Figures are rough values for commodity hardware in 2021 from Slotin’s Algorithms for Modern Hardware; your machine will differ, but not by orders of magnitude. The bars use a log scale, so each step to the right multiplies the time.
Definition

A CPU cache is a small, fast memory between the CPU and main memory that keeps copies of recently used data. Modern CPUs have several, in a cache hierarchy: L1 is the smallest and fastest and sits inside each core; L2 and then L3 are bigger and slower; main memory (RAM) is far bigger and slower still[2]. Latency is how long one access takes, from asking to receiving.

Nanoseconds are hard to feel. Stretch time so that an L1 hit takes one second, and see how long the other trips take:

Try itIf an L1 hit took one second
// Rough 2021 latencies in ns (Slotin)
const levels = [
  { name: 'L1', ns: 2 },
  { name: 'L2', ns: 5 },
  { name: 'L3', ns: 20 },
  { name: 'RAM', ns: 100 },
];
for (const l of levels) {
  const secs = l.ns / 2;
  console.log(l.name, secs, 'seconds');
}

Hits and misses

Now follow one read through the ladder, then a second read of the same thing, then a read of something new.

Step throughA trip through the caches
s += a.x; // first time
s += a.x; // again, soon after
s += b.x; // somewhere new
The CPU looks for data in L1, then L2, then L3, then main memory. A timeline below shows how long each read took.CPUL1–missL2–L3–RAMa, b, …TIME PER READ (rough)
The CPU needs a.x. It looks in the smallest, fastest cache first: L1. It isn’t there. That is a cache miss.
1/6
Definition

When the data the CPU needs is already in a cache, that is a hit. When it isn’t, that is a cache miss, and the CPU must look further down the hierarchy, perhaps all the way to main memory. Misses cost far more than hits, so a few of them can dominate a loop’s running time.

You can do the arithmetic yourself. Say a hit costs 2 nanoseconds and a miss 100. If 1 read in 10 misses, what does an average read cost?

// 9 hits at 2 ns, 1 miss at 100 ns
const avg = (9 * 2 + 1 * 100) / 10;
console.log(avg); // 11.8

Even a 90% hit rate leaves reads several times slower than all hits. That is why the million-particle loop slowed down. A thousand particles fit in the caches, so once the loop has run, nearly every read hits. For a million particles, the six 8-byte numbers alone come to 48 megabytes, far more than the caches hold, so the loop keeps missing.

Under the hood

Look again at the two charts. The struct-of-arrays bars stay short at both sizes, while the array-of-objects bars grow. With the objects, each mass sits inside its own object, surrounded by fields the loop never uses, and the loop must also read a pointer to reach it. A Float64Array of masses packs nothing but masses, so far fewer bytes pass through the caches for the same answer. In this book’s benchmark run, struct of arrays beat array of objects by more on this loop than on any other. The next chapter shows exactly how the hardware moves those bytes, and why packing pays off so well. It is the first real argument for Viscosity.

Your turn

ExerciseA toy cache

Write countMisses(addrs, n) for a toy cache with n slots. An address a may only live in slot a % n. Go through addrs in order. If slot a % n already holds a, that is a hit. Otherwise it is a miss, and the slot now holds a (whatever was there is thrown out). Return the number of misses.

function countMisses(addrs, n) {
  // your code here
}

// expect 1:
console.log(countMisses([5, 5, 5], 4));
Extra practiceThe cost of a few misses

Write avgTime(rate, hit, miss) that returns the average time of one read, when a fraction rate (between 0 and 1) of reads hit and cost hit nanoseconds each, and the rest miss and cost miss nanoseconds each.

function avgTime(rate, hit, miss) {
  // your code here
}

// expect 11.8:
console.log(avgTime(0.9, 2, 100));

What you learned

  • The CPU fetches, decodes and executes one instruction after another, working on values held in registers.
  • Main memory is far slower than the CPU, so a cache hierarchy (L1, L2, L3) keeps recently used data close by.
  • Latency is how long one access takes. A hit in L1 takes a few nanoseconds; a trip to main memory takes around a hundred.
  • A cache miss sends the CPU further down the hierarchy. A handful of misses can dominate a loop.
  • When data outgrows the caches, the same loop gets slower per element.

Check your understanding

0/6
  1. 1.

    What does a CPU repeatedly do to run a program?

  2. 2.

    Which list goes from fastest to slowest?

  3. 3.

    Why are caches small, rather than as big as main memory?

  4. 4.

    A loop reads the same small array 100 times. Where do most of its reads come from after the first pass?

  5. 5.

    Hits cost 2 ns and misses cost 100 ns. What is the average cost per read at a 90% hit rate?

  6. 6.

    In the benchmark, summing mass cost more per particle at 1,000,000 particles than at 1,000. What is the best explanation?

References

  1. [1] Apple Inc. (2025). Optimize CPU performance with Instruments (WWDC25 session 308). Apple Developer. developer.apple.com/videos/play/wwdc2025/308/ (checked 2026-10-31)
  2. [2] Sergey Slotin. Memory Hierarchy, in Algorithms for Modern Hardware. algorithmica.org (open online book). en.algorithmica.org/hpc/external-memory/hierarchy/ (checked 2026-10-31)
  3. [3] Sergey Slotin. Instruction-Level Parallelism, in Algorithms for Modern Hardware. algorithmica.org (open online book). en.algorithmica.org/hpc/pipelining/ (checked 2026-10-31)