all_lessons / modern_gpu / lessons / 03 · mma→wgmma→tcgen05 lesson 4 / 15

Part II · the five hardware primitives

Tensor cores across generations — mmawgmmatcgen05

The tensor-core operation D = A·B + C has read the same since Volta. Yet a kernel that saturates one generation is slow — or silently wrong — on the next. The reason is that the tensor core consumes operands in very specific hardware layouts, and three things kept moving underneath that one equation: how the MMA is issued, how the operands are laid out, and where the accumulator lives. Following that drift is what forces every later primitive in this track.

Forced move — the bottleneck from lesson 02
Lesson 02 handed us a layout algebra: shape:stride, the swizzle physical_col = logical_col ⊕ row, and the slogan "layout is part of the instruction interface." But an algebra needs an instruction to be the interface to. The tensor-core instruction is that interface — and it has changed shape three times. Register-held operands and a register-held accumulator scaled fine on Ampere, then stopped scaling as the MMA tiles grew. This lesson tracks what changed and why, because each change is the seed of a later primitive: the accumulator leaving registers forces TMEM, the asynchronous issue forces TMA and mbarriers.

Two constraints that never went away

Before anything moves, fix the two costs that are invariant across all three generations — they are the reason layout is non-negotiable in the first place:

Every generation pays these two taxes. What changes is the machinery around the MMA that decides who pays them and when.

The invariant and the three moving parts

The math never moves: a tile of A (M×K) times a tile of B (K×N) accumulates into C (M×N), producing D. The hardware contract around it has three knobs, and each generation turns one:

who issues the MMA where operands A, B live where the accumulator C/D lives
genMMA instructionABaccumulator C/Dwho issues
Ampere
(sm_80)
mma.sync.aligned.m16n8k16 registers registers registers full warp (32 lanes), synchronous
Hopper
(sm_90)
wgmma.mma_async (.ss/.rs) SMEM descr or regs SMEM descr registers warpgroup (128 threads), async
Blackwell
(sm_100)
tcgen05.mma (cta_group::1/::2) SMEM descr SMEM descr TMEM one elected thread, async

Read the table top to bottom and one column jumps out: the accumulator marches out of the register file — regs → regs → TMEM. That is the through-line of this lesson, and the widget below is built to make it visible. But to see why each move was forced, walk the rows one at a time.

Generations · where the operands and accumulator live

Pick a generation. The diagram redraws where A, B, and the accumulator physically sit (register file vs an SMEM descriptor vs TMEM), and which agent commits the MMA (a full warp vs a 128-thread warpgroup vs a single elected thread). The accumulator box is highlighted as it migrates out of registers — the one change that forces lesson 05.

Operand & accumulator residence across generations
The tensor core's math is fixed (D = A·B + C). What moves is where each operand lives and who issues the instruction. Watch the accumulator leave the register file.
MMA instruction
issued by
accumulator home
issue model

Ampere — registers, fragments, and ldmatrix

On Ampere the whole MMA lives in the register file. The instruction is mma.sync.aligned.m16n8k16; the .sync means all 32 lanes of the warp execute it together, synchronously — it returns when the math is done. (This is the world the gpu_kernels tensor-core lesson teaches; we extend it forward from here.)

Because the operands are register fragments, each lane owns a precise slice of each matrix. For the m16n8 fp32 accumulator the tile is 16×8 distributed over 32 lanes, 4 fp32 values per lane. The exact map: lane l holds rows { l/4 , l/4 + 8 } and columns { 2·(l mod 4) , 2·(l mod 4)+1 }. Each 8×8 half of that fragment — the m8n8 tile that recurs on every generation — is, in CuTe layout notation,

S[(8,4,2):(4@laneid, 1@laneid, 1@m)]

You do not place operands into that layout by hand. The PTX ldmatrix instruction does it:

// load A/B fragments SMEM → registers, doing the cross-lane shuffle
ldmatrix.sync.aligned.m8n8.x1.shared.b16   {r0},               [addr];
ldmatrix.sync.aligned.m8n8.x2.shared.b16   {r0,r1},            [addr];
ldmatrix.sync.aligned.m8n8.x4.shared.b16   {r0,r1,r2,r3},      [addr];
// optional .trans transposes the 8×8 tile as it lands (for B / col-major)
ldmatrix.sync.aligned.m8n8.x4.trans.shared.b16 {r0,r1,r2,r3}, [addr];

ldmatrix loads one, two, or four 8×8 tiles of 16-bit elements and shuffles them across lanes into the fragment registers mma.sync expects. The addressing is the subtle part: for matrix m, row r, the base address is supplied by lane m·8 + r — so .x1 reads its 8 row-base pointers from lanes 0–7, .x2 from lanes 0–15, and .x4 from all of lanes 0–31. You give each lane the SMEM address of one row; the hardware does the transpose-and-scatter.

Two consequences set up everything that follows:

Hopper — the SMEM matrix descriptor, and swizzle becomes a name

Ampere's ldmatrix step is pure overhead: it occupies the warp moving bytes that the tensor core is about to consume anyway. Hopper removes it. wgmma ("warpgroup MMA") reads its operands directly from shared memory through a matrix descriptor — a 64-bit handle the kernel builds once that tells the hardware how the SMEM tile is laid out:

An mma is now issued by a whole warpgroup — 128 threads (4 warps) acting as one — and it is asynchronous: it returns immediately and completes later, which is exactly why lesson 06's mbarriers exist. The two operand-source variants:

The accumulator is still a register fragment — that is the one thing Hopper does not change, and it is the wall Blackwell will hit. The shape family is wgmma.mma_async.sync.aligned.m64nNk16 with N ∈ {8 … 256}, so one instruction can produce up to a 64×256 tile.

The decisive new rule is what the descriptor's swizzle mode implies. On Ampere swizzle was loose index math; on Hopper it becomes a named format:

SWIZZLE_NONE SWIZZLE_32B SWIZZLE_64B SWIZZLE_128B
The contract that bites
The TMA descriptor that writes the swizzled SMEM tile and the wgmma descriptor that reads it must name the same swizzle mode. Name SWIZZLE_128B on the write and SWIZZLE_NONE on the read and the kernel compiles, launches, and computes — on scrambled operands. The output is finite garbage, not a crash. This is "layout is part of the instruction interface" made literal: the layout is now a field inside the instruction's descriptor.

Blackwell — tcgen05.mma, one issuing thread, and the accumulator leaves registers

Hopper kept the accumulator in registers, and that became the ceiling. As MMA tiles grew, a register-held accumulator ate the register budget, which throttled occupancy and capped tile size. Blackwell breaks the link: tcgen05.mma writes its accumulator to TMEM — a dedicated tensor-core memory that is not the register file (lesson 05 is entirely about it). This is the big change.

Three more shifts ride along:

Since the accumulator now lives in TMEM, you need an instruction to get it back for the epilogue:

// accumulator stays in TMEM during the K-loop, then:
tcgen05.ld.sync.aligned...   // TMEM → registers  (epilogue read-back)
tcgen05.st.sync.aligned...   // registers → TMEM  (e.g. correction write-back)
tcgen05.cp...                // SMEM → TMEM        (stage block-scale factors)

Block-scaled low precision

Blackwell also adds block-scaled narrow formats, where a small group of elements shares one scale factor:

This adds two new operands to the MMA: scale tensors SFA(M, K/B) and SFB(N, K/B) for block size B. They are staged SMEM → TMEM via tcgen05.cp, and a scale_vec selector packs them at 1X / 2X / 4X granularity. The point for this lesson: the scale factors are more operands with their own layout contract — the descriptor discipline does not get simpler at low precision, it gets one tensor wider.

The recurring m8n8 fragment

One small object survives all three generations: the 8×8 register fragment. Trace it:

Ampere
ldmatrix builds the 8×8 fragment from SMEM so mma.sync can consume it; the result is written out with per-thread stores.
Hopper
wgmma reads A/B from SMEM descriptors, but writes its accumulator as that same register fragment.
Blackwell
the accumulator lives in TMEM during compute, but tcgen05.ld reloads it into that same 8×8 fragment before the epilogue.

That continuity is not trivia — it is why the same cast-and-store epilogue code (downcast fp32 → bf16, apply bias/activation, write to global) is reusable across all three generations. The accumulator's home changed; the shape it arrives in for the epilogue did not. Lane l still lands on row l/4 of that fragment after a tcgen05.ld, exactly as it did after mma.sync on Ampere.

Takeaway
D = A·B + C is constant; three things drifted under it — who issues the MMA (warp → warpgroup → one elected thread), where A and B live (register fragments → SMEM matrix descriptors), and where the accumulator lives (registers → registers → TMEM). The descriptors did not remove layout work; they made the layout an explicit field inside the instruction. So the iron rule is: the TMA descriptor that writes a swizzled SMEM tile, the MMA descriptor that reads it, and the buffer's actual layout must all describe the same physical arrangement. Disagree and the hardware reads the wrong bytes, or reads them slowly. Layout is part of the instruction interface.
Where this connects
The Ampere half of this story — mma.sync, fragments, ldmatrix, the lane→element map, WMMA — is taught from scratch in the prequel gpu_kernels/09 · tensor cores; this lesson extends it to the async Hopper/Blackwell paradigm. The accumulator's move to TMEM is the entire subject of lesson 05 · TMEM. And the descriptor that stages A and B into swizzled SMEM is the next bottleneck:

Blackwell reads A and B from SMEM via descriptors — but having the math warps copy those tiles in by hand wastes the exact issue slots the tensor core needs. Who loads the tiles? That is lesson 04 · TMA.