all_lessons / modern_gpu / lessons / 10 · GEMM → SOTA lesson 11 / 15

Part III · GEMM from tiled to SOTA

GEMM III — warp specialization & clusters to SOTA

This is the climax of the GEMM ladder. We have a persistent kernel with a TMA-fed software pipeline — correct, prefetching, and still leaving the tensor core idle half the time. The reason is structural, not a tuning knob: one team of threads is being asked to load and compute and write, so it can only ever do one at a time. The forced move is to stop sharing the work and give each engine its own warps. Then we pool two SMs into a cluster, then stack a second math consumer on top — and the kernel lands at cuBLAS parity.

Forced move — the bottleneck from lesson 09
Lesson 09 got us a persistent kernel whose K-loop issues TMA loads ahead of the math via a PIPE_DEPTH=2 ring. But look at who runs that loop: all the threads, in lockstep. Every thread walks the same path — load, then compute, then write — so while it is loading, the tensor cores have nothing to do, and while it is computing, the TMA engine has nothing to do. The software pipeline prefetches the next tile's bytes, but the single team of threads is still the one place where all three engines meet, and a team can only be in one place at a time. That is the "prefetch ≠ overlap" honesty note from lesson 09 coming due. The forced move: give the engines separate warps — a warp whose only job is to feed the TMA, a warp whose only job is to issue the MMA, and warps whose only job is to drain the result — so the memory engine and the math engine run at the same time on independent warps.

The whole ladder, in one table

Before the climax, here is the entire journey on one screen. Every row is a forced move that removed one more thing starving the tensor core. The target throughout: a B200 (148 SMs, 228 KB SMEM/SM), M = N = K = 4096, fp16 in, fp32 accumulate.

stepwhat changedtimevs naive
1naive — one thread per output element, all of it from GMEM70 ms
3SMEM tiling — stage tiles on-chip, reuse across a block53.6 ms~1.3×
4TMA async — the copy engine replaces thread-copy (lesson 09)0.49 ms~142×
7warp specialization — producer / consumer / writeback warps0.23 ms~309×
82-CTA cluster — one cooperative MMA over a 256×256 tile0.104 ms~676×
9multi-consumer — two consumers share one staged B tile0.094 ms~744× · cuBLAS parity

Read the shape of this table, not just the numbers. One step dominates everything: TMA async is ≈142× — the inflection where the kernel stopped paying the thread-copy tax (lesson 09). Everything in this lesson is the long tail that closes the last gap: the software-pipeline-plus-warp-specialization combination is worth roughly 2.2×, clusters another roughly 2.2×, and the final multi-consumer step about 10%. By Step 8 the kernel is compute-bound — the clusters doubled arithmetic intensity and removed the last big memory stalls, so there is simply very little memory latency left to hide. The closing 10% being small is not a disappointment; it is exactly what "we hit the compute ceiling" looks like.

Step 7 — warp specialization: give each engine its own warps

The single-warpgroup kernel from lesson 09 multiplexes three jobs onto one team of 128 threads. Warp specialization assigns those jobs to distinct warps by role, so the memory engine and the tensor-core engine run concurrently on independent warps. We use two warpgroups, WG_NUMBER = 2 (256 threads, 8 warps), and split the roles:

rolewhich warp(s)job
TMA producerwarpgroup 1, warp 3continuously issues TMA loads of A and B into the SMEM ring
MMA consumerwarpgroup 1, warp 0runs the tcgen05.mma the instant a staged tile is ready
writebackwarpgroup 0, all 4 warpsreads the finished accumulator from TMEM and writes it to GMEM

Now the producer warp can be filling stage k+1 of the SMEM ring while the consumer warp is multiplying stage k — the TMA engine and the tensor core are both busy in the same wall-clock window. That is the overlap the software pipeline alone could not deliver, because previously the same threads had to finish loading before they could start computing.

Four barriers = two producer–consumer rings

