all_lessons / modern_gpu / lessons / 11 · FA4 algorithm lesson 12 / 15

Part IV · the second capstone & the craft

Flash Attention 4 — the algorithm and the two-MMA problem

Lesson 10 reached cuBLAS parity on GEMM: one MMA, repeated, fed by a perfectly choreographed supply chain. Attention reuses all of that machinery — TMA loads, tcgen05 MMAs, the TMEM accumulator, warp-specialized mbarriers — and yet it is fundamentally harder. Not because the hardware is different. Because attention is not one MMA repeated; it is two MMAs with real work wedged between them. This lesson derives the algorithm and shows exactly where that wedge breaks the clean GEMM pipeline. Lesson 12 is the warp choreography that tames it.

Forced move — the bottleneck from lesson 10
The warp-specialized GEMM works because the steady state is monotonous: a producer streams tiles into SMEM, a consumer fires tcgen05.mma into a TMEM accumulator, the rings turn, and nothing ever stands between two MMAs except more of the same MMA. Attention breaks that monotony. Computing O = softmax(Q·Kᵀ / √d) · V requires two different matrix multiplies — first the scores S = Q·Kᵀ, then the output O = P·V — and between them sits a softmax: exponentials and row-wise reductions that run on the CUDA cores, not the tensor core. That softmax sits directly on the critical path between the two MMAs. The clean producer→consumer ring no longer describes the kernel. We have to rebuild the schedule around a tensor-core pipeline that keeps getting interrupted by scalar math.

The memory-bound trap, restated from the roofline

Start with the obvious algorithm and watch it fall off the roofline from lesson 01. Attention over a sequence of length N with head dimension d wants the full score matrix:

S = Q·Kᵀ  ∈  ℝN×N,  then  P = softmax(S / √d),  then  O = P·V

The trap is the size of S. At a sequence length of 4096, the score matrix is 4096 × 4096 fp32 values ≈ 64 MB. That is orders of magnitude larger than the SM's shared memory, and far larger than the single 128×512 TMEM region you just learned to allocate in lesson 05. There is simply nowhere on-chip to put it.

So the naive kernel does the only thing it can: it writes S out to HBM, reads it back to compute the row-wise softmax, writes P back to HBM, then reads it again for the second matmul. The arithmetic is cheap; the traffic is enormous. This is the memory-bound trap — an N×N intermediate that the tensor core can produce far faster than HBM can absorb. The whole point of FlashAttention is to never materialize S.

From the MLC course
This is the same roofline reasoning from lesson 01, now biting on a real workload. The score matrix is the textbook "attention's score-matrix trap": a quadratic intermediate that turns a compute-rich operation into a bandwidth-bound one the moment you spill it. Everything below is the standard FlashAttention reformulation (FA2/FA3 lineage) re-expressed on Blackwell's tcgen05/TMEM machinery.

Online softmax — three running states instead of one big matrix

The escape is to stream K and V in blocks and never hold more than one block's worth of scores at a time. The difficulty is softmax: a normal softmax needs the max and the sum over the entire row before it can normalize anything, and we are refusing to see the whole row at once. Online softmax is the trick that lets a row's normalization be built up incrementally.

For one query row we keep exactly three running states:

For each K/V block we run the update below. It is the entire algorithm; read it slowly, because every later complication is a consequence of these six lines:

// pseudocode — one K/V block, for one query row.   maps to: Score MMA, CUDA-core softmax, Value MMA
S        = Q_block · K_blockᵀ                 // [score MMA]  raw scores for this block
m_new    = max(row_max, rowmax(S))            // extend the running max
scale    = exp( (row_max - m_new) / √d )      // correction factor for everything accumulated so far
P        = exp( (S - m_new) / √d )            // this block's UN-normalized probabilities
row_sum  = row_sum · scale + rowsum(P)        // rescale old denominator, add this block's mass
O        = O · scale + P · V_block            // [value MMA] rescale old output, add this block's contribution
row_max  = m_new                              // commit the new max

The key insight: one scale fixes everything at once

Here is why this works, and it is the single most important idea in the algorithm. The exponentials are computed relative to the running max. When a new block contains a larger score, row_max increases — and the instant it does, every quantity we accumulated under the old max is suddenly in the wrong scale. The old row_sum was a sum of exp(score − old_max); the old O was a weighted sum using those same too-large exponentials.

A single correction factor repairs both:

scale = exp( (old_max − new_max) / √d )   (always ≤ 1, since new_max ≥ old_max)

Multiplying both the denominator row_sum and the output accumulator O by this one scale pulls everything accumulated so far down into the new common scale, so the earlier blocks and the block we are about to add now share the same reference max. One scalar, two corrections — that is what makes "see the row one block at a time" arithmetically identical to "see the whole row at once."

Deferred normalization

Notice what P = exp((S − m_new)/√d) is not: it is not the final, normalized attention weight. We never divide by row_sum inside the loop. The output accumulator O is a sum of un-normalized contributions, kept in a consistent scale by the rescaling. Normalization is deferred to after the last K/V block, where the kernel writes:

