Part II — Kernels that matter · Chapter 8

Matmul II: register blocking, warp tiling, tensor cores

~70 min read2 interactive widgets4 plateslab 7 · ~60 min on your GPU

The chapter in one paragraph

Chapter 7 ended at a wall: two shared-memory loads per multiply-add, one eighth of peak. This chapter climbs it. Register blocking makes each thread compute a small block of outputs, so that every value it loads from shared memory feeds several FMAs: with 8×8 outputs per thread, 16 loads feed 64 FMAs and shared memory stops being the limit. Wider 16-byte loads and a transposed tile cut the number of load instructions by four. Warp tiling then gives each warp a square region of the output, because the shared-memory traffic a warp causes grows with the perimeter of its region, not its area. That hierarchy (block tile in shared memory, warp tile, thread tile in registers) is how every fast GEMM is built, including the ones on tensor cores: units that execute a small matrix multiply as a single warp-wide instruction. The chapter ends by putting the same matmul on your card’s tensor cores through WMMA, in FP16 with FP32 accumulation, and explains what separates these kernels from cuBLAS.

In this chapter

  1. The ratio, and the only faster memory
  2. v4: one column of outputs per thread
  3. v5: a block of outputs per thread
  4. v6: wider loads and a transposed tile
  5. v7: warp tiling
  6. Tensor cores
  7. Feeding the tensor cores: t1 to t3
  8. What cuBLAS does that we do not
  9. Predictions, and what the profiler should show
  10. Lab 7: register blocking and tensor cores
  11. Check your understanding

1. The ratio, and the only faster memory

The tiled kernel of chapter 7 does one FMA per two shared-memory loads, and an SM can issue four warp-wide FMAs but only one warp-wide shared load per cycle. To use the FMA units, each value that comes out of shared memory has to be used several times, and the only place to keep a value between uses is a register: the fastest storage on the chip, private to one thread, read by the FMA units directly and at full speed.

The trick that makes this possible is to look at matrix multiply differently. The dot-product view says C[i][j] = ∑k A[i][k]·B[k][j]: one output at a time. The outer-product view swaps the loops: for each k, column k of A times row k of B is a whole matrix of products, and C is the sum of those K matrices. A thread that owns a TM×TN block of C only needs, at each k, TM values of A’s column and TN values of B’s row, and every pair of them is one of its FMAs.

So the design of a fast GEMM is a nest of the same idea at three levels, each trading on-chip storage for reuse: a block tile in shared memory (reuse across the block, chapter 7), a warp tile (section 5), and a thread tile in registers (sections 2–4). The rest of the chapter adds them one at a time, as lab 7’s kernels v4 to v7.

2. v4: one column of outputs per thread

The smallest step: each thread computes TM = 8 outputs stacked in one column of the tile. Per step k it loads one value of B into a register and reuses it for all 8 of its outputs, reading the 8 A values it needs from shared memory. The block computes a 64×64 tile with 512 threads, and each phase loads a 64×8 slice of A and an 8×64 slice of B, exactly one element of each per thread:

for (int k = 0; k < BK; ++k) {                 // BK = 8
    float b = Bs[k][tcol];                        // one load...
    for (int i = 0; i < TM; ++i)                  // ...reused 8 times
        acc[i] += As[trow * TM + i][k] * b;       // the same As word for the whole warp: broadcast
}

Nine loads for eight FMAs: 1.125 per FMA instead of 2, which moves the shared-memory ceiling from 1/8 of peak to about 1/4.5. The As loads are broadcasts, since all 32 lanes of a warp share trow, and the 8 accumulators live in registers. Boehm measured this step at 36% of cuBLAS, almost three times the tiled kernel, for a change of a dozen lines.

Your move: the eight As loads per k read As[trow*8+0][k], As[trow*8+1][k], … Can the compiler merge them into two 16-byte LDS.128 loads, as it might have in chapter 7?

No. The eight values are in the same column of As[64][8], so they are 8 floats (32 bytes) apart, not contiguous; a vector load needs consecutive, aligned addresses. Contiguous in k they would be, but a thread needs them across i at a fixed k. The fix is to store the A tile transposed, so that for a fixed k the rows are contiguous: that is v6. make sass in lab 7 shows the plain LDS in v4 and the LDS.128 in v6.

