all_lessons / modern_gpu / lessons / 04 · TMA lesson 5 / 15

Part II · the five hardware primitives

TMA — the async bulk copy engine

The tensor core reads its operands from shared memory through a descriptor. But getting the tile into shared memory the old way means the math threads do the copy themselves — computing addresses, issuing loads, bookkeeping the swizzle — burning the exact instruction slots the tensor core needs. The forced move is to take the copy off the threads entirely and hand it to a dedicated hardware unit. That unit is the Tensor Memory Accelerator.

Forced move — the bottleneck from lesson 03
Lesson 03 ended on Blackwell's tcgen05.mma reading A and B from SMEM through descriptors. That settles where the operands are read from. It says nothing about how they got into SMEM — and the classic answer is the problem: the math warps copy the tiles in themselves. Each thread computes a global address, issues a load, tracks the swizzle, and counts off elements, with the entire copy path threaded through the instruction stream of the warps that are supposed to be feeding the tensor core. Every cycle a lane spends on address arithmetic is a cycle the tensor core is starved. The only way out is to stop making the threads copy. Issue one instruction that describes the whole tile and let a separate engine move the bytes while the warps do math.

What the issuing thread actually does

A TMA copy is issued by a single thread. That thread does not loop over elements and it does not compute per-element addresses. It provides exactly two things:

  1. a tensor-map descriptor — a small object that records the tensor's shape, its strides, the element size, the tile shape to copy, and the swizzle mode; and
  2. the SMEM destination address — where the tile should land.

Then it issues the copy and keeps going. The rest of the CTA is free to compute on a tile that already arrived. The engine walks the rectangular region described by the descriptor, reads it from global memory, and writes it into shared memory — asynchronously, off to the side of the warps.

Contrast this with the Ampere world. Ampere's cp.async (the LDGSTS "load-global-store-shared" instruction) is still thread-issued: every lane issues its own async copy of its own slice, so the warp still spends instructions generating 32 addresses even though the store-to-SMEM now bypasses the register file. TMA collapses all of that into one issue from one thread describing the whole tile. That is the difference between "async per element, still on the threads" and "one engine, off the threads."

The descriptor records the layout, including the swizzle

tensor shape the full GMEM tensor's extents (e.g. M × K)
strides byte strides per dimension (row-major, col-major, padded…)
element size bytes per element (fp16 = 2, fp8 = 1, …)
tile / box shape the rectangular sub-region this op copies
swizzle mode SWIZZLE_NONE / 32B / 64B / 128B
fill / OOB mode what to do at tensor edges (zero-fill, etc.)

The swizzle field is the load-bearing one. Swizzle is applied automatically as the bytes land in SMEM — there is no separate "swizzle pass" the kernel runs afterward. The TMA engine permutes bytes on the write so the tile arrives already in the layout the tensor core expects to read. This is the other half of lesson 03's iron rule made concrete: the swizzle mode in the TMA descriptor must match the swizzle mode named in the MMA descriptor (per lessons 02–03), or the tensor core reads scrambled bytes — a finite-garbage bug, not a crash.

3D TMA — one op tiles and swizzles
The SMEM destination is commonly described as a 3-D box: (group, row, col). The inner two coordinates (row, col) address within one swizzle atom (e.g. an 8×128-byte block); the outer group dimension walks across atoms. So a single TMA operation does two jobs at once: it tiles the data into swizzle atoms and it swizzles each atom on the way in. The kernel never writes a manual atom-by-atom swizzle loop — the descriptor's geometry expresses it.

Completion is where loads and stores split apart

Issuing the copy is the easy half. The hard half — and the central teaching point of this lesson — is knowing when it finished, because the consumer cannot touch the tile just because the instruction was issued. The data is in flight. And here loads and stores complete through two different mechanisms, for a simple reason: a load has an in-kernel consumer that must wait; a store usually does not.

