Triton from zero · Lesson 7 of 7

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.

The meeting analogy

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)
linethe 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)) * scaleL2/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 storeL5 — fusion: S and P never touch HBM.
Gotcha — padded columns must be −∞, not 0

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
Why rescaling is exact (not an approximation)

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 (× = −∞):

QKᵀ → softmax → PV, live

d = 3, so scale = 1/√3 ≈ 0.577. All numbers computed right here in your browser.

Q (4×3)
K (4×3)
S = Q·Kᵀ·scale (4×4)
P = softmax(S) (4×4)
O = P·V (4×2)

Click a score cell. V is fixed at [[1,0],[0,1],[1,1],[1,0]].

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 kernelbuilt from lessonstatus
kernels/rmsnorm.pyL3 — the reduction dance, fused✔ in repo
kernels/swiglu.pyL5 — 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

LessonStatus
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