cs336 / lessons/07 · kernelslesson 8 / 20

Part II — Systems

Kernels — fusion and FlashAttention

Lesson 06 left you with a verdict about the chip: most of what a language model does — every norm, every softmax, every elementwise add, and attention during decode — is memory-bound. The tensor cores sit idle while the kernel waits on HBM. But that verdict was about a single op in isolation. Run a real model in naive PyTorch and it is far worse: each op is its own kernel, its own launch, its own round trip to HBM. This lesson closes that gap two ways — by fusing chains of memory-bound ops into one trip, and by the headline case of fusion, FlashAttention, which refuses to ever write the T×T score matrix to HBM at all.

The previous step left this broken
Lesson 06 measured ops one at a time against the roofline and found the LM's elementwise/norm/softmax/attention-decode ops parked far below the ridge point — bandwidth-bound, wasting the ~1,000 TFLOP/s of tensor cores. What it did not price is the composition. Naive eager execution runs dropout(bias(x)) then norm(...) then the next thing as three separate kernels: each one loads the whole activation tensor from HBM, does a few FLOPs per element, and writes it back. For a memory-bound chain the bytes moved — not the math — set the wall-clock, and you are paying that bandwidth bill once per op when you could pay it once for the whole chain. Attention is the extreme: it materializes an entire (B·h·T·T) score tensor in HBM, so its memory and traffic grow as O(T²) and it OOMs at long context before it is ever compute-bound.
Linear position
Forced by: memory-bound ops (lesson 6) plus one-kernel-per-op eager execution means the GPU starves on HBM traffic — and naive attention's O(T²) score matrix makes it the worst offender.
New idea: kernel fusion — do many ops per byte loaded, so a memory-bound chain pays one HBM round trip instead of many — and its headline case FlashAttention: tile Q, K, V into SRAM and use the online-softmax running-(max, sum) recurrence so the T×T scores are never written to HBM. Result: attention memory drops from O(T²) to O(T).
Forces next: fusion makes one GPU efficient, but lesson 5's ledger still says a 7B's training state (~112 GB) does not fit on one 80 GB device no matter how fast the kernels run — you must split the work across many GPUs (lesson 8).
The plan
Five moves. (1) Price a kernel launch and an HBM round trip, and show why fusion wins for memory-bound chains (fused dropout+bias+norm, fused softmax, fused cross-entropy). (2) Watch naive attention materialize S = QKᵀ and blow up as O(T²). (3) Derive the online-softmax recurrence — running max m, running denominator , rescale — the trick that lets you stream K, V tiles. (4) Assemble FlashAttention's dataflow and a naive-vs-flash complexity table. (5) See how you actually write these in Triton's tile model. Drive the memory widget until naive OOMs and Flash sails past.

1 · The cost of a kernel: launch + a round trip to HBM

A kernel is one GPU program launched from the host: the CPU tells the GPU "run this function over this grid of threads." Two costs come with every launch. First, launch overhead — on the order of a few microseconds (≈3–10 μs) to queue and dispatch the grid. Second, and usually dominant, the HBM round trip: a typical elementwise kernel reads its input tensor from HBM, does a handful of FLOPs per element, and writes the result back to HBM. The math is trivial; the bytes are not.

Put numbers on it. Take a residual-stream activation of shape (B, T, d) = (8, 4096, 4096) in bf16: that is 8 · 4096 · 4096 · 2 ≈ 268 MB. On an H100 at ≈3.3 TB/s, just reading it costs 268 MB / 3.3 TB/s ≈ 81 μs; a read-plus-write op costs ≈162 μs. The arithmetic — say a bias add and a dropout, two FLOPs per element — is 2 · 134M ≈ 0.27 GFLOP, which the tensor cores would finish in well under a microsecond. The op is ≈99% waiting on memory. This is the §06 roofline restated as a wall-clock: the kernel's time is set by bytes / bandwidth, and the FLOPs are free.

