A06 — Tiled Matrix Multiply (GEMM)¶
Track A · Module 06 · Status: DONE · Est. 8 hours · Depth 3/5
Prerequisites: A01 (kernels, grid/block/thread, error checking, event timing) and A02 (coalescing, shared memory / LDS,
__syncthreads, bank conflicts). A03 (occupancy) and A04 (reduction) deepen the analysis but are not required. Backends: AMD (hipcc) · NVIDIA (nvcc) · Triton (Python). Setup: SETUP.md · Terms: GLOSSARY.md · Sources: REFERENCES.md.
1. TL;DR + Layman analogy¶
TL;DR. Matrix multiply is where essentially all of deep
learning's arithmetic happens — linear layers, attention projections, and convolutions (via implicit
GEMM) are all C = A · B. A naive GEMM does 2N³ FLOPs but issues ~2N³ operand loads, giving an
arithmetic intensity of 0.25 FLOP/byte that is
independent of N — it is pinned to the far-left, hopelessly memory-bound end of the
roofline. Staging T × T tiles in
shared memory reuses each loaded
element T times, so intensity becomes T/4 and scales with the tile. Giving each thread a
small register-held micro-tile of C breaks the tile size free of the 1024-thread block limit,
which is the only way a SIMT kernel gets past the roofline ridge point and becomes genuinely
compute-bound.
Layman analogy — the library. You are computing thousands of sums, and every number you need lives on a shelf in a library across the building.
- Naive GEMM is walking to the shelf, fetching one number, walking back to your desk, adding it, and walking back for the next. The arithmetic is trivial; you spend your entire day walking.
- Shared-memory tiling is checking out a whole stack of
books to a table in the reading room that your study group shares. One trip, then everyone at the
table reuses those books many times before anyone walks back to the shelves. The walking is
amortized over
Tuses. - Register blocking is keeping a couple of pages open in your own hands. No trip to the shared table at all for those — the fastest possible access, but you only have two hands.
The analogy is load-bearing: it correctly predicts that the ratio of work to trips is what matters
(intensity), that a bigger stack means fewer trips (I ∝ T), and that the table has a fixed size, so
a bigger stack means fewer study groups can be seated (shared memory vs
occupancy).
Where it breaks. Three places, and each one is a real GPU constraint the library has no analogue
for. (1) The reading-room table is shared by a team working in lockstep, so before anyone reads a
book, everyone must confirm the whole stack has arrived — that is __syncthreads(), and there is no
polite social equivalent. (2) The corridor to the shelves has a fixed total width shared by all
study groups at once; adding groups does not add bandwidth, which is why occupancy alone never fixes
a memory-bound kernel. (3) Real libraries do not have a special desk that multiplies 16×16 blocks of
numbers in one motion — GPUs do, and they are called
matrix cores (§3).
By the end of this module you can: derive the arithmetic intensity of naive vs tiled vs
register-blocked GEMM from first principles and predict which side of the roofline each lands on;
implement all three in HIP, CUDA, and Triton with correct boundary handling for arbitrary M/N/K;
explain precisely what each __syncthreads() protects; and argue, with numbers, why your kernel
should not and will not beat rocBLAS or cuBLAS.
2. First Principles¶
Why GEMM is the only kernel that really matters¶
Take a transformer layer. A forward pass through it is:
| Operation | Shape | What it is |
|---|---|---|
| QKV projection | [B·S, d] × [d, 3d] |
GEMM |
Attention scores QKᵀ |
[S, d] × [d, S] per head |
GEMM (batched) |
Attention ·V |
[S, S] × [S, d] per head |
GEMM (batched) |
| Output projection | [B·S, d] × [d, d] |
GEMM |
| MLP up / down | [B·S, d] × [d, 4d], [B·S, 4d] × [d] |
GEMM |
| Softmax, LayerNorm, GELU | elementwise / row-wise | not GEMM — but ~1% of the FLOPs |
Convolutions join the list via implicit GEMM (im2col-free reformulation of a convolution as a matrix product), which is how cuDNN and MIOpen implement most conv layers. So: GEMM efficiency is model efficiency. A 2× GEMM improvement is roughly a 2× training-throughput improvement; a 2% improvement in your softmax is noise.
The derivation that explains everything¶
Set M = N = K for clarity, fp32 (4 bytes/element).
Work is fixed. Every output element is a K-long dot product — K multiplies and K adds:
Traffic is a design choice. This is the whole module in one sentence.
Naive. One thread per output element reads a full row of A and a full column of B:
per thread: 2K FLOPs, 2K · 4 = 8K bytes
intensity : 2K / 8K = 0.25 FLOP/byte <- constant, independent of N
whole kernel: N² · 8K bytes = 8N³ bytes
At N = 4096 that is 549.8 GB of HBM traffic to multiply matrices that together occupy
201 MB. You move the data set 2N/3 = 2731 times over.
Tiled. A block computes a T × T tile of C by sweeping K in T-deep slices, staging a
T × T piece of A and of B in shared memory each step:
elements loaded per block = (K/T) · 2T² = 2TK
blocks = (N/T)²
total bytes = (N/T)² · 2TK · 4 = 8N³ / T
intensity = 2N³ / (8N³/T) = T / 4 FLOP/byte
Every element loaded is used T times instead of once. Intensity now scales with the tile:
T |
Intensity | HBM traffic at N = 4096 |
|---|---|---|
| naive | 0.25 | 549.8 GB |
| 16 | 4 | 34.4 GB |
| 32 | 8 | 17.2 GB |
| 64 | 16 | 8.6 GB |
| 128 | 32 | 4.3 GB |
A table states the ratio; it takes a scale drawing to feel it. The two bars below are the first and third rows, at the same scale:
The hairline near the origin is the 201 MB the three matrices actually occupy. Everything to the right of it on the top bar is the same bytes being fetched again.
Where the ridge point is¶
The roofline ridge point is
peak FLOP/s ÷ peak bytes/s — the intensity at which a kernel stops being bandwidth-limited and
starts being math-limited.
| Device (datasheet, approximate) | fp32 vector peak | HBM bandwidth | Ridge point |
|---|---|---|---|
| AMD MI300X (CDNA 3) | ~163 TFLOP/s | ~5.3 TB/s | ~31 FLOP/byte |
| NVIDIA H100 SXM (Hopper) | ~67 TFLOP/s | ~3.35 TB/s | ~20 FLOP/byte |
Read the two tables together and the entire optimization ladder falls out:
- Naive at
0.25is ~100× below the ridge. It is not "a bit slow" — it is running the memory system at full tilt and the FMA units at ~1% duty cycle. T = 32reaches8— a 32× traffic reduction, and still 2.5–4× short of the ridge.- Crossing the ridge needs
T ≥ 80(H100) orT ≥ 124(MI300X).
And here is the wall: with one thread per output element, a T × T tile needs T² threads.
T = 80 means 6400 threads per block, and the hardware maximum is 1024 on both vendors. The
one-thread-per-output design cannot reach the compute-bound regime, ever, on any problem size.
That single fact is the reason register blocking exists. It is worked numerically in
exercises/03_intensity_math.md.
The reuse picture¶
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#F8FAFC',
'primaryTextColor': '#0F172A',
'primaryBorderColor': '#0891B2',
'lineColor': '#64748B',
'secondaryColor': '#E0F2FE',
'tertiaryColor': '#EEF2FF',
'fontFamily': 'Inter, Segoe UI, sans-serif'
}
}}%%
flowchart LR
subgraph NAIVE["Naive: 1 load feeds 1 FMA"]
direction TB
HBM1["HBM<br/>A row + B column"] -->|"2K loads per output"| T1["thread computes<br/>one element of C"]
T1 --> O1["0.25 FLOP/byte<br/>memory-bound"]
end
subgraph TILED["Tiled: 1 load feeds T FMAs"]
direction TB
HBM2["HBM<br/>T x T slice of A and B"] -->|"one coalesced tile load"| LDS["shared memory / LDS<br/>staged tile"]
LDS -->|"read T times by<br/>T different threads"| T2["block computes<br/>T x T tile of C"]
T2 --> O2["T/4 FLOP/byte<br/>scales with tile"]
end
subgraph REG["Register-blocked: 1 LDS read feeds TM x TN FMAs"]
direction TB
LDS2["shared memory / LDS"] -->|"TM + TN reads"| RF["registers<br/>a[TM], b[TN]"]
RF -->|"TM x TN FMAs"| T3["thread computes<br/>TM x TN patch of C"]
T3 --> O3["large tile with only<br/>256 threads -> compute-bound"]
end
classDef warn fill:#FEF3C7,stroke:#F59E0B,color:#0F172A
classDef mid fill:#0891B2,stroke:#0F172A,color:#fff
classDef success fill:#10B981,stroke:#0F172A,color:#fff
classDef neutral fill:#F8FAFC,stroke:#0891B2,color:#0F172A
class HBM1,T1 neutral
class O1 warn
class HBM2,LDS,T2,LDS2,RF,T3 neutral
class O2 mid
class O3 success
Each rung of the ladder does the same trick one level further up the memory pyramid: move a datum
once into faster storage, then extract more arithmetic per move. Naive extracts 1 FMA per global
load; tiling extracts T; register blocking extracts TM × TN FMAs per shared-memory read.
3. Deep Dive¶
The tiled kernel, and what each line is protecting¶
__shared__ float As[TILE][TILE];
__shared__ float Bs[TILE][TILE];
float acc = 0.0f;
for (int t = 0; t < (K + TILE - 1) / TILE; ++t) {
const int aCol = t * TILE + tx;
const int bRow = t * TILE + ty;
As[ty][tx] = (row < M && aCol < K) ? A[row * K + aCol] : 0.0f;
Bs[ty][tx] = (bRow < K && col < N) ? B[bRow * N + col] : 0.0f;
__syncthreads(); // barrier 1
for (int k = 0; k < TILE; ++k) acc += As[ty][k] * Bs[k][tx];
__syncthreads(); // barrier 2
}
if (row < M && col < N) C[row * N + col] = acc;
Why the loads are coalesced. In both staging statements the index that varies with
threadIdx.x is the column of a row-major matrix. Consecutive lanes therefore touch consecutive
addresses and the hardware fuses them into one wide transaction — the
A02 rule, applied to the tile
load itself. Swap the roles and the tile load becomes the new bottleneck even though the compute is
unchanged.
Why : 0.0f and not a skipped write. Zero is the additive identity, so a zero-padded tile
contributes nothing to the dot product while keeping every thread on the same straight-line code
path. A skipped write leaves the previous iteration's value in that shared slot, and it is then
read by every thread in the block. See §6 and exercises/02_ragged_dims.cpp.
The two barriers protect opposite hazards.
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#F8FAFC',
'primaryTextColor': '#0F172A',
'primaryBorderColor': '#0891B2',
'lineColor': '#64748B',
'secondaryColor': '#E0F2FE',
'tertiaryColor': '#EEF2FF',
'fontFamily': 'Inter, Segoe UI, sans-serif'
}
}}%%
sequenceDiagram
participant W0 as Wavefront 0 fast
participant LDS as Shared tile As Bs
participant W1 as Wavefront 1 slow
Note over W0,W1: iteration t
W0->>LDS: write its slots of the tile
W1->>LDS: write its slots of the tile
Note over W0,W1: barrier 1 -- without it W0 reads slots W1 has not written yet [RAW race]
LDS-->>W0: read tile, accumulate
LDS-->>W1: read tile, accumulate
Note over W0,W1: barrier 2 -- without it W0 runs ahead and overwrites the tile W1 is still reading [WAR race]
Note over W0,W1: iteration t+1
- Barrier 1 is a read-after-write guard: the tile must be fully populated before anyone reads it. Drop it and a fast wavefront reads slots a slow one has not written yet.
- Barrier 2 is a write-after-read guard: the tile must be fully consumed before iteration
t+1overwrites it. Drop it and a fast wavefront racing into the next iteration clobbers data a slow wavefront is still accumulating from.
Both failures are nondeterministic and usually small — the answer is mostly right, which is
far worse than being obviously wrong. And the standard rule applies: __syncthreads() must be
reached by every thread in the block, so it can never sit inside divergent control flow. Note that
a single-wavefront block appears to work without barriers on some hardware; that is an accident of
scheduling, not a guarantee, and it breaks the moment someone changes the block size.
Register blocking: decoupling tile size from thread count¶
The tiled kernel's fatal constraint is threads = T². Register blocking breaks it by giving each
thread a TM × TN patch of C:
block tile: BM x BN of C, swept in BK-deep slices
threads: (BM/TM) x (BN/TN)
per thread: acc[TM][TN] kept in registers for the whole kernel
With BM = BN = 64, TM = TN = 4 you get a 64×64 tile from 256 threads instead of an illegal
4096. The inner loop becomes:
for (int kk = 0; kk < BK; ++kk) {
float a[TM], b[TN];
for (int m = 0; m < TM; ++m) a[m] = As[kk][threadIdx.y * TM + m]; // TM shared reads
for (int n = 0; n < TN; ++n) b[n] = Bs[kk][threadIdx.x * TN + n]; // TN shared reads
for (int m = 0; m < TM; ++m)
for (int n = 0; n < TN; ++n) acc[m][n] += a[m] * b[n]; // TM*TN FMAs
}
TM + TN = 8 shared-memory reads feed TM × TN = 16 FMAs. The tiled kernel needed 2 shared reads
per FMA; this needs 0.5 — a 4× reduction in shared-memory traffic per FLOP, which is exactly the
factor TM (for TM = TN). Global intensity also improves, to
BM·BN / (2·(BM + BN)) FLOP/byte — note it is independent of BK, so BK is free to be small
(cheap shared memory, deeper pipelining) without hurting intensity.
The cost is registers. acc[4][4] alone is 16 VGPRs per
thread; an 8 × 8 micro-tile is 64
before you count operands and addresses. Push too far and the compiler spills to scratch memory —
which is HBM wearing a disguise, and instantly undoes everything. Check it explicitly:
hipcc -Rpass-analysis=kernel-resource-usage ... # AMD: VGPR/SGPR/LDS/spill counts
nvcc -Xptxas -v ... # NVIDIA: registers, spill stores/loads
Layout, padding, and the bank conflicts that remain¶
The hip/matmul.cpp register-blocked kernel stores A's tile transposed (As[BK][BM]) so that
the inner loop walks m contiguously. That transposing store is a textbook A02
bank-conflict
generator: with a row stride of BM = 64 words, all 16 loader lanes writing column aCol land in
the same bank. The fix is the same one-word pad:
Two honest notes that matter more than the fix itself:
- The plain tiled kernel needs no padding.
As[ty][k]is the same address for every lane of a 32-lane warp (they sharety) → hardware broadcast, and only two addresses across a 64-lane wavefront;Bs[k][tx]walks 32 consecutive words → 32 distinct banks. Reaching for+1reflexively is cargo-cult; check the access pattern first. - A residual 2-way conflict remains on
Bs[kk][tx * TN + n]: the stride-4 pattern maps lanestxandtx + 8onto the same bank. Padding does not help here because the conflict comes from the stride, not the row length. The real fix is a vectorizedfloat4shared read, which also quarters the instruction count. Left as a stretch goal in §9.
The next two rungs (explained; not implemented here)¶
Double buffering / prefetch. In the kernel above, the block alternates strictly between "load"
and "compute" with barriers between them — the memory pipe idles during compute and the FMA units
idle during load. Double buffering keeps two shared buffers and issues the global loads for slice
t+1 before consuming slice t, so the two overlap:
It costs 2× shared memory and one extra barrier per iteration, and it needs the loads issued early
enough that the latency is genuinely hidden. NVIDIA gives you cp.async (Ampere+) and the
cuda::pipeline API for asynchronous
global→shared copies; AMD CDNA has buffer_load ... lds
direct-to-LDS paths that Composable Kernel and hipBLASLt exploit. This is the single largest
remaining win for a hand-written kernel and it is the reason
CUTLASS parameterizes on a stage
count.
Split-K / Stream-K. When M and N are small but K is huge (common in inference: a
[1, d] × [d, 4d] GEMV-shaped layer), there are not enough output tiles to fill the GPU. Split-K
partitions the reduction dimension across blocks and combines partial sums with a second pass or
atomics. Stream-K generalizes it to balance work across exactly the number of resident CUs. This is
also where GEMM stops being a pure tiling problem and becomes a scheduling problem.
Matrix cores: MFMA and Tensor Cores¶
Everything above optimizes a SIMT fp32 FMA kernel. Modern accelerators have a second, much faster math path that a hand-written SIMT kernel simply cannot reach.
| AMD CDNA (MI200/MI300) | NVIDIA (Volta → Hopper/Blackwell) | |
|---|---|---|
| Name | Matrix Cores, MFMA instructions |
Tensor Cores, mma / wmma / wgmma |
| Granularity | one wavefront (64 lanes) cooperatively | one warp (32 lanes), or a warpgroup on Hopper |
| Typical shapes | v_mfma_f32_16x16x16_f16, v_mfma_f32_32x32x8_f16 |
m16n8k16 (mma.sync), m16n16k16 (WMMA) |
| Inputs / accumulate | fp16 / bf16 / fp8 / int8 in, fp32 accumulate | same family |
| Speedup vs fp32 SIMT | roughly an order of magnitude | roughly an order of magnitude |
| How you reach it | __builtin_amdgcn_mfma_*, rocWMMA, Composable Kernel |
nvcuda::wmma, inline PTX mma.sync, CUTLASS / CuTe |
Three consequences you should be able to state in an interview:
- A hand-written fp32 SIMT GEMM is capped by the vector FMA ceiling, which is roughly 8–16× below the matrix-core ceiling. No amount of tiling closes that gap — you are optimizing toward the wrong roof.
- Matrix cores make the memory problem worse, not better. Raising the compute ceiling by 8×
moves the ridge point right by 8×, so a matrix-core GEMM needs far more reuse (larger tiles,
deeper pipelining, L2-aware scheduling) to stay fed. See the bonus in
exercises/03_intensity_math.md. - The fragment layouts are fixed and non-obvious. Each MFMA/MMA shape dictates precisely which lane holds which element of the A/B fragments and the accumulator. Getting that right by hand, for every shape and precision, is what CUTLASS/CuTe (NVIDIA) and rocWMMA/Composable Kernel (AMD) exist to do for you (A10 is that layer).
CDNA vs Hopper, for this kernel¶
| Concern | AMD CDNA3 | NVIDIA Hopper | Effect on your tile choice |
|---|---|---|---|
| Lockstep width | wavefront = 64 | warp = 32 | Make BN/TN a multiple of the width so a wavefront covers whole rows of the C tile |
| Scratchpad | LDS, 64 KB per CU | shared memory, up to 228 KB per SM | Size tiles against the smaller budget for portability |
| Banks | 32 × 4 B | 32 × 4 B | The +1 padding trick ports unchanged |
| Async global→shared | buffer_load direct-to-LDS |
cp.async / TMA |
Double buffering is spelled differently on each |
| Max block | 1024 threads | 1024 threads | The constraint that forces register blocking, on both |
4. Hands-On Labs¶
All commands run from this module directory.
# AMD
make hip GPU_ARCH=gfx942 # build hip/ programs
make run-hip # run them
# NVIDIA
make cuda SM_ARCH=sm_90 # build cuda/ programs
make run-cuda
# Triton (either vendor)
python triton/matmul.py
The drills are self-contained too: exercises/ and solutions/ each
have their own Makefile, so you can cd exercises && make run without coming back here. From this
directory, make exercises / make solutions build them in place, and make clean cleans all three.
Match
GPU_ARCHto your GPU.gfx950= MI350,gfx942= MI300,gfx90a= MI200. Find yours with/opt/rocm/bin/offload-arch. Troubleshooting forlibamdhip64.soandhipErrorNoBinaryForGpuis in A01 §4.
Lab 1 — The optimization ladder (matmul)¶
Files: hip/matmul.cpp · cuda/matmul.cu.
One program, three kernels, one set of matrices. Each variant is verified against a double-precision CPU reference and timed with GPU events; the program prints ms, GFLOP/s, speedup, and the max relative error.
The problem size is a compile-time constant so you can sweep it without editing code:
make hip MDIM=1024 # default: 1024^3, ~2.1 GFLOP per call
make hip MDIM=2048 # 8x the work; the CPU reference takes noticeably longer
make hip MDIM=1000 # NOT a multiple of 32 or 64 -> exercises every boundary guard
What to observe.
- All three print
PASSED, including atMDIM=1000. Correctness under ragged dimensions is a feature of the kernels, not of the size you happened to pick. naive < tiled < regblockin GFLOP/s, with the largest jump at the first step. The arithmetic is byte-for-byte identical across all three; only the data movement differs.- The
max rel errcolumn sits around1e-6(roughly√K · eps), not0. That is not a bug — see the tolerance note below. - The footer prints each variant's analytic intensity. Check that the measured ordering matches what the intensity predicts.
[!NOTE] Why the tolerance is relative and why it is
1e-4. The GPU accumulates in a different order than the CPU reference (per-tile partial sums vs one running sum), and fp32 addition is not associative — so bit-exact equality is the wrong test; both answers are correct, they just round differently. fp32 haseps ≈ 1.2e-7, and summingKterms accumulates roughly√K · epsof relative error under random rounding: about4e-6atK = 1024. A1e-4bound leaves ~25× headroom for largerKwhile remaining far below the O(1) relative error that any real bug (missing guard, missing barrier, transposed index) produces. Inputs are drawn from[0, 1)so all partial sums are positive: that avoids catastrophic cancellation, which would make a relative check meaningless near zero. The reference itself accumulates indoubleso it is not the thing under test.
Lab 2 — Triton block matmul (triton/matmul.py)¶
File: triton/matmul.py.
The same algorithm one abstraction level up: you declare a BLOCK_M × BLOCK_N tile of C and a
BLOCK_K slice of the reduction, and the compiler chooses the shared-memory staging, the barriers,
and (where available) whether tl.dot lowers onto matrix cores.
| C++ concept | Triton spelling |
|---|---|
| block / workgroup owning a C tile | one program, tl.program_id |
acc[TM][TN] register micro-tile |
acc = tl.zeros((BLOCK_M, BLOCK_N), tl.float32) |
if (row < M && col < N) guard |
mask= on tl.load / tl.store |
the mandatory : 0.0f zero-fill |
other=0.0 |
the entire for kk inner product |
tl.dot(a, b) |
__syncthreads() |
implicit — the compiler inserts it |
What to observe: it prints PASSED plus TFLOP/s for both the Triton kernel and torch.matmul
(which calls rocBLAS/cuBLAS). The vendor library wins. That gap, and what closes it, is §6.
Lab 3 (optional) — Profile the ladder¶
# AMD
make profile-hip # rocprofv3 summary + traces
# NVIDIA
make profile-cuda # nsys timeline; then: ncu --set full ./cuda/matmul.exe
What to look for, in this order:
| Metric | naive | tiled | regblock |
|---|---|---|---|
| DRAM / HBM read throughput | at or near peak | much lower | lower still |
| Memory (DRAM) throughput % vs Compute (SM/CU) throughput % | memory ≫ compute | closer | compute should lead |
| Shared-memory traffic | ~0 | high | ~4× lower per FLOP |
| Shared-memory bank conflicts | n/a | ~0 (see §3) | small, non-zero |
| Registers per thread | low | low | notably higher |
The single most useful view is Nsight Compute's Speed Of Light section (or Omniperf's roofline): watch the point walk right across the roofline as you go down the ladder.
5. Performance Analysis¶
The question, and how to answer it before you measure¶
Same question as always: memory-bound or compute-bound? For GEMM you can answer it on paper, which is what makes this kernel such a good teaching vehicle. Compute the intensity, compare it to the ridge point, and the profiler should merely confirm you.
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#F8FAFC',
'primaryTextColor': '#0F172A',
'primaryBorderColor': '#0891B2',
'lineColor': '#64748B',
'secondaryColor': '#E0F2FE',
'tertiaryColor': '#EEF2FF',
'fontFamily': 'Inter, Segoe UI, sans-serif'
}
}}%%
flowchart LR
N["naive<br/>0.25 FLOP/byte"] -->|"+ shared-memory tiling<br/>reuse each load T times"| T["tiled T=32<br/>8 FLOP/byte"]
T -->|"+ register micro-tile<br/>decouple tile from threads"| R["regblock 64x64<br/>16 FLOP/byte"]
R -->|"+ bigger tile 128x128<br/>+ double buffering"| X["tuned SIMT<br/>32 FLOP/byte"]
X -->|"+ MFMA / Tensor Cores<br/>raises the ceiling itself"| L["rocBLAS / cuBLAS<br/>vendor library"]
RIDGE["ridge point<br/>~20-31 FLOP/byte<br/>(H100 / MI300X, fp32)"]
T -.->|"below: bandwidth-limited"| RIDGE
X -.->|"above: math-limited"| RIDGE
classDef warn fill:#FEF3C7,stroke:#F59E0B,color:#0F172A
classDef mid fill:#0891B2,stroke:#0F172A,color:#fff
classDef success fill:#10B981,stroke:#0F172A,color:#fff
classDef neutral fill:#F8FAFC,stroke:#0891B2,color:#0F172A
class N,T warn
class R mid
class X,L success
class RIDGE neutral
The diagram is the module's thesis: the first three rungs move you right along the roofline toward the ridge, and only the last one raises the roof.
What to expect from Lab 1¶
[!IMPORTANT] The table below deliberately gives no absolute numbers. GEMM throughput depends on the GPU, the clocks, the ROCm/CUDA version, and
MDIM; quoting a number here would be fiction and you would then "fail" to reproduce it. What is reproducible is the ordering and the rough shape.
| Variant | Global intensity | Expected relative throughput | Limited by |
|---|---|---|---|
naive |
0.25 FLOP/byte | baseline, 1× |
HBM bandwidth, hard |
tiled 32×32 |
8 FLOP/byte | typically several × naive | still HBM, plus the 1024-thread occupancy penalty |
regblock 64×64, 4×4 |
16 FLOP/byte | typically a further ~1.5–3× | shared-memory bandwidth and register pressure |
rocBLAS / cuBLAS (torch.matmul in Lab 2) |
— | typically several × regblock again |
matrix cores, near the machine's real ceiling |
Two calibration notes so you can judge your own run:
- A teaching kernel landing at roughly 10–30% of the vendor library is the expected outcome, not a failure. §6 explains exactly what the remaining gap is made of.
- If
tiledis not faster thannaive, suspect a missing barrier (results would also be wrong), an uncoalesced tile load, or a shared-memory allocation so large that only one block is resident.
Reasoning about the delta, not just observing it¶
Use the numbers to build the habit:
- Predict: intensity → side of the ridge → expected limiter.
- Measure: ms and GFLOP/s from the program; throughput percentages from the profiler.
- Reconcile:
achieved GFLOP/s ÷ (intensity × measured HBM GB/s)should be ≈ 1 for a bandwidth-limited kernel. When it is not, something other than your model is limiting you — occupancy, bank conflicts, or spills — and that is the interesting case.
For naive, that reconciliation should land close to 1 (it really is bandwidth-limited). For
regblock it should not, which is your evidence that you have left the memory-bound regime.
6. Challenges, Drawbacks & Tradeoffs¶
The honest headline: this kernel will not beat rocBLAS or cuBLAS¶
Not "might not". Will not. Expect somewhere in the region of 10–30% of vendor-library throughput for the register-blocked fp32 kernel, and less against a library running in bf16/fp8. That is the correct outcome for a kernel whose purpose is to be readable. The gap is made of four things, none of which are accidents:
- Matrix cores. rocBLAS/hipBLASLt emit
MFMA; cuBLAS/CUTLASS emitmma/wgmma. These reach a math ceiling roughly an order of magnitude above the fp32 SIMT FMA path this module uses. You are optimizing toward a lower roof. This alone accounts for most of the gap. - Assembly-level scheduling. Vendor kernels control instruction ordering, software pipelining depth, register allocation, and the exact interleaving of global loads, LDS writes, LDS reads and MFMA issue — often in hand-written assembly (Tensile for rocBLAS) or a template metaprogramming layer that generates it (CUTLASS/CuTe, Composable Kernel).
- Autotuned tile shapes. There is no single best
(BM, BN, BK, TM, TN, stages, warps). The optimum depends on architecture, precision, and the specificM/N/K. Libraries ship thousands of pre-tuned variants and a heuristic (or an on-line tuner) to pick one. Our tile is a single hard-coded compromise. - Shape specialization. Tall-skinny, batched, GEMV-like, and split-K regimes each get a different algorithm entirely. Our one kernel handles them all correctly and none of them optimally.
The production answer is therefore always: call the library. Use rocblas_sgemm / hipblaslt,
cublasGemmEx / CUTLASS, or just torch.matmul. You write GEMM by hand for exactly two reasons:
to understand it (this module), and to fuse something into it that the library cannot — an
epilogue activation, a bias, a quantization step, or an attention-shaped access pattern
(FlashAttention). Fusion, not raw GEMM speed, is where hand-written kernels still win.
Failure-mode decision tree¶
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#F8FAFC',
'primaryTextColor': '#0F172A',
'primaryBorderColor': '#0891B2',
'lineColor': '#64748B',
'secondaryColor': '#E0F2FE',
'tertiaryColor': '#EEF2FF',
'fontFamily': 'Inter, Segoe UI, sans-serif'
}
}}%%
flowchart TD
Q{"Tiled GEMM misbehaving?"}
Q -->|"wrong, nondeterministic"| E1["Missing or misplaced __syncthreads()"]
Q -->|"wrong only on some shapes"| E2["Boundary guard bug"]
Q -->|"wrong by ~1e-3, always"| E3["Precision, not a bug"]
Q -->|"correct but no faster"| E4["Uncoalesced tile load"]
Q -->|"slower than the small tile"| E5["Occupancy cliff or register spill"]
E1 --> F1["One barrier after the load,<br/>one after the compute;<br/>never inside divergent code"]
E2 --> F2["Zero-fill out-of-range tile slots;<br/>guard the store too;<br/>test K not a multiple of TILE"]
E3 --> F3["Compare with a relative tolerance<br/>against a double reference"]
E4 --> F4["Make threadIdx.x index the<br/>fastest-varying dimension"]
E5 --> F5["Check VGPR/LDS use and spills;<br/>shrink TM x TN or BK"]
classDef warn fill:#FEF3C7,stroke:#F59E0B,color:#0F172A
classDef success fill:#10B981,stroke:#0F172A,color:#fff
class Q,E1,E2,E3,E4,E5 warn
class F1,F2,F3,F4,F5 success
Pitfall 1 — The boundary guard that fails silently¶
The classic. Three distinct bugs hide here, and they have different signatures:
| Bug | Symptom | Why |
|---|---|---|
Guarded tile load with no zero-fill, K not a multiple of TILE |
Every element wrong | The stale high-k shared slots are read by every in-range thread |
| Guarded tile load with no zero-fill, only M/N ragged | Nothing — it passes | The load is thread-matched to the read: out-of-range slots are only consumed by out-of-range threads, whose stores the guard discards |
| Missing store guard | Fringe garbage, then out-of-bounds writes | C[row*N + col] with col ≥ N aliases C[row+1][col-N], corrupting valid results before it ever leaves the allocation |
That middle row is the one that bites people: a kernel can pass every rectangular test you throw at
it and still be broken, because you never tested a ragged K.
exercises/02_ragged_dims.cpp walks all three.
Pitfall 2 — Bigger tiles are not monotonically better¶
Doubling TILE doubles intensity and quadruples shared-memory use, halving (or worse) the number
of resident blocks. Past some point the loss of latency hiding beats the gain in reuse. There is no
formula; sweep and measure. This is the same occupancy-vs-reuse tension
A02 §6 raised,
now with a second dimension (TM × TN register pressure) added to it.
Pitfall 3 — Register spilling silently reverts everything¶
Raise TM × TN far enough and the compiler runs out of registers and spills acc to scratch — which
lives in HBM. Your "register-blocked" kernel is now doing global traffic per FMA and is slower than
the plain tiled version. It will still be correct, which is why you must read the resource-usage
output rather than trust the design.
Pitfall 4 — Benchmarking artefacts¶
- No warmup. The first launch pays lazy module load and cold caches. Always discard it.
- Timing with host clocks. Launches are asynchronous; use GPU events (or synchronize explicitly).
- A size that flatters you. Powers of two avoid every boundary path and can also alias badly in
cache. Always also run a ragged size (
make hip MDIM=1000). - Counting FLOPs wrong. GEMM is
2·M·N·K, with the factor 2 for the multiply and the add. Dropping it halves your reported GFLOP/s; adding a spurious factor doubles it.
Pitfall 5 — Numerical expectations¶
fp32 addition is not associative, so a tiled kernel and a naive kernel produce different (both
correct) answers, and switching tile sizes changes the last bits of your output. This matters in
practice: it is why a "bitwise reproducible" training run must pin the GEMM algorithm, and why
comparing two frameworks' outputs with == is always wrong. Accumulate in fp32 even when operands
are fp16/bf16 — that is what matrix cores do natively, and for good reason.
[!TIP]
📐 Reference — tiled GEMM review checklist¶
- Both barriers present? One after the tile load, one after the inner product. Neither inside divergent control flow.
- Every shared slot written every iteration? Value or
0.0f— never a skipped write.- Store guarded on both
row < Mandcol < N?- Tested with
Knot a multiple of the tile? And withK < TILE?- Tile loads coalesced?
threadIdx.xmust index the fastest-varying (column) dimension.- Resource usage checked?
-Rpass-analysis=kernel-resource-usage(hipcc) or-Xptxas -v(nvcc): VGPRs, LDS bytes, and zero spills.- FLOPs counted as
2·M·N·K, timing from GPU events, after a warmup.- Verified against a
doublereference with a relative tolerance, not==.
7. Real-World Use Cases¶
- Every dense layer of every transformer. QKV and output projections, and both MLP matrices, are GEMMs — the overwhelming majority of the FLOPs in training and in the prefill phase of inference. A06 is the kernel behind that number.
- Attention.
QKᵀandscores·Vare batched GEMMs. FlashAttention (Dao et al., 2022) is, at its core, this module's tiling applied to attention so that theS × Sscore matrix is never materialized in HBM — the same reuse argument, applied to a fused kernel. Track A07/A09 build it. - Convolutions. cuDNN and MIOpen implement most convolutions as implicit GEMM, mapping the conv into a tiled matrix product without materializing the im2col matrix.
- The libraries themselves. rocBLAS / hipBLASLt and Composable Kernel on AMD; CUTLASS and cuBLAS on NVIDIA. Reading CUTLASS's efficient GEMM document after this module is the natural next step — you will recognize every layer of its hierarchy.
- Quantized inference. int8/fp8 GEMM kernels in vLLM, TensorRT-LLM, and SGLang are this same structure with a narrower operand type, a dequantization epilogue fused in, and matrix-core inner loops.
- Triton and
torch.compile. Whentorch.compilefuses an epilogue into a matmul, it emits a Triton kernel shaped exactly liketriton/matmul.pyand autotunes the block sizes — the automated version of this module's manual ladder.
Where to go next. B02 generalizes the intensity arithmetic of §2 into a method you apply to any kernel, so "which side of the ridge" stops being a GEMM-only trick. A07 takes up the one reason §6 gives for writing a matmul by hand — fusion — and builds a kernel whose payoff is the epilogue the library will not give you. A10 is where the block → warp → thread tile hierarchy you hand-wrote here becomes a library abstraction, with the matrix-core fragment layouts §3 only described.
8. Cited References¶
Sources shared with other modules are cited by anchor into the master bibliography, where the URL and its retrieval date live. Sources only this module leans on keep their URL here.
- CUDA C++ Programming Guide —
its Shared Memory chapter is the canonical tiled matrix-multiply example Lab 1 follows; its
Warp Matrix Functions (WMMA) chapter is the Tensor Core fragment API §3 points at; and it is the
normative description of the
cp.async/cuda::pipelinestaging §3 describes. - CUDA C++ Best Practices Guide — coalescing, shared memory, occupancy, and the measure-then-optimize discipline used in §5.
- CUTLASS and CuTe (NVIDIA) — production template library for GEMM and the subject of A10. Its Efficient GEMM in CUDA document is the reference for the multi-level (block → warp → thread) blocking of §3 and for the stage-count pipelining that §3 only sketches. https://github.com/NVIDIA/cutlass/blob/main/media/docs/cpp/efficient_gemm.md
- rocBLAS documentation (AMD's BLAS,
rocblas_sgemmand friends) — the library §5's last table row and §6's headline measure you against. Retrieved 8 Aug 2026. https://rocm.docs.amd.com/projects/rocBLAS/en/latest/ - hipBLASLt documentation — the tuned, epilogue-fusing GEMM path on CDNA, and the production answer §6 arrives at. Retrieved 8 Aug 2026. https://rocm.docs.amd.com/projects/hipBLASLt/en/latest/
- AMD Matrix Cores (ROCm blog) — what MFMA instructions do, their shapes, and how to reach them; the AMD half of §3's matrix-core comparison. Retrieved 8 Aug 2026. https://rocm.blogs.amd.com/software-tools-optimization/matrix-cores/README.html
- AMD matrix instruction calculator — enumerates every MFMA shape and its per-lane register layout, which is the "fixed and non-obvious" fragment problem §3 says the libraries exist to solve.
- AMD CDNA 3 white paper — CU structure, LDS, and the matrix-core throughput behind §3's claim that a hand-written fp32 SIMT kernel is optimizing toward the lower roof.
- AMD Composable Kernel — the tile-programming framework behind many ROCm GEMM/fused kernels, and the AMD counterpart to the CUTLASS layering in §3. Retrieved 8 Aug 2026. https://github.com/ROCm/composable_kernel
- Simon Boehm. How to Optimize a CUDA Matmul Kernel for cuBLAS-like Performance: a Worklog — a careful, reproducible step-by-step of exactly the ladder in §5, with measurements. https://siboehm.com/articles/22/CUDA-MMM
- Volkov & Demmel (2008). Benchmarking GPUs to Tune Dense Linear Algebra (SC '08) — the paper that established register blocking and low occupancy as legitimate GEMM strategies. https://doi.org/10.1109/SC.2008.5214359
- Roofline (Williams, Waterman & Patterson, 2009) — the model used throughout §2 and §5, and the source of the ridge-point definition the whole optimization ladder is measured against.
- FlashAttention (Dao et al., 2022) — tiling applied to attention; the §7 payoff.
- Triton documentation and tutorials — the Matrix Multiplication tutorial is the block-level model used in Lab 2, including L2 grouping and autotuning.
- HIP Programming Guide —
__shared__,__syncthreads, launch bounds, and LDS limits.
9. Self-Assessment & Interview Drills¶
Conceptual (answers below)¶
- Why is the naive GEMM's arithmetic intensity independent of
N? Does making the matrices bigger ever help it? - A
T × Ttiled GEMM has intensityT/4FLOP/byte in fp32. Derive it in three lines. - Your GPU's ridge point is 20 FLOP/byte. What tile size does the one-thread-per-output design need to become compute-bound, and why can it never get there?
- There are two
__syncthreads()in the tiled k-loop. Name the hazard each one prevents, and describe the observable symptom of removing each. - A tiled GEMM passes for
1024×1024×1024and for1000×700×1024, but fails for1000×700×1000. What is the bug, and why did the second shape not catch it? - Register blocking gives each thread a
4 × 4micro-tile. By what factor does it reduce shared-memory reads per FLOP, and what resource does it consume instead? - Why does
BKnot appear in the register-blocked kernel's global arithmetic intensity? What isBKtrading off? - You switch your GEMM from fp32 to bf16 with matrix cores and the kernel gets only 2× faster instead of 8×. Give the most likely explanation in one sentence.
- Your hand-written kernel reaches 20% of
torch.matmul. Is that a bug? Justify your answer with two specific mechanisms. - Why does a tiled GEMM produce a numerically different result from a naive one, and why is that not a correctness failure? What does it imply for reproducible training?
Answers
- Each thread does
2KFLOPs and reads2Koperands (8Kbytes), soI = 2K/8K = 0.25— theKcancels. ScalingNscales work and traffic equally. Bigger matrices never improve the naive kernel's position on the roofline; only reuse does. - Per block:
(K/T)iterations ×2T²elements =2TKelements. Blocks:(N/T)². Total bytes= (N/T)² · 2TK · 4 = 8N³/T. WithFLOPs = 2N³,I = 2N³ / (8N³/T) = T/4. T/4 ≥ 20 → T ≥ 80. That needs80² = 6400threads per block against a hardware maximum of 1024, so the configuration is illegal — and even if it were legal,2·80²·4 = 50 KBof shared memory would leave only one resident block, killing latency hiding. One thread per output caps you atT = 32(I = 8), permanently memory-bound. Register blocking is the only escape.- Barrier 1 (after the load) prevents read-after-write: a fast wavefront reading tile slots a
slow one has not written. Barrier 2 (after the inner product) prevents write-after-read: a
fast wavefront starting iteration
t+1and overwriting the tile a slow one is still reading. Both symptoms are the same and are the worst kind: nondeterministic, usually-small errors that change run to run and often disappear under a debugger or a smaller block size. - The tile loads are guarded but do not zero-fill out-of-range slots, so on a ragged
Kthe final k-slice reads stale shared memory. The1000×700×1024shape did not catch it becauseKwas a multiple of the tile: M/N raggedness is self-contained (the load is thread-matched to the read, so out-of-range slots are only consumed by threads whose stores the guard discards). Always test a raggedK. TM + TN = 8shared reads feedTM × TN = 16FMAs, i.e. 0.5 reads/FLOP versus 2 for the plain tiled kernel — a 4× reduction (the factor isTMwhenTM = TN). It spends registers:acc[4][4]is 16 VGPRs before operands, and pushing it too far causes spills to scratch memory (which is HBM), reverting the entire optimization.- Per block the kernel loads
(K/BK) · BK · (BM + BN) = K·(BM + BN)elements —BKcancels, givingI = BM·BN / (2·(BM+BN)).BKtrades shared-memory footprint and pipelining depth (a smallBKleaves room for double buffering and more resident blocks) against loop overhead and barrier frequency. - You became memory-bound. Raising the math ceiling ~8× moves the ridge point ~8× to the right, so the tile that was compute-bound in fp32 no longer supplies enough reuse — the kernel is now waiting on HBM, and the fix is bigger tiles plus double buffering, not faster math.
- Not a bug — that is the expected range for a readable teaching kernel. Two concrete
mechanisms: (a) the library issues matrix-core instructions (MFMA /
mma) whose peak is roughly an order of magnitude above the fp32 SIMT FMA path you are using; (b) the library uses autotuned tile shapes plus deep software pipelining (multi-stagecp.async/ direct-to-LDS loads) selected per architecture and perM/N/K, where you have one hard-coded compromise. Either one alone explains a several-fold gap. - fp32 addition is not associative, and the tiled kernel sums per-tile partial results in a
different order from the naive running sum. Both are correct; they differ by rounding on the
order of
√K · eps. Implication: compare with a relative tolerance against adoublereference, never==; and a bitwise-reproducible training run must pin the GEMM algorithm, tile shape, and split-K strategy, because changing any of them legitimately changes the last bits.
Coding & Algorithms drills¶
Do these in exercises/; reference answers in solutions/. Expected
terminal output (PASS and instructive FAIL states) is in
exercises/README.md → Sample sandbox output.
- Write the tiled kernel (
exercises/01_tiled_matmul.cpp) — from a skeleton, implement the two cooperative tile loads, the barrier before the compute phase, and the inner product. Verify against the provided naive kernel and compare GFLOP/s. Then explain the second barrier, which is given to you. - Fix the ragged dimensions (
exercises/02_ragged_dims.cpp) — the boundary-guard drill. Three shapes, two of which pass with the buggy kernel. Make all three pass, then answer why the M/N-ragged case passed and what an unguarded store would really do. - Arithmetic-intensity drill (
exercises/03_intensity_math.md) — paper only. FLOPs, bytes, and intensity for naive and forT = 16/32/64; find the ridge crossing on a given device; show why one-thread-per-output cannot reach it; then redo it for bf16 + matrix cores and state the new bottleneck.
Stretch¶
- Sweep the tile shape. Parameterize
BM/BN/BK/TM/TNand sweep them. Plot GFLOP/s against shared-memory bytes per block and against registers per thread. You will find a ridge, not a slope — explain it with the occupancy-vs-reuse tradeoff from §6. - Kill the residual bank conflict. Replace the scalar
Bs[kk][tx*TN + n]reads with afloat4vector read (and the tile loads withfloat4global loads). Confirm with the profiler's bank-conflict counter that it drops, and note the instruction-count win as well. - Double buffer it. Add a second shared buffer and issue the global loads for slice
t+1before consuming slicet. Measure. Then read how CUTLASS parameterizes this as a stage count. - Compare against the library. Link
rocblas_sgemm(orcublasSgemm) into Lab 1 as a fourth row in the table. Knowing the real gap, in a number you measured yourself, is worth more than any claim in this README. - Implement one MFMA / WMMA tile. Using
rocWMMAornvcuda::wmma, replace the innermostTM × TNFMA block with a single fixed-shape matrix-core operation for fp16 inputs. Do not expect it to be fast the first time — the exercise is understanding the fragment layout.