Mini FlashAttention
The capstone: every lesson of this course, fused into one kernel — QKᵀ scores, causal masking,
row softmax and the PV projection, never once touching HBM in between. This is the kernel decode-lab’s
attention.py will grow from.
▶ How to run the code (Colab · T4 GPU)
Create a Colab notebook with a GPU runtime (Runtime ▸ Change runtime type ▸ T4 GPU), then in the first cell run:
!nvidia-smi
!pip install -q triton
Paste the code from this lesson (or the whole companion script) into the next cell. No GPU at hand? Run
locally — the script falls back to Triton’s CPU interpreter automatically
(source /home/tensor/.venv/bin/activate, then cd decode-lab/artifacts and
python lesson7_flash_attention.py). You should see PART A/B PASSED at the end.
1What attention is
Attention is how a transformer token looks at the other tokens and decides what to copy into its own representation. Three matrices per head:
- Q (query) — “what am I looking for?” (your token’s projection)
- K (keys) — “what do I contain?” (every token’s projection)
- V (values) — “what can I hand over?” (every token’s payload)
The math, in three lines:
S = Q @ K^T * scale # scores: how much each query wants each key (N×N)
P = softmax(S, rows) # probabilities: lesson 3's dance, per row (N×N)
O = P @ V # outputs: weighted mix of the values (N×d)
With scale = 1/√d keeping the scores’ variance sane. In autoregressive decode, the causal
mask keeps S[i, j] = −∞ for j > i — a token never attends to the future.
Q is your question, K is each person’s expertise tag, V is what they’d contribute. You read everyone’s tag (scores), decide how much to trust each (softmax), and your final takeaway is a weighted mix of their contributions (P@V). The causal mask is the rule that you can only quote people who spoke before you.
2Why fuse it
The unfused path (torch) writes the N×N scores matrix to HBM, reads it back for the softmax, writes P, reads P back for the PV product. For a 4096-token sequence that’s a 4096² × 4 bytes = 64 MB round trips per head, times 32 heads, times 32 layers — and the scores matrix is exactly as expensive as the tokens it describes. FlashAttention’s entire thesis: never write S to memory. Compute a tile of scores, softmax it in registers, multiply by V, keep only the output. The kernel below does exactly that — the full sequence in one tile (Part A), then the real flash version that chunks K/V and streams (Part B).
3One-shot fused attention
Everything you know, in one kernel. Every lesson is in here — spot them all:
@triton.jit
def attention_kernel(q_ptr, k_ptr, v_ptr, o_ptr, NSEQ, HEAD, scale,
BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_D: tl.constexpr,
CAUSAL: tl.constexpr):
pid_m = tl.program_id(0)
offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M) # rows of Q I own (L2)
offs_n = tl.arange(0, BLOCK_N) # all sequence positions
offs_d = tl.arange(0, BLOCK_D) # head dim
q = tl.load(q_ptr + offs_m[:, None] * HEAD + offs_d[None, :],
mask=offs_m[:, None] < NSEQ, other=0.0)
k_t = tl.load(k_ptr + offs_n[:, None] * HEAD + offs_d[None, :],
mask=offs_n[:, None] < NSEQ, other=0.0) # (BLOCK_N, HEAD)
v = tl.load(v_ptr + offs_n[:, None] * HEAD + offs_d[None, :],
mask=offs_n[:, None] < NSEQ, other=0.0) # (BLOCK_N, HEAD)
s = tl.dot(q, tl.trans(k_t)) * scale # (BLOCK_M, BLOCK_N) scores
if CAUSAL:
s = tl.where(offs_m[:, None] >= offs_n[None, :], s, -float("inf"))
m = tl.max(s, axis=1) # lesson 3, in miniature
m = tl.where(offs_m < NSEQ, m, 0.0) # ghost-row pin
p = tl.exp(s - m[:, None])
l = tl.sum(p, axis=1)
o = tl.dot(p.to(tl.float32), v) / l[:, None] # P @ V, normalized
tl.store(o_ptr + offs_m[:, None] * HEAD + offs_d[None, :], o,
mask=offs_m[:, None] < NSEQ)
| line | the lesson it’s from |
|---|---|
pid_m / offs_m[:, None] | L2 — 2D thinking: one program owns a block of Q rows. |
tl.trans(k_t) | L2/L4 — the transpose-read: scores need KT, so read K sideways. |
tl.dot(q, tl.trans(k_t)) * scale | L2/L4 — tensor-core scores, one instruction for a whole tile. |
tl.where(..., -float("inf")) | L3 — the mask trick: causal future = −∞, softmax sees nothing. |
tl.max / tl.exp / tl.sum ... l[:, None] | L3 — the two-pass dance, verbatim. |
o = tl.dot(p, v) / l[:, None] | The clever bit: normalize after the PV product — division commutes with the matmul, so the softmax normalizer rides along. |
| one load, one store | L5 — fusion: S and P never touch HBM. |
I shipped Part B with this bug: ragged K loads other=0.0, which gives padded columns a
score of 0 — a perfectly valid-looking probability mass that shifts every row. The fix is one line:
s = tl.where(offs_kn[None, :] < NSEQ, s, -float("inf")) — the same −∞-for-the-max-pass rule
from lesson 3, applied to the score tile. Silent, subtle, exactly what decode-lab’s parity gate is for.
4The flash trick: online softmax
Part A holds the whole sequence in one tile — fine for N ≤ 128, impossible for N = 4096. The real
FlashAttention streams K/V in BLOCK_N-column chunks, and that creates a problem: softmax needs
the whole row’s max and sum, but each chunk arrives separately. The fix is the paper’s
key idea — keep a running state per row and rescale when the max moves:
# running state per row:
m_i = running max score # starts at -inf
l_i = running sum of exp() # starts at 0
acc = running P @ V # starts at 0
for each K/V chunk:
s = Q @ K_chunk^T * scale
m_new = max(m_i, max(s)) # maybe a bigger score arrived
alpha = exp(m_i - m_new) # rescale factor for OLD state
p = exp(s - m_new) # new chunk, stable
l_i = l_i * alpha + sum(p) # old sum re-weighted
acc = acc * alpha + p @ V_chunk # old output re-weighted
m_i = m_new
O = acc / l_i # after the last chunk
exp(x_old − m_new) = exp(x_old − m_old) · exp(m_old − m_new) — the old probabilities
rescale by a single factor, because every term in the row shares the same max. So the running
l_i and acc are exactly the full-softmax values, just accumulated chunk
by chunk. Three scalars per row (m, l, acc) and the N×N scores matrix
never exists. That’s the whole trick — everything else is the lessons you already know.
The companion’s Part B implements exactly this loop (plus the ghost-row guard:
m_new = tl.where(m_new == -inf, 0.0, m_new), so fully-masked rows can’t NaN). Verify it
against torch — it matches to 1e-7 on ragged sequences too.
5Attention playground
Watch the three-line math happen on real numbers. Click a score cell to trace it through the softmax row; flip the causal mask and watch the future disappear (× = −∞):
6The surgery map, complete
You started with “why GPUs exist”. Here is where the course lands — decode-lab’s surgery map, with the lessons that unlock each row:
| decode-lab kernel | built from lesson | status |
|---|---|---|
kernels/rmsnorm.py | L3 — the reduction dance, fused | ✔ in repo |
kernels/swiglu.py | L5 — fusion + L4 transpose reads | ✔ in repo |
kernels/attention.py (GQA + sliding window + KV cache) | L7 — this kernel, plus a banded mask instead of a full causal one, and KV slices | ⏳ your next port |
kernels/gemv.py (lm_head, decode) | L6 homework #4 — the GEMV you wrote, autotuned | ⏳ next |
| whistle port (phase 2) | L5/L6 — fused + launch-bound reality | ⏳ later |
7Homework — the last set
Run lesson7_flash_attention.py on Colab, then try each of these. Hints are collapsible —
try it first, peek only if stuck.
1 · Break Part B’s padded-column bug on purpose, then fix it
Remove the s = tl.where(offs_kn[None, :] < NSEQ, s, -float("inf")) line and
run N=37: the last chunk’s padded columns score 0 and quietly pollute every row — max_err jumps to
~1e-1. This is the exact bug I shipped. Lesson: in attention, padded means −∞, never 0.
2 · Swap the causal mask for a sliding-window (banded) mask
decode-lab’s Qwen3 uses a 1024-token window: token i attends to tokens
[i−1024, i]. Change the where-condition to
(offs_m[:, None] >= offs_kn[None, :]) & (offs_m[:, None] - offs_kn[None, :] < WINDOW) with
WINDOW: tl.constexpr. Verify vs a banded torch reference. This is the exact surgery
kernels/attention.py needs.
3 · The real test: GQA — one K/V shared by two Q heads
Qwen3 uses GQA: 32 Q heads, 8 K/V heads. Group heads: Q head h uses KV head h // 4.
Load K/V with offs_kv = offs_d + (h // G) * HEAD and compute per-Q-head scores against the
shared K/V — the scores tile becomes (BLOCK_M, BLOCK_N) per head but K/V are read once per
group. Verify against the torch reference. This is the real structure of the repo’s attention.
4 · Fuse the KV cache slice into the kernel
In decode, K/V come from a cache of past tokens. The kernel needs a
start offset: offs_kn = start + n * BLOCK_N + offs_n, so it attends to the last
NSEQ cached positions without copying them. Verify against a torch slice of the cache. Now the
kernel is decode-ready.
5 · Brain-bender: why can’t Part A handle N = 4096, and what exactly does Part B save?
Part A’s scores tile is (BLOCK_M × N) — at N=4096 that’s 4096 columns of fp32 per row in registers/shared memory: it doesn’t fit. Part B bounds the tile at (BLOCK_M × BLOCK_N) and pays the streaming cost: each K/V chunk is read once per Q-block (O(M·N·d) traffic with block factors), but the N×N scores matrix never materializes — the paper’s headline: attention memory drops from O(N²) to O(N), which is the entire reason 4096-token contexts are feasible. Bonus: with the rescaling, would Part B’s answer differ from Part A’s if they both fit? No — the rescaling is exact, as the tests show (1e-7 agreement).
8The whole journey
| Lesson | Status |
|---|---|
| 1 · The Triton mental model — grids, tiles, masks, vector add | ✔ covered |
| 2 · 2D tiles, broadcasting, strides — the tiled matmul | ✔ covered |
| 3 · Reductions — softmax & fused rmsnorm | ✔ covered |
4 · Block pointers — make_block_ptr, advance, boundary checks | ✔ covered |
| 5 · Fused kernels — one pass instead of many (swiglu) | ✔ covered |
| 6 · Performance — warps, stages, autotune, do_bench | ✔ covered |
| 7 · Mini FlashAttention — the capstone | ✔ covered |
Next: port into decode-lab — attention.py, gemv.py, then whistle | ⏳ the real work |