Type at least 2 letters. Enter opens the first result, Esc closes.

    ML Systems · Lesson 4 · One GPU: hardware, roofline and kernels

    FlashAttention and online softmax

    Advanced 80 min 5 interactive figures 4 videos Colab notebook

    Before this lesson: the roofline model (lesson 2) and tiling and fusion (lesson 3). Attention with queries, keys and values (deep learning 9; the matrix form is in linear algebra 15). The log-sum-exp trick (subtract the max before exp; deep learning 2). Time: about 80 minutes, plus the notebook.


    Why this matters #

    Attention is the only transformer operation whose cost grows with N2N^2, where NN is the sequence length. At N=16,384N = 16{,}384 tokens, one head's score matrix in bf16 is 16,3842⋅216{,}384^2 \cdot 2 bytes =512= 512 MiB, and there is one per head, per layer and per sequence. FlashAttention (Dao et al. 2022) computes exact attention tile by tile and never stores that matrix. PyTorch's scaled_dot_product_attention uses FlashAttention-2 kernels on recent NVIDIA GPUs, and LLM inference engines build on the same kernels.

    Its key formula, the merge of two partial softmax results, appears again and again: in split-KV decoding (this lesson and lesson 12), in ring attention across GPUs (lesson 10), and in any softmax over a long axis. For science, O(N)O(N) memory makes long token sequences trainable: global weather fields cut into patches, long seismic records, genomes.

    1. Predict first #

    2. The picture #

    One query, one pass #

    Start with the simplest case: one query, and a number (not a vector) as the value of each key. The query has a score xix_i for each key i=1,…,Ni = 1, \dots, N. We want the softmax-weighted average of the values:

    y=∑i=1Nsoftmax⁡(x)i vi=∑iexivi∑iexi.y = \sum_{i=1}^N \operatorname{softmax}(x)_i \, v_i = \frac{\sum_i e^{x_i} v_i}{\sum_i e^{x_i}} .

    Why subtract the max. In floating point, exe^{x} overflows quickly: e89>3.4⋅1038e^{89} > 3.4 \cdot 10^{38}, the largest fp32 number. So code first subtracts m=max⁡ixim = \max_i x_i. Every exponent xi−mx_i - m is then ≤0\le 0, every exi−me^{x_i - m} lies in (0,1](0, 1], and nothing overflows. The factor e−me^{-m} appears in the numerator and in the denominator, so it cancels:

    y=∑iexi−mvi∑iexi−m.y = \frac{\sum_i e^{x_i - m} v_i}{\sum_i e^{x_i - m}} .

    The problem. mm is known only after the last score. So the simple "safe softmax" reads the scores three times: pass 1 finds mm, pass 2 sums exi−me^{x_i - m}, pass 3 forms the weighted sum. On a GPU every pass over a long row is a pass over HBM.

    The online idea. Read the scores in blocks, and keep three numbers:

    • mm: the largest score seen so far;
    • ℓ\ell: the sum ∑seenexi−m\sum_\text{seen} e^{x_i - m}, relative to the current mm;
    • o~\tilde o: the sum ∑seenexi−mvi\sum_\text{seen} e^{x_i - m} v_i, also relative to the current mm.

    When a new block raises the max from moldm_\text{old} to mnewm_\text{new}, the old sums are measured against the wrong max. Fix them with one multiplication. Each old term exi−molde^{x_i - m_\text{old}} times emold−mnewe^{m_\text{old} - m_\text{new}} is exi−mnewe^{x_i - m_\text{new}}, exactly the term we want. So multiply ℓ\ell and o~\tilde o by the rescale factor emold−mnew≤1e^{m_\text{old} - m_\text{new}} \le 1, then add the new block's terms. At the end, y=o~/ℓy = \tilde o / \ell.

    Small numbers. Scores (1,2)(1, 2) arrive first: m=2m = 2 and ℓ=e1−2+e2−2=1.368\ell = e^{1-2} + e^{2-2} = 1.368. Then the score 44 arrives: m=4m = 4, the factor is e2−4=0.135e^{2-4} = 0.135, and the rescaled old sum is 1.368⋅0.135=0.185=e1−4+e2−41.368 \cdot 0.135 = 0.185 = e^{1-4} + e^{2-4}. Adding the new term e4−4=1e^{4-4} = 1 gives ℓ=1.185\ell = 1.185, which is ∑iexi−4\sum_i e^{x_i - 4} over all three scores, as if we had known the max from the start.

    Interactive figure: loading…

    Press Next block and watch the yellow state. The top row shows the scores; the current block is magenta. The bars below are the weights exi−me^{x_i - m} of the scores seen so far, relative to the current max. When a block raises the max, all old bars shrink by the same rescale factor. At the end, o~/ℓ\tilde o / \ell equals the exact softmax average (difference about 10−1610^{-16}). Try one huge score: the naive exe^{x} overflows, the online version does not.

    Quick check

    The running state of one query has max m=3m = 3. A new block arrives whose largest score is 55. By what factor must you multiply the old running sum ℓ\ell (and the old o~\tilde o)?

    The GPU picture: keep the tile on chip #

    Now use the real sizes. Q,K,VQ, K, V are N×dN \times d matrices: NN tokens, head dimension dd. The output is O=softmax⁡(τQK⊤)VO = \operatorname{softmax}(\tau Q K^\top) V with τ=1/d\tau = 1/\sqrt{d}, the softmax taken row by row. Recall from lesson 1 that HBM is large and slow (80 GB, 3.35 TB/s on an H100 SXM), and that each SM has a small, fast on-chip memory (up to 228 KB of shared memory plus a 256 KB register file).

    Standard attention runs three kernels, and each one goes through HBM:

    Step Reads from HBM Writes to HBM
    S=τQK⊤S = \tau Q K^\top Q,KQ, K SS (N×NN \times N)
    P=softmax⁡(S)P = \operatorname{softmax}(S) SS PP (N×NN \times N)
    O=PVO = P V P,VP, V OO

    FlashAttention fuses the three steps into one kernel. A thread block owns BrB_r query rows. It loads their QQ tile once into on-chip memory and keeps it there. Then it streams tiles of BcB_c keys and values through on-chip memory. For each K,VK, V tile it computes the Br×BcB_r \times B_c score tile, updates the online state (m,ℓ,o~)(m, \ell, \tilde o) of each of its rows, and throws the score tile away. At the end it divides o~\tilde o by ℓ\ell, writes its rows of OO, and writes one extra number per row: the logsumexp lsei=mi+log⁡ℓi\mathrm{lse}_i = m_i + \log \ell_i, which the backward pass needs.

    Interactive figure: loading…

    Press Play (or Step) and compare the two modes. The grid is the N×NN \times N score matrix in tiles. In FlashAttention mode, one thread block keeps its yellow QQ tile on chip while the magenta K,VK, V tile moves along the row; the score tiles it computes (blue) never go to HBM. In standard mode, the whole matrix is written to and read from HBM (orange). Compare the HBM counters, then turn on causal to see the skipped tiles.

    3. Definitions and main results #

    The online softmax theorem #

    Theorem (online softmax). Take one query row. Its scores x∈RNx \in \mathbb{R}^N arrive in blocks x(1),…,x(T)x^{(1)}, \dots, x^{(T)}, with value rows vi∈Rdv_i \in \mathbb{R}^d. Start with m0=−∞m_0 = -\infty, ℓ0=0\ell_0 = 0, o~0=0\tilde o_0 = 0. For block j=1,…,Tj = 1, \dots, T:

    mj=max⁡(mj−1, max⁡i∈block jxi),ℓj=emj−1−mj ℓj−1+∑i∈block jexi−mj,o~j=emj−1−mj o~j−1+∑i∈block jexi−mj vi.\begin{aligned} m_j &= \max\big(m_{j-1},\ \max_{i \in \text{block } j} x_i\big),\\ \ell_j &= e^{m_{j-1} - m_j}\, \ell_{j-1} + \textstyle\sum_{i \in \text{block } j} e^{x_i - m_j},\\ \tilde o_j &= e^{m_{j-1} - m_j}\, \tilde o_{j-1} + \textstyle\sum_{i \in \text{block } j} e^{x_i - m_j}\, v_i . \end{aligned}

    Then o~T/ℓT=∑isoftmax⁡(x)i vi\tilde o_T / \ell_T = \sum_i \operatorname{softmax}(x)_i\, v_i exactly, and mT+log⁡ℓT=lse(x)m_T + \log \ell_T = \mathrm{lse}(x).

    Proof (by induction on the blocks).

    1. Claim after block jj: mjm_j is the max of all scores seen so far, ℓj=∑seenexi−mj\ell_j = \sum_{\text{seen}} e^{x_i - m_j} and o~j=∑seenexi−mjvi\tilde o_j = \sum_{\text{seen}} e^{x_i - m_j} v_i.
    2. Base case j=1j = 1: m1m_1 is the max of block 1. The factor em0−m1=e−∞=0e^{m_0 - m_1} = e^{-\infty} = 0 removes the empty start values, so ℓ1\ell_1 and o~1\tilde o_1 are the block-1 sums relative to m1m_1.
    3. Step j−1→jj - 1 \to j: the max of the old max and the new block's max is the max so far. By the claim for j−1j - 1, ℓj−1=∑oldexi−mj−1\ell_{j-1} = \sum_\text{old} e^{x_i - m_{j-1}}. Multiplying by emj−1−mje^{m_{j-1} - m_j} turns each old term into exi−mje^{x_i - m_j}. Adding the new block's terms gives the sum over all seen scores, relative to mjm_j. The same argument works for o~j\tilde o_j, term by term, because each term of o~\tilde o is a term of ℓ\ell times a fixed vector viv_i.
    4. End: after block TT every score has been seen and mT=m=max⁡ixim_T = m = \max_i x_i. So o~T/ℓT=∑iexi−mvi/∑iexi−m\tilde o_T / \ell_T = \sum_i e^{x_i - m} v_i \big/ \sum_i e^{x_i - m}. The factor e−me^{-m} cancels, and this is ∑isoftmax⁡(x)ivi\sum_i \operatorname{softmax}(x)_i v_i, with no approximation.
    5. No overflow: xi≤mjx_i \le m_j and mj−1≤mjm_{j-1} \le m_j, so every exponent is ≤0\le 0 and every exponential lies in [0,1][0, 1].
    6. Logsumexp: mT+log⁡ℓT=log⁡(emT∑iexi−mT)=log⁡∑iexi=lse(x)m_T + \log \ell_T = \log\big(e^{m_T} \sum_i e^{x_i - m_T}\big) = \log \sum_i e^{x_i} = \mathrm{lse}(x). ■\blacksquare

    Why the rescale is exact. The factor depends only on the two maxima, not on ii. So it is the same for every old term, and one multiplication fixes the whole sum. This is the only property the proof needs (see the quiz at the end of this section for a variant where it fails).

    Keep o~\tilde o unnormalised. FlashAttention-2 keeps o~\tilde o as a sum and divides by ℓ\ell once, at the end. The first FlashAttention kept a normalised output and rescaled it at every block, which costs extra non-matmul FLOPs.

    The tiled forward pass. For one thread block with query rows QiQ_i (Br×dB_r \times d):

    1. Load QiQ_i into on-chip memory. Set m=−∞m = -\infty, ℓ=0\ell = 0, o~=0\tilde o = 0 for each row.
    2. For each key tile jj: load Kj,VjK_j, V_j (Bc×dB_c \times d each). Compute the score tile Sij=τQiKj⊤S_{ij} = \tau Q_i K_j^\top (Br×BcB_r \times B_c) on chip.
    3. Update each row with the theorem: new max, rescale factor, ℓ\ell and o~\tilde o (o~←emold−mo~+eSij−mVj\tilde o \leftarrow e^{m_\text{old} - m}\tilde o + e^{S_{ij} - m} V_j, a small matmul).
    4. After the last tile: write Oi=o~/ℓO_i = \tilde o / \ell and lsei=m+log⁡ℓ\mathrm{lse}_i = m + \log \ell to HBM.

    Steps 2 and 3 are two small matmuls (QiKj⊤Q_i K_j^\top and P~ijVj\tilde P_{ij} V_j) plus elementwise work, so the tensor cores do most of the arithmetic.

    Merging two partial results #

    Split the keys of one query into two disjoint sets AA and BB. Suppose a thread block (or a GPU) has computed attention over AA alone: the normalised output and the logsumexp

    oA=∑i∈Aexi−lseA vi,lseA=log⁡∑i∈Aexi,o_A = \sum_{i \in A} e^{x_i - \mathrm{lse}_A}\, v_i, \qquad \mathrm{lse}_A = \log \sum_{i \in A} e^{x_i},

    and the same for BB. The merge formula gives attention over all keys:

    lse=log⁡(elseA+elseB),o=elseA−lse oA+elseB−lse oB.\mathrm{lse} = \log\big(e^{\mathrm{lse}_A} + e^{\mathrm{lse}_B}\big), \qquad o = e^{\mathrm{lse}_A - \mathrm{lse}}\, o_A + e^{\mathrm{lse}_B - \mathrm{lse}}\, o_B .

    Proof.

    1. elseA+elseB=∑i∈Aexi+∑i∈Bexi=∑i∈A∪Bexie^{\mathrm{lse}_A} + e^{\mathrm{lse}_B} = \sum_{i \in A} e^{x_i} + \sum_{i \in B} e^{x_i} = \sum_{i \in A \cup B} e^{x_i}, because AA and BB are disjoint. Take the log: lse\mathrm{lse} is the logsumexp over all keys.
    2. elseA−lse oA=∑i∈AelseA−lse exi−lseAvi=∑i∈Aexi−lse vie^{\mathrm{lse}_A - \mathrm{lse}}\, o_A = \sum_{i \in A} e^{\mathrm{lse}_A - \mathrm{lse}}\, e^{x_i - \mathrm{lse}_A} v_i = \sum_{i \in A} e^{x_i - \mathrm{lse}}\, v_i. The same holds for BB.
    3. Add the two: o=∑i∈A∪Bexi−lse vi=∑isoftmax⁡(x)i vio = \sum_{i \in A \cup B} e^{x_i - \mathrm{lse}}\, v_i = \sum_i \operatorname{softmax}(x)_i\, v_i. ■\blacksquare

    The two weights wA=elseA−lsew_A = e^{\mathrm{lse}_A - \mathrm{lse}} and wB=elseB−lsew_B = e^{\mathrm{lse}_B - \mathrm{lse}} are positive and add up to 1: wAw_A is the share of the total softmax mass that lies in AA. So the merge is a weighted average, not a plain average and not a sum.

    Compute it stably. Write lse=max⁡(lseA,lseB)+log⁡(1+e−∣lseA−lseB∣)\mathrm{lse} = \max(\mathrm{lse}_A, \mathrm{lse}_B) + \log\big(1 + e^{-|\mathrm{lse}_A - \mathrm{lse}_B|}\big) (NumPy: np.logaddexp). An empty or fully masked set has lse=−∞\mathrm{lse} = -\infty and weight 0. If both sets are empty, return o=0o = 0 and lse=−∞\mathrm{lse} = -\infty. Never compute −∞−(−∞)-\infty - (-\infty): it is nan, and one nan spreads through the whole output.

    Interactive figure: loading…

    Drag the split point to cut eight keys into a set A and a set B. Each set computes its own softmax average oo and its lse\mathrm{lse}; the merge weights them by else−lsetotale^{\mathrm{lse} - \mathrm{lse}_\text{total}}. The merged output equals full attention for every split. The magenta line is the common mistake, the plain average of oAo_A and oBo_B. Try mask B to see a set with lse=−∞\mathrm{lse} = -\infty.

    Quick check

    Two thread blocks return (oA,lseA)=(2,ln⁡1)(o_A, \mathrm{lse}_A) = (2, \ln 1) and (oB,lseB)=(5,ln⁡3)(o_B, \mathrm{lse}_B) = (5, \ln 3) for the same query (scalar values). What is the merged output oo?

    HBM traffic #

    Theorem (Dao et al. 2022, Theorem 2). Let d≤M≤Ndd \le M \le Nd. Standard attention needs Θ(Nd+N2)\Theta(Nd + N^2) HBM accesses. FlashAttention needs Θ(N2d2/M)\Theta(N^2 d^2 / M) HBM accesses.

    Standard attention, counted. Per head, from the table in section 2:

    • S=τQK⊤S = \tau QK^\top: read QQ and KK (2Nd2Nd), write SS (N2N^2);
    • P=softmax⁡(S)P = \operatorname{softmax}(S): read SS, write PP (2N22N^2);
    • O=PVO = PV: read PP and VV (N2+NdN^2 + Nd), write OO (NdNd).

    Total: 4N2+4Nd=Θ(Nd+N2)4N^2 + 4Nd = \Theta(Nd + N^2). A mask or dropout adds more passes over N×NN \times N matrices.

    FlashAttention, counted. Use the FlashAttention-2 loop order: the outer loop runs over the N/BrN / B_r query tiles. Each query tile reads its QQ rows once, reads all of KK and VV (2Nd2Nd elements), and writes its OO rows once. Over all tiles:

    traffic=Nd⏟Q+Nd⏟O+NBr⋅2Nd⏟K, V=2Nd+2N2dBr,\text{traffic} = \underbrace{Nd}_{Q} + \underbrace{Nd}_{O} + \frac{N}{B_r} \cdot \underbrace{2Nd}_{K,\,V} = 2Nd + \frac{2N^2 d}{B_r},

    plus NN for the lse values, which we ignore. The query tile is the reuse factor: each K,VK, V element read from HBM is used by BrB_r query rows. This is the same idea as the tile size TT of the tiled matmul in lesson 3.

    How large can BrB_r be? The tiles must fit on chip. Up to a constant, they are Br×dB_r \times d matrices, so the largest BrB_r that fits is a constant times M/dM/d. Put Br=Θ(M/d)B_r = \Theta(M/d) into the count: 2N2d/Br=Θ(N2d2/M)2N^2 d / B_r = \Theta(N^2 d^2 / M). That is where d2/Md^2/M comes from.

    The on-chip budget, and the original loop order

    On chip, one thread block holds QiQ_i and o~i\tilde o_i (Br×dB_r \times d each), KjK_j and VjV_j (Bc×dB_c \times d each), the score tile (Br×BcB_r \times B_c) and m,ℓm, \ell (BrB_r each). Choose Br=⌊M/(6d)⌋B_r = \lfloor M/(6d) \rfloor (at least 1) and Bc=min⁡(Br,d)B_c = \min(B_r, d). Then, for M≥6dM \ge 6d and d≥8d \ge 8:

    1. QiQ_i and o~i\tilde o_i take 2Brd≤M/32B_r d \le M/3 elements;
    2. KjK_j and VjV_j take 2Bcd≤2Brd≤M/32B_c d \le 2B_r d \le M/3;
    3. the score tile takes BrBc≤Brd≤M/6B_r B_c \le B_r d \le M/6;
    4. mm and ℓ\ell take 2Br≤M/(3d)≤M/242B_r \le M/(3d) \le M/24.

    The sum is at most 0.875 M0.875\,M, so everything fits. This choice is Br=Θ(M/d)B_r = \Theta(M/d), and the traffic is 2Nd+2N2d/Br=Θ(N2d2/M)2Nd + 2N^2 d/B_r = \Theta(N^2 d^2/M). The 2Nd2Nd term is the smaller one, because M≤NdM \le Nd gives N2d2/M≥NdN^2 d^2 / M \ge Nd. Real kernels choose the tile sizes by measurement: FlashAttention-2 takes BrB_r and BcB_c from {64,128}\{64, 128\}, depending on dd and the shared memory of the GPU.

    The first FlashAttention paper loops the other way: the outer loop runs over K,VK, V tiles of size Bc=⌈M/(4d)⌉B_c = \lceil M/(4d) \rceil, the inner loop over query tiles of size Br=min⁡(⌈M/(4d)⌉,d)B_r = \min(\lceil M/(4d)\rceil, d). Then KK and VV are read once, but QQ, OO, mm and ℓ\ell are read and written once per K,VK, V tile, that is N/Bc=Θ(Nd/M)N/B_c = \Theta(Nd/M) times. Each pass moves Θ(Nd)\Theta(Nd) elements, so the total is again Θ(N2d2/M)\Theta(N^2 d^2 / M).

    Dao et al. also prove a lower bound (their Proposition 3): no exact attention algorithm needs only o(N2d2/M)o(N^2 d^2 / M) HBM accesses for all MM in [d,Nd][d, Nd]. So, up to constants, the tiled order is optimal.

    Compare the two. For N≫dN \gg d the ratio standard / FlashAttention is about 4N2/(2N2d/Br)=2Br/d4N^2 / (2N^2 d / B_r) = 2B_r / d. With Br=128B_r = 128: 4 for d=64d = 64, and 2 for d=128d = 128.

    The roofline view (lesson 2). Both versions do the same forward FLOPs: QK⊤QK^\top and PVPV cost 2N2d2N^2 d each, 4N2d4N^2 d in total. In bf16 (2 bytes per element) the arithmetic intensity is about

    • standard: 4N2d2⋅4N2=d2\dfrac{4N^2 d}{2 \cdot 4N^2} = \dfrac{d}{2} FLOP/byte (32 for d=64d = 64);
    • FlashAttention: 4N2d2⋅2N2d/Br=Br\dfrac{4N^2 d}{2 \cdot 2N^2 d / B_r} = B_r FLOP/byte (128 for Br=128B_r = 128).

    The H100 ridge point is 989/3.35≈295989 / 3.35 \approx 295 FLOP/byte, so standard attention is deep in the memory-bound region. The count for FlashAttention is pessimistic: thread blocks that run at the same time usually work on the same head, so many K,VK, V re-reads hit the 50 MB L2 cache instead of HBM. The real HBM traffic is lower, and well-tuned kernels are close to compute-bound.

    Interactive figure: loading…

    Move the sequence length NN and watch the two lines. The chart shows the minimum time of one head's forward pass on the roofline: standard and FlashAttention; the blue line is the compute time 4N2d/P4N^2 d / P. Standard attention stays far above the compute line. Change BrB_r to see the reuse factor at work, and turn on L2 serves re-reads to see the ideal case. The readout also shows the memory each version keeps for the backward pass.

    Backward pass: recompute instead of store #

    The backward pass needs PP. Standard autograd stores it: N2N^2 numbers per head. FlashAttention stores only OO and the per-row lsei\mathrm{lse}_i, and recomputes PP tile by tile. With dOdO the gradient of the loss with respect to OO, for each tile:

    1. recompute Sij=τQiKj⊤S_{ij} = \tau Q_i K_j^\top, then Pij=eSij−lseiP_{ij} = e^{S_{ij} - \mathrm{lse}_i} (no max and no sum needed: the lse already contains both, since es−m/ℓ=es−(m+log⁡ℓ)e^{s - m}/\ell = e^{s - (m + \log \ell)});
    2. dVj+=Pij⊤dOidV_j \mathrel{+}= P_{ij}^\top dO_i and dPij=dOiVj⊤dP_{ij} = dO_i V_j^\top;
    3. dSij=Pij∘(dPij−Di)dS_{ij} = P_{ij} \circ (dP_{ij} - D_i), with Di=∑k(dO∘O)ikD_i = \sum_k (dO \circ O)_{ik} (one number per row). This is the softmax backward (linear algebra 16); the trick Di=∑jPij dPij=dOi⋅OiD_i = \sum_j P_{ij}\, dP_{ij} = dO_i \cdot O_i avoids a sum over the whole row;
    4. dQi+=τ dSijKjdQ_i \mathrel{+}= \tau\, dS_{ij} K_j and dKj+=τ dSij⊤QidK_j \mathrel{+}= \tau\, dS_{ij}^\top Q_i.

    FLOPs. The standard backward has four matmuls (dVdV, dPdP, dQdQ, dKdK), 2N2d2N^2 d each, so 8N2d8N^2 d. FlashAttention adds the recomputation of SS: 10N2d10N^2 d, which is 25% more.

    Time. It is still much faster, because attention is memory-bound and FlashAttention removes most of the memory traffic. Dao et al. (2022, Figure 2) measured forward plus backward for GPT-2 medium (sequence length 1024, head dimension 64, 16 heads, batch 64) on an A100:

    Standard FlashAttention
    GFLOPs 66.6 75.2
    HBM reads and writes 40.3 GB 4.4 GB
    run time 41.7 ms 7.3 ms

    13% more FLOPs, 9 times fewer bytes, 5.7 times faster: the run time follows the bytes.

    Quick check

    Which statement about the FlashAttention backward pass is correct?

    What FlashAttention-2 changed #

    FlashAttention-2 (Dao 2023) keeps the algorithm and changes how the work is split:

    1. Fewer non-matmul FLOPs. It keeps o~\tilde o unnormalised, divides once at the end, and stores only the lse. This matters because on an A100 a bf16 matmul runs at 312 TFLOP/s but non-matmul fp32 work at 19.5 TFLOP/s: each non-matmul FLOP costs 16 times more.
    2. Parallelism over the sequence. FlashAttention-1 used one thread block per (sequence, head). With long sequences the batch is small, and batch × heads can be smaller than the 108 SMs of an A100. FlashAttention-2 gives each query tile its own thread block, so long sequences also fill the GPU.
    3. Work split across warps. Inside a thread block, FlashAttention-1 split KK and VV across the 4 warps, so the warps had to add their partial outputs through shared memory. FlashAttention-2 splits the query rows across warps. All warps read the same K,VK, V tiles and need no communication.

    Result: about 2 times faster than FlashAttention-1, at 50–73% of the A100 peak (up to 73% in the forward pass, up to 63% in the backward pass).

    Quick check

    Which attention variant can not be computed by the one-pass online softmax as it is, with a fixed state (m,ℓ,o~)(m, \ell, \tilde o) per row?

    Split-KV for decoding (flash-decoding) #

    During decoding (lesson 12) each sequence has one new query per step. Parallelism over query tiles then gives only batch × heads thread blocks. With batch 1 and 32 heads, that is 32 blocks for the 132 SMs of an H100: at most 32 SMs work. Decode attention is memory-bound (it reads the whole KV cache, lesson 2), and one SM cannot pull a large share of the HBM bandwidth. So idle SMs mean wasted bandwidth.

    Split-KV cuts each sequence's KV cache into kk chunks and gives each chunk its own thread block. Each block returns a partial (o,lse)(o, \mathrm{lse}) for its chunk. A small second kernel merges the kk partials of each row with the merge formula. The extra work is tiny (kk vectors per row), and now batch × heads × kk blocks fill the GPU. Dao, Haziza, Massa and Sizov (2023) report up to 8 times faster decoding for very long sequences.

    Interactive figure: loading…

    Raise the number of splits kk and watch the SMs fill. Each square is one SM: orange SMs stream the KV cache from HBM, violet SMs are idle. The time model assumes that the attention time scales with the number of busy SMs. Notice that a large batch fills the GPU without splitting, and that a short KV cache gains little because the extra merge kernel costs a few microseconds.

    Answers to section 1. (1) Yes. Keep the running max mm, the running sum ℓ\ell and the running weighted sum o~\tilde o; rescale both sums by emold−mnewe^{m_\text{old} - m_\text{new}} when the max grows, and output o~/ℓ\tilde o / \ell at the end. Three numbers per query (with vector values: mm, ℓ\ell and a dd-vector). (2) Attention is memory-bound, and FlashAttention removes the N2N^2 traffic; the run time follows bytes, not FLOPs. It stores OO and one logsumexp per query row.

    Causal masks and FlashAttention-3

    Causal masking. With a causal mask, query ii sees only keys j≤ij \le i. Cut SS into T×TT \times T tiles with T=N/BT = N/B (square tiles, Br=Bc=BB_r = B_c = B). Tiles fully above the diagonal contain only masked scores: the kernel skips them and does not even load their K,VK, V. Tiles on the diagonal are computed with the mask; tiles below it need no mask. So T(T+1)/2T(T+1)/2 of the T2T^2 tiles are computed, about half for large TT (for N=16,384N = 16{,}384 and B=128B = 128: 8,256 of 16,384 tiles). Causal attention therefore costs about half the FLOPs and half the K,VK, V traffic.

    FlashAttention-3 (Shah et al. 2024) targets Hopper GPUs. FlashAttention-2 reaches only about 35% of the H100 peak. FlashAttention-3 uses the asynchronous Hopper hardware (the TMA copy engine and warp specialization, so that loads, matmuls and the softmax overlap) and fp8 tensor cores. It reports up to 740 TFLOP/s in fp16 (75% of the H100 peak) and close to 1.2 PFLOP/s in fp8. One reason the overlap matters: an H100 SXM5 does 989 TFLOP/s of fp16 matmul but only 3.9 TFLOP/s of special functions such as the exponential, so the exp of the softmax is not free.

    4. Worked example #

    (a) Online softmax by hand #

    Scores x=(ln⁡2, 0, ln⁡8, ln⁡4)x = (\ln 2,\ 0,\ \ln 8,\ \ln 4), so ex=(2,1,8,4)e^{x} = (2, 1, 8, 4): whole numbers you can follow. Values v=(1,2,3,4)v = (1, 2, 3, 4). Two blocks of two scores.

    Block 1, scores (ln⁡2,0)(\ln 2, 0):

    1. m1=max⁡(−∞,ln⁡2,0)=ln⁡2m_1 = \max(-\infty, \ln 2, 0) = \ln 2. The factor em0−m1=e−∞=0e^{m_0 - m_1} = e^{-\infty} = 0 clears the start values.
    2. ℓ1=eln⁡2−ln⁡2+e0−ln⁡2=1+12=1.5\ell_1 = e^{\ln 2 - \ln 2} + e^{0 - \ln 2} = 1 + \tfrac12 = 1.5.
    3. o~1=1⋅1+12⋅2=2\tilde o_1 = 1 \cdot 1 + \tfrac12 \cdot 2 = 2.

    Block 2, scores (ln⁡8,ln⁡4)(\ln 8, \ln 4):

    1. m2=max⁡(ln⁡2,ln⁡8,ln⁡4)=ln⁡8m_2 = \max(\ln 2, \ln 8, \ln 4) = \ln 8. The rescale factor is eln⁡2−ln⁡8=2/8=14e^{\ln 2 - \ln 8} = 2/8 = \tfrac14.
    2. ℓ2=14⋅1.5+eln⁡8−ln⁡8+eln⁡4−ln⁡8=0.375+1+0.5=1.875\ell_2 = \tfrac14 \cdot 1.5 + e^{\ln 8 - \ln 8} + e^{\ln 4 - \ln 8} = 0.375 + 1 + 0.5 = 1.875.
    3. o~2=14⋅2+1⋅3+12⋅4=0.5+3+2=5.5\tilde o_2 = \tfrac14 \cdot 2 + 1 \cdot 3 + \tfrac12 \cdot 4 = 0.5 + 3 + 2 = 5.5.
    step mm factor emold−me^{m_\text{old} - m} ℓ\ell o~\tilde o
    block 1: (ln⁡2,0)(\ln 2, 0) ln⁡2\ln 2 0 (start) 1.5 2
    block 2: (ln⁡8,ln⁡4)(\ln 8, \ln 4) ln⁡8\ln 8 14\tfrac14 1.875 5.5
    • Output: o~2/ℓ2=5.5/1.875=44/15≈2.933\tilde o_2 / \ell_2 = 5.5 / 1.875 = 44/15 \approx 2.933.
    • Direct check: (2⋅1+1⋅2+8⋅3+4⋅4)/(2+1+8+4)=44/15(2 \cdot 1 + 1 \cdot 2 + 8 \cdot 3 + 4 \cdot 4) / (2 + 1 + 8 + 4) = 44/15. ✓
    • Logsumexp: m2+log⁡ℓ2=ln⁡8+ln⁡1.875=ln⁡15m_2 + \log \ell_2 = \ln 8 + \ln 1.875 = \ln 15, and log⁡(2+1+8+4)=ln⁡15\log(2 + 1 + 8 + 4) = \ln 15. ✓

    (b) The same result by a merge #

    Treat the two blocks as two key sets, computed separately.

    • Set AA (keys 1, 2): lseA=ln⁡(2+1)=ln⁡3\mathrm{lse}_A = \ln(2 + 1) = \ln 3 and oA=(2⋅1+1⋅2)/3=4/3o_A = (2 \cdot 1 + 1 \cdot 2)/3 = 4/3.
    • Set BB (keys 3, 4): lseB=ln⁡(8+4)=ln⁡12\mathrm{lse}_B = \ln(8 + 4) = \ln 12 and oB=(8⋅3+4⋅4)/12=10/3o_B = (8 \cdot 3 + 4 \cdot 4)/12 = 10/3.
    • Merge: lse=ln⁡(3+12)=ln⁡15\mathrm{lse} = \ln(3 + 12) = \ln 15, weights wA=3/15=0.2w_A = 3/15 = 0.2 and wB=12/15=0.8w_B = 12/15 = 0.8.
    • o=0.2⋅43+0.8⋅103=415+4015=44/15o = 0.2 \cdot \tfrac43 + 0.8 \cdot \tfrac{10}{3} = \tfrac{4}{15} + \tfrac{40}{15} = 44/15. ✓

    The plain average (4/3+10/3)/2=7/3≈2.333(4/3 + 10/3)/2 = 7/3 \approx 2.333 would be wrong.

    (c) At scale #

    N=16,384N = 16{,}384 tokens (for example a 2-D field cut into 128×128128 \times 128 patches), d=64d = 64, 16 heads, bf16, one H100 SXM (989 TFLOP/s dense bf16, 3.35 TB/s).

    1. Memory. SS is N2⋅2N^2 \cdot 2 B =512= 512 MiB per head, 8 GiB for 16 heads, for each layer and sequence. FlashAttention keeps 16⋅N⋅416 \cdot N \cdot 4 B =1= 1 MiB of fp32 lse values.
    2. Compute per head. 4N2d=6.87⋅10104N^2 d = 6.87 \cdot 10^{10} FLOP, so at least 6.87⋅1010/989⋅1012=696.87 \cdot 10^{10} / 989 \cdot 10^{12} = 69 µs.
    3. Standard traffic. 4N2+4Nd=1.08⋅1094N^2 + 4Nd = 1.08 \cdot 10^9 elements =2.16= 2.16 GB, so at least 2.16⋅109/3.35⋅1012=6442.16 \cdot 10^9 / 3.35 \cdot 10^{12} = 644 µs. Memory-bound: 9 times the compute time.
    4. FlashAttention traffic, Br=128B_r = 128, no cache. 2Nd+2N2d/Br=2.71⋅1082Nd + 2N^2 d / B_r = 2.71 \cdot 10^8 elements =541= 541 MB, so at least 162 µs. That is 4 times less (=2Br/d= 2B_r/d).
    5. With L2. KK and VV of one head are 2⋅Nd⋅22 \cdot N d \cdot 2 B =4= 4 MiB, so they fit in the 50 MB L2. If L2 serves every re-read, the HBM traffic falls to about 4Nd4Nd elements (read Q,K,VQ, K, V, write OO): 8.4 MB, 2.5 µs. The kernel is then compute-bound.
    Quick check

    Repeat step 4 of (c) with head dimension d=128d = 128 instead of 64 (same N=16,384N = 16{,}384, Br=128B_r = 128, no cache). By about what factor does FlashAttention reduce the HBM traffic compared with standard attention?

    5. In code #

    NumPy checks the hand computation and a full tiled forward pass, with and without a causal mask. The notebook has these cells and the exercises.

    import numpy as np
    from scipy.special import logsumexp
    
    # (a) the online softmax of the worked example, two blocks of two
    x, v = np.log([2., 1., 8., 4.]), np.array([1., 2., 3., 4.])
    m, l, o = -np.inf, 0.0, 0.0
    for blk in (slice(0, 2), slice(2, 4)):
        m_new = max(m, x[blk].max())
        f = np.exp(m - m_new)                                  # rescale factor (0 at the start)
        l = f * l + np.exp(x[blk] - m_new).sum()
        o = f * o + (np.exp(x[blk] - m_new) * v[blk]).sum()
        m = m_new
        print(round(f, 4), l, o)                               # 0.0 1.5 2.0, then 0.25 1.875 5.5
    print(o / l, 44 / 15, np.isclose(m + np.log(l), np.log(15)))   # 2.9333... 2.9333... True
    
    # (b) a tiled forward pass: Q tile on chip, K/V tiles streamed, O(N) extra memory
    def flash_forward(Q, K, V, Br=16, Bc=16, causal=False):
        N, d = Q.shape
        O, lse = np.zeros_like(Q), np.empty(N)
        for i in range(0, N, Br):
            q = Q[i:i + Br]; r = len(q)
            m, l, acc = np.full(r, -np.inf), np.zeros(r), np.zeros((r, d))
            for j in range(0, N, Bc):
                if causal and j > i + r - 1:
                    break                                      # tile above the diagonal: skip
                s = q @ K[j:j + Bc].T / np.sqrt(d)
                if causal:
                    rows, cols = np.arange(i, i + r)[:, None], np.arange(j, j + s.shape[1])
                    s = np.where(cols <= rows, s, -np.inf)
                m_new = np.maximum(m, s.max(axis=1))
                p, f = np.exp(s - m_new[:, None]), np.exp(m - m_new)
                l = f * l + p.sum(axis=1)
                acc = f[:, None] * acc + p @ V[j:j + Bc]
                m = m_new
            O[i:i + r], lse[i:i + r] = acc / l[:, None], m + np.log(l)
        return O, lse
    
    def naive(Q, K, V, causal=False):
        S = Q @ K.T / np.sqrt(Q.shape[1])                      # the full N x N matrix
        if causal:
            S = np.where(np.tri(len(Q), dtype=bool), S, -np.inf)
        lse = logsumexp(S, axis=1)
        return np.exp(S - lse[:, None]) @ V, lse
    
    rng = np.random.default_rng(0)
    Q, K, V = (rng.normal(size=(100, 32)) for _ in range(3))   # N = 100 is not a multiple of 16
    for causal in (False, True):
        (O, lse), (O2, lse2) = flash_forward(Q, K, V, causal=causal), naive(Q, K, V, causal)
        print(causal, np.abs(O - O2).max() < 1e-12, np.abs(lse - lse2).max() < 1e-12)
    # False True True
    # True True True
    

    PyTorch has a fused attention kernel. A short check (CPU, float64) shows the two facts you rely on: the result is exact, and autograd keeps much less memory for the backward pass.

    import math
    import torch
    import torch.nn.functional as F
    
    torch.manual_seed(0)
    N, d = 1000, 64
    # shapes (batch, heads, N, d)
    q, k, v = (torch.randn(1, 1, N, d, dtype=torch.float64) for _ in range(3))
    O_std = torch.softmax(q @ k.transpose(-1, -2) / math.sqrt(d), dim=-1) @ v  # builds S, P
    print(torch.allclose(F.scaled_dot_product_attention(q, k, v), O_std))      # True
    
    def saved_bytes(fn, *args):
        """Bytes of the distinct tensors autograd keeps for the backward pass."""
        seen = {}
        def pack(t):
            seen[t.untyped_storage().data_ptr()] = t.untyped_storage().nbytes()
            return t
        with torch.autograd.graph.saved_tensors_hooks(pack, lambda t: t):
            fn(*args)
        return sum(seen.values())
    
    x = [t.float().requires_grad_() for t in (q, k, v)]       # fp32: 4 bytes per element
    naive_t = lambda q, k, v: torch.softmax(q @ k.transpose(-1, -2) / math.sqrt(d), dim=-1) @ v
    print(saved_bytes(naive_t, *x), saved_bytes(F.scaled_dot_product_attention, *x))
    # 4768000 1028000
    

    The naive version keeps Q,K,VQ, K, V (768,000 bytes) and the 1000×10001000 \times 1000 matrix PP (4,000,000 bytes). The fused kernel keeps Q,K,VQ, K, V, OO (256,000 bytes) and the 1,000 lse values (4,000 bytes). The gap grows as N2N^2. (Checked with PyTorch 2.x on a CPU; which kernel PyTorch picks depends on the device and the inputs.)

    6. Where it shows up #

    • Training. torch.nn.functional.scaled_dot_product_attention dispatches to FlashAttention-2 kernels on recent NVIDIA GPUs (since PyTorch 2.2). Long-context training uses causal and sliding-window masks, both with tile skipping. The activation memory of attention drops from O(N2)O(N^2) to O(N)O(N) (lesson 6).
    • Inference. Prefill uses the same kernel. Decoding uses split-KV and paged KV caches (lesson 12).
    • Across GPUs. Ring attention (lesson 10) sends K,VK, V blocks around a ring of GPUs; each GPU merges the partial results with exactly the merge formula of this lesson.
    • ML for science. A global 0.25° grid has 721×1440≈106721 \times 1440 \approx 10^6 points per level. Even after patching, weather and climate transformers see tens of thousands of tokens; O(N)O(N) memory makes this trainable. The same holds for long seismic waveforms and genome sequences.
    • Beyond attention. Any softmax over a long axis can be streamed this way, for example the cross-entropy loss over a large vocabulary: process the vocabulary in chunks and keep (m,ℓ)(m, \ell).

    7. Common mistakes #

    • Rescaling ℓ\ell but not o~\tilde o when the max grows. Both sums are relative to mm; both need the factor emold−mnewe^{m_\text{old} - m_\text{new}}.
    • Normalising each tile and adding (or averaging) the tiles. Partial results need the weights elseA−lsee^{\mathrm{lse}_A - \mathrm{lse}}; a plain sum or average is wrong.
    • nan from fully masked rows or tiles. e−∞−(−∞)e^{-\infty - (-\infty)} is nan. Guard the case m=−∞m = -\infty (and lse=−∞\mathrm{lse} = -\infty in a merge).
    • "FlashAttention approximates attention." It is exact. Only the order of the floating-point operations changes, so results agree up to rounding.
    • "FlashAttention reduces FLOPs." The forward FLOPs are the same, and the backward does more. It reduces memory traffic and memory use.
    • Keeping mm, ℓ\ell, lse\mathrm{lse} or o~\tilde o in bf16. Keep them in fp32. bf16 has only about 3 significant decimal digits (lesson 5), and the sums collect many terms.
    • Expecting the training speed-up at decode. With one query there is no N×NN \times N matrix to avoid; decoding needs split-KV to use all SMs.

    8. Check yourself #

    1. The running state of one query is m=3m = 3, ℓ=2\ell = 2, o~=5\tilde o = 5 (scalar values). A block with scores (5,3)(5, 3) and values (1,0)(1, 0) arrives. Give the new mm, ℓ\ell and o~\tilde o.
      Answerm=5m = 5 and the factor is e3−5=e−2≈0.135e^{3-5} = e^{-2} \approx 0.135. ℓ=2e−2+e5−5+e3−5=1+3e−2≈1.406\ell = 2e^{-2} + e^{5-5} + e^{3-5} = 1 + 3e^{-2} \approx 1.406. o~=5e−2+1⋅1+e−2⋅0=1+5e−2≈1.677\tilde o = 5e^{-2} + 1 \cdot 1 + e^{-2} \cdot 0 = 1 + 5e^{-2} \approx 1.677. The output so far is o~/ℓ≈1.193\tilde o/\ell \approx 1.193.
    2. Merge (oA,lseA)=(1,ln⁡2)(o_A, \mathrm{lse}_A) = (1, \ln 2) and (oB,lseB)=(4,ln⁡6)(o_B, \mathrm{lse}_B) = (4, \ln 6).
      Answerlse=ln⁡(2+6)=ln⁡8\mathrm{lse} = \ln(2 + 6) = \ln 8. The weights are 2/82/8 and 6/86/8, so o=28⋅1+68⋅4=3.25o = \tfrac28 \cdot 1 + \tfrac68 \cdot 4 = 3.25.
    3. N=32,768N = 32{,}768. What does FlashAttention store per head for the backward pass, and how does it compare with storing PP in bf16?
      AnswerOO (needed anyway, as the output) and NN fp32 lse values: 32,768⋅432{,}768 \cdot 4 B =128= 128 KiB. PP would be N2⋅2N^2 \cdot 2 B =2= 2 GiB, which is 16,384 times more.
    4. With d=128d = 128 and Br=128B_r = 128, the no-cache traffic ratio standard / FlashAttention is only 2. Give two reasons why FlashAttention still wins clearly.
      Answer(1) The L2 cache serves many K,VK, V re-reads, so the real HBM traffic of FlashAttention is far below the no-cache count. (2) The N×NN \times N matrices disappear: memory is O(N)O(N), so longer sequences and larger batches fit. Also, masks and dropout add no extra N×NN \times N passes, and one kernel replaces several.
    5. Decoding on an H100 (132 SMs): batch 4, 16 heads, multi-head attention. How many thread blocks run without split-KV, and with the KV cache split into 8 chunks?
      AnswerWithout split: 4⋅16=644 \cdot 16 = 64 blocks, so at most 64 of 132 SMs are busy. With 8 chunks: 64⋅8=51264 \cdot 8 = 512 blocks, enough to fill all SMs (about 4 waves). A small merge kernel then combines 8 partials per row.
    6. Why does the backward pass not need mm and ℓ\ell separately?
      AnswerPij=eSij−mi/ℓi=eSij−(mi+log⁡ℓi)=eSij−lseiP_{ij} = e^{S_{ij} - m_i} / \ell_i = e^{S_{ij} - (m_i + \log \ell_i)} = e^{S_{ij} - \mathrm{lse}_i}. One number per row is enough, and every exponent is ≤0\le 0, so nothing overflows.
    Recall cardsAnswer in your head first, then reveal. Add the cards to your review deck to see them again just before you would forget.
    1. Write the online softmax update for ℓ\ell when a new block arrives.

    2. Why is multiplying the old sums by emold−mnewe^{m_\text{old} - m_\text{new}} exact?

    3. Why does the safe softmax subtract the max?

    4. Give the formula that merges (oA,lseA)(o_A, \mathrm{lse}_A) and (oB,lseB)(o_B, \mathrm{lse}_B).

    5. Merge (oA,lseA)=(2,ln⁡1)(o_A, \mathrm{lse}_A) = (2, \ln 1) with (oB,lseB)=(5,ln⁡3)(o_B, \mathrm{lse}_B) = (5, \ln 3).

    6. HBM traffic of standard attention and of FlashAttention, per head?

    7. What does FlashAttention store for the backward pass, and what does it recompute?

    8. Why is the FlashAttention backward faster despite 25% more matmul FLOPs?

    9. What does split-KV (flash-decoding) do?

    10. How large is one head's score matrix at N=16,384N = 16{,}384 in bf16?

    Open the review →

    9. Interview angle #

    • "Write online softmax, or a tiled attention forward pass, in NumPy." Keep (m,ℓ,o~)(m, \ell, \tilde o) per row, rescale by emold−mnewe^{m_\text{old} - m_\text{new}}, divide once at the end, return lse. Guard fully masked rows.
    • "Why is FlashAttention faster if it does the same FLOPs?" Attention is memory-bound. Fusion keeps SS and PP on chip; traffic falls from Θ(N2)\Theta(N^2) to Θ(N2d2/M)\Theta(N^2 d^2 / M), and in practice L2 serves most re-reads.
    • "What is its memory cost?" O(N)O(N) extra per head (the lse values) instead of O(N2)O(N^2).
    • "How does it handle a causal mask?" It skips tiles above the diagonal and masks only the diagonal tiles; about half the work.
    • "Does it help decoding?" Not by itself: there is one query and no N×NN \times N matrix. Split-KV adds parallelism over the KV length and merges the partials with lse.
    • "How do you combine attention computed on different GPUs?" With the lse merge: it is associative, so partials can be merged in any order (ring attention, split-KV).

    10. Practice #

    The notebook for this lesson runs in Colab, with NumPy only. It has five exercises, each with a check cell and a folded solution:

    1. online_softmax_stats(x, block): the running (m,ℓ)(m, \ell) of a stream of scores, checked against NumPy and SciPy's logsumexp, also with a huge score and with −∞-\infty entries.
    2. merge_partials(o1, lse1, o2, lse2) for many rows at once, with empty (fully masked) sets.
    3. flash_attention_forward(Q, K, V, Br, Bc, causal): the tiled forward pass, checked against naive attention to 10−1010^{-10}, with and without a causal mask.
    4. Count HBM traffic with a CountingMemory: write the formulas for standard and tiled attention, check them against the counts, and plot the ratio over NN and the tile size.
    5. Split-KV decoding: split a KV cache into kk chunks, compute the partials, merge them, and check.

    11. Watch and read #

    A short visual tour of tiling and the online softmax; a good first look before the papers.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    The author explains IO-awareness, tiling and recomputation, and the measured speed-ups.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    How the algorithm maps onto a real GPU kernel, step by step.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    A long, complete derivation of the forward and backward passes and a Triton implementation; watch the parts you need.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play