Week 7 · Lesson 3

Upcast: unrolling loops into registers

A tile's 32 running sums only help if they stay in registers. An array indexed by a loop variable lives in memory, so the C compiler loads and stores it on every step. Unroll the loops that index it, and every index becomes a constant: the array turns into 32 plain variables, and the C compiler keeps them in registers.

axis=upcast

A range can now say how its loop runs:

%kj = range 8 axis=upcast

An upcast loop means the same thing as a plain loop, and run treats it exactly like one. The difference is in render: instead of a for loop, emit the body 8 times, with kj replaced by the literal 0, then 1, up to 7. The bound must be a literal from 1 to 32, because the body is copied that many times.

Here is the innermost loop of the tiled GEMM before and after, in the C your compiler emits (simplified):

/* plain loops: acc[] is indexed by variables, so it stays in memory */
for (int32_t ki = 0; ki < 4; ki++)
  for (int32_t kj = 0; kj < 8; kj++)
    acc[ki * 8 + kj] = acc[ki * 8 + kj] + A[(4*ib + ki) * K + k] * B[k * N + 8*jb + kj];

/* upcast: every index is a literal */
float a0 = A[(4*ib + 0) * K + k];   /* one A value per tile row: a0 .. a3 */
acc[0] = acc[0] + a0 * B[k * N + 8*jb + 0];
acc[1] = acc[1] + a0 * B[k * N + 8*jb + 1];
...
acc[31] = acc[31] + a3 * B[k * N + 8*jb + 7];

With constant indices and an array that never escapes, the C compiler replaces acc[0] to acc[31] with 32 variables, and those go in registers. The eight B values for one row of the tile sit next to each other in memory, so the compiler can also use SIMD instructions: one instruction that does 4 or 8 multiplies (or adds) on adjacent floats. SIMD works on separate outputs side by side, so every sum still adds in its own order, and the bits don't change.

What it's worth

Measured on the 768×768 GEMM, single-threaded, against the same loops without axes:

C compilerplain loopsupcast tilespeedup
clang 1998 ms24 ms4.1×
gcc 14148 ms82 ms1.8×

The two compilers differ a lot. Both get faster, and neither changes a bit of the result. The speed test for upcast alone, s_gemm_768_upcast, asks for at least 0.65 of the reference's speed. A renderer that turns upcast loops into plain for loops lands around 0.25 with clang and 0.55 with gcc, and fails.

Upcast and the accumulator rule

Week 3's rule for loads still decides where things run. In the tiled GEMM, the load of acc[ki*8 + kj] sits inside the k loop, because the k loop stores into the version it reads. Unrolling doesn't change that: it happens after linearizing, when you print C. Unroll in the renderer, not in the graph, and the rules from earlier weeks keep working untouched.

Unroll a dot product

Make the k loop of this 8-element dot product an upcast loop. The result, including its bits, must not change.

Unrolling everything is not faster

Registers run out. A 4×8 tile needs 32 accumulators plus a few loaded values, which fits. A 16×16 tile needs 256, which spills back to memory and is slower than a smaller tile. Code size grows too: the C for the full schedule is about 28 KB, which is why upcast tests raise the size limit with # max_bytes:.

Check yourself

What does run do differently for an upcast loop?

Do axes change results?

No axis changes what a program computes. Upcast is an instruction to the renderer: copy the body instead of looping.

Why can the C compiler keep acc[0..31] in registers after unrolling, but not before?

Can you write register[k] in machine code?

There's no "register number k" instruction. An array indexed by a loop variable needs an address in memory. With constant indices, the compiler treats each element as its own variable.

Is %r = range %n axis=upcast valid, with %n = add 4 4?

The spec says "a literal from 1 to 32".

The rule is about the program as written, not about what a simplifier could fold. The test err_upcast_not_literal checks it.

SIMD multiplies 8 adjacent floats in one instruction. Does that change any result's bits?

Which outputs share an instruction?

The 8 lanes are 8 different C[i][j]. Each lane's add rounds like the scalar add would. What would change bits is fusing a multiply and an add, which -ffp-contract=off forbids.