Now chain three such ops — the classic dropout(bias(x)) followed by a LayerNorm. Run eagerly, that is three launches and three round trips: read x, write; read, write; read, write — roughly the tensor's bytes crossing HBM, ≈3 launches. Fusion rewrites the chain as one kernel: load each tile of x from HBM into fast on-chip SRAM/registers once, apply bias, dropout, and the norm while the data is hot, write the final result once. One launch, one read, one write — ≈ the bytes instead of . For a memory-bound chain that is a ≈3× speedup, and it comes purely from moving fewer bytes, not from doing less math.

fused dropout + bias + add + norm
The residual epilogue. Eager = 4 round trips; fused = 1. The canonical "free" win — same FLOPs, a quarter of the traffic.
fused softmax
max, subtract, exp, sum, divide are 5 passes naively. Fuse into one kernel that keeps the row in SRAM and streams it once.
fused cross-entropy
The (B·T·V) logit tensor is huge (V≈100k). A fused CE computes log-softmax + the gather + the gradient without ever materializing the full softmax in HBM — saves both memory and traffic.

The principle generalizes: for memory-bound work, value is measured in FLOPs per byte loaded. Fusion raises that ratio by doing more work per trip to HBM. torch.compile and Triton exist largely to find and emit these fused kernels automatically. The one place where the payoff is so large it gets its own algorithm — not just a fused epilogue — is attention.

2 · Naive attention: the T×T matrix that eats HBM

Recall the attention math from lesson 2. For each head you compute scores, mask, softmax, then weight the values:

S = QKᵀ / √dhead  (T×T)  →  P = softmax(S)  (T×T)  →  O = P·V  (T×dhead)

A naive implementation does this literally: it computes S, writes the full (B, h, T, T) tensor to HBM, reads it back to apply the mask and softmax, writes P, reads it back to multiply by V. The killer is the shape of S: it is quadratic in the sequence length T, and it lives in HBM.

Make it concrete. Take B = 8, h = 32 heads, bf16 (2 bytes). The score matrix alone is B · h · T² · 2 bytes:

context TS = QKᵀ in HBM (B·h·T²·2 bytes)verdict on one 80 GB H100
2,0488 · 32 · 2,048² · 2 ≈ 2.1 GBfits, but already wasteful
8,1928 · 32 · 8,192² · 2 ≈ 34 GBdominates the device
16,3848 · 32 · 16,384² · 2 ≈ 137 GBOOM — past 80 GB
32,7688 · 32 · 32,768² · 2 ≈ 549 GBOOM by 7×

Two separate problems, both fatal. Memory: the score matrix grows as O(T²) and OOMs at modest context — long-context training is simply impossible this way. Traffic: even when it fits, every byte of that O(T²) tensor is written to HBM and read back two or three times, so attention is bandwidth-bound — the matmuls themselves are cheap relative to the scores being shuttled in and out. The output we actually want, O, is only (B, h, T, d_head) — linear in T. We are paying O(T²) memory to produce an O(T) result. The entire trick is to never write the big intermediate down.

3 · The online-softmax recurrence

The obstacle is softmax. Softmax over a row needs the whole row at once: you subtract the row max (for numerical stability — elarge overflows), exponentiate, and divide by the row sum. That seems to demand having all T scores in hand, which is exactly the O(T²) matrix we want to avoid. Online softmax dissolves that demand: it computes the same softmax incrementally as scores arrive in tiles, carrying just two running scalars per query row.

Process the key/value sequence in blocks. Keep, for each query, a running maximum m (the largest score seen so far), a running denominator (the sum of exponentials, shifted by m), and a running output accumulator o. When a new block of scores s arrives:

mnew = max(m, max(s))   |   correction = e(m − mnew)
ℓ ← ℓ · correction + Σ e(s − mnew)   |   o ← o · correction + Σ e(s − mnew) · v