3. v5: a block of outputs per thread

Now the full outer product of figure 1: each thread owns an 8×8 block, so the block tile grows to 128×128 with 256 threads, and the inner loop becomes

for (int k = 0; k < BK; ++k) {
    for (int i = 0; i < TM; ++i) regM[i] = As[trow * TM + i][k];   // 8 loads
    for (int j = 0; j < TN; ++j) regN[j] = Bs[k][tcol * TN + j];   // 8 loads
    for (int i = 0; i < TM; ++i)
        for (int j = 0; j < TN; ++j)
            acc[i][j] += regM[i] * regN[j];                        // 64 FMAs
}

0.25 loads per FMA: shared memory can now supply the FMA units with room to spare, and the ceiling of chapter 7 is gone. The block tile got bigger too: 128×128 with BK = 8 means each phase loads 128 + 128 values per k for 2×128×128 FLOPs, a global intensity of 32 FLOP/B if nothing hit in L2 (chapter 4’s “register tiling” point). Each thread now loads four elements of each tile per phase, in a strided loop, which keeps the loads coalesced.

The price: registers

64 accumulators, 16 operand registers, plus indices and addresses: expect around 100 registers per thread (-Xptxas -v prints the exact number when you build). At that size an SM’s 64K registers hold only two 256-thread blocks: 16 warps, 33% occupancy. Chapter 2 would have called that a problem. It is not, and why it is not is one of the most useful ideas in GPU performance.

Occupancy exists to hide latency: while one warp waits, another issues. But latency can also be hidden inside a warp, if the warp has many independent instructions to issue while it waits. The 64 FMAs of one step are all independent of each other, and the loads of the next step can be issued before they are done. That is instruction-level parallelism doing the job of thread-level parallelism. Vasily Volkov made this point in a 2010 talk titled “Better performance at lower occupancy”, and every fast GEMM since lives at low occupancy with a large register tile. Chapter 2’s callout, “occupancy is a means, not a goal”, is this.

Spills

The one register budget you must not exceed is the hardware’s: 255 per thread. A kernel that needs more spills: the compiler parks values in “local” memory, which is really DRAM behind the L1 (chapter 3), and every access to them is a slow load or store in the middle of your inner loop. -Xptxas -v prints bytes spill stores for every kernel. For a GEMM inner loop, the number you want there is 0.

Your move: why not make the thread tile 16×16, for 0.125 loads per FMA?

256 accumulators alone exceed the 255-register limit, so the kernel would spill in its hottest loop. And even without the limit, 0.25 loads per FMA already puts shared memory below the FMA rate: once a resource is not the bottleneck, reducing its use further buys nothing (chapter 4’s lesson again). The thread tile is sized to be just big enough, and the remaining register budget goes to the next level of the hierarchy.

4. v6: wider loads and a transposed tile

v5 fixed the ratio but still issues one instruction per float moved: 16 LDS per step for 64 FFMA, plus the global loads of the tiles, one float each. Instruction issue is itself a shared resource (one instruction per scheduler per cycle), so v6 moves 16 bytes per instruction wherever it can:

// one step k: 4 LDS.128 feed 64 FFMA
*reinterpret_cast<float4*>(&regM[0]) = *reinterpret_cast<const float4*>(&As[k][trow * 8]);
*reinterpret_cast<float4*>(&regM[4]) = *reinterpret_cast<const float4*>(&As[k][trow * 8 + 4]);
*reinterpret_cast<float4*>(&regN[0]) = *reinterpret_cast<const float4*>(&Bs[k][tcol * 8]);
*reinterpret_cast<float4*>(&regN[4]) = *reinterpret_cast<const float4*>(&Bs[k][tcol * 8 + 4]);

Vector loads come with a condition: the address must be a multiple of 16 bytes. A row of A starts at row * K * 4 bytes, which is 16-byte aligned for every row only if K is a multiple of 4; the same goes for N and B. So lab 7’s v6 and v7 come in two variants, chosen once per launch: with VEC they use 16-byte loads wherever a group of 4 is fully inside the matrix and fall back to four guarded scalar loads at the edges; without it (an odd K or N) they always use the scalar path. The test shapes include both kinds.

