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

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.

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

Try itEvery element vs every 8th
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

Intuition

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.
Definition

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.

Step throughCounting cache lines
s += ps[i].mass; // objects
s += mass[i]; // Float64Array
Cache lines fetched when summing mass: eight particle objects versus one Float64Array of massesARRAY OF OBJECTSps8 pointers in one lineps[0]ps[1]ps[2]ps[3]ps[4]ps[5]ps[6]ps[7]FLOAT64ARRAY OF MASSESmass[0..7] in one line; mass[8..15] in the nextlines fetched:objects 0array 0
Memory moves in whole blocks of 64 bytes, called cache lines. Top: eight particle objects, pretending each one fills one line. Bottom: the same eight masses in a Float64Array, 8 bytes each, so all eight fit in one line.
1/5

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].

Definition

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:

Step throughStride: the gap between reads
Eight cache lines of 64 bytes, with dots where eight reads land for strides of 8, 48 and 64 bytesFloat64Array of masses: stride 8 bytesline 0line 1line 2line 3line 4line 5line 6line 78 reads → 1 line → 64 bytes fetched for 64 used
Stride 8: the masses sit side by side. Eight reads touch one line, and every byte fetched is used.
1/3
Definition

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:

Sum every mass: three layouts
Median ns per element, n = 100,000 (shorter is faster)
array of objects: median 5.60 ns, p95 6.11 ns per elementarray of objects5.6packed Float64Array: median 1.83 ns, p95 1.93 ns per elementpacked Float64Array1.8 3.1× fasterstruct of arrays: median 0.66 ns, p95 0.81 ns per elementstruct of arrays0.7 8.5× faster
Table view
layoutmedian nsp95 ns
array of objects5.606.11
packed Float64Array1.831.93
struct of arrays0.660.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.

Same answer, three strides. Objects are slowest; the packed buffer (stride 48) is in between; the struct of arrays (stride 8) is fastest.

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.

Measure it yourselfAoS vs SoA on this device
array of objects
struct of arrays

Each layout runs in its own fresh worker: warm-up, then the median of 25 samples.

Under the hood

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

ExerciseHow many lines?

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]));
Extra practiceLines for one field

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 a is Math.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. 1.

    With 64-byte cache lines, which pair of addresses is in the same line?

  2. 2.

    You read one 8-byte number that isn’t cached. How many bytes come from memory?

  3. 3.

    A loop sums one field from records of 6 float64 fields packed together. What is its stride?

  4. 4.

    Which is an example of temporal locality?

  5. 5.

    In the benchmark, summing mass was faster from a struct of arrays than from the packed Float64Array buffer. Why?

  6. 6.

    What does bandwidth measure?

References

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