Under the Hood
Book ↗
Projects/Building a GPT
Project 08

Flash Attention and Tiled Kernels

memory-efficient attention.

The actual idea behind FlashAttention is not a new algorithm. It's a different schedule for the same algorithm — one that never materializes the full (T, T) score matrix in memory. This project demonstrates the schedule on CPU with explicit memory accounting.

The Concept

Naive attention computes scores = Q @ K.T (shape (T, T)), softmaxes it, multiplies by V. That intermediate (T, T) matrix is the memory bottleneck. At T = 8192, fp16, it's 128 MB per head per layer.

Tiled attention processes Q in chunks of q_block rows. For each chunk, it walks over K and V in chunks of kv_block columns, maintaining a running max and softmax denominator. At the end of each Q chunk, it normalizes and writes back. The full (T, T) matrix is never built.

The trick is the online softmax:

m_new = max(m, max(scores_block))
alpha = exp(m - m_new)            # rescale old accumulator
beta  = exp(scores_block - m_new) # this block's contributions
output_so_far = output_so_far * alpha + beta @ V_block
denom         = denom         * alpha + sum(beta)

When you finish all K/V chunks for a given Q chunk, divide accumulator by denominator. The result is exactly what softmax-then-matmul-by-V produces — same math, never materializing (T, T).

Why It Matters

Long-context LLMs (32k, 100k tokens) are not possible with naive attention because the (T, T) matrix would be tens of GB at scale. FlashAttention's tiled schedule is the reason it's even feasible to run attention over a book's worth of tokens.


The Build

build.py

$python projects/08_flash-attention-and-tiled-kernels/build.py --tiny

"""
Project 8: Flash Attention and Tiled Kernels — a CPU reference implementation.

We can't write a real CUDA kernel here, but we can demonstrate the key insight
of FlashAttention: you can compute the exact same attention output **without
ever materializing the full (T, T) score matrix**. The math is identical; only
the schedule is different.

Two implementations:

1. **Naive attention**: builds the (T, T) score matrix in memory, softmaxes,
   multiplies by V. Peak memory: O(T^2).
2. **Tiled attention**: processes Q in chunks of `q_block` rows. For each Q
   chunk, walks over K/V in chunks of `kv_block` columns, accumulating a
   running max + softmax-numerator. Peak intermediate memory: O(T * q_block).

This project verifies they produce identical outputs (up to FP error) and
prints the peak intermediate-tensor size for each.

Run:
    python build.py --tiny
    python build.py --full     # T=512 stress test
"""

from __future__ import annotations

import argparse
import math
from pathlib import Path

import torch


def naive_attention(
    Q: torch.Tensor, K: torch.Tensor, V: torch.Tensor, causal: bool = True
) -> tuple[torch.Tensor, int]:
    """Standard attention: build (T,T) scores, softmax, mix V. Returns (output, peak_mem_floats)."""
    T, d_head = Q.shape
    scores = (Q @ K.T) / math.sqrt(d_head)  # (T, T)  ← THIS is what eats memory
    if causal:
        mask = torch.tril(torch.ones(T, T, dtype=torch.bool))
        scores = scores.masked_fill(~mask, float("-inf"))
    weights = torch.softmax(scores, dim=-1)
    out = weights @ V
    peak_mem = scores.numel() + weights.numel()  # (T,T) + (T,T) intermediate floats
    return out, peak_mem


