all_lessons / modern_gpu / lessons / 06 · mbarriers lesson 7 / 15

Part II · the five hardware primitives

mbarriers and the phase model

An asynchronous engine that returns immediately needs an asynchronous way to tell you it has finished. The classic block-wide __syncthreads() cannot do this job — it is thread-only, stateless, and can name only a single rendezvous point. The replacement is the mbarrier: a hardware sync object that counts arrivals, counts bytes, and carries a phase bit so it can be safely reused round after round. That phase bit is the single most important — and most error-prone — idea in the whole async paradigm. Get it wrong and the kernel computes silent garbage.

Forced move — the bottleneck from lessons 04–05
Lessons 04 and 05 gave us two engines that return before they finish: a TMA copy issued by one thread that fills an SMEM tile on its own time, and a tcgen05 MMA whose accumulator lands in TMEM some cycles later. The math warps must wait for those — but for what, exactly? __syncthreads() waits for threads to reach a line. It cannot wait for "the copy engine has delivered 32 KB," it has no idea the TMA unit even exists, and it forgets everything the instant it returns. The forced move is a richer object that can: track a byte budget, accept an arrival from a non-thread agent, name several concurrent barriers, and — crucially — persist across loop iterations. That object is the mbarrier, and the mechanism that makes reuse safe is the phase.

Why __syncthreads() is the wrong tool

The barrier from the gpu_kernels world is a synchronous, all-threads-in-the-block, single-point rendezvous. Line up every thread, release them together, done. For the async supply chain it fails on four counts at once:

requirement of the async paradigm__syncthreads()mbarrier
wait for a partial set of arrivals (not all threads)no — all-or-nothingyes — arrival counter
wait until a copy engine has delivered N bytesno — threads onlyyes — transaction-byte count
accept an arrival from a non-thread agent (TMA unit, tensor core)noyes — engines signal it directly
name several barriers and wait on each independentlyno — one implicit pointyes — each is a named SMEM object
persist across many loop iterationsstateless — re-arms by re-runningyes — via the phase bit

An mbarrier ("memory barrier," sometimes "async barrier") is a 64-bit object that lives in shared memory. The kernel allocates it, initializes it, and from then on producers arrive on it and consumers wait on it. Its entire state is two numbers packed into those 64 bits.

The two pieces of state

Everything about correctness flows from understanding exactly what an mbarrier holds:

In the toolchain the object is set up with a single PTX instruction, and the count is the number of arrivals that constitute "done" for one round:

// allocate a 64-bit mbarrier in shared memory, expect `count` arrivals, start in phase 0
mbarrier.init.shared.b64 [bar], count;

The operations

Three operations act on the object. The second one does double duty and is the source of most confusion, so read it twice:

The completion condition, precisely
A round on an mbarrier completes — and the phase flips — exactly when arrival count = 0 AND pending byte count = 0. A pure-thread handoff uses plain arrive and never touches the byte count (it stays 0, so only arrivals matter). A TMA handoff uses arrive.expect_tx(bytes) so the byte budget gates completion as well; the load is "ready" only when the last byte has landed.

The phase flip rule

Here is the mechanism that makes a single barrier reusable forever. Each time a barrier completes all arrivals (and all bytes) for its round, it flips:

phase: 0 → 1 → 0 → 1 → … (toggle on every completed round)

The kernel does not read the bit back from the barrier. Instead it keeps the expected phase in a register and toggles that register after each successful wait. First use is phase 0, so:

In code this is one line — the line whose omission is the canonical bug of the entire paradigm:

int phase = 0;
for (int it = 0; it < num_iters; ++it) {
    // ... producer arrives on `bar` for this round ...
    mbarrier.try_wait.parity.shared.b64  done, [bar], phase;   // block until bit != phase
    // ... consume the freshly-ready data ...
    phase ^= 1;                                                // TOGGLE — distinguish next round
}

The PTX spin is the non-blocking mbarrier.try_wait.parity.shared.b64 in a loop — "parity" is NVIDIA's name for the phase bit. CUTLASS wraps this whole dance as PipelineTmaAsync so you rarely write the spin by hand, but the phase register is still yours to manage.

Interactive · the mbarrier state inspector and the phase-reuse bug

This is the centerpiece of the lesson. Drive an mbarrier by hand: init it, have a thread arrive.expect_tx to announce a byte budget, let the TMA engine deliver bytes in chunks, and wait. Watch the arrival counter and pending-byte count fall to zero, the phase bit flip, and the round complete. Then step through several pipeline iterations and watch the bit toggle 0 → 1 → 0 …

Now flip the "forget phase ^= 1" toggle. The consumer stops toggling its expected phase, so on iteration 1 it waits for phase 0 — which the barrier already left behind in iteration 0. The wait returns immediately on last round's completion, before the new load has landed. The inspector flags the early return: the consumer reads a half-filled tile. That is silent corruption.

mbarrier · arrival counter + byte budget + phase bit, across pipeline iterations
A TMA→MMA handoff on one reused barrier. init(1) expects one arrival; arrive.expect_tx announces a 32 KB budget and counts as that arrival; the TMA engine delivers in 8 KB chunks (complete-tx); wait blocks until both reach zero, then the phase flips. The consumer toggles its expected phase per round — unless you tell it to forget.
barrier phase bit
arrival count (missing)
pending bytes
consumer expects phase

Run it once cleanly, then again with the bug toggle on and watch iteration 1's wait return on the stale completion — the byte bar is still red (data not yet delivered) when the consumer is released.