The single subtle step is the rescale. When a later block contains a larger score, the running max jumps, and every exponential computed against the old max is now off by a factor e(mold − mnew). So before folding in the new block you multiply the old and o by that correction, bringing the running totals onto the new reference. At the end, the true softmax-weighted output is just o / ℓ. Critically, m, , and o are tiny — a couple of scalars and one d_head-vector per query — so you can stream arbitrarily many K, V tiles past a query while holding constant memory. This is the same running-reduction pattern Triton teaches for blocked reductions (cross-link below); FlashAttention is online softmax plus a value accumulator.

for each query row q:
    m = -inf;  l = 0;  o = 0                 # running max, denom, output
    for each K,V tile (Kj, Vj):              # stream tiles; never store all of S
        s   = q @ Kj.T / sqrt(d_head)        # this tile's scores  (block-wide)
        m_n = max(m, max(s))                 # update running max
        c   = exp(m - m_n)                   # correction for the jump
        p   = exp(s - m_n)                   # rescaled exponentials, this tile
        l   = l * c + sum(p)                 # rescale old denom, add new
        o   = o * c + p @ Vj                 # rescale old output, add new
        m   = m_n
    out = o / l                              # exact softmax(QK^T)V, O(T) memory

4 · FlashAttention: tile, accumulate in SRAM, never write S

FlashAttention (Dao et al., 2022; FlashAttention-2/3 since) is the online-softmax recurrence laid out as one fused CUDA/Triton kernel that is aware of the memory hierarchy. The dataflow:

tile Q, K, VSplit Q into row blocks and K, V into column blocks sized to fit SRAM (tens of MB). Each thread block owns one Q tile and loops over all K, V tiles.
load to SRAMBring a Q tile and the current K, V tile from HBM into on-chip SRAM once. All the per-tile matmuls and exponentials happen on hot data at ≈20× HBM bandwidth.
online accumulateRun the running-(m, ℓ, o) recurrence over the K, V tiles for that Q block. The T×T scores for the tile live only in SRAM/registers and are discarded after use.
write O onceWhen the K, V loop finishes, divide by ℓ and write the Q tile's output O — shape (T, d_head), linear in T — back to HBM. S is never materialized in HBM.
backward = recomputeStore only O plus the per-row statistics (m, ℓ) — a few scalars per query, O(T). In the backward pass, recompute S tile-by-tile from Q, K (cheap matmul) instead of reading a stored S that was never written.

That last move is the key trade in the systems half made local to one kernel: it is activation checkpointing for attention (lesson 5). Recomputing S in the backward costs extra FLOPs, but FLOPs are exactly what was free for this memory-bound op — so you spend the cheap resource to save the scarce one (HBM bytes and bandwidth). Because the score matrix never touches HBM, attention memory is O(T) and the HBM traffic collapses from the O(T²) of shuttling S to the O(T·d) of streaming K, V once.

quantitynaive attentionFlashAttention
extra memory for scoresO(B·h·T²) — S in HBMO(B·h·T·dhead) — only O; S stays in SRAM
HBM trafficO(T²) — write + reread S a few timesO(T·d) — stream Q, K, V tiles once
compute (FLOPs)O(T²·d)O(T²·d) + recompute in backward
bottleneckmemory-bound — bandwidth-limited, OOMs at long Tcompute-bound — uses the tensor cores
longest feasible TOOM near ~12ktens to hundreds of thousands

The headline is not just "less memory" — it is that FlashAttention moves attention across the roofline. Naive attention is memory-bound; the fused, tiled version keeps the data on-chip and finally puts the tensor cores to work, so it is both more memory-frugal and 2–4× faster in wall-clock at long context. This is the difference between 2k-token toy models and the 128k-context models that ship.

5 · How you actually write these: the Triton tile model

You will not write FlashAttention in raw CUDA by hand in this course — you will write it in Triton, the Python-embedded kernel language whose whole abstraction is the tile. In Triton you write the program for one block: a @triton.jit function receives pointers and a program_id, computes which tile of the output it owns, loads input tiles with tl.load into what becomes SRAM/registers, does block-level matmuls and reductions (tl.dot, tl.max, tl.sum) on that hot data, and writes its tile back with tl.store. The compiler handles the threads-within-a-block; you reason at the granularity of tiles and HBM↔SRAM movement — exactly the level at which fusion and FlashAttention are designed.