def tiled_attention(
    Q: torch.Tensor,
    K: torch.Tensor,
    V: torch.Tensor,
    q_block: int = 32,
    kv_block: int = 32,
    causal: bool = True,
) -> tuple[torch.Tensor, int]:
    """
    Process Q in chunks of q_block; for each chunk walk over K/V in chunks of kv_block.

    Uses the online-softmax trick: maintain a running max `m` and running denominator `l`
    per query row, never building the full (T, T) score matrix.
    """
    T, d_head = Q.shape
    out = torch.zeros_like(Q)

    peak_intermediate = 0  # max floats held at once

    for i_start in range(0, T, q_block):
        i_end = min(i_start + q_block, T)
        Qi = Q[i_start:i_end]  # (qb, d_head)
        qb = Qi.shape[0]

        # Running max and softmax-denominator for this Q chunk's rows.
        m = torch.full((qb,), float("-inf"))
        l = torch.zeros(qb)
        Oi = torch.zeros(qb, d_head)

        for j_start in range(0, T, kv_block):
            j_end = min(j_start + kv_block, T)
            Kj = K[j_start:j_end]  # (kb, d_head)
            Vj = V[j_start:j_end]
            kb = Kj.shape[0]

            scores_ij = (Qi @ Kj.T) / math.sqrt(d_head)  # (qb, kb)
            if causal:
                # Apply causal mask: query position i can attend to key position j only if j <= i.
                row_idx = torch.arange(i_start, i_end).unsqueeze(1)
                col_idx = torch.arange(j_start, j_end).unsqueeze(0)
                mask = col_idx <= row_idx
                scores_ij = scores_ij.masked_fill(~mask, float("-inf"))

            # Online softmax update.
            mij = scores_ij.max(dim=-1).values  # (qb,)
            mi_new = torch.maximum(m, mij)
            # Rescale previous accumulator.
            alpha = torch.exp(m - mi_new)
            beta = torch.exp(scores_ij - mi_new.unsqueeze(-1))
            # Mask out positions that are -inf (otherwise NaN from exp(-inf - -inf)).
            beta = torch.nan_to_num(beta, nan=0.0)
            Oi = Oi * alpha.unsqueeze(-1) + beta @ Vj
            l = l * alpha + beta.sum(dim=-1)
            m = mi_new

            # Track peak intermediate
            peak_intermediate = max(peak_intermediate, scores_ij.numel() + Oi.numel())

        # Finalize: divide accumulator by denominator.
        out[i_start:i_end] = Oi / l.unsqueeze(-1)

    return out, peak_intermediate