Your move: storing the A tile transposed means each thread writes its 4 loaded floats to 4 different rows of As. Is that store a bank-conflict problem?

Mildly, and it does not matter much. Each element is stored into the tile once per phase, while the loads out of the tile happen at every step k, by many threads. Optimizing the rare operation at the expense of the frequent one would be backwards. Boehm tried two schemes to remove the store conflicts and both came out slower. Lab 7’s make ncu-table prints shared-store bank conflicts separately so you can see how small they are relative to the loads.

5. v7: warp tiling

In v5 and v6, threads are numbered across the block tile row by row: thread t owns rows 8×(t/16) and columns 8×(t mod 16). A warp, threads 0–31, therefore owns a strip 16 rows tall and 128 columns wide. At each step k the warp as a whole reads 16 values of A and 128 values of B from shared memory to produce its 2,048 outputs.

The amount of shared memory a warp reads per step is the perimeter of its region (height + width), while the work it does is the area (height × width). For a fixed area, a square minimizes the perimeter. So v7 gives each warp an explicit square: the 128×128 block tile is split into four 64×64 warp tiles, one per warp (128 threads), and each thread’s 128 outputs are arranged inside its warp’s square.

The indexing is the only hard part. With a 64×64 warp tile and 32 lanes of 128 outputs each, v7 splits the warp tile into WNITER = 4 vertical strips of 16 columns; in each strip, a lane owns an 8×4 block (lane / 4 picks the 8 rows, lane mod 4 picks the 4 columns). Per step k a thread loads 8 values of A (two LDS.128) and 4 × 4 of B (four LDS.128) and does 8 × 16 = 128 FMAs:

// lab 7, sgemm_warptile: BM = BN = 128, BK = 16, WM = WN = 64, WNITER = 4, TM = 8, TN = 4
const int warpRow = warp / 2, warpCol = warp % 2;          // which 64 x 64 square
const int tRowInWarp = lane / 4, tColInWarp = lane % 4;    // which 8 x 4 block in each strip
for (int k = 0; k < BK; ++k) {
    load 8 floats:   As[k][warpRow*64 + tRowInWarp*8 + 0..7]                 -> regM[0..7]
    for ws in 0..3:  Bs[k][warpCol*64 + ws*16 + tColInWarp*4 + 0..3]         -> regN[ws*4 + 0..3]
    acc[8][16] += outer(regM, regN)                                          // 128 FMAs
}

Besides the traffic, the warp tile matters for a second reason, and it is the one that makes this section the bridge to the rest of the chapter: a warp is the unit a tensor core works on. A tensor-core instruction takes a small tile of A and B held collectively by the 32 lanes of a warp. Every tensor-core GEMM is therefore organized exactly like v7: block tile in shared memory, warp tile per warp, and inside the warp, fragments in registers.

Explore the hierarchy. The presets are lab 7’s kernels; change the shapes and watch registers, occupancy, loads per FMA and shared traffic per output move:

Your move: v7 has 128 threads per block and about 170 registers per thread. What is its occupancy, and should that worry you?

It depends on the exact count, which is a nice lesson in itself. Registers are allocated per warp in units of 256, so a 4-warp block at 168 registers takes 4 × 5,376 = 21,504 and three blocks fit in 64K (12 warps, 25%); at 176 it takes 22,528 and only two fit (8 warps, 17%). make prints the real number. Either way it should not worry you: each warp has 128 independent FMAs per step to issue and the loads for the next step can overlap them. What would worry you is bytes spill stores above zero, or a profiler showing the SM idle on long_scoreboard at the start of each phase: that is latency the instruction-level parallelism did not cover, and the cure is double buffering (section 7), not more warps.

6. Tensor cores

Everything so far used the FP32 lanes, the “CUDA cores”: one multiply-add per lane per cycle. Since Volta (2017), each SM also has tensor cores: units that multiply small matrices. A tensor-core instruction is issued by a whole warp and computes D = A·B + C on tiles whose elements are spread across the 32 lanes’ registers. On your card the basic FP16 instruction is mma.sync.m16n8k16: A is 16×16, B is 16×8, C and D are 16×8, so one warp instruction performs 2,048 multiply-adds, where an FFMA performs 32.

The table behind that figure, in the units of chapter 1:

