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.
- 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.
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 1.50 | 2.13 |
| struct of arrays | 0.64 | 0.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.
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 8.94 | 10.81 |
| struct of arrays | 1.13 | 1.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.
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
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.
load r1, [r2] ; r1 = xs[i]add r0, r0, r1 ; s = s + r1add r2, r2, 8 ; next element
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.
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.
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:
// 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.
s += a.x; // first times += a.x; // again, soon afters += b.x; // somewhere new
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.
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
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));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.
What does a CPU repeatedly do to run a program?
- 2.
Which list goes from fastest to slowest?
- 3.
Why are caches small, rather than as big as main memory?
- 4.
A loop reads the same small array 100 times. Where do most of its reads come from after the first pass?
- 5.
Hits cost 2 ns and misses cost 100 ns. What is the average cost per read at a 90% hit rate?
- 6.
In the benchmark, summing
masscost more per particle at 1,000,000 particles than at 1,000. What is the best explanation?
References
- [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] 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] Sergey Slotin. Instruction-Level Parallelism, in Algorithms for Modern Hardware. algorithmica.org (open online book). en.algorithmica.org/hpc/pipelining/ (checked 2026-10-31)