Week 7 · Lesson 2

Caches and tiling: reuse what you've loaded

A matrix multiply touches each number of A and B many times. Where it reads them from decides its speed. Tiling reorganizes the loops so that each number, once loaded, is used many times before it is thrown away, without changing a single sum.

The memory hierarchy

Lesson 0.2 introduced caches. Here are the levels with rough sizes and costs on a current desktop processor. The exact numbers vary, the ratios don't:

levelsizetime to read
registersa few hundred bytesfree, as part of an instruction
L1 cache32 to 64 KB per coreabout 1 ns
L2 cache1 to 2 MB per coreabout 4 ns
L3 cachetens of MB, shared10 to 20 ns
main memorygigabytesabout 100 ns

Memory moves in cache lines of 64 bytes, 16 floats. Reading one float brings its 15 neighbors along for free. Two things make a kernel fast: using all 16 floats of every line you load (walking memory in order), and using each value many times while it's still close (reuse).

Where a plain GEMM wastes time

The plain week 3 GEMM computes one output at a time: for each (i, j), a loop over k reads row i of A and column j of B. Every multiply needs two loads. Reading down a column of B jumps N floats per step, so each load touches a new cache line and uses one float of it. And row i of A, just loaded, is used once and then reloaded for the next j.

Tiling

Compute a tile of outputs at once, say 4 rows by 8 columns. For each k, load 4 values of A (one per row of the tile) and 8 of B (one per column), and do all 32 multiply-adds they make possible. That's 12 loads for 32 multiply-adds instead of 64 loads. The 8 values of B sit next to each other, so they come from one or two cache lines. And the 32 running sums can live in registers for the whole k loop.

Why the sums don't change

Each output C[i][j] is still its own sum over k in the same order: A[i][0]·B[0][j] first, then + A[i][1]·B[1][j], and so on. Tiling only changes which outputs are worked on side by side. The order of the adds inside any one sum is untouched, so every result is bit for bit the same as the plain loops'. Reordering k itself, say summing in blocks, would change the bits, and the spec doesn't allow it.

A tiled GEMM in .uir

The week 7 tests write the tile out explicitly. Ranges ib and jb pick the tile, ki and kj (4 and 8) index inside it, and a 32-element alloc holds the tile's sums:

%acc = alloc 32 dtype=f32
%ib  = range 192            # tile row: rows 4ib .. 4ib+3
%jb  = range 96             # tile column: columns 8jb .. 8jb+7
# start each sum from its first product, A[i][0]·B[0][j]
...
# then for k = 1 .. 767: for each (ki, kj) in the tile, acc[ki*8 + kj] += A[i][k]·B[k][j]
%k0  = range 767
%k   = add %k0 1
%ki  = range 4
%kj  = range 8
...
# finally write the tile to C

Each tile's sums start from the tile's own first product, not from zero, for two reasons. First, it's the same order reduce folds in, so the result equals the matmul sugar's bit for bit. Second, it's a placement rule from week 3: a store that doesn't depend on jb runs outside the jb loop. A reset to 0.0 would run once for the whole program instead of once per tile. The first product depends on the tile's indices, so it lands inside the loop where it belongs.

On its own, writing a tile as loops already helps a little. The big win comes in lesson 7.3, when the loops inside the tile are unrolled and the 32 sums become registers.

Loop order is the program's

Your compiler doesn't choose this order this week: the program spells it out, and your job is to render it faithfully and fast. Choosing tile sizes automatically is a scheduling problem the course comes back to. For now, notice that the order is a choice, and that a good one costs nothing in correctness.

Check yourself

A cache line is 64 bytes. How many f32 values is that?

An f32 is 4 bytes.

64 / 4 = 16. Read one and the other 15 come along.

With a 4×8 tile, how many values of A and B do you load per value of k?

One A value per tile row, one B value per tile column.

4 + 8 = 12 loads, for 4 · 8 = 32 multiply-adds. One output at a time needs 2 loads per multiply-add: 64 loads for the same work.

Does computing a 4×8 tile at once change any output's bits?

What order are the adds for one C[i][j] in?

Tiling changes which sums run side by side, not the order inside any sum. k = 0, 1, 2, ... for every output, exactly as before.

A tile's sums are reset with store acc 0.0, a store that depends only on the in-tile ranges. What goes wrong?

Week 3: where does a store run?

A value or store runs inside every loop whose range it depends on, and no deeper. Starting from the first product A[i][0]·B[0][j] depends on the tile indices, so it runs once per tile.

Why not also split the k sum into blocks and add the blocks up at the end?

Is f32 addition associative?

No. (a + b) + c and a + (b + c) can round differently. Libraries like BLAS do reorder sums, which is one reason their results differ in the last bits from a simple loop's. This course keeps the order fixed.