@triton.jit
def flash_fwd(Q, K, V, O, L, sm_scale, T, d, BLOCK_M, BLOCK_N):
    pid = tl.program_id(0)                    # this block owns one Q tile
    q   = tl.load(Q + q_tile_offsets)         # (BLOCK_M, d) into SRAM
    m   = tl.full([BLOCK_M], -float('inf'))   # running max  (per query row)
    l   = tl.zeros([BLOCK_M])                 # running denom
    acc = tl.zeros([BLOCK_M, d])              # running output
    for j in range(0, T, BLOCK_N):            # stream K,V tiles
        k = tl.load(K + j_offsets)            # (BLOCK_N, d)
        v = tl.load(V + j_offsets)
        s = tl.dot(q, tl.trans(k)) * sm_scale # (BLOCK_M, BLOCK_N) in SRAM
        m_new = tl.maximum(m, tl.max(s, 1))
        c     = tl.exp(m - m_new)             # rescale factor
        p     = tl.exp(s - m_new[:, None])
        l     = l * c + tl.sum(p, 1)
        acc   = acc * c[:, None] + tl.dot(p, v)
        m     = m_new
    tl.store(O + o_offsets, acc / l[:, None]) # write O once; S never hit HBM
    tl.store(L + l_offsets, m + tl.log(l))    # save logsumexp for the backward

This is exactly CS336 Assignment 2: you implement fused kernels and a FlashAttention forward + backward in Triton, then benchmark them against the naive PyTorch version and watch the memory and time curves separate. For the full line-by-line derivation see the deeper treatments — triton_kernels · 12 FlashAttention for the kernel itself, triton_kernels · 06 reductions for the online-softmax/running-reduction pattern, and gpu_kernels · 12 the vLLM kernel stack for how fused attention and paged KV are wired into a production serving engine.

6 · Drive it: naive vs Flash memory

The widget below plots the one number that decides everything: HBM bytes for the attention score path, as you slide context length T (and batch B, heads h). Naive grows as O(T²) — it materializes S. FlashAttention grows as O(T) — it only ever writes O plus a couple of scalars per row. The dashed line is one 80 GB H100. Watch where each curve crosses it: naive OOMs at around ten thousand tokens (~12k), while Flash is still a rounding error at 100k. Toggle to relative HBM traffic to see the same gap in bandwidth, which is the speed story.

Attention memory: naive vs FlashAttention
Slide context T. Naive attention's score matrix is B·h·T²·2 bytes in HBM — it crosses the 80 GB line and OOMs at modest T. FlashAttention keeps only the output and per-row stats, ≈B·h·T·d·2 bytes — linear in T, so it scales far past. The knob that breaks: push T right and watch the naive bar turn red while Flash stays flat.
Naive — S in HBM
Flash — O + stats
Naive / Flash ratio
Naive fits 80 GB?
naive O(T²) FlashAttention O(T) 80 GB H100 mode: HBM bytes

The shape of the two curves is the whole lesson. Below modest T they look comparable; then the naive parabola lifts off and slams into the 80 GB ceiling while the Flash line stays nearly on the floor. Long context is not a clever inference trick — it is a direct consequence of a kernel that refuses to write down an O(T²) intermediate.

Failure modes & checklist

Failure modes

  • Optimizing a compute-bound op by "fusing" it. Fusion helps memory-bound chains; a big GEMM is already compute-bound, so fusing its epilogue saves little. Signal: you fused everything and the profiler still shows the matmul kernel dominating — that op was never bandwidth-limited.
  • Online softmax without the rescale. Forgetting to multiply the running and o by the correction when the max jumps. Signal: numerically off-by-a-factor outputs that look "almost right," worse for later tiles where the max grows.
  • Tiles too big for SRAM. Picking BLOCK_M/BLOCK_N so the working set spills out of SRAM back to HBM, silently undoing the whole point. Signal: occupancy/register-spill warnings and a kernel no faster than naive.
  • Forgetting the backward recompute. Trying to store S for the backward "to be safe" reintroduces the O(T²) HBM tensor. Signal: forward is FlashAttention-fast but memory still scales as — you never freed the scores.
  • Assuming Flash changes the math. Treating it as an approximation. Signal: chasing a numerical "discrepancy" against naive — there is none beyond float noise; FlashAttention computes the exact softmax attention.