Ofinal = O / row_sum

Deferring the divide keeps the inner loop free of a per-block reciprocal on every element of O — one division per row at the very end instead of one per row per block. The inner loop stays lean; the normalization happens once.

The exp2 trick — fold the scale into the exponent

Every line above calls exp(x / √d), and exponentials are expensive scalar ops sitting right on the critical path. Two micro-optimizations collapse into one. First, the hardware has a fast base-2 exponential (the SASS MUFU.EX2 path), not a natural one. Second, the 1/√d scale is a constant. So fold both into a single precomputed constant and evaluate every exponential as a base-2 exp2 on the raw score:

scale_log2 = log2(e) / √d   ⟹   exp(x / √d) = exp2( x · scale_log2 )

Now there is no separate 1/√d multiply and no natural-exp conversion on the critical path — just x · scale_log2 feeding the hardware exp2. This is the standard FA2/FA3 softmax optimization. It looks like a triviality, but it matters precisely because softmax is on the critical path: shaving the exponential shaves the wedge between the two MMAs.

Why this keeps coming up
Fold the constant into the exponent, use the hardware primitive, do the work once. The same instinct that produced scale_log2 here is the instinct behind deferred normalization (one divide, not many) and behind the rescale insight (one factor, two corrections). Attention optimization is, to a surprising degree, softmax optimization.

Why FA4 is harder than GEMM — the crux

We can now state the thesis precisely:

GEMM is one MMA repeated; FA4 is two MMAs with real work wedged between them: online softmax, causal masking, and the rescaling that keeps earlier and later blocks in a common scale.

Trace where each piece runs. The two matrix multiplies — the Score MMA (S = Q·Kᵀ) and the Value MMA (O += P·V) — run on the tensor core. The softmax between them (exponentials plus row-wise reductions for rowmax and rowsum) runs on the CUDA cores. And because the Value MMA cannot start until P exists, and P cannot exist until the softmax finishes, the softmax sits directly on the critical path between the two tensor-core operations. In GEMM, nothing is ever on the critical path between two MMAs except the next MMA. That is the whole difference.

The consequence is the line worth memorizing:

So much of attention optimization is really softmax optimization — reformulating exp and overlapping softmax with the MMAs instead of stalling on it.

