19. Cache lines and locality
The CPU never fetches just the number you asked for. It fetches the whole 64-byte block around it. Lay your data out so that the rest of the block is useful, and loops get several times faster.
- Explain what a cache line is and work out which line an address falls in.
- Tell spatial locality from temporal locality, with an example of each.
- Compute the stride of a loop over different layouts, and count the lines it fetches.
- Explain why wasted bytes in each line waste bandwidth, using a real benchmark.
Fewer reads, same time?
Here is a strange experiment. Make a big Float64Array, then add 1 to
every element. Then do it again, but only to every 8th element:
for (let i = 0; i < n; i += 1) xs[i] += 1;
for (let i = 0; i < n; i += 8) xs[i] += 1;
The second loop does one eighth of the work. You would expect it to take one eighth of the time. Try it:
const n = 1 << 23; // 8M doubles: 64 MB
const xs = new Float64Array(n);
function touch(step) {
const t0 = performance.now();
for (let i = 0; i < n; i += step) {
xs[i] += 1;
}
return performance.now() - t0;
}
touch(1);
touch(8); // warm up first
// performance.now() is a clock in ms
const a = touch(1);
const b = touch(8);
console.log('all ms:', a.toFixed(1));
console.log('8th ms:', b.toFixed(1));
const r = (a / b).toFixed(1);
console.log('8th was', r, 'x faster');If your machine behaves like most, the second loop is nowhere near eight times faster. Something other than the number of reads is setting the pace.
Memory moves in blocks
The warehouse from chapter 18 doesn’t ship single screws. Ask for one and it sends the whole box the screw came in. If the next thing you need is in that box too, it’s already on your desk. If not, the rest of the box was a wasted trip.
The box is called a cache line. On most processors it is 64 bytes [1]: Linux assumes 64-byte lines for 64-bit x86 and for 64-bit ARM[4]. Some chips use bigger lines; Apple describes its caches as using 64- or 128-byte lines[5]. This book uses 64.
Eight float64s fill one line. So every eighth element of xs is in a
different line, and the second loop still has to fetch every line of
the array. Same lines, same trips to memory, nearly the same time.
Memory is divided into lines at fixed boundaries, so working out which line an address is in is one division:
const line = Math.floor(addr / 64);
// addresses 0..63 are line 0,
// 64..127 are line 1, and so on.
A cache line is the block that caches move between levels: 64 bytes
on most mainstream processors. Asking for one byte fetches its whole
line[1]. The line of address a is
Math.floor(a / 64).
Counting lines, not reads
Now go back to chapter 18’s loop, summing every mass, and count lines.
The objects here use a toy layout, one per line, to keep the picture
simple.
s += ps[i].mass; // objectss += mass[i]; // Float64Array
The objects need a new line for every particle, plus the lines holding
the pointers. The Float64Array needs one line per eight particles. That
is the right way to think about memory cost: count the lines a loop
touches, not the values it reads[1].
Spatial locality means using data near data you just used, so it arrives in lines you have already fetched. Temporal locality means using the same data again soon, while it is still in a cache[2]. A loop over a contiguous typed array has excellent spatial locality.
Temporal locality is about when. These two loops do the same sums:
// two passes: xs is fetched twice
for (const v of xs) a += v;
for (const v of xs) b += v * v;
// one pass: each line is used for both
for (const v of xs) {
a += v;
b += v * v;
}
If xs is too big for the caches, the second pass of the first version
finds its early lines long gone and fetches everything again. The fused
loop uses each value while it is still hot.
Stride
The gap, in bytes, from one read to the next is the loop’s stride. Step through three layouts of the same masses:
The stride of a loop is the distance in memory between consecutive
accesses. For one field of a packed record with k fields of 8 bytes,
the stride is 8 × k bytes. When the stride is small, many reads share
a line. Once it reaches 64 bytes, every read pays for a whole line.
Fetching unused bytes costs more than time spent waiting. It also uses up bandwidth: how many bytes per second can flow from memory to the CPU. A rough 2021 table puts main memory at around ten gigabytes per second, and L1 several times higher[3]. A loop that streams through a big array is limited by that flow. If only 8 bytes of every 64 are useful, seven eighths of the flow is wasted.
This book’s benchmark measured exactly these three layouts:
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 5.60 | 6.11 |
| packed Float64Array | 1.83 | 1.93 |
| struct of arrays | 0.66 | 0.81 |
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 order matches the line counts. The packed buffer removes the objects and pointers, but it still drags five unused fields through the cache for every mass. The struct of arrays fetches nothing it doesn’t use.
Now measure it on the device you are reading this on. Pick “sum every
mass” to repeat the chart above, or the particle update loop,
x += vx.
Each layout runs in its own fresh worker: warm-up, then the median of 25 samples.
This is the heart of Viscosity. Your code says ps[i].mass, and as far
as the hardware is concerned that means “fetch the line holding this
particle, use 8 bytes of it”. Viscosity’s compiler rewrites the loop to
read mass[i] from a Float64Array instead: stride 8, every byte used.
Whether that is worth it depends on the loop. A loop that reads every
field of each particle uses whole lines either way, so the benefit
shrinks, and Part VII’s cost model has to tell those cases apart.
Your turn
Write countLines(addrs) that returns how many different 64-byte cache lines the byte addresses in addrs fall in. Address a is in line Math.floor(a / 64).
function countLines(addrs) {
// your code here
}
// expect 2:
console.log(countLines([0, 8, 63, 64]));Records are packed from address 0, each with k fields of 8 bytes. A loop reads field 0 of records 0 to n - 1. Write fieldLines(n, k) that returns how many different 64-byte lines that loop touches.
function fieldLines(n, k) {
// your code here
}
// expect 6:
console.log(fieldLines(8, 6));What you learned
- Memory moves in cache lines: 64-byte blocks on most mainstream
processors. The line of address
aisMath.floor(a / 64). - Count lines touched, not values read.
- Spatial locality: use data near what you just used. Temporal locality: use the same data again soon.
- Stride is the distance between consecutive accesses. Small strides share lines; strides of 64 bytes or more pay for a line every read.
- Bandwidth is how many bytes per second can flow. Unused bytes in a line waste it.
Check your understanding
0/6- 1.
With 64-byte cache lines, which pair of addresses is in the same line?
- 2.
You read one 8-byte number that isn’t cached. How many bytes come from memory?
- 3.
A loop sums one field from records of 6 float64 fields packed together. What is its stride?
- 4.
Which is an example of temporal locality?
- 5.
In the benchmark, summing
masswas faster from a struct of arrays than from the packed Float64Array buffer. Why? - 6.
What does bandwidth measure?
References
- [1] Sergey Slotin. Cache Lines, in Algorithms for Modern Hardware. algorithmica.org (open online book). en.algorithmica.org/hpc/cpu-cache/cache-lines/ (checked 2026-10-31)
- [2] Sergey Slotin. Spatial and Temporal Locality, in Algorithms for Modern Hardware. algorithmica.org (open online book). en.algorithmica.org/hpc/external-memory/locality/ (checked 2026-10-31)
- [3] 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)
- [4] Linux kernel contributors. arch/x86/Kconfig.cpu (X86_L1_CACHE_SHIFT). Linux kernel source. github.com/torvalds/linux/blob/master/arch/x86/Kconfig.cpu (checked 2026-10-31)
- [5] Apple Inc. (2025). Optimize CPU performance with Instruments (WWDC25 session 308). Apple Developer. developer.apple.com/videos/play/wwdc2025/308/ (checked 2026-10-31)