Lab 06 — Attention kernels: online softmax → FlashAttention → Triton¶
Build: (A) the FlashAttention forward and backward algorithms in PyTorch, tile by tile; (B) a fused softmax and a FlashAttention forward kernel in Triton that runs on your RTX 5060.
Time: 12–16 h · Reads first: compute & hardware, inference §5
Run: pytest labs/06_attention_kernels (your code) · pytest labs/06_attention_kernels --impl=solution (reference). Triton tests use the CPU interpreter when there is no GPU.
This is the lab that turns "I know FlashAttention exists" into "I can derive it, implement it, and tell you when it won't help". Inference and performance teams (NVIDIA, frontier-lab inference, vLLM/SGLang) interview for exactly this.
1. Why naive attention is slow: it's the memory, not the math¶
Naive attention writes the T×T score matrix S to HBM, reads it back for the softmax, writes P, then reads P again to multiply by V. For T = 8192 in bf16 that is 128 MiB per head per batch element, pushed through HBM several times. The FLOPs are fine; the bytes aren't. On the roofline (lab 03) the softmax is ~1 FLOP/byte, far below the ridge.
2. Online softmax (Milakov & Gimelshein, 2018)¶
Keep a running max m and running sum l = Σ exp(xᵢ − m). When a new block raises the max to
m', every previous term was computed with the wrong offset, so rescale: l ← l·e^{m−m'} + Σ e^{x−m'}.
At the end, logsumexp = m + log l. One pass, O(1) state per row, no overflow.
Why the rescale is exact: Σ e^{x−m'} = e^{m−m'} · Σ e^{x−m}. Subtracting any constant from every
exponent and multiplying back is an identity; the max is just the constant that keeps every
exponent ≤ 0, so nothing overflows.
3. FlashAttention forward (Dao et al., 2022)¶
Apply the same trick to the output. For each query tile keep (m, l, acc), and for each key tile:
S = Q_i K_jᵀ · scale (tile only, lives in SRAM/registers)
m' = max(m, rowmax S)
P̃ = exp(S − m')
acc ← acc · e^{m−m'} + P̃ V_j (rescale what you had, add the new part)
l ← l · e^{m−m'} + rowsum P̃
At the end, O = acc / l and LSE = m + log l. The T×T matrix never exists. HBM traffic drops
from Θ(T·d + T²) to Θ(T²·d²/M) for SRAM size M (their Theorem 2), a large win when d² < M
(d = 64–128, M ≈ 100–200 KB per SM). Causal attention skips whole key tiles above the diagonal,
about half the work.
keys → tile 0 tile 1 tile 2 tile 3
queries tile 0 [ diag ][ skip ][ skip ][ skip ]
↓ tile 1 [ full ][ diag ][ skip ][ skip ]
tile 2 [ full ][ full ][ diag ][ skip ]
tile 3 [ full ][ full ][ full ][ diag ]
full: no mask needed · diag: element-wise causal mask · skip: never loaded
4. FlashAttention backward: the one-line trick¶
Given dO: dV = Pᵀ dO, dP = dO Vᵀ, dS = P ∘ (dP − D) with Dᵢ = Σⱼ Pᵢⱼ dPᵢⱼ. Naively D
needs all of P. But Σⱼ Pᵢⱼ (dOᵢ·Vⱼ) = dOᵢ · Σⱼ Pᵢⱼ Vⱼ = dOᵢ · Oᵢ, so D = rowsum(dO ∘ O):
O(T) work, no P needed. P itself is recomputed per tile as exp(S − LSE). That recomputation
is why FlashAttention saves only LSE (T floats per head) instead of P (T² floats).
Recomputing costs extra FLOPs and saves far more in memory traffic: a trade you should now be
able to justify with the roofline.
Then dQ = dS K · scale and dK = dSᵀ Q · scale. The reference loops over key tiles (outer) and
query tiles (inner), so dK and dV for a key tile accumulate in one place while dQ receives
contributions from every key tile.
5. Triton in one page¶
- You write a program that processes one tile.
tl.program_id(axis)says which one; the launch grid says how many. tl.arange(0, BLOCK)builds index vectors (BLOCK must be a power of 2 and atl.constexpr). Pointer arithmetic plustl.load(ptr, mask=..., other=...)handles ragged edges.tl.dottargets the tensor cores. Accumulate in fp32 and cast on store.- Everything inside a program lives in registers or SRAM. Global memory is touched only by
tl.loadandtl.store, so fusion comes for free. - Debug on CPU with
TRITON_INTERPRET=1: kernels run as NumPy, you canprint, and the tests do this automatically when there is no GPU.
The reference kernel's layout: grid (cdiv(T, BLOCK_M), B·H), so program (m, bh) owns BLOCK_M
query rows of one (batch, head). It loads its Q tile once, loops over K/V tiles, and writes O and
LSE once. With contiguous (B, H, T, D) inputs, stride_bh = T·D and stride_t = D.
What to implement¶
Work in this order; each step has a test that must pass before you move on.
| # | function | test(s) | what the test pins down |
|---|---|---|---|
| 1 | online_softmax_stats |
test_online_softmax_matches_logsumexp, test_online_softmax_handles_growing_max |
m + log l equals logsumexp; the rescale survives a max that jumps from −40 to 1000 |
| 2 | flash_attention_forward |
test_flash_forward_matches_naive (causal and not; block sizes 7, 11, 16, 32 that do not divide T), test_flash_forward_cross_attention_lengths |
output and LSE match naive_attention in float64; T_q ≠ T_k works |
| 3 | flash_attention_backward |
test_flash_backward_matches_autograd, test_autograd_function_end_to_end |
dq, dk, dv match autograd exactly (float64), causal and not |
| 4 | _softmax_kernel (Triton) |
test_triton_softmax |
rows of 37, 128 and 1000 columns (not powers of two) |
| 5 | _flash_fwd_kernel (Triton) |
test_triton_flash_attention |
T = 16 and 37, causal and not, D = 32; LSE shape (B, H, T) |
naive_attention, the FlashAttention autograd wrapper and the Python launchers
(triton_softmax, triton_flash_attention) are given.
Predict before you measure¶
Write these in your journal before running the benchmark below:
- Bytes of the full score matrix for
B=8, H=16, T=2048in bf16. (Answer: 8·16·2048²·2 B = 1 GiB.) - Forward FLOPs of causal attention at that shape:
4·B·H·T²·D / 2. At what time would your GPU's measured peak finish it? - Which will be larger for
T = 512: the gain from fusion or the launch overhead? Why does FlashAttention's advantage grow with T?
On your RTX 5060 (Blackwell, sm_120)¶
Benchmark your kernel against PyTorch's scaled_dot_product_attention (which dispatches to
FlashAttention or cuDNN kernels):
import torch, triton
from labs._impl import load
ak = load("labs/06_attention_kernels/x.py", "exercise")
q, k, v = (torch.randn(8, 16, 2048, 64, device="cuda", dtype=torch.bfloat16) for _ in range(3))
for name, fn in {"yours": lambda: ak.triton_flash_attention(q, k, v, causal=True, block_m=64, block_n=64),
"sdpa": lambda: torch.nn.functional.scaled_dot_product_attention(q, k, v, is_causal=True)}.items():
ms = triton.testing.do_bench(fn)
print(f"{name}: {ms:.3f} ms, {2 * 2 * 8 * 16 * 2048**2 * 64 / 2 / ms / 1e9:.1f} TFLOP/s (causal)")
(load takes any path inside the lab directory; x.py only locates the folder.) Tune
block_m, block_n and num_warps, and explain what changes. Typical findings: larger
tiles reuse more but spill registers. A 2× gap to the vendor kernel is normal for a first kernel;
closing it teaches you pipelining (num_stages), which is the next step.
Scale-up sweep: repeat for T ∈ {512, 1024, 2048, 4096, 8192} and plot TFLOP/s for yours, SDPA and
naive_attention in bf16. The naive curve falls off (and eventually runs out of memory) as T²
bytes dominate; the tiled curves rise toward a plateau. That plot is the whole argument of this
lab in one picture.
On Windows, Triton needs WSL2 or the community triton-windows package (see
SETUP.md). Part A needs only PyTorch.
Tip
Debug Part B on CPU first: TRITON_INTERPRET=1 pytest labs/06_attention_kernels -k triton.
In the interpreter you can print tensors inside the kernel. Once it passes, run on the GPU,
where tf32 dot products make the tests use a looser tolerance (2e-3).
Common bugs¶
- Forgetting to rescale
accwhen the max changes: outputs are right only when the first tile holds the row max. The growing-max test and odd block sizes catch it. exp(−inf − (−inf)) = NaN: a row whose every score in a tile is masked hasm = −inf. In Part A this is safe only because key tiles are visited from 0 upward: every real row sees key 0 first, somis already finite when a later tile masks the whole row. Iterate key tiles in another order (as a split-K kernel does) and you must guard it. In Triton, padded rows beyond T and masked columns must be handled withmask=/other=andtl.where, not by hoping.- Causal skip off by one: the last key tile a query tile needs is the one containing its last row,
i1 − 1. Skipping one tile too many passes small tests and failsT=33, bq=7, bk=11. - Masking only in the forward: the backward must apply the same causal mask before
exp(S − LSE). - Using
m + log lfrom the wrong dtype: keep statistics in fp32 (or float64 in tests); bf16 LSE loses precision in the backward. - Non-power-of-two
BLOCKin Triton:tl.arangerequires a power of two; the launcher usestriton.next_power_of_2(n_cols)for the softmax. - Wrong strides: the launcher makes inputs contiguous. If you pass transposed views to your own launcher,
stride_tis no longerD.
Check yourself¶
- Why does FlashAttention not reduce FLOPs, yet run 2–4× faster?
- For decoding one token against a 32k-token KV cache, why does plain FlashAttention underuse the GPU, and what does FlashDecoding (split-K) change?
- Why does the backward pass save LSE but not the max m and sum l separately?
- Would FlashAttention help a d = 1024 head dimension? Why or why not?
- Your Triton kernel is correct on CPU but produces NaNs on GPU for causal masks with T not divisible by the block. Where do you look?
- Where does
D = rowsum(dO ∘ O)come from, and why does it matter for memory? - With GQA (8 KV heads, 32 query heads), how would you change the kernel so a K/V tile loaded once serves several query heads?
Answers
- Attention at realistic sizes is memory-bound on the softmax and mask traffic. FlashAttention removes Θ(T²) HBM reads and writes, so the time drops even though the FLOPs rise slightly (from recomputation in backward).
- With one query row, the only parallelism is over batch × heads, often fewer programs than there are SMs, and each program walks 32k keys sequentially. Split-K partitions the keys across programs, each computing a partial
(m, l, acc), then a small reduction merges them with the same rescaling rule. - LSE = m + log l is all the backward needs to recompute P = exp(S − LSE). It is one number per row instead of two, and it is numerically safe.
- Much less. The Θ(T²d²/M) bound only beats Θ(T²) when d² < M. At d = 1024 a tile barely fits in SRAM, so reuse collapses.
- Rows or tiles where every score is masked:
max = −infgivesexp(−inf − (−inf)) = NaN. Check the loop bounds for causal skipping, the masked loads (other=), and whether padded rows beyond T are computed with fully masked scores. Dᵢ = Σⱼ Pᵢⱼ dPᵢⱼ = Σⱼ Pᵢⱼ (dOᵢ · Vⱼ) = dOᵢ · Oᵢ. It turns a T×T reduction into an O(T·d) one computed from tensors already saved, so the backward never needs P stored.- Map the program to a KV head and loop over (or stack) the query heads that share it, so each K/V tile loaded from HBM is reused by 4 query heads. That raises arithmetic intensity by the group size, which is the point of GQA at decode time.
Stretch¶
- Write the backward pass in Triton (two kernels: dK/dV with key tiles outer, dQ with query tiles outer) and plug it into the autograd
Function. - FlashDecoding: split keys across programs for batch-1 decode and merge the partial
(m, l, acc)in a second kernel. Measure against your forward kernel with T_q = 1 and T_k = 32k. - Add
num_stagessoftware pipelining and autotuning (@triton.autotuneover block sizes and warps). Record which configuration wins at each T and why. - Try FP8 (E4M3) Q and K for the
QKᵀproduct on Blackwell and measure accuracy against speed. (Read FlashAttention-3 for how Hopper and Blackwell do this with warp specialization.)