MathRTX 5070, dense TFLOPSNote
FP32 on CUDA cores30.9chapters 7 and 8 so far
TF32 tensor≈ 30.9no faster than FP32 on GeForce cards; worth it on data-center GPUs
FP16 / BF16 in, FP32 accumulate61.7this chapter’s hgemm
FP16 in, FP16 accumulate123.5chapter 6’s accumulation problem, so rarely used for long sums
FP8 in, FP32 accumulate123.5Part IV
FP4 (block-scaled)493.9Part IV

Two things in that table deserve a comment. First, on a GeForce card the tensor-core advantage for FP16 with FP32 accumulation is “only” 2× the CUDA cores; on an H100 it is about 15×. The halving is a product decision (NVIDIA runs FP32 accumulation at half rate on consumer cards), not physics, and it means that on your card the bytes saved by FP16 matter as much as the FLOPs. Second, the ridge moves: at 61.7 TFLOPS and 672 GB/s, a kernel needs 92 FLOP/B to be compute-bound (chapter 1), so tensor-core GEMMs need bigger tiles and more reuse than FP32 ones to reach their roof.

Where the operands live: fragments

A tensor-core instruction has no pointer operands. Its A, B, C and D tiles are in registers, distributed over the lanes of the warp: for m16n8k16, each lane holds 8 halves of A (in 4 registers), 4 halves of B (2 registers) and 4 floats of C and D. Which lane holds which element is fixed by the PTX specification, and it is not an obvious layout. Click a lane:

Two consequences. The 32 lanes together hold each tile exactly once: the warp cooperates, and no single thread “has” a row. And getting data from shared memory into this layout is its own problem: a plain load per element would be slow, so there is a dedicated instruction, ldmatrix (LDSM in SASS), that loads 8×8 blocks of 16-bit values from shared memory straight into the fragment layout, each lane supplying the address of one row.

Three ways to program them

LevelWhat you writeUsed in
WMMA (nvcuda::wmma)C++ fragments of 16×16×16, load / multiply / store calls; the layout is hiddenthis chapter
PTX mma.sync + ldmatrixinline assembly with explicit fragment layouts; full control over data movementFlashAttention (chapter 14)
CUTLASS / CuTeC++ templates that describe tiles and layouts; the library generates the codePart V

WMMA is the simplest and it compiles to the same tensor-core instructions, but because it hides the layout, you cannot do elementwise work on a fragment that depends on the element’s position (masking, per-row scaling) without a round trip through shared memory. That is why serious kernels drop down to mma.sync.

What your sm_120 has, and what it does not

Tensor-core programming changed with every data-center generation: Hopper (sm_90) added wgmma, an asynchronous instruction issued by a group of four warps and reading operands straight from shared memory; data-center Blackwell (sm_100) added tcgen05 and a dedicated tensor memory. Your consumer Blackwell (sm_120) has neither. It runs the warp-level mma.sync family introduced with Ampere, extended with FP8, FP6 and FP4 inputs and block scaling, plus the asynchronous copy engines (cp.async, and the bulk tensor-copy unit). So the model in this chapter, warps issuing synchronous MMAs on register fragments, is exactly what your card executes, and what you learn here transfers directly to Ampere and Ada. The Hopper and B200 models are Part VI’s rented-GPU experiment.

7. Feeding the tensor cores: t1 to t3

A tensor core consumes operands fast: at 256 multiply-adds per SM per clock, an m16n8k16 (2,048 multiply-adds, 768 bytes of A and B) finishes in 8 clocks per SM. If every fragment came from shared memory once per MMA, that is 96 bytes per clock, most of shared memory’s 128. So the whole hierarchy of this chapter reappears, one level up: fragments must be reused from registers across several MMAs (the warp tile), tiles must be reused from shared memory across warps (the block tile), and the loads must overlap the math. Lab 7’s hgemm takes the three steps:

What makes t3 possible is cp.async: a copy from global to shared memory that the thread issues and then does not wait for. It does not pass through registers, so the thread can go straight on to issuing tensor-core instructions. In CUDA C++ it is __pipeline_memcpy_async(dst, src, 16); copies are grouped with __pipeline_commit(), and __pipeline_wait_prior(n) waits until at most n groups are still in flight. The main loop of t3:

load_tiles_async(buffer 0, tile 0);  commit();
for (int t = 0; t < tiles; ++t) {
    if (t + 1 < tiles) load_tiles_async(buffer (t + 1) % 2, tile t + 1);   // start the next copy
    commit();
    wait_prior(1);          // everything except the newest group has landed: tile t is in
    __syncthreads();        // ...for every thread's copies, not just mine
    compute(buffer t % 2);  // 8 warps x 8 MMAs x 2 steps
    __syncthreads();        // nobody reads buffer t % 2 any more: iteration t + 1 may refill it
}
Your move: delete the second __syncthreads() in t3. The copy at iteration t+1 writes into buffer (t+2) % 2. Which buffer is that, and who might still be reading it?

(t+2) % 2 = t % 2: the buffer iteration t just computed from. A fast warp reaches iteration t+1 and starts copying tile t+2 over it while slower warps are still running their MMAs on tile t. It is chapter 3’s write-after-read barrier again, in a new costume. Asynchronous copies make it worse, not better: the overwrite happens at some unpredictable later moment, so the bug is even more timing-dependent. (Production kernels use one barrier object per pipeline stage instead of whole-block barriers, so that warps wait only for the stage they need.)

Why the tensor-core kernels pad instead of guarding

WMMA loads and stores whole 16×16 tiles, with alignment rules on the pointers. Handling ragged edges means either masking inside fragments (impossible with WMMA’s hidden layout) or a slow path per edge tile, and libraries spend a lot of code on it. Lab 7 pads every matrix with zeros to a multiple of 128 and checks the logical shape, which is also what many inference engines do: their shapes are known in advance and chosen to be tile-friendly.

8. What cuBLAS does that we do not

v7 and t3 contain the ideas that matter. The remaining distance to cuBLAS is made of many smaller ones, each worth a few percent, and knowing their names is useful even before you need them:

The last point matters most for this course. The square 4096³ GEMM of the benchmark is prefill’s shape. Decode’s shape is M = 1 to a few dozen, where none of this chapter’s compute tricks apply and the kernel is a memory-bound GEMV: the game there is bytes, and Part IV’s quantized GEMV kernels are where it is played.

9. Predictions, and what the profiler should show

At 4096³, from the mechanisms and from Boehm’s measured ratios on a different card; ranges, not promises:

Kernel% of cuBLAS FP32RegistersOccupancyWhat to look for
v3 tiled (lab 6)10–15%~30100%mio_throttle, 2 LDS per FFMA
v4 1D blocking25–40%~40~100%LDS per FFMA ≈ 1.1
v5 2D blocking50–70%~100~33%LDS per FFMA ≈ 0.25; load bank conflicts appear
v6 16-byte loads60–80%~100~33%LDS.128 in the SASS; 4× fewer LDS instructions than v5
v7 warp tiling70–90%~17017–25%fewest shared wavefronts per FFMA; zero spills
Tensor-core kernel% of cuBLAS HGEMMWhat to look for
t1 fragments from global10–30%high L2 traffic, long_scoreboard; the tensor pipe mostly idle
t2 shared-memory tiles40–70%tensor pipe busier; barrier and long_scoreboard at phase boundaries
t3 async double buffer50–80%fewer stalls at phase boundaries than t2
cuBLAS HGEMM100% (expect 60–85% of 61.7 TFLOPS)for scale: about 2–3× cuBLAS SGEMM

Two comparisons to make when the numbers are in. Your best FP32 kernel against cuBLAS FP32, which is the chapter’s exam. And cuBLAS HGEMM against cuBLAS SGEMM on the same shape: that ratio is what switching the spine engine’s linear layers to FP16 is worth, before any quantization.

10. Lab 7: register blocking and tensor cores

Lab 7SGEMM v4–v7 and WMMA t1–t3, against cuBLAS~60 min · mlsys/labs/lab07-matmul-registers/

Two programs on lab 6’s harness: sgemm2 (FP32, v3 as the baseline, then v4 to v7, then cuBLAS) and hgemm (FP16 in, FP32 accumulate: t1 to t3, then cuBLAS GemmEx). Both are tested on awkward shapes against a double-precision reference; the FP32 kernels handle any shape, the tensor-core kernels run on zero-padded copies.