The reuse bug — the heart of this lesson
Suppose a barrier was used for one TMA load and already completed (phase flipped 0 → 1). The next loop iteration reuses the same barrier. If the consumer does not track phase and calls try_wait(bar, 0) again, the barrier is already in phase 1 — so 0 ≠ 1 is true immediately. The consumer observes last round's completion and assumes the new load is ready. It reads the SMEM tile before the TMA engine has refilled it → it consumes stale or half-written bytes → silent corruption. No crash, no error, just wrong numbers (often row-striped, because each warp races a different stage). The phase bit is exactly what distinguishes "this round's completion" from "last round's completion" on a reused barrier. The bug is always the same one line: phase ^= 1 omitted. It recurs as a checkpoint in the GEMM lessons.

tcgen05 does not arrive on its own

The TMA engine politely issues complete-tx updates as it delivers bytes, so a load self-completes its barrier. The tensor core does not. A tcgen05 MMA finishes asynchronously but advances no barrier unless you explicitly attach an arrival to its commit path:

// the MMA completes async — you must route its completion to a barrier:
tcgen05.commit.mbarrier::arrive  [mma_done];   // the MMA path arrives here when math is done
// the epilogue then waits on mma_done before reading TMEM
// (tcgen05.cp — SMEM→TMEM scale-factor copies — attaches an arrival the same way)

So the rule splits by agent: TMA loads carry their own completion through expect_tx + complete-tx; MMA commits must be wired to a barrier by hand. Forgetting this wiring is its own deadlock — the epilogue waits on a barrier that no one will ever arrive on.

Multi-stage pipelines — one barrier per stage, not per iteration

A pipelined K-loop runs the same TMA → MMA handoff hundreds of times. The naive reading of "one barrier per handoff" would allocate hundreds of barriers — one per iteration. That is wrong, and it would exhaust SMEM. The phase bit exists precisely so you don't have to.

Instead you allocate a small fixed number of SMEM buffers (the "stages" of a software pipeline), a matching fixed number of barriers, and a small set of phase registers. Each logical iteration maps to one physical stage:

stage = iteration mod num_stages

The stage index selects which barrier and which buffer to use; the per-stage phase register (toggled each time that stage is reused) distinguishes the current use from the previous one. A two-stage pipeline is therefore exactly 2 mbarriers + 2 phase registers — not two hundred. Stage 0 handles even iterations, stage 1 handles odd ones, and each stage's phase toggles every time the loop comes back around to it.

This is the structure lesson 09 builds the PIPE_DEPTH=2 double-buffer ring on, and the reason barriers also signal resource reuse: a stage's SMEM buffer cannot be overwritten by the next load until that stage's consumers are done with it — which the barrier's next round enforces.

The three canonical handoffs

Across every modern kernel, mbarriers coordinate exactly three kinds of producer→consumer handoff. Two of them are barrier waits; the first needs an extra ingredient.

handoffproducer signalsconsumer waitsthe subtlety
1. thread → async engine threads write SMEM, then a fence the TMA store / MMA that reads that SMEM thread writes must be made visible to the async proxy first — a barrier is not enough, you need fence.proxy_async.
2. TMA → MMA TMA load completes via expect_tx + complete-tx the MMA path try_waits, then reads the SMEM tile the load's byte budget gates the wait — the MMA reads only after the last byte lands.
3. MMA → epilogue MMA commit arrives via tcgen05.commit.mbarrier::arrive the epilogue try_waits, then reads TMEM the MMA does not self-arrive — you must wire its commit to the completion barrier.

Handoff 1 is the one beginners miss because it looks like a normal barrier problem but isn't. The TMA store and the tensor core operate in a different memory "proxy" than ordinary thread stores; an mbarrier orders arrivals, but it does not by itself flush a thread's SMEM writes into view of the async proxy. A fence.proxy_async is what publishes those writes before the engine reads them. Skip it and the engine reads pre-write bytes — another silent-corruption path, distinct from the phase bug.

Takeaway
__syncthreads() is a stateless, all-threads, single-point barrier; it cannot represent partial arrivals, a transaction-byte budget, arrivals from the TMA unit or the tensor core, or several named barriers at once. The mbarrier can — it is a 64-bit SMEM object holding an arrival counter and a phase bit, and a round completes (and the bit flips) only when arrival count = 0 AND pending bytes = 0. The phase bit is what lets one barrier be reused across hundreds of iterations: the kernel keeps an expected phase in a register, waits for the bit to differ from it, then toggles it with phase ^= 1. Omit that toggle and a consumer sees last round's completion and reads data that hasn't arrived — silent corruption. Allocate one barrier per stage (stage = iteration mod num_stages), wire tcgen05 commits to a barrier by hand, and fence thread→engine writes with fence.proxy_async.
Where this connects
This lesson defines the phase mechanics that the capstones depend on. In lesson 08 · GEMM I, Step 2's K-loop accumulation introduces the phase-flip table as an explicit checkpoint — "forget phase ^= 1 → wait returns early → silent corruption" is the bug it makes you trace by hand. In lesson 10 · GEMM III, warp specialization runs two producer–consumer rings on four named barriers with opposite initial phases (the producer passes immediately, the consumer blocks) — the same phase logic, doubled. And lesson 13 · debugging catalogs phase desync as a top deadlock-and-corruption class, with the tell-tale row-stripe symptom you saw in the widget. The async paradigm puts the entire correctness burden here: no compiler pass verifies your phases or byte counts.

mbarriers coordinate engines within one CTA — but one CTA's SMEM can't stage a big-enough tile, and neighboring CTAs redundantly reload the same operands. How do CTAs cooperate across SMs? That is lesson 07 · clusters, distributed shared memory & CLC.