Skip to content

Attention kernel chains: tiling, the online softmax, merges and masks

8 chains

Attention kernel questions are about where bytes live and which small state survives a tile. The two softmax chains use the same three scores, 1, 2 and 3, so the answer can be checked against a plain softmax: 0.090, 0.245, 0.665.

What FlashAttention removes from HBM

inputs   one head, N = 8,192 tokens, head dim 128, BF16
scores   N x N x 2 B = 128 MiB per head, written and read back by naive attention
naive    about 520 MiB of traffic per head, nearly all of it the score matrix
compulsory  read Q, K, V and write O once: 4 x 8,192 x 128 x 2 B = 8 MiB
FLOPs    4 x N^2 x d = 34.4 GFLOP, unchanged by tiling
sanity   34.4 GFLOP over 8 MiB is about 4,096 FLOP per byte: compute-bound

Tiling changes bytes moved, never FLOPs. Prefill attention becomes a compute problem once the score matrix stops going to memory.

Drilled in: FlashAttention HBM bytes for one head · FlashAttention generations

The online softmax on numbers

inputs   scores 1 and 2 arrive in the first block, 3 in the second
block 1  m = 2, l = e^(1-2) + e^(2-2) = 1.368
block 2  new max 3: alpha = e^(2-3) = 0.368
         l = 1.368 x 0.368 + e^(3-3) = 1.503
result   weight of the score 3 = 1 / 1.503 = 0.665
sanity   matches softmax(1, 2, 3); the accumulated output is rescaled by the
         same alpha whenever the max rises

The state per query row is the running max, the running sum and the unnormalized output. Dividing by the sum once at the end, rather than after each block, is the FlashAttention-2 change.

Drilled in: Derive the online softmax · Online softmax recurrence

The log-sum-exp merge

inputs   the same scores split across two workers: [1, 2] and [3]
lse      lse_1 = ln(e^1 + e^2) = 2.313; lse_2 = 3
merged   lse = ln(e^2.313 + e^3) = 3.408
weights  e^(2.313 - 3.408) = 0.335 and e^(3 - 3.408) = 0.665
sanity   0.335 is 0.090 + 0.245, the first worker's share of the softmax

Each worker writes a normalized partial output and its lse. The merge weights each partial by exp(lse_s - lse), so any split of the keys gives the exact result.

Drilled in: Split-KV split count and LSE merge · Split-KV flash decoding

How many splits for decode

inputs   Llama 3.1 8B decode at batch 1: 8 KV heads, 128K tokens of context
         H100 SXM with 132 SMs
unsplit  one thread block per KV head: 8 blocks, 6% of the SMs
split 16 16 chunks of 8K keys x 8 heads = 128 blocks, 0.97 of one wave
sanity   one wave keeps every SM busy without a second, mostly idle wave

Splitting pays when batch times KV heads is far below the SM count. At high batch there are already enough blocks, and the merge becomes pure overhead.

Drilled in: Split-KV split count and LSE merge

The causal mask for a decode step

inputs   T new queries over S cached keys; query row i is at position S - T + i
rule     key j is visible when j <= i + (S - T)
decode   T = 1, S = 4,096: the query sees keys 0 to 4,095
top-left a mask aligned to the top-left corner lets that query see key 0 only
sanity   the mask is lower-triangular and aligned to the bottom-right corner

PyTorch's is_causal=True aligns top-left when T < S. Using it for a decode or chunked-prefill step returns attention over the wrong keys without an error.

Drilled in: Causal mask when queries are shorter than keys

What the backward pass saves

inputs   32 heads, N = 8,192 tokens per layer
saved    Q, K, V, O and one FP32 log-sum-exp per query row:
         32 x 8,192 x 4 B = 1 MiB of LSE per layer
not saved  the probabilities P: 32 x 8,192^2 x 2 B = 4 GiB per layer in BF16
backward rebuilds each P tile as exp(S - LSE) on chip
sanity   one extra GEMM per tile buys back 4 GiB per layer

The softmax gradient needs one more vector, D = rowsum(dO x O), computed once before the main loop. Nothing else of size N x N is kept.

Drilled in: What FlashAttention-2's backward saves

GQA loads KV once per group

inputs   Llama 3.3 70B: 64 query heads, 8 KV heads, group g = 8; TP=8
         batch 64, 8K context, 320 KiB of KV per token in BF16
grouped  64 x 8,192 x 327,680 B / 8 = 20 GiB of KV read per GPU per step
per head without grouping, each query head re-reads its KV head: up to 160 GiB
intensity  1 FLOP per KV byte for one head, 8 when 8 heads share the tile
sanity   the cache is already 8x smaller than MHA; the kernel has to read
         it once to keep the saving

Stacking the group's query heads as rows of one tile also gives a Hopper matrix instruction 8 of its 64 rows to work on, where a single decode query would give it one.

Drilled in: GQA kernel that loads KV once per group

The block-table cost of paged attention

inputs   Llama 3.1 8B, 16-token blocks, BF16
block    16 x 8 KV heads x 128 x 2 (K, V) x 2 B = 64 KiB per layer
table    one int32 per block: 4 B per 64 KiB = 0.006%
sanity   the bytes are nothing; the cost is a dependent load before every
         block and tiles that span several pages

A 128-token tile over 16-token blocks touches 8 separate pages, so the kernel needs 8 table lookups before it can issue the loads that matter.

Drilled in: Paged attention block-table gather

Next card: GPU Performance Interview Chains