Checklist

  • Profile before fusing. Confirm the op is memory-bound (FLOPs/byte below the §06 ridge) — that is where fusion pays.
  • Count round trips, not ops. Cost of a memory-bound chain ≈ (bytes moved) / bandwidth; fuse to make it one read + one write.
  • Carry (m, ℓ) and rescale. Any streaming softmax needs the running max, running denom, and the correction factor on every max jump.
  • Size tiles to SRAM. Keep Q, K, V tiles + accumulator on-chip; tune BLOCK sizes to occupancy.
  • Recompute, don't store, in backward. Save only O and the logsumexp per row; regenerate S from Q, K.
  • Use the library in production. Hand-roll once to understand it (Assignment 2); ship F.scaled_dot_product_attention / FlashAttention.

Checkpoint

Try it
Open the widget and set B = 8, h = 32. Slide T up until the naive bar first turns red (crosses 80 GB) — note the context length; it lands around 12.8k (just past the 8k-fits / 16k-OOMs rows of the §2 table). Now read the Flash KPI at that same T: it is well under a gigabyte. Compute the ratio by hand at T = 16,384 and d_head = 128: naive is ∝ T larger than Flash because T² / (T·d_head) = T/128 ≈ 128×. Then flip the "relative HBM traffic" toggle and confirm the bandwidth gap grows the same way (same O(T) vs O(T²) shape) — that traffic gap is why FlashAttention is also 2–4× faster, not just smaller. Finally, predict: if you double h, does the crossing T move? (It does — naive scales with h·T², so more heads OOM you sooner.)

Where this points next

You have now made a single GPU efficient: fused epilogues keep memory-bound chains to one HBM round trip, and FlashAttention turns attention from an O(T²) memory hog into a compute-bound, long-context-capable kernel. The chip is no longer the thing wasting your budget. But efficiency on one device does not change the ledger from lesson 5: a 7B model's training state — bf16 params + fp32 master + Adam's two moments + grads — is ≈112 GB, and that overflows an 80 GB H100 before a single activation is stored, no matter how fast every kernel runs. Fusion shrank the activation term and made attention fit; it cannot shrink the optimizer state below one device. The model still does not fit. The only move left is to stop fitting it on one GPU and spread the work — parameters, gradients, optimizer state, and the batch — across many. That is lesson 8, Data parallelism — DDP → ZeRO → FSDP.

Takeaway
For memory-bound work — the bulk of an LM's non-GEMM ops — the wall-clock is set by bytes moved across HBM, not FLOPs, so the lever is fewer round trips per byte loaded. Kernel fusion collapses a chain of memory-bound ops (dropout + bias + add + norm, softmax, cross-entropy) into one load-compute-store, turning ≈6× traffic into ≈2×. Its headline case is FlashAttention: naive attention materializes the (B·h·T·T) score matrix in HBM, costing O(T²) memory and traffic and OOMing at around ten thousand tokens (~12k). The online-softmax recurrence — carry a running max m, running denominator , and output accumulator o, rescaling by e(mold−mnew) whenever the max jumps — lets you stream K, V tiles while holding constant memory, so the scores live only in SRAM and are never written down. Tile Q, K, V to SRAM, accumulate, write only the O(T) output, and recompute S from saved (m, ℓ) in the backward (cheap FLOPs to save scarce bytes). The result is O(T) attention memory, collapsed HBM traffic, a 2–4× speedup, and the 100k-context models that ship. You write all of this in Triton's tile model — CS336 Assignment 2. And yet, per lesson 5's 16-bytes/param ledger, the model still doesn't fit on one GPU: that forces multi-GPU.

Interview prompts