Writing FlashAttention 2 in Triton — a step-by-step build¶
This is the load-bearing project of Phase 3. When you finish, you own the algorithm at the kernel level. No amount of paper reading substitutes.
Reference implementations to consult (in order):
Official Triton tutorial 06 — https://triton-lang.org/main/getting-started/tutorials/06-fused-attention.html
triton/python/tutorials/06-fused-attention.pyin the repoDao-AILab flash-attention
csrc/flash_attn/kernels/(CUDA reference)Umar Jamil’s walkthrough of FA2 in Triton on YouTube (search “Umar Jamil FlashAttention Triton”)
Prerequisites (do not skip):
Online softmax derived on paper (
01_online_softmax.md)FA1 paper read end-to-end (arXiv <phone_number_or_numberic_id_or_random_id_54>)
Triton fused softmax written from memory
Triton matmul from tutorial 03 understood, autotuned, and profiled
Step 0 — the scaffolding¶
import torch
import triton
import triton.language as tl
@triton.jit
def _fa_fwd_kernel(
Q, K, V, O, L, # tensors (fp16/bf16) and log-sum-exp buffer (fp32)
stride_qb, stride_qh, stride_qm, stride_qd,
stride_kb, stride_kh, stride_kn, stride_kd,
stride_vb, stride_vh, stride_vn, stride_vd,
stride_ob, stride_oh, stride_om, stride_od,
B, H, N_Q, N_KV, # runtime shapes
softmax_scale, # 1 / sqrt(d)
IS_CAUSAL: tl.constexpr,
BLOCK_M: tl.constexpr, # Q tile rows
BLOCK_N: tl.constexpr, # KV tile rows
HEAD_DIM: tl.constexpr, # power-of-two, 64 or 128
):
...
Notes:
Store the log-sum-exp
Lper Q-row for the backward pass. Even if you’re not writing backward, do this — it’s free correctness insurance and gives you a debugging channel.HEAD_DIMMUST be aconstexpr. Runtime head dim in a Triton attention kernel is a footgun.
Step 1 — grid layout: one program per (batch, head, Q-tile)¶
grid = (triton.cdiv(N_Q, BLOCK_M), B * H, 1) # (M_tiles, B*H, 1)
Inside the kernel:
pid_m = tl.program_id(0) # which Q-tile
pid_bh = tl.program_id(1) # which (batch, head)
off_b = pid_bh // H
off_h = pid_bh % H
Why this layout: FA2’s contribution vs FA1 is exactly this — parallelize along the M (Q sequence) dimension. Program pid_m owns Q rows [pid_m*BLOCK_M, (pid_m+1)*BLOCK_M), iterates over all KV tiles.
Step 2 — compute Q, K, V base pointers¶
q_ptr = Q + off_b * stride_qb + off_h * stride_qh
k_ptr = K + off_b * stride_kb + off_h * stride_kh
v_ptr = V + off_b * stride_vb + off_h * stride_vh
o_ptr = O + off_b * stride_ob + off_h * stride_oh
Use tl.make_block_ptr for cleaner slicing on Hopper+ (it maps to TMA descriptors when possible):
Q_block_ptr = tl.make_block_ptr(
base=q_ptr,
shape=(N_Q, HEAD_DIM),
strides=(stride_qm, stride_qd),
offsets=(pid_m * BLOCK_M, 0),
block_shape=(BLOCK_M, HEAD_DIM),
order=(1, 0),
)
Repeat for K (as (HEAD_DIM, N_KV) for the QK^T contraction) and V (as (N_KV, HEAD_DIM)).
Step 3 — load Q once, initialize state¶
q = tl.load(Q_block_ptr) # (BLOCK_M, HEAD_DIM), bf16
q = (q * softmax_scale).to(tl.bfloat16) # premultiply by scale
m_i = tl.zeros([BLOCK_M], dtype=tl.float32) - float("inf") # running max
l_i = tl.zeros([BLOCK_M], dtype=tl.float32) # running denominator
o_i = tl.zeros([BLOCK_M, HEAD_DIM], dtype=tl.float32) # running output
Critical: m_i, l_i, o_i are fp32. Even though inputs/outputs are bf16, all softmax state accumulates in fp32. This is non-negotiable.
Step 4 — the main loop over KV tiles¶
# For causal attention: only iterate over KV tiles ≤ this Q tile
if IS_CAUSAL:
hi = (pid_m + 1) * BLOCK_M
else:
hi = N_KV
for start_n in range(0, hi, BLOCK_N):
start_n = tl.multiple_of(start_n, BLOCK_N)
k = tl.load(K_block_ptr) # (HEAD_DIM, BLOCK_N)
v = tl.load(V_block_ptr) # (BLOCK_N, HEAD_DIM)
# 4a) scores
s = tl.dot(q, k) # (BLOCK_M, BLOCK_N), accumulated fp32
# 4b) mask
if IS_CAUSAL:
offs_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
offs_n = start_n + tl.arange(0, BLOCK_N)
s = tl.where(offs_m[:, None] >= offs_n[None, :], s, float("-inf"))
# 4c) online softmax update
m_new = tl.maximum(m_i, tl.max(s, axis=1)) # (BLOCK_M,)
alpha = tl.exp(m_i - m_new) # rescale factor for old state
p = tl.exp(s - m_new[:, None]) # unnormalized weights, (BLOCK_M, BLOCK_N)
l_i = l_i * alpha + tl.sum(p, axis=1)
o_i = o_i * alpha[:, None] + tl.dot(p.to(v.dtype), v) # p in bf16 for tensor-core matmul
m_i = m_new
# advance block pointers
K_block_ptr = tl.advance(K_block_ptr, (0, BLOCK_N))
V_block_ptr = tl.advance(V_block_ptr, (BLOCK_N, 0))
Read this loop line by line and compare to §5 of 01_online_softmax.md. It is the exact recurrence, with (a) causal masking, (b) fp32 accumulate.
Step 5 — final normalization and store¶
o_i = o_i / l_i[:, None] # deferred divide (FA2 optimization)
L_i = m_i + tl.log(l_i) # log-sum-exp for backward
O_block_ptr = tl.make_block_ptr(base=o_ptr, ...)
tl.store(O_block_ptr, o_i.to(O.dtype.element_ty))
tl.store(L_ptr + offs_m, L_i)
FA2 optimization: the divide by l_i happens ONCE at the end, not per tile. In FA1 it was per tile. This is the “reduce non-matmul FLOPs” refinement.
Step 6 — Python wrapper + autotune¶
@triton.autotune(
configs=[
triton.Config({"BLOCK_M": 64, "BLOCK_N": 64}, num_warps=4, num_stages=3),
triton.Config({"BLOCK_M": 128, "BLOCK_N": 64}, num_warps=4, num_stages=3),
triton.Config({"BLOCK_M": 128, "BLOCK_N": 128}, num_warps=8, num_stages=3),
triton.Config({"BLOCK_M": 64, "BLOCK_N": 128}, num_warps=8, num_stages=3),
],
key=["N_Q", "N_KV", "HEAD_DIM", "IS_CAUSAL"],
)
@triton.jit
def _fa_fwd_kernel(...): ...
def flash_attention_forward(q, k, v, causal=False):
B, H, N_Q, D = q.shape
_, _, N_KV, _ = k.shape
o = torch.empty_like(q)
L = torch.empty((B, H, N_Q), dtype=torch.float32, device=q.device)
softmax_scale = 1.0 / (D ** 0.5)
grid = lambda META: (triton.cdiv(N_Q, META["BLOCK_M"]), B * H, 1)
_fa_fwd_kernel[grid](q, k, v, o, L, ...strides..., B, H, N_Q, N_KV,
softmax_scale, causal, HEAD_DIM=D)
return o, L
The bug catalog — every mistake I know about¶
Correctness bugs (found by comparing to torch.nn.functional.scaled_dot_product_attention):
Forgot to accumulate in fp32. Output looks right at small N; drifts at N > 4k. Fix:
m_i,l_i,o_iare fp32 regardless of input dtype.Applied softmax scale after
tl.dotinstead of premultiplying Q. Numerically equivalent in fp32, but at bf16 you lose bits. Premultiply Q by1/sqrt(d)before the loop.Masked with 0 instead of -inf. After
exp(0 - m_new)you get 1, not 0. Correct mask value isfloat("-inf").Wrong causal comparison direction.
offs_m >= offs_nmeans Q-row m attends to K-col n ifm >= n. Get it wrong and you either look ahead (bad) or exclude the diagonal (very bad).Update order in the recurrence. You must compute
m_newfirst, thenalpha = exp(m_i - m_new), then apply tol_iando_i, THEN assignm_i = m_new. Overwritem_itoo early and everything is silently wrong.p.to(v.dtype)missing.tl.doton Hopper wants both operands in the same low-precision dtype for tensor cores. Ifpstays fp32 you fall off tensor cores and lose ~10× perf.Non-power-of-2 head_dim. Triton’s dot requires
HEAD_DIMto be a compile-time power of two. Pad K/V to the nearest power of two or route non-standard head dims to SDPA.Fully-masked row → NaN. If a causal row has zero valid keys (shouldn’t happen with causal + Q index ≥ 0, but happens with sliding window at position 0),
l_i = 0→ divide by zero. Guard:o_i = o_i / tl.maximum(l_i, 1e-6)[:, None].Stride confusion between Q/K when N_Q ≠ N_KV. Cross-attention has different Q and KV sequence lengths. Two separate strides, two separate offset variables.
Autotune cache poisoning. After changing kernel logic during development, wipe
~/.triton/cache/. Otherwise you’re running an old compiled version and going insane.
Performance bugs:
num_warpstoo high. 8 is aggressive; 4 is often faster at smallBLOCK_M. Autotune.num_stagestoo high on small SMEM. More pipeline stages = more SMEM pressure. On Ampere 3 is a good start; Hopper handles 4–5 with TMA.BLOCK_N = 128atHEAD_DIM = 128may spill becauseS = BLOCK_M × BLOCK_N= 128×128 fp32 = 64 KB. Watch for spill in Nsight (stall_reason.long_scoreboardon local memory).Not using
tl.multiple_of. Tells the compiler about alignment; drops guard instructions inside the loop.
Numerics verification harness¶
def test_flash_attention():
torch.manual_seed(0)
B, H, N, D = 2, 16, 4096, 64
q = torch.randn(B, H, N, D, dtype=torch.bfloat16, device="cuda")
k = torch.randn(B, H, N, D, dtype=torch.bfloat16, device="cuda")
v = torch.randn(B, H, N, D, dtype=torch.bfloat16, device="cuda")
o_ref = torch.nn.functional.scaled_dot_product_attention(q, k, v, is_causal=True)
o_ours, _ = flash_attention_forward(q, k, v, causal=True)
torch.testing.assert_close(o_ours, o_ref, atol=1e-2, rtol=1e-2)
print("OK")
# Run across shape sweep too
for shape in [(1,8,512,64), (1,8,2048,64), (4,32,4096,128),
(1,8,8192,128), (2,16,16384,64)]:
...
Tolerances: bf16 with fp32 accumulate → atol=1e-2, rtol=1e-2. If you can’t hit that, you have a bug. If you’re comfortably inside 1e-3 for a specific shape, your accumulator or scale ordering is probably fine.
Profiling checklist (Nsight Compute, --set full)¶
SM Utilization should be ≥ 70% at (B=2, H=16, N=4096, D=64). If < 40%, launch config is wrong or loop is memory-bound.
Tensor Core Utilization should be ≥ 50%. If 0%, you didn’t cast
pback to bf16 beforetl.dot.Long Scoreboard Stalls should be low (< 20%). If high, K/V loads are not overlapped — check
num_stages.Shared Memory usage — if spilling to local memory, reduce
BLOCK_M × BLOCK_N.Compare to
torch.nn.functional.scaled_dot_product_attention(..., is_causal=True)timings. Target: within 2× of SDPA’s flash backend on H100 for causal bf16 at N=4096. If you land within 1.5×, you’ve written a real FA2.
What to do after your FA2 works¶
Backward pass. Use the stored
L(log-sum-exp) tensor. Backward has its own tile schedule and its own bugs. Do this only if time permits — a working forward is the priority.Read FA3. The Hopper primitives (warp-spec + wgmma + pingpong) will now feel concrete because you know what you’re accelerating.
Sliding-window mask. Add a
WINDOW_SIZEconstexpr, change the causal mask formula. Half a day.ALiBi bias. One-line change in the score computation. Understand where in the loop you’d add it.
Contribute to KernelBench. Your FA2 forward is a legitimate submission for the attention task category.
References¶
Triton fused attention tutorial: https://triton-lang.org/main/getting-started/tutorials/06-fused-attention.html
FA1: arXiv <phone_number_or_numberic_id_or_random_id_54>
Dao-AILab reference: https://github.com/Dao-AILab/flash-attention
Umar Jamil’s step-by-step Triton FA video (search YouTube): highly recommended companion