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 filesEach 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.pyThe 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
# 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 →