Concurrency means the warps no longer march together, so we cannot coordinate them with a single block-wide rendezvous. Each handoff between two roles needs its own mbarrier, and each handoff is bidirectional — the consumer must also tell the producer "I'm done with that buffer, you may refill it." So the kernel runs two producer–consumer rings, four named barriers total:

Ring 1 — the SMEM buffer (TMA ⇄ MMA)

tma2mma (a TMABar, TMA→MMA): "SMEM data is ready, go compute." The TMA load self-completes it via expect_tx + complete-tx.

mma2tma (a TCGen05Bar, MMA→TMA): "I've consumed that SMEM buffer, you may overwrite it." The MMA commit arrives on it.

Ring 2 — the TMEM accumulator (MMA ⇄ writeback)

mma2ld (a TCGen05Bar, MMA→writeback): "the TMEM results are ready, go drain them."

ld2mma (an MBarrier, init(128), writeback→MMA): "TMEM is free for the next tile's accumulation." Init count is 128 because all 128 threads of the writeback warpgroup must finish reading before the MMA may overwrite TMEM.

A small state object, PipelineState(PIPE_DEPTH, phase), carries the per-ring bookkeeping: a .stage index (which physical buffer in the ring) and a .phase bit (lesson 06's parity), advanced together by .advance(). This is the same stage-and-phase machinery from lesson 06, now instantiated twice — once per ring.

// PSEUDOCODE (TIRx-style) — the warp-specialized mainloop skeleton.
// Maps to CUTLASS warp-specialized GEMM: producer/consumer warps + PipelineTmaAsync.

WG_NUMBER = 2                       // two warpgroups = 256 threads = 8 warps

if   warpgroup == 1 and warp == 3:  // ── TMA PRODUCER ───────────────────────
    state = PipelineState(PIPE_DEPTH, phase=1)        // NOTE: starts phase 1
    for k in range(num_k_tiles):
        wait(mma2tma, state.phase)                    // buffer free to refill?
        tma_load(A[k], B[k]  ->  Asmem[state.stage], Bsmem[state.stage],
                 arrive=tma2mma[state.stage])         // expect_tx self-completes it
        state.advance()

elif warpgroup == 1 and warp == 0:  // ── MMA CONSUMER ──────────────────────
    state = PipelineState(PIPE_DEPTH, phase=0)        // NOTE: starts phase 0
    for k in range(num_k_tiles):
        wait(tma2mma, state.phase)                    // SMEM tile ready?
        tcgen05.mma(Asmem[state.stage], Bsmem[state.stage], -> TMEM, accum=(k>0),
                    arrive=mma2tma[state.stage])       // free the SMEM buffer
        state.advance()
    tcgen05.commit(arrive=mma2ld)                      // TMEM results ready

elif warpgroup == 0:                // ── WRITEBACK (all 4 warps) ────────────
    wait(mma2ld, phase=0)                              // TMEM ready?
    regs = tcgen05.ld(TMEM)                            // 4-warp readback, lesson 05
    tma_store(regs -> C_gmem)                          // commit_group / wait_group
    arrive(ld2mma)                                     // TMEM free for next tile

The tcgen05.mma here is the Blackwell tensor-core instruction from lesson 03, reading SMEM operands and accumulating into TMEM; tcgen05.ld is the 4-warp TMEM→register readback from lesson 05. The barrier waits lower to mbarrier.try_wait.parity and the loads to cp.async.bulk.tensor — exactly the PTX from lessons 04 and 06.

The subtlest bug: opposite initial phases

Look again at the two PipelineState initializations above. The producer starts phase=1; the consumer starts phase=0. This is not a typo, and getting it wrong is the most insidious failure in the whole kernel. It follows directly from lesson 06's rule that try_wait(bar, phase) passes when the barrier's phase differs from the argument:

So the two ends are deliberately one phase apart: one must run, one must wait, and the opposite initial phases encode exactly that asymmetry on the same fresh barrier.

Give both ends the same initial phase → deadlock, or worse
If you initialize both the producer and the consumer at phase=0, then on the empty barrier both evaluate 0 = 0 and both block — neither side ever starts, a clean deadlock. The more dangerous mirror: give both phase=1 and both pass immediately on the empty barrier — the consumer reads a buffer the producer hasn't filled, the producer overwrites a buffer the consumer hasn't read, and you get silent corruption with no hang to tip you off. The two warps run different code, so this never shows up as a symmetric "everyone waits at one line" bug — it shows up as wrong numbers or a partial hang. The opposite-initial-phase setup is the only correct one.

warpgroup_sync(10), not cta_sync()

Once the warpgroups run different code paths, the block-wide barrier becomes a trap. cta_sync() uses hardware barrier #0 and insists that every thread in the CTA arrive at it. But inside a warpgroup branch — if warpgroup == 1 and warp == 3 — only some of the CTA's threads are even executing that code. A cta_sync() placed there waits for threads that will never arrive, because they took a different branch entirely.

cta_sync() inside a warpgroup branch → deadlock
In the lockstep kernels of lessons 08–09, cta_sync() was the right rendezvous because every thread ran the same code and reached the same line. The moment warps specialize into divergent roles, that assumption breaks: cta_sync() demands all-CTA arrival on barrier #0, but a per-role branch only has a subset of threads present, so the barrier never completes. The fix is a per-warpgroup named barrier: warpgroup_sync(10) uses a named barrier (id 10) scoped to just the 128 threads of one warpgroup, so it rendezvouses exactly the threads that are actually there. Use named warpgroup barriers for intra-role sync; use the four mbarriers for cross-role handoffs; never reach for cta_sync() in specialized code.

A real-toolchain technique the course kernel omits: setmaxnreg

Honesty note — setmaxnreg is a production add-on, not in the source
In real CUTLASS / PTX warp-specialized kernels, the producer and consumer warpgroups reallocate the register file between themselves. The producer warpgroup — which mostly issues TMA descriptors and needs few registers — runs setmaxnreg.dec.sync.aligned.u32 to release registers, and the math-heavy consumer runs setmaxnreg.inc to claim them. This lets the consumer hold a fatter accumulator footprint without dropping occupancy. The MLC course kernel we are following does not implement setmaxnreg — so treat it as a real-toolchain optimization that production kernels layer on top of the warp-specialization structure shown here, not as something present in the course source. The mental model still holds: different roles want different register budgets, and the hardware lets you move them.

Why PIPE_DEPTH=2

The pipeline depth is the number of SMEM buffers in Ring 1. PIPE_DEPTH=2 is the smallest depth that lets load and compute overlap at all: with two buffers, the producer can fill buffer B while the consumer drains buffer A, then they swap. With a depth of one there is only a single buffer, so the producer and consumer would contend for it and serialize again — no overlap. Going deeper (3, 4, …) hides more memory latency, because the producer can run further ahead of the consumer, right up until the SMEM budget runs out (228 KB/SM caps how many tiles you can stage). At depth 2 with warp specialization, the kernel hits 0.23 ms (~309×) — more than double the serial-pipeline Step 4, exactly because the two engines now run concurrently.

Interactive · warp-role choreography vs lockstep

This is the centerpiece. The left timeline is lesson 09's lockstep kernel: one team of threads does load → compute → write, so only one engine is ever lit. The right timeline is this lesson's warp-specialized kernel: the TMA producer (WG1.w3), the MMA consumer (WG1.w0), and the writeback warpgroup (WG0) run as independent lanes, and you can watch the producer fill stage k+1 while the consumer multiplies stage k. The four barriers are drawn as the two rings; step through the startup to see the producer's wait(phase=1) pass immediately while the consumer's wait(phase=0) blocks. The KPIs track the tensor-core busy fraction: lockstep wastes two-thirds of the tensor core (~33%), while specialization keeps it fed back-to-back in steady state (~100%) — the only idle left is the one-tile fill and drain, which amortize away as the K-loop grows. Driving that fraction to 100% is the whole point of the track.

warp specialization · concurrent engines vs lockstep, with the two barrier rings
Left: lockstep (lesson 09) — one team, one engine lit at a time. Right: specialized — producer / consumer / writeback overlap. Step the startup to see the opposite-initial-phase handshake; play to watch the steady state. Tensor-core busy % is computed from the lit spans.
TMA producer · WG1.w3 MMA consumer · WG1.w0 writeback · WG0 idle / blocked
cycle (steady state)
tensor-core busy · lockstep
tensor-core busy · specialized (steady)
startup handshake

Step 8 — the 2-CTA cluster: one cooperative MMA over a 256×256 tile

Warp specialization keeps a single SM's tensor core busy, but lesson 07 showed a bigger lever: pool two SMs. In Step 8 the two CTAs join a thread-block cluster and issue one cooperative tcgen05.mma with cta_group=2 that reads operands from both CTAs' SMEM and produces a single 256×256 output tile (versus 128×128 from one CTA).

The split mirrors lesson 07's cooperation pattern, by rows:

// PSEUDOCODE → CuTe/PTX mapping for the cluster step
B_remote     = map_shared_rank(Bsmem, cta_id=1)   // peer's SMEM  ↔  mapa.shared::cluster
tma2mma_cta0 = tma2mma.remote_view(0)             // raise CTA-0's barrier from CTA-1

// TMA arrive byte count is multiplied by CTA_GROUP — each CTA loads its OWN bytes:
arrive.expect_tx(bytes_per_tile * CTA_GROUP)

// the SINGLE cooperative MMA is issued only by CTA-0 (cbx == 0) and wakes BOTH CTAs:
if cbx == 0:
    tcgen05.mma.cta_group::2(A_pair, B_pair -> TMEM_pair,
                             cta_mask = 0b11)      // 3 = both CTAs' barriers fire

ld2mma.init(128 * CTA_GROUP)                       // = 256: both warpgroups must drain
cluster_sync()                                     // replaces cta_sync() before TMEM dealloc

Three details are worth pinning down, because they are the ones easy to get wrong (and they are this lesson's exercises):

From the course (mapped to hardware)
The toolchain wiring under these pseudocode names: cluster launch is cudaLaunchKernelEx with a cluster-dimension attribute (lesson 07); map_shared_rank is mapa.shared::cluster; cluster_sync lowers to barrier.cluster.arrive / barrier.cluster.wait; the multicast load is cp.async.bulk.tensor … .multicast::cluster; and the cooperative MMA is the Blackwell two-SM tcgen05.mma.cta_group::2. Treat the DSL spellings as concepts and the PTX as the contract.

The payoff: each CTA's B slice is now reused against the other CTA's A slice as well as its own, so the same loaded B bytes feed twice as much math — arithmetic intensity roughly doubles. That is the move lesson 07 promised, and it drops the kernel to 0.104 ms (~676×), already within ~10% of cuBLAS and close to the compute roof. On a B200 whose roofline ridge sits near 250 FLOP/byte (lesson 00), pushing AI up is pushing straight toward the ceiling.

Step 9 — multi-consumer: two math warps share one B tile

Step 8 doubled B's reuse by sharing it across two CTAs. Step 9 squeezes B again, this time within the cluster, by adding a second MMA consumer so two consumers share the same staged B tile. We grow the warpgroup count to WG_NUMBER=3 with NUM_CONSUMER=2, and the cluster output grows from 256×256 to 512×256 (split along M):

rolewarpjob
consumer 0WG2.w0A[…,0] × B → TMEM cols [0:256]
consumer 1WG2.w1A[…,1] × B → TMEM cols [256:512]
TMA producerWG2.w3loads 2 A blocks + 1 B per stage
writeback (consumer 0)WG0drains TMEM cols [0:256]
writeback (consumer 1)WG1drains TMEM cols [256:512]
Why share B and not A? (Exercise 3)
Each consumer handles a different set of M rows (a different A block) but multiplies against the same B tile. So a single B load now feeds 2× the MMA work, and B's load cost per useful FLOP is halved. Sharing A instead would be pointless here: the two consumers already need different A blocks (different rows), so there is nothing to share on the A side. The operand worth sharing is always the one that multiple math units consume identically — that is B. (This rhymes with lesson 07's headline: the win is on the operand you were otherwise re-reading.)

Two implementation facts come with the second consumer:

The result: 0.094 ms (~744×) — cuBLAS parity. The final gain is only ~10%, and that is precisely what to expect this close to the compute ceiling: by Step 8 the kernel was already compute-bound, so there is very little memory stall left to hide. Halving B's per-FLOP load shaves the last sliver of memory traffic, and the kernel sits on the roofline.

Where the roofline ended up

Trace the regime across the ladder: the kernel was memory-bound until roughly Step 7 — the tensor core kept waiting on operands. The cluster steps (8 and 9) doubled arithmetic intensity twice over, removing the last big memory stalls, so by Step 8 the kernel is compute-bound: it is limited by how fast the tensor core can multiply, not by how fast HBM can deliver. That crossover is the whole point of the track's engine — feeding the tensor core was the entire kernel, and once it is fed, the math sets the wall-clock. cuBLAS lives at the same ceiling because it is solving the same feeding problem with the same hardware.

The bridge to Flash Attention 4

Everything assembled in this lesson — TMA loads into an SMEM ring, the tcgen05 cooperative MMA, TMEM readback, and warp-specialized barriers with opposite initial phases — carries straight into the next capstone. Flash Attention 4 reuses all of it. What changes is the schedule: a GEMM is one MMA repeated down the K-loop, but attention wedges an online-softmax step between two MMA phases (score S = QKᵀ, then value O += P·V) instead of repeating a single one. The machinery is identical; the dependency graph is fundamentally harder.

Takeaway
A software pipeline still runs the whole K-loop with one team of threads in lockstep, so only one engine is ever lit. Warp specialization (Step 7) splits the SM into a TMA producer warp, an MMA consumer warp, and a writeback warpgroup, coordinated by four mbarriers = two producer–consumer rings (SMEM: tma2mma/mma2tma; TMEM: mma2ld/ld2mma). The producer and consumer take opposite initial phases so the producer passes its first wait and the consumer blocks — same phase deadlocks or silently corrupts. Use warpgroup_sync(10), never cta_sync(), in specialized code, and know that production kernels add setmaxnreg register reallocation on top (the course omits it). The 2-CTA cluster (Step 8) issues one cta_group::2 MMA over a 256×256 tile with B shared via DSMEM — arithmetic intensity doubles, and the kernel becomes compute-bound. The multi-consumer step (Step 9) shares one B tile across two math warps for a 512×256 tile, halving B's per-FLOP load. The journey: 70 ms → 0.094 ms, ~744×, cuBLAS parity.
Where this connects
The four-barrier choreography is the lesson 06 phase model run twice — and the opposite-initial-phase setup is the doubled form of lesson 06's reuse bug, made structural. The 2-CTA cluster, DSMEM (map_shared_rank), multicast, and cta_group::2 are the cooperation primitives from lesson 07, finally spent on the GEMM they were planted for. Forward, lesson 11 · Flash Attention 4 reuses this exact machinery and adds an online-softmax step between two MMA phases. For the serving-stack context — where FlashAttention plugs into a real inference engine — see gpu_kernels · 12 · the vLLM kernel stack.

We have driven one MMA, repeated, to the compute ceiling. Attention is two MMAs with softmax wedged between — the same machinery, but a fundamentally harder schedule. That is lesson 11 · Flash Attention 4: the algorithm.