all_lessons / modern_gpu / lessons / 12 · FA4 choreography lesson 13 / 15

Part IV · the second capstone and the craft

Flash Attention 4 — warp-role choreography and the edges

Lesson 11 gave the FA4 algorithm: two MMAs with softmax, masking, and rescaling wedged between, all sharing one TMEM allocation. That structure is what makes the kernel hard — it forces many roles and many handoffs. This lesson is the choreography. Who does what, which barriers connect them, and how the real-world edges — causal masking, grouped-query attention, scheduling — fold in.

The bottleneck lesson 11 left us
We have the algorithm: S = QKᵀ·scale → softmax → O += P·V, with the score MMA and the value MMA separated by exp2, row reductions, masking, and an online rescale of O. We even know S, P, and O can share one 128×512 TMEM region by aliasing fp32 and fp16 bytes. What we don't have is the assignment: which of the 512 threads issues the MMAs, which compute softmax, which rescale O, and how a dozen barriers keep them from stepping on each other. The forced move is to stop thinking of FA4 as an algorithm and start thinking of it as a schedule of roles connected by named barriers — because nothing else lets two whole warpgroups do softmax in parallel with the tensor core.
A note on numbers
This page describes structure, not speed. It claims no FA4 performance figures and no FA4-vs-FA3 speedups — none are measured here. The only concrete config quoted is the correctness test at the end (B=1, S=1024, Hq=32, Hkv=8, D=128, fp16). Everything else is the role/barrier graph, which is what you actually have to get right to make the kernel run at all.

The cast: 512 threads, split by function not by data

FA4 launches four warpgroups per CTA — 4 × 128 = 512 threads. The split that matters is not "this warpgroup owns this slice of the output." It is by function: a warpgroup (or even a single warp) is a job title. This is warp specialization from lesson 10, pushed to its limit: GEMM needed a producer, a consumer, and a writeback team; FA4 needs a memory mover, an MMA driver, two softmax teams, a correction team, and a store warp — because there is real CUDA-core work (the softmax) sitting on the critical path between the two MMAs, and it has to overlap with the math.

Thread groupRoleWhat it does
WG3 · warp 1TMA loadIssues the bulk copies that bring Q, K, V from GMEM into SMEM. Touches no math.
WG3 · warp 0MMA driverIssues both the score MMA (S = QKᵀ) and the value MMA (O += P·V) via tcgen05. The whole tensor-core program runs from this one warp.
WG3 · warp 2TMA storeWrites the finished O tile from SMEM back to GMEM.
WG0Softmax · Q stage 0Reads S from TMEM, computes the row-max / exp2 / row-sum, writes P back to TMEM.
WG1Softmax · Q stage 1Same softmax job, but for the other Q pipeline stage — so one stage's softmax overlaps the other stage's MMA.
WG2Correction & epilogueRescales the O accumulator in TMEM when the running max moves, normalizes by row_sum, and stages the output.
The critical asymmetry — the signature of FA4
Every MMA — both the score MMA and the value MMA — issues from WG3 · warp 0 alone. WG0 and WG1 never issue an MMA; they only do softmax. So one warp drives both tensor-core MMAs, while two whole warpgroups (256 threads) do softmax and one more (128 threads) does correction. This is the opposite of the GEMM intuition where the consumer warpgroup is the math. In FA4 the tensor core is driven by 32 threads and fed by everyone else.

Why one warp for both MMAs? Because on Blackwell a tcgen05.mma is issued by a single elected thread anyway (lesson 03) — the warp is just the issuing context, not the compute width. Putting both MMAs on the same warp means the score→value ordering and the TMEM accumulator lifetime are decided in one place, by one program counter, with no cross-warpgroup negotiation. The cost of that simplicity is that everything else — softmax, correction, loads, stores — has to be choreographed around that single MMA stream with barriers.

The wiring: about a dozen named barriers

Lesson 06 taught the mbarrier: an SMEM object with an arrival count and a phase bit, where completion flips the phase and a waiter blocks until the phase differs from what it last saw. GEMM used four such barriers (two producer–consumer rings). FA4 needs roughly twelve, because there are more handoffs: load→score, score→softmax, softmax→value, value→correction, correction→store, plus a brand-new channel that GEMM never had.

TMABar — byte-count completion TCGen05Bar — MMA completion MBarrier — pure thread handoff