cd ~/unibo-lessons/mlsys/labs/lab07-matmul-registers
make               # read the -Xptxas -v lines: registers and spills per kernel
make test          # sgemm2 and hgemm: all PASS
make bench         # the two tables at 1024, 2048, 4096 cubed
make sass          # FFMA, LDS, LDS.128, HMMA, LDSM per kernel
make ncu-table     # FP32 kernels: shared loads, wavefronts, conflicts, stalls
make ncu-tensor    # tensor-core kernels: Speed of Light, pipes, memory
StepPrediction to write down first
make: registers of v5 and v7~100 and ~170, 0 bytes spilled
sass: LDS per FFMA, v4 / v5 / v6≈ 1.1 / 0.25 / 0.0625 (as LDS.128)
bench: the % of cuBLAS columnsection 9’s first table
bench: t1, t2, t3section 9’s second table
bench: cuBLAS HGEMM / cuBLAS SGEMM2–3×
sass: t1 vs t2both issue HMMA; does WMMA’s shared-memory load compile to LDSM?

Paste back the PASTE: lines, the register lines from make, and the output of make sass and make ncu-table.

Stretch

Two options. Autotune v7: turn BM, BN, BK, WM, WN, TM, TN into template parameters, instantiate a dozen valid combinations (the hierarchy widget checks validity), and benchmark them at 4096³ and at 4096×768×3072 (a GPT-2 MLP layer at M = 4096). Is the winner the same for both shapes? Three stages: turn t3’s double buffer into a triple buffer with wait_prior(2), and check first that it still fits in shared memory.

Check your understanding

Why does computing an 8×8 block of outputs per thread reduce shared-memory traffic per FMA by 8×?

Per step k, the thread needs only 8 values of A (its rows) and 8 of B (its columns), which it loads once into registers; every one of the 64 pairs is an FMA. 16 loads for 64 FMAs is 0.25 loads per FMA, against 2 for one output per thread.

v5 runs at about 33% occupancy and v7 at about 25%, yet both are much faster than the 100%-occupancy tiled kernel. Why is that not a contradiction?

Occupancy hides latency by switching warps; instruction-level parallelism hides it inside a warp. With 64 or 128 independent FMAs per step and loads for the next step issued early, each warp keeps the pipes busy on its own. The tiled kernel had occupancy but was limited by shared-memory bandwidth, which more warps cannot fix.

Why is the A tile stored transposed in v6?

So that a thread’s TM values of A for one k are contiguous in shared memory and can be read with LDS.128, 4 floats per instruction. In the untransposed layout they are a column, BK floats apart.

A warp owns a 16×128 strip in one kernel and a 64×64 square in another. Per step k, how many values does each read from shared memory, and what is the lesson?

16 + 128 = 144 for 2,048 outputs, against 64 + 64 = 128 for 4,096. Shared traffic grows with the perimeter of the warp’s region and work with its area, so square warp tiles move fewer bytes per output: 2.25× fewer here.

What does one mma.sync.m16n8k16 compute, and who issues it?

D = A·B + C with A 16×16, B 16×8, C and D 16×8: 2,048 multiply-adds. It is issued by a whole warp, and the tiles live distributed across the registers of its 32 lanes.

On your card, FP16 with FP32 accumulation is 61.7 TFLOPS against 30.9 for FP32. On an H100 the ratio is about 15×. What follows for how you use FP16 on a GeForce card?

The math speedup is only 2×, so a large part of FP16’s value on your card is the halved bytes: memory-bound kernels (every GEMV of decode) run up to 2× faster for that reason alone. And any kernel limited by something other than the FMA rate gains less than 2×.

What does cp.async change compared with an ordinary load followed by a shared-memory store?

The copy goes from global to shared memory without passing through registers, and the issuing thread does not wait for it. The thread can continue with math while the copy is in flight, and waits only when it needs the data (__pipeline_wait_prior). That is what lets a double buffer overlap the next tile’s load with the current tile’s math.

Which tensor-core programming models does sm_120 support: mma.sync, wgmma, tcgen05?

Only the warp-level mma.sync family (with FP8/FP6/FP4 block-scaled extensions). wgmma is Hopper-only (sm_90a) and tcgen05 is data-center Blackwell (sm_100a).

Sources and further reading