The four computational stages (vs GEMM's one)

Where the GEMM mainloop had a single repeated stage (MMA into TMEM), FA4's inner loop has four, and three of them move data across address spaces:

① Score MMA tensor core
S = Q·Kᵀ into TMEM. The first of the two MMAs.
② Softmax numerator CUDA cores
read S from TMEM into registers (in 32-column chunks), compute P = exp2((S − m_new)·scale_log2), write P back to TMEM as fp16.
③ Correction / rescale CUDA cores
when row_max changed: read the old O from TMEM, multiply by scale, write it back. (Also rescales row_sum.)
④ Value MMA tensor core
consume P (TMEM) + V (SMEM), accumulate into O (TMEM). The second of the two MMAs.

Stages ② and ③ are the wedge. Both add TMEM → register → TMEM round-trips that GEMM never had, and both happen between the two MMAs. The conclusion the course draws is exactly right:

FA4 is harder than GEMM not because it relies on different hardware, but because there are more tile values and more handoffs between them.

The tile-primitive graph

Draw the data flow as tiles and the hops between them. The GEMM version of this graph was a straight pipe: GMEM → SMEM → TMEM (accumulate) → GMEM. FA4 inserts two new rows — softmax and correction — and each one adds TMEM↔register traffic:

Q, K, V in GMEM — the inputs.
Q, K, V in SMEM, via TMA load (the producer side from lesson 04).
S in TMEM, via the Score MMA Q·Kᵀ.
P new vs GEMM: softmax numerator — TMEM → registers → TMEM (write P as fp16).
O (rescale) new vs GEMM: correction — when the max moves, TMEM → registers (×scale) → TMEM.
O in TMEM, via the Value MMA P·V (accumulated across blocks).
O in GMEM — normalize O / row_sum, stage through SMEM, TMA store.

The two red rows are the entire cost of attention over GEMM. They are why the next lesson needs so many warp roles: somebody has to do the softmax and the correction while the tensor core is busy, or the pipeline stalls.

Interactive · online softmax, block by block

Watch the three running states evolve as K/V blocks stream in left-to-right for a single query row. Each block computes its own scores; whenever a block raises row_max, a rescale event fires — the old O and row_sum are multiplied by scale (highlighted) so earlier and later blocks share a common scale. Compare the streaming states (three tiny numbers, constant size) against the materialize-full-scores mode (a 64 MB bar that does not fit on-chip). At the end, the deferred normalize divides O by row_sum.

Online softmax · running states vs the 64 MB score matrix
One query row, raw scores Q·Kᵀ streamed in blocks. row_max / row_sum / O update per block; a rising max triggers a rescale (×scale on the old O and row_sum). "Materialize" mode shows what FlashAttention refuses to allocate.
block
— / —
row_max
row_sum
on-chip footprint

The contrast is the whole motivation in one picture: the streaming footprint is three scalars plus a 1×d accumulator — it never grows with N. The materialized score matrix grows as and is already ~64 MB at N = 4096, with nowhere on-chip to live. Online softmax is what makes attention fit.

TMEM layout — S, P, and O share one allocation via fp32/fp16 aliasing

The four stages produce three tile values that all want to live in TMEM: the scores S, the probabilities P, and the output O. TMEM is scarce (lesson 05: 128 Lane × ≤512 Col per CTA). FA4's answer is to make all three share one 128×512 TMEM allocation (N_COLS_TMEM = 512) by overlaying two views on the identical physical bytes:

With MMA_N = 128 and a Q pipeline depth of 2, the layout per stage is:

fp32 view
(512 cols)
S_region · fp32 cols 0–127
O_region · fp32 cols 128–255
fp16 alias
(same bytes)
P_region · fp16 alias over S_region's bytes
(O stays fp32 here)
Why aliasing S and P is legal
It looks like a memory bug — two names for the same bytes — and it would be, except for the barriers. Each region is reused strictly after its previous consumer finishes: S is produced by the Score MMA, consumed by the softmax (stage ②), and only then are its bytes rewritten as P; P is then consumed by the Value MMA. The mbarriers from lesson 06 guarantee that timing — the producer of the next use cannot start until the consumer of the previous use has signaled done. The aliasing is the payoff of getting the phase model right: correct synchronization is what lets you safely overlap two tiles in one buffer.

The constants worth carrying into lesson 12

The FA4 kernel is parameterized by a handful of tile and pipeline constants. They are worth stating once here, because the choreography in the next lesson is built around them:

constantvaluewhat it sizes
BLK_M128query rows per tile (the M dimension).
BLK_N128key/value rows per K/V block (the streamed dimension).
HEAD_DIM128the head dimension d.
MMA_N128the MMA N tile; sets the S/O region widths above.
MMA_K16the MMA K step.
K_SPLIT96 (= 6·MMA_K)the first Value-MMA chunk (the K dimension is split for overlap).
N_COLS_TMEM512the single shared TMEM allocation S/P/O alias into.
Q pipeline depth2how many Q tiles are in flight; sets the per-stage region offsets.
K/V pipeline depth3how many K/V blocks the TMA producer stages ahead.
Honesty — which Flash Attention this is
The target here is Blackwell (tcgen05 / TMEM), and this corresponds to the FlashAttention-4 design (Tri Dao / Together AI) on sm_100. It is distinct from FlashAttention-3 (Hopper sm_90, wgmma, the FP8 ping-pong / warp-specialized softmax schedule) — same algorithmic skeleton, different hardware contract. The reference implementation is verified numerically against PyTorch's F.scaled_dot_product_attention. This page gives no performance numbers, and you should not infer any FA4-vs-FA3 speedups from it.
Takeaway
Attention computes O = softmax(Q·Kᵀ/√d)·V, and the naive route materializes the full N×N score matrix — ~64 MB at N=4096, with nowhere on-chip to put it, so it spills to HBM and goes memory-bound. Online softmax escapes the trap by streaming K/V in blocks and keeping three running states — row_max, row_sum, and the TMEM-resident O — where a single scale = exp((old_max−new_max)/√d) rescales both the denominator and the output whenever the max moves, and normalization is deferred to one O/row_sum at the end. The exp2 trick folds 1/√d and log2(e) into scale_log2 so every exponential is one hardware exp2 on the raw score. The crux: GEMM is one MMA repeated; FA4 is two MMAs — Score (Q·Kᵀ) and Value (P·V) — with a CUDA-core softmax wedged on the critical path between them. That gives four stages instead of one and three tiles (S, P, O) sharing one 128×512 TMEM allocation via an fp32/fp16 alias, legal only because the barriers serialize each reuse. FA4 is harder than GEMM not because the hardware differs, but because there are more tile values and more handoffs.
Where this connects
The TMA loads, tcgen05 MMAs, TMEM accumulator, and warp-specialized barriers are all inherited wholesale from lesson 10 (warp-specialized GEMM) — FA4 changes the schedule, not the primitives. The fp32/fp16 aliasing of one TMEM allocation is the trick previewed at the end of lesson 05 (TMEM), now load-bearing. For where attention sits in a real serving stack, see gpu_kernels/12 · the vLLM kernel stack; for the algorithm's role inside the full LLM, the attention and FlashAttention material in cs336/lessons/.

The algorithm is clear — but four stages, three tiles (S/P/O), and a softmax on the critical path mean many roles and many handoffs: who issues the two MMAs, who runs the softmax, who does the rescale, and how do they hand off without stalling the tensor core? That is lesson 12 · Flash Attention 4: warp-role choreography.