TMA load (GMEM → SMEM)TMA store (SMEM → GMEM)
who waits, and why the consumer (the MMA) must wait until the tile is fully present before reading it usually no in-kernel consumer (it's the final output); the kernel only needs to know when the SMEM source is reusable
completion signal mbarrier + byte count commit group + wait group
how the engine does complete-tx updates as bytes land; the barrier flips only when the byte count hits zero cp.async.bulk.commit_group bundles outstanding stores; cp.async.bulk.wait_group N blocks until ≤ N groups remain

The rule, in one line: loads wait via an mbarrier and a byte count; stores wait via a commit/wait group. Keep these straight — using the wrong one is a classic source of silent corruption (lesson 13 catalogs it).

Load completion — the mbarrier and the byte budget

The asymmetry is worth slowing down on, because it is not "wait until the instruction retires." A TMA load completes through an mbarrier (a shared-memory async barrier; lesson 06 covers it in full — this is a preview) in a precise sequence:

  1. init / reuse an mbarrier object for this pipeline stage.
  2. mbarrier.arrive.expect_tx(bytes) — this does two things at once: it records how many bytes the barrier should expect (the tile's byte count), and it counts as the issuing thread's own arrival.
  3. issue the TMA load, pointed at that same mbarrier.
  4. the engine performs complete-tx updates as bytes arrive, decrementing the pending-byte counter.
  5. the barrier's phase flips only when both conditions hold: the arrival count is satisfied and the pending byte count has reached zero.
  6. the consumer waits on that phase; when it flips, the tile is guaranteed fully present and safe to read.

So the completion condition is a conjunction — arrivals done AND bytes == 0. The byte budget is why a plain thread-only barrier cannot do this job: __syncthreads() can wait for threads, but it has no notion of "200 KB are still in flight from a copy engine." The expect_tx count is the hook that lets a barrier wait on a data-movement event rather than a thread-arrival event. (This is exactly why TMA forces lesson 06 into existence.)

Store completion — commit group and wait group

A TMA store typically writes the kernel's final output back to GMEM, where nothing inside the kernel reads it again. There is no consumer to release — so an mbarrier would be ceremony with no reader. What the kernel does need to know is when the SMEM region it stored from is free to be overwritten by the next tile. That is a producer-side question, answered by a lighter mechanism:

// issue one or more bulk-tensor stores SMEM → GMEM …
cp.async.bulk.tensor.2d.global.shared::cta  [tmap, {x,y}], [smem_src];
// … then bundle everything issued so far into a group:
cp.async.bulk.commit_group;
// … later, block until at most N groups are still outstanding:
cp.async.bulk.wait_group 0;          // 0 ⇒ all prior stores have drained
// after the wait, the SMEM source region is safe to reuse.

No byte count, no mbarrier, no phase. You commit a batch and later wait for the batch to drain. That is the whole store protocol.

Interactive · how a TMA load actually completes

The model below runs the load-completion sequence step by step. In TMA mode, one elected thread announces expect_tx(bytes) and issues the copy, then is free; the engine fills the byte budget on its own lane; the mbarrier phase bit flips only when bytes reach 0 and the arrival is in; then the MMA consumer is released to read. Flip to thread-copy mode to watch the alternative: the math warp itself drives the copy, lane by lane, with the tensor core idle the entire time. Step through, or hit play.

TMA load synchronization flow · engine-driven vs thread-driven
TMA mode: issue → expect_tx → bytes land on the engine's lane → phase flips at (bytes==0 ∧ arrived) → consumer reads. Thread-copy mode: the math warp does the copy itself; the tensor core stays idle. Watch which lane is busy.
phase / step
bytes pending
mbarrier phase bit
0
tensor core

Two things the animation is built to make obvious. First, in TMA mode the issuing thread's lane goes idle the instant it issues — the work moves to the engine lane — whereas in thread-copy mode the math lane stays pinned to the copy. Second, the phase bit does not flip early: it waits for the byte counter to hit exactly zero. If you (wrongly) released the consumer on "instruction issued," it would read a half-arrived tile. That single guarantee — flip only at bytes == 0 ∧ arrived — is the entire reason a byte-counting barrier exists.

Why this only pays off inside a pipeline

Issuing one async copy and then immediately waiting for it buys nothing — you've just renamed a synchronous load. TMA earns its keep when the copy for a future tile is in flight while the tensor core computes the current one. That is a pipeline, and it needs at least two SMEM buffers ("stages"):

That choreography is precisely why TMA and mbarriers always appear together in Hopper and Blackwell kernels: TMA moves the bytes off the threads, and the mbarrier is the only thing that can tell the consumer "your stage is ready" and the producer "your stage is free." Neither is useful without the other. We build this double-buffered mainloop explicitly in the GEMM ladder; here, just hold the shape of it.

The real toolchain — PTX and the descriptor object

None of this is a course abstraction; it is the actual CUDA/PTX surface. The device-side instruction family is:

// TMA load: GMEM → SMEM, multidim, descriptor-driven, swizzled,
// completion tracked by an mbarrier via complete-tx byte updates.
cp.async.bulk.tensor.2d.shared::cluster.global.mbarrier::complete_tx::bytes
    [smem_dst], [tensor_map, {coord_x, coord_y}], [mbar];

// the matching arrival that announces the byte budget (see lesson 06):
mbarrier.arrive.expect_tx.shared::cta.b64  _, [mbar], ;

// TMA store: SMEM → GMEM, drained via commit/wait group:
cp.async.bulk.tensor.2d.global.shared::cta  [tensor_map, {x,y}], [smem_src];
cp.async.bulk.commit_group;
cp.async.bulk.wait_group  0;

A few real distinctions worth nailing down:

// host side: encode the tensor map once, pass it as a grid-constant kernel arg
CUtensorMap tmap;
cuTensorMapEncodeTiled(&tmap, CU_TENSOR_MAP_DATA_TYPE_FLOAT16,
    /*rank=*/2, gmem_ptr, gmem_extents, gmem_strides,
    tile_extents, /*elem_strides=*/{1,1},
    CU_TENSOR_MAP_INTERLEAVE_NONE,
    CU_TENSOR_MAP_SWIZZLE_128B,            // MUST match the MMA descriptor
    CU_TENSOR_MAP_L2_PROMOTION_L2_128B,
    CU_TENSOR_MAP_FLOAT_OOB_FILL_NONE);
// kernel signature:
__global__ void gemm(const __grid_constant__ CUtensorMap tmapA, ...);
Takeaway
TMA takes the copy off the math threads. One thread hands the engine a tensor-map descriptor (shape · strides · element size · tile shape · swizzle) plus an SMEM destination; the engine moves the rectangular tile asynchronously and swizzles each atom as it lands, so the tile arrives in the layout the tensor core reads. Completion is asymmetric and you must respect it: loads complete through an mbarrier + an expect_tx byte count — the phase flips only when arrivals are in and pending bytes hit zero — while stores complete through commit_group / wait_group, signalling only that the SMEM source is reusable. TMA pays off only in a pipeline, where it fills a future stage while the tensor core consumes the current one — which is why it never travels without an mbarrier.
Where this connects
The async-copy story starts in the prequel gpu_kernels/09 · tensor cores (its "async copies — the Hopper trick" section sketches cp.async and names TMA); this lesson is the full hardware mechanism behind that sketch. TMA tiles plug straight into the operand descriptors from lesson 03, and they feed the kernels that train the models in cs336 · Language Modeling from Scratch. But the completion machinery has a gap we only previewed: what exactly is an mbarrier, how does the phase bit work across many loop iterations, and what breaks when you reuse one wrong? That is lesson 06 · mbarriers and the phase model.

TMA keeps the operands flowing into SMEM — but the accumulator the tensor core writes has outgrown the register file. Where does it live now? That is lesson 05 · TMEM.