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.
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.
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.
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.
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.
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.
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.
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.
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:
float4: each thread loads 4 consecutive floats of A or B with one 16-byte instruction. For the B tile, a warp’s 32 such loads cover one 512-byte row segment; for the A tile, 32-byte pieces of 16 rows. Either way every sector fetched is fully used.As[BK][BM]. Now for a fixed k, a thread’s 8 values of A are contiguous, and regM is two LDS.128 instead of eight LDS. regN was already contiguous in Bs: two more LDS.128. Four load instructions per 64 FMAs.// one step k: 4 LDS.128 feed 64 FFMA
*reinterpret_cast<float4*>(®M[0]) = *reinterpret_cast<const float4*>(&As[k][trow * 8]);
*reinterpret_cast<float4*>(®M[4]) = *reinterpret_cast<const float4*>(&As[k][trow * 8 + 4]);
*reinterpret_cast<float4*>(®N[0]) = *reinterpret_cast<const float4*>(&Bs[k][tcol * 8]);
*reinterpret_cast<float4*>(®N[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.
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.
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:
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.
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:
| Math | RTX 5070, dense TFLOPS | Note |
|---|---|---|
| FP32 on CUDA cores | 30.9 | chapters 7 and 8 so far |
| TF32 tensor | ≈ 30.9 | no faster than FP32 on GeForce cards; worth it on data-center GPUs |
| FP16 / BF16 in, FP32 accumulate | 61.7 | this chapter’s hgemm |
| FP16 in, FP16 accumulate | 123.5 | chapter 6’s accumulation problem, so rarely used for long sums |
| FP8 in, FP32 accumulate | 123.5 | Part IV |
| FP4 (block-scaled) | 493.9 | Part 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.
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.
| Level | What you write | Used in |
|---|---|---|
WMMA (nvcuda::wmma) | C++ fragments of 16×16×16, load / multiply / store calls; the layout is hidden | this chapter |
PTX mma.sync + ldmatrix | inline assembly with explicit fragment layouts; full control over data movement | FlashAttention (chapter 14) |
| CUTLASS / CuTe | C++ templates that describe tiles and layouts; the library generates the code | Part 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.
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.
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:
mma_sync waits for its own global loads.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
}
__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.)
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.
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:
ldmatrix loads out of it are conflict-free, without wasting memory.ldmatrix and hand-placed mma.sync, with fragments double-buffered in registers, so that the loads for the next k step overlap the MMAs of this one.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.
At 4096³, from the mechanisms and from Boehm’s measured ratios on a different card; ranges, not promises:
| Kernel | % of cuBLAS FP32 | Registers | Occupancy | What to look for |
|---|---|---|---|---|
| v3 tiled (lab 6) | 10–15% | ~30 | 100% | mio_throttle, 2 LDS per FFMA |
| v4 1D blocking | 25–40% | ~40 | ~100% | LDS per FFMA ≈ 1.1 |
| v5 2D blocking | 50–70% | ~100 | ~33% | LDS per FFMA ≈ 0.25; load bank conflicts appear |
| v6 16-byte loads | 60–80% | ~100 | ~33% | LDS.128 in the SASS; 4× fewer LDS instructions than v5 |
| v7 warp tiling | 70–90% | ~170 | 17–25% | fewest shared wavefronts per FFMA; zero spills |
| Tensor-core kernel | % of cuBLAS HGEMM | What to look for |
|---|---|---|
| t1 fragments from global | 10–30% | high L2 traffic, long_scoreboard; the tensor pipe mostly idle |
| t2 shared-memory tiles | 40–70% | tensor pipe busier; barrier and long_scoreboard at phase boundaries |
| t3 async double buffer | 50–80% | fewer stalls at phase boundaries than t2 |
| cuBLAS HGEMM | 100% (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.
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
| Step | Prediction 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 column | section 9’s first table |
bench: t1, t2, t3 | section 9’s second table |
bench: cuBLAS HGEMM / cuBLAS SGEMM | 2–3× |
sass: t1 vs t2 | both 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.
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.
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.
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.
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.
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.
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.
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×.
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.
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).
__pipeline_memcpy_async); PTX ISA: mma.sync.m16n8k16 fragment layouts (the lane mapping in the fragment explorer), ldmatrix, cp.async, and the sm_90a / sm_100a-only instructions.