The three flavors matter because they complete on different signals. A TMABar flips when the announced byte count lands (TMA's expect_tx); a TCGen05Bar flips when the tensor core says the MMA is done (tcgen05.commit); a plain MBarrier flips on a counted arrival. Mixing them up — waiting on a thread-arrival barrier for a byte transfer that hasn't landed — is one of the classic ways this kernel silently corrupts.

BarrierConnectsMeaning when it flips
q_load.full / .emptyTMA ↔ score MMAA Q tile has landed in SMEM (full) / the MMA is done with that SMEM slot, refill it (empty).
kv_load.full / .emptyTMA ↔ MMAA K or V tile has landed (full) / its SMEM slot is free to refill (empty).
s_readyscore MMA → softmaxS for this block is written and readable in TMEM.
p_o_rescalesoftmax + WG2 → value MMAThe first 96 columns of P are in TMEM and the O slot is safe (rescaled or released).
p_ready_2softmax → value MMAThe final 32 columns of P are in TMEM.
o_readyvalue MMA → epilogueThe O accumulator for this block is complete.
softmax_corr.full / .emptysoftmax → WG2The scalar mailbox. Softmax has deposited acc_scale / the final row_sum in an SMEM slot for the correction warp to read.
corr_epi.full / .emptyepilogue → TMA storeThe normalized output tile is staged and ready to be written to GMEM.
The new thing FA4 adds over GEMM — a scalar channel
softmax_corr.full/empty is not a tile handoff. It is a scalar mailbox: the softmax warpgroup computes a single number per row — the rescale factor acc_scale and, at the end, the row_sum — and passes it to the correction warpgroup through a small SMEM slot, gated by a full/empty barrier pair. GEMM never needed this; its only running state is the accumulator tile, which lives in TMEM and is handed off whole. FA4's running state includes scalars (the max moved by this much), and those scalars have to cross from the team that computed them to the team that applies them. That scalar channel, layered on top of the tile-level barriers, is the coordination signature of attention.

The value MMA waits on three gates at once

The single hardest synchronization point: before WG3·w0 can issue O += P·V, three independent conditions must all hold. It cannot proceed on any two of them.

1 · V in SMEMThe TMA load warp has landed this block's V tile and flipped kv_load.full.
2 · P from softmaxP is written to TMEM by the softmax warpgroup: p_o_rescale covers the first 96 columns, p_ready_2 the last 32.
3 · an O slotWG2 has either released the O slot or finished rescaling it (p_o_rescale doubles as the O-safe signal). The value MMA must not accumulate into a stale O.

That third gate is the subtle one and it is why correction and the value MMA are coupled: the value MMA accumulates into O, but the correction warp also writes O (the rescale). If the MMA accumulated before the rescale, it would add P·V onto an O that is still in the old, wrong scale. The barrier serializes them: rescale first (or release if no rescale was needed), then accumulate.

The interleave: why softmax hides behind the tensor core

Here is the move that makes the whole thing fast. The MMA driver does not run all the score MMAs and then all the value MMAs. Once both Q pipeline stages have primed, it interleaves them, ping-ponging between the two Q stages and between the two MMA kinds:

score  Q0 · K[n-1]
score  Q1 · K[n-1]
value  P0 · V[n-1]
score  Q0 · K[n-2]
value  P1 · V[n-1]
score  Q1 · K[n-2]
value  P0 · V[n-2]
…

Read the dependency: the moment S for Q0 is produced by a score MMA, WG0 can start its softmax on it — and while WG0 chews through exp2 and the row reductions, the MMA driver is already running the score MMA for Q1 and the value MMA for the previous block. Softmax on one Q stage overlaps the MMA work on the other. The inter-phase latency that lesson 11 warned about — the tensor core stalling while CUDA cores do softmax — is hidden because the tensor core always has the other stage's MMA to chew on. Pipeline depths: Q = 2, K/V = 3, TMEM = 2 — two Q stages is exactly what makes the ping-pong possible; one stage would serialize softmax behind MMA again.

Widget · the FA4 pipeline timeline

Five role lanes, a few inner-loop iterations. Watch the MMA-driver lane (lane 2) interleave score and value MMAs while the two softmax lanes (3 and 4) light up on opposite Q stages — softmax on one stage running underneath the MMA on the other. The correction lane (5) fires only when a block's max moved enough to need a rescale. Toggle causal masking to see diagonal-straddling blocks marked (the score MMA still runs; softmax masks the invalid columns to −inf).

FA4 inner loop · five role lanes, the score/value interleave
Lanes: WG3.w1 TMA load · WG3.w0 score+value MMA (interleaved) · WG0 softmax Q0 · WG1 softmax Q1 · WG2 correction. A value MMA for block i runs only after its P and O-slot are ready; the next block's score MMA fills the gap.
current op
MMA driver busy
softmax overlap
rescales fired

The correction: the heart of why FA4 is hard

Online softmax means the running max can change when a new K/V block arrives. When it does, every value already accumulated into O was scaled by the old max and is now in the wrong scale. WG2's job is to fix that, in place, in TMEM. The fix is a full round trip — there is no "scale TMEM" instruction — so the accumulator goes TMEM → registers → TMEM in 16-wide chunks (RESCALE_TILE = 16):

// WG2, per 16-column chunk of the O tile (pseudocode → tcgen05.ld / .st)
o_row  = tcgen05.ld(O_region, chunk)        // TMEM → registers
acc_scale = exp2((m_old - m_new) * scale_log2)
o_row *= acc_scale                           // rescale in registers
tcgen05.st(O_region, chunk, o_row)           // registers → TMEM
tcgen05.wait.st()                            // make the store visible

That round trip over the whole O tile is expensive, so it is guarded twice:

The ordering constraint ties back to the three-gate rule above: the value MMA must not accumulate into O until WG2 has rescaled or explicitly released it. Correction and accumulation share the O region, and the barrier (p_o_rescale) is what keeps one from clobbering the other.

Edge 1 · causal masking — three regions, not one

Causal attention forbids a query from attending to keys in its future. Naively you would compute the full score matrix and then mask the upper triangle — but that wastes the entire above-diagonal half. FA4 uses the same three-region split as FA2/FA3, deciding per (Q block, K/V block) which case applies:

Above diagonal

The K/V block is entirely in the future of every query in this Q block. Never loaded, never computed. get_n_block_max computes the last K/V block that can contribute to a Q block; everything past it is skipped at the scheduler level — no TMA, no MMA.

On the diagonal

The block straddles the diagonal: some columns are valid, some are future. The score MMA still runs on the full tile, but softmax masks the invalid columns to −inf before exp2 (via mask_r2p building a bitmask over 32-wide chunks). Since exp2(−inf) = 0, masked columns contribute to neither the row-max nor the numerator sum.

Below diagonal

Every key is in the past of every query in this block. All columns are valid, so no mask is needed — the plain compute path runs.

Two things follow. First, masking the diagonal block before exp2 (not after) is what keeps the running softmax correct — a future column must never inflate the max or the sum. Second, the diagonal block is the source of work imbalance: an early Q block touches almost no K/V blocks (it can only look a little way back), while a late Q block touches all of them. That imbalance is exactly what motivates a smarter scheduler.

Edge 2 · GQA — free in the compute core

Grouped-query attention shrinks the KV cache by letting several query heads share one K/V head. With num_qo_heads = 32 and num_kv_heads = 8, the ratio is

GQA_RATIO = num_qo_heads / num_kv_heads = 32 / 8 = 4

so four query heads ride on each K/V head. FA4 packs those GQA_RATIO query heads into one Q tile. With BLK_M = 128, each packed head gets SEQ_Q_PER_TILE = BLK_M / GQA_RATIO = 128 / 4 = 32 query positions, and a row index is reinterpreted as

seq_pos = row // GQA_RATIO
q_head  = row %  GQA_RATIO

through a 3D Q-load view. Here is the elegant punchline:

GQA lives only at the boundaries
GQA touches only the Q-load and O-store steps. Inside the compute path nothing changes: the score MMA still sees a plain 128 × HEAD_DIM Q tile, and K and V are loaded once and reused across all GQA_RATIO packed heads. The whole point of GQA — read K/V fewer times — is realized for free, because the packed heads share the same loaded K/V tiles in SMEM. GQA costs nothing in the compute core.

Edge 3 · tile scheduling — which CTA owns which task

FA4 is a persistent kernel (lesson 09): a fixed pool of CTAs stays resident and pulls work. The unit of work is an attention task — a tuple (batch, kv_head, m_block). The scheduler's only job is to decide which task each CTA takes next; it never touches how the tile is computed.

ModeSchedulerWhy
Non-causalFlashAttentionLinearSchedulerEvery task is equal work (full K/V range). A fixed CTA pool advancing evenly is optimal — no balancing needed.
CausalFlashAttentionLPTSchedulerCausal masking makes work unequal: early Q blocks attend to ~1 K/V block, late ones attend to all. Longest-processing-time front-loads the heavy blocks so all CTAs finish around the same time, while still grouping tasks to preserve L2 locality.

The LPT scheduler is the direct payoff of the diagonal-block imbalance from Edge 1: because late Q blocks are demonstrably heavier, scheduling them first balances the finish times instead of leaving one CTA grinding through a long tail while the others sit idle. The governing rule, the same one from the GEMM ladder:

Scheduling vs computing
Scheduling determines which tile a CTA owns, never how it computes that tile. The choreography inside a tile — the roles, the barriers, the interleave, the correction — is identical no matter which scheduler handed the CTA its work. Swapping Linear for LPT changes the order tasks are claimed; it does not touch a single barrier.

Does it actually compute attention? The correctness test

The reference is PyTorch's F.scaled_dot_product_attention with GQA enabled. The test config:

The tolerance is loose on purpose: it has to absorb fp16 rounding, the exp2 reformulation (lesson 11 folded 1/√d and log₂(e) into scale_log2 so the hardware exp2 replaces a software exp), the online-softmax reordering (a streaming sum lands in a different order than a batched one), and the final cast back to fp16. A tight tolerance would flag these as bugs when they are exactly the approximations the kernel is built on.

The whole point, in one table: what FA4 adds over GEMM

Lessons 08–10 built GEMM; lessons 11–12 built FA4. Every difference between them traces to a single structural change — a second MMA with softmax inserted between the phases. The hardware is the same Blackwell tensor core, TMA, TMEM, and mbarriers; FA4 just has more tiles and more handoffs.

AspectGEMM (08–10)Flash Attention 4 (11–12)
MMA phasesone MMA, repeatedtwo MMAs — score, then value
Work between MMAsnoneonline softmax + masking + O rescaling
Running stateaccumulator onlyrow_max + row_sum + O
Main intermediateaccumulator tileS, P, O TMEM regions (aliased)
Warp rolesTMA producer · MMA consumer · writebackTMA load · MMA driver · 2× softmax · correction · TMA store
Barriersmostly load / compute / writeback+ score/softmax/value/correction handoffs + the scalar mailbox
Scheduling unitoutput tileattention task (batch, kv_head, m_block)
Takeaway
FA4 is GEMM with a softmax wedged into the loop — and that one insertion cascades into six roles across 512 threads, ~12 named barriers in three flavors, a scalar mailbox the GEMM never needed, a score/value interleave that hides softmax behind the tensor core, and an in-place TMEM rescale guarded so it skips when the max barely moves. The single elegant simplification is that the hard edges stay at the boundaries: causal masking is three regions decided by the scheduler and a pre-exp2 mask on the diagonal; GQA lives only at Q-load/O-store and is free in the compute core; scheduling picks which task a CTA owns but never how it computes it. The asymmetry to remember: one warp drives both MMAs while two whole warpgroups do softmax.
Where this connects
This is warp specialization (lesson 10 · GEMM III) applied to a kernel with CUDA-core work on the critical path, built on the FA4 algorithm and the shared-TMEM aliasing from lesson 11 · FA4: the algorithm. The fused-attention kernels these mechanisms produce are what a serving stack actually calls — see gpu_kernels/12 · the vLLM kernel stack for where attention plugs into inference. Next, lesson 13 takes on the question this choreography forces.
The bottleneck this hands lesson 13
Look at what we just built: ~12 barriers, two warpgroups running on opposite phases, a scalar mailbox, and fp32/fp16 aliasing sharing one TMEM allocation. One wrong phase, one missing fence, one barrier waited on with the wrong flavor — and the kernel either hangs or silently corrupts. And here is the brutal part: no compiler verifies any of it. The correctness of every handoff is on you. So how do you debug this class of kernel — and what does the compiler actually do when it lowers it? That is lesson 13.