def build_random_qkv(
    T: int, d_head: int, seed: int = 0
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
    g = torch.Generator().manual_seed(seed)
    Q = torch.randn(T, d_head, generator=g)
    K = torch.randn(T, d_head, generator=g)
    V = torch.randn(T, d_head, generator=g)
    return Q, K, V


def main() -> int:
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument("--tiny", action="store_true")
    parser.add_argument("--full", action="store_true")
    parser.add_argument("--T", type=int, default=None, help="Sequence length")
    parser.add_argument("--d-head", type=int, default=32)
    parser.add_argument("--q-block", type=int, default=32)
    parser.add_argument("--kv-block", type=int, default=32)
    parser.add_argument("--seed", type=int, default=0)
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=Path(__file__).parent / "outputs",
    )
    args = parser.parse_args()

    if args.T is None:
        args.T = 512 if args.full else 128

    args.output_dir.mkdir(parents=True, exist_ok=True)

    Q, K, V = build_random_qkv(args.T, args.d_head, seed=args.seed)
    print(f"Sequence length T = {args.T}, d_head = {args.d_head}")
    print(f"Q,K,V each: ({args.T}, {args.d_head}) = {Q.numel()} floats each\n")

    # === Naive ===
    out_naive, peak_naive = naive_attention(Q, K, V, causal=True)
    print(f"naive  :  output_shape={tuple(out_naive.shape)}  peak_intermediate={peak_naive} floats")

    # === Tiled ===
    out_tiled, peak_tiled = tiled_attention(
        Q, K, V, q_block=args.q_block, kv_block=args.kv_block, causal=True
    )
    print(f"tiled  :  output_shape={tuple(out_tiled.shape)}  peak_intermediate={peak_tiled} floats")

    # === Correctness check ===
    max_diff = (out_naive - out_tiled).abs().max().item()
    mean_diff = (out_naive - out_tiled).abs().mean().item()
    print(f"\nmax |naive - tiled|  = {max_diff:.2e}")
    print(f"mean |naive - tiled| = {mean_diff:.2e}")
    print("(should be near zero — both compute the same attention)")

    # Memory ratio
    ratio = peak_naive / max(peak_tiled, 1)
    print(
        f"\nNaive peak intermediate: {peak_naive} floats  (= {peak_naive * 4 / 1024:.1f} KB at fp32)"
    )
    print(
        f"Tiled peak intermediate: {peak_tiled} floats  (= {peak_tiled * 4 / 1024:.1f} KB at fp32)"
    )
    print(f"Memory reduction: {ratio:.2f}x")

    log = args.output_dir / "run_log.txt"
    log.write_text(
        f"# Project 8 run log\n"
        f"T                       = {args.T}\n"
        f"d_head                  = {args.d_head}\n"
        f"q_block                 = {args.q_block}\n"
        f"kv_block                = {args.kv_block}\n"
        f"\n"
        f"naive peak intermediate = {peak_naive} floats ({peak_naive * 4 / 1024:.1f} KB fp32)\n"
        f"tiled peak intermediate = {peak_tiled} floats ({peak_tiled * 4 / 1024:.1f} KB fp32)\n"
        f"memory reduction        = {ratio:.2f}x\n"
        f"\n"
        f"max |naive - tiled|     = {max_diff:.2e}\n"
        f"mean |naive - tiled|    = {mean_diff:.2e}\n"
        f"\n"
        "Lesson: FlashAttention's actual contribution is *not* a new algorithm.\n"
        "It is a different scheduling of the same algorithm. By processing Q in\n"
        "chunks and tracking running softmax statistics (max + denominator),\n"
        "you never need to hold the full (T, T) score matrix in memory.\n"
        "\n"
        "On CPU this won't save wall time (numpy/torch already vectorize well),\n"
        "but on GPU the peak intermediate memory determines whether you can run\n"
        "longer contexts at all. T = 8192, d_head = 128:\n"
        "  naive intermediate = 8192*8192 = 64 MB per attention head per layer\n"
        "  tiled intermediate = 8192*64   = 0.5 MB per attention head per layer\n"
        "That difference is what makes long-context LLMs feasible.\n",
        encoding="utf-8",
    )
    print(f"\nOutputs written to {args.output_dir.resolve()}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

The Steps

6 reference files

Each step is the code for one piece, in isolation — read them as the book introduces them. They assemble into build.py above.

Step 01Measure Where The Naive Version Diesstep_01_measure-where-the-naive-version-dies.py
"""
Project 8: Step 1 — Measure where the naive version dies

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

def naive_attention(q, k, v):
    # q, k, v: (batch, heads, seq, dim)
    d = q.shape[-1]
    scores = (q @ k.transpose(-2, -1)) / (d ** 0.5)   # (B, H, N, N)
    weights = torch.softmax(scores, dim=-1)            # (B, H, N, N)
    return weights @ v                                  # (B, H, N, D)

import torch

def measure_naive(seq_lens, batch=2, heads=8, dim=64, device="cuda", dtype=torch.float16):
    results = {}
    for n in seq_lens:
        torch.cuda.empty_cache()
        torch.cuda.reset_peak_memory_stats()
        q = torch.randn(batch, heads, n, dim, device=device, dtype=dtype)
        k = torch.randn_like(q)
        v = torch.randn_like(q)
        try:
            out = naive_attention(q, k, v)
            torch.cuda.synchronize()
            peak = torch.cuda.max_memory_allocated() / 1e9
            results[n] = peak
        except torch.cuda.OutOfMemoryError:
            results[n] = None
        del q, k, v
        if results[n] is not None:
            del out
    return results

print(measure_naive([1024, 2048, 4096, 8192]))
Step 02Decompose The Computation Into Tilesstep_02_decompose-the-computation-into-tiles.py
"""
Project 8: Step 2 — Decompose the computation into tiles

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

BR = 64   # query row tile size
BC = 64   # key/value column tile size

def tiled_attention_skeleton(q, k, v, BR=64, BC=64):
    B, H, N, D = q.shape
    out = torch.zeros_like(q)

    for i in range(0, N, BR):
        q_tile = q[:, :, i:i+BR, :]                   # (B, H, BR, D)
        # running statistics for this query row tile, set up in Step 3
        for j in range(0, N, BC):
            k_tile = k[:, :, j:j+BC, :]               # (B, H, BC, D)
            v_tile = v[:, :, j:j+BC, :]               # (B, H, BC, D)
            scores_tile = q_tile @ k_tile.transpose(-2, -1)  # (B, H, BR, BC)
            # update running statistics and partial output using scores_tile
            ...
        # write final row tile of out
        out[:, :, i:i+BR, :] = ...
    return out
Step 03Build The Streaming Softmaxstep_03_build-the-streaming-softmax.py
"""
Project 8: Step 3 — Build the streaming softmax

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

m_tile = s_tile.max(dim=-1, keepdim=True).values     # local max of the tile
p_tile = torch.exp(s_tile - m_tile)                  # local stable exponentials
l_tile = p_tile.sum(dim=-1, keepdim=True)            # local sum of exponentials

m_new = torch.maximum(m_running, m_tile)
alpha = torch.exp(m_running - m_new)                 # rescaling for old stats
beta  = torch.exp(m_tile    - m_new)                 # rescaling for tile stats
l_new = alpha * l_running + beta * l_tile
o_new = alpha * o_running + beta * (p_tile @ v_tile)
m_running, l_running, o_running = m_new, l_new, o_new

o_final = o_running / l_running

def tiled_attention(q, k, v, BR=64, BC=64, causal=False):
    B, H, N, D = q.shape
    scale = D ** -0.5
    out = torch.zeros_like(q)

    for i in range(0, N, BR):
        q_tile = q[:, :, i:i+BR, :]
        m = torch.full((B, H, q_tile.shape[2], 1), float("-inf"),
                       device=q.device, dtype=q.dtype)
        l = torch.zeros((B, H, q_tile.shape[2], 1),
                        device=q.device, dtype=q.dtype)
        o = torch.zeros_like(q_tile)

        for j in range(0, N, BC):
            k_tile = k[:, :, j:j+BC, :]
            v_tile = v[:, :, j:j+BC, :]

            s = (q_tile @ k_tile.transpose(-2, -1)) * scale
            if causal:
                # mask future positions inside this tile
                row_idx = torch.arange(i, i + q_tile.shape[2], device=q.device)[:, None]
                col_idx = torch.arange(j, j + k_tile.shape[2], device=q.device)[None, :]
                s = s.masked_fill(col_idx > row_idx, float("-inf"))

            m_tile = s.max(dim=-1, keepdim=True).values
            p = torch.exp(s - m_tile)
            l_tile = p.sum(dim=-1, keepdim=True)

            m_new = torch.maximum(m, m_tile)
            alpha = torch.exp(m - m_new)
            beta = torch.exp(m_tile - m_new)
            l = alpha * l + beta * l_tile
            o = alpha * o + beta * (p @ v_tile)
            m = m_new

        out[:, :, i:i+BR, :] = o / l

    return out
Step 04Verify Against The Naive Versionstep_04_verify-against-the-naive-version.py
"""
Project 8: Step 4 — Verify against the naive version

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

torch.manual_seed(0)
B, H, N, D = 2, 4, 256, 32
q = torch.randn(B, H, N, D, device="cuda")
k = torch.randn(B, H, N, D, device="cuda")
v = torch.randn(B, H, N, D, device="cuda")

ref = naive_attention(q, k, v)
out = tiled_attention(q, k, v, BR=32, BC=32)

diff = (ref - out).abs().max().item()
print(f"max absolute difference: {diff:.6e}")
Step 05Measure Memory Versus The Naive Versionstep_05_measure-memory-versus-the-naive-version.py
"""
Project 8: Step 5 — Measure memory versus the naive version

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

def measure_tiled(seq_lens, batch=2, heads=8, dim=64, device="cuda", dtype=torch.float16):
    results = {}
    for n in seq_lens:
        torch.cuda.empty_cache()
        torch.cuda.reset_peak_memory_stats()
        q = torch.randn(batch, heads, n, dim, device=device, dtype=dtype)
        k = torch.randn_like(q)
        v = torch.randn_like(q)
        out = tiled_attention(q, k, v, BR=64, BC=64)
        torch.cuda.synchronize()
        results[n] = torch.cuda.max_memory_allocated() / 1e9
        del q, k, v, out
    return results

print(measure_tiled([1024, 2048, 4096, 8192, 16384, 32768]))
Step 07Benchmark Wall Clockstep_07_benchmark-wall-clock.py
"""
Project 8: Step 7 — Benchmark wall-clock

Pedagogical reference: this file shows the code for this step in isolation.
For the full assembled, runnable build, use build.py in this same folder.
"""

import time

def bench(fn, q, k, v, repeats=10):
    for _ in range(3):  # warmup
        fn(q, k, v)
    torch.cuda.synchronize()
    t0 = time.perf_counter()
    for _ in range(repeats):
        fn(q, k, v)
    torch.cuda.synchronize()
    return (time.perf_counter() - t0) / repeats

Break It

break_it.py

The bug everyone hits when writing tiled attention by hand is the online softmax: forgetting to rescale the accumulator when a later block produces a higher max. That bug doesn't crash — the output just silently disagrees with naive attention. The parametrized tests in tests/test_unit.py catch exactly this; if you mess up the rescale logic, test_tiled_matches_naive fails immediately. That test suite IS the break-detector.


$python projects/08_flash-attention-and-tiled-kernels/break_it.py --tiny

"""
Project 8: BREAK IT experiment.

Deliberately sabotages one mechanism from build.py to show what happens
when it's removed. Compare outputs to the working version.
"""

m_tile = s.max(dim=-1, keepdim=True).values
p = torch.exp(s - m_tile)
l_tile = p.sum(dim=-1, keepdim=True)

# m_tile = s.max(dim=-1, keepdim=True).values   # removed
p = torch.exp(s)                                # no stability shift
l_tile = p.sum(dim=-1, keepdim=True)

# m_new = torch.maximum(m, m_tile)
# alpha = torch.exp(m - m_new)
# beta = torch.exp(m_tile - m_new)
# l = alpha * l + beta * l_tile
# o = alpha * o + beta * (p @ v_tile)
# m = m_new
l = l + l_tile
o = o + (p @ v_tile)

Outputs

run_log.txt
# Project 8 run log
T                       = 128
d_head                  = 32
q_block                 = 32
kv_block                = 32

naive peak intermediate = 32768 floats (128.0 KB fp32)
tiled peak intermediate = 2048 floats (8.0 KB fp32)
memory reduction        = 16.00x

max |naive - tiled|     = 3.58e-07
mean |naive - tiled|    = 2.87e-08

Lesson: FlashAttention's actual contribution is *not* a new algorithm.
It is a different scheduling of the same algorithm. By processing Q in
chunks and tracking running softmax statistics (max + denominator),
you never need to hold the full (T, T) score matrix in memory.

On CPU this won't save wall time (numpy/torch already vectorize well),
but on GPU the peak intermediate memory determines whether you can run
longer contexts at all. T = 8192, d_head = 128:
  naive intermediate = 8192*8192 = 64 MB per attention head per layer
  tiled intermediate = 8192*64   = 0.5 MB per attention head per layer
That difference is what makes long-context LLMs feasible.

Read it in the book

This page ships the code. The full derivation — why the chain rule is mechanical not magical, what the computational graph buys you, the long-form reasoning — lives in Chapter 8 of Under the Hood.

Get the book →