Learn › How Transformers Work
·FREE·PAPER·10 min read

FlashAttention from Scratch — The Paradox of Doing More Math to Go Faster

FlashAttention deliberately recomputes the same math in the backward pass. It does strictly more arithmetic and still runs faster — because on a GPU, moving numbers costs more than multiplying them. From counting HBM round trips, through the softmax wall that blocks tiling, to the online-softmax recurrence that breaks it, and the silent backend fallback that bites people in production.

Key points

  • FlashAttention is not an approximation; it produces the same answer as the naive implementation.
  • The naive implementation builds an N×N table in the middle despite N×d input and output, shuttling it through HBM repeatedly.
  • Online softmax shrinks the old accumulation by a single scalar e^{m−m'} when the maximum updates, then adds the new block.
  • The backward pass rebuilds P in SRAM instead of storing it; arithmetic rises, but in a bandwidth-bound regime bytes are the costly currency.
  • Passing a mask can silently drop dispatch from the flash backend to MATH, and you notice only through a slowdown or an OOM.
ModalitytextTaskinference

FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness


An optimization that adds arithmetic

When you hear "make it faster," you usually picture cutting wasted work. FlashAttention does the opposite. In the backward pass it recomputes almost everything the forward pass already computed. The arithmetic count strictly goes up. And it is still faster than the naive implementation.

The trick fits in one line: on a modern GPU, fetching a number from memory costs more than multiplying it. That sets an exchange rate — if recomputing saves you a trip to memory, recomputing is the cheaper option. And FlashAttention is not an approximation. Nothing is dropped or sparsified; it produces the same answer as the naive version. What changed is the route to the answer, not the answer.

The metaphor: never write the score sheet down

Picture a library where the documents live in a basement warehouse and your desk is tiny. Trips to the warehouse are slow; work on the desk is instant.

The job: score 1,000 applicants on 1,000 criteria, then blend their opinions in proportion to the scores. The naive approach builds a million-cell score sheet, can't fit it on the desk, files it in the warehouse, fetches it back to compute row sums, converts to proportions, files it again, and fetches it a third time to blend the opinions.

FlashAttention never writes the sheet down. It splits the applicants into batches of 50, scores each batch on the desk, and keeps only a running partial answer sitting on the desk. When the last batch is done, the finished answer is already there.

The warehouse is the GPU's HBM (large, slow, off-chip); the desk is SRAM (small, fast, on-chip). How many orders of magnitude separate them is covered in The GPU Memory Hierarchy.

Recap: only the middle blows up to N×N

As shown in Attention from Scratch, self-attention is three lines. Let NN be the sequence length (number of tokens) and dd the head dimension.

S=QK⊤d,P=softmax(S),O=PVS = \frac{QK^\top}{\sqrt{d}}, \qquad P = \mathrm{softmax}(S), \qquad O = PV
(1)

In words, attention scores every token against every other token to get an N×N table S. Each row of S is then turned into proportions that sum to one (P), and those proportions are used to blend the V rows into the output O. Input and output are N×d, but the middle table is N×N.

Q,K,VQ, K, V are all N×dN \times d matrices whose rows are each token's "question," "name tag," and "content." SS is the N×NN \times N score table pairing everyone with everyone, PP is that table rescaled so each row sums to one, and OO is the output (N×dN \times d). So input and output are both N×dN \times d, but the middle swells to N×NN \times N. That is the root of everything.

FIG 1Attention weights arcing across a round table. FlashAttention computes exactly these weights and this output — the only thing that changes is whether the table of weights is ever written down

The slow part is the traffic, not the math

Numbers help. With N=4096N = 4096 and d=128d = 128, QQ holds about 520K elements while SS holds about 16.8M — 32× more. Quadruple the sequence length and QQ grows 4× while SS grows 16×, widening the ratio to 128×.

The naive implementation shuttles that giant table through HBM repeatedly: write SS, read it back for the softmax, write PP, read it again for the product with VV. Bytes moved scale as N2N^2 while arithmetic scales as N2dN^2 d, so you get only about dd operations per byte moved. That is squarely bandwidth-bound territory.

And both SS and PP are intermediates that get thrown away. We are hauling something larger than the final output to the warehouse and back, only to discard it. That is the opening.

The softmax wall that blocks tiling

"Cut the big matrix into blocks and process whatever fits in fast memory" — tiling has been standard practice for matrix multiplication for decades. So why wasn't attention doing it?

Because softmax gets in the way.

softmax(s)j=esj−m∑kesk−m,m=max⁡ksk\mathrm{softmax}(s)_j = \frac{e^{s_j - m}}{\sum_{k} e^{s_k - m}}, \qquad m = \max_k s_k

In words, softmax subtracts the row's maximum from every score, exponentiates, and divides by the sum. Because the same constant scales numerator and denominator, the answer is unchanged, but e^x no longer overflows. Both m and the denominator are only final once the whole row has been seen.

Here sjs_j is one cell of the score table and mm is that row's maximum. Subtracting the maximum before exponentiating is the standard guard against exe^x overflowing; it multiplies numerator and denominator by the same constant, so the answer is unchanged.

The trouble is mm and that sum in the denominator. Neither is final until you have seen the whole row. After one block, a larger score might still show up later. So implementations produced the entire table before normalizing, which meant holding N×NN \times N — or so it seemed for a long time.

Online softmax: shrink the running total after the fact

The wall comes down with online softmax. The idea is simple: when the maximum gets updated, rescale everything you have accumulated so far, all at once.

Walk the row block by block. You carry exactly three things:

From the new block, compute its local maximum m~\tilde{m}, local exponential sum ℓ~\tilde{\ell}, and local weighted sum P~V~\tilde{P}\tilde{V}. Then update with three lines:

m′=max⁡(m,m~),ℓ′=em−m′ℓ+em~−m′ℓ~,O~′=em−m′O~+em~−m′P~V~m' = \max(m, \tilde{m}), \quad \ell' = e^{m - m'}\ell + e^{\tilde{m} - m'}\tilde{\ell}, \quad \tilde{O}' = e^{m - m'}\tilde{O} + e^{\tilde{m} - m'}\tilde{P}\tilde{V}
(2)

In words, when a new block arrives, take the larger of the old and new maxima. Then scale the old running totals by e^{m−m'} and the new block's by e^{m̃−m'} so both sit on the same footing, and add them. If the maximum did not change, the old totals pass through untouched.

In words: pick the new maximum, scale the old accumulation by em−m′e^{m - m'} and the new block by em~−m′e^{\tilde{m} - m'} to put them on the same footing, then add. If the maximum didn't change, em−m′=1e^{m-m'} = 1 and the old total passes through untouched.

The crux is that the shrink factor em−m′e^{m - m'} is a single scalar. You never revisit the thousands of terms you already added — one multiply against the accumulated vector covers all of them. That is why you can add from left to right without fearing a later update to the maximum. After the last block, divide once: O=O~/ℓO = \tilde{O}/\ell. The result is mathematically identical to the naive computation; no approximation enters anywhere.

The recurrence itself comes from the 2018 online-softmax paper. FlashAttention extended it to fold in the product with VV and redesigned it around the GPU's memory hierarchy.

In pseudocode

import numpy as np

def flash_attention(Q, K, V, Bc=128):        # Bc = block width that fits on the "desk"
    N, d = Q.shape
    O = np.zeros((N, d))                      # running weighted sum (unnormalized)
    m = np.full(N, -np.inf)                   # per-row maximum
    l = np.zeros(N)                           # per-row sum of exponentials

    for j in range(0, N, Bc):                 # bring K/V to the desk one block at a time
        Kj, Vj = K[j:j+Bc], V[j:j+Bc]
        S = Q @ Kj.T / np.sqrt(d)             # only N×Bc here; N×N never exists
        m_new = np.maximum(m, S.max(axis=1))
        alpha = np.exp(m - m_new)             # factor that shrinks the old accumulation
        P = np.exp(S - m_new[:, None])
        l = alpha * l + P.sum(axis=1)
        O = alpha[:, None] * O + P @ Vj       # shrink in bulk, then add
        m = m_new

    return O / l[:, None]                     # divide exactly once, at the end

Check that no N × N array appears anywhere. What lives in memory is an N × Bc block plus two scalars per row. A real CUDA kernel also tiles the QQ side and assigns each pair of blocks to one SM, so SS never leaves SRAM.

How much HBM traffic does this actually save?

The paper formalizes this as IO complexity. The naive implementation makes Θ(Nd+N2)\Theta(Nd + N^2) HBM accesses; FlashAttention makes Θ(N2d2M−1)\Theta(N^2 d^2 M^{-1}), where MM is the size of SRAM in elements.

So both scale as N2N^2, but FlashAttention carries a small coefficient d2/Md^2/M. With realistic values — dd of 64 to 128 and MM on the order of 100 KB — d2d^2 is far smaller than MM, and the traffic drops by orders of magnitude. Same asymptotic order, coefficient worth orders of magnitude: a very typical shape for a real optimization.

FIG 2Switch to the log axis and watch how far N and N² separate as n grows. What FlashAttention changed is not the shape of the curve but the coefficient sitting in front of N²

Recomputing on purpose in the backward pass

Here is the paradox from the opening. The backward pass needs PP. The naive implementation saves it to HBM during the forward pass and reads it back. FlashAttention doesn't save it. It saves the output OO and the per-row mm and ℓ\ell — quantities that scale with NN, not N×NN \times N.

During the backward pass it rebuilds the needed blocks of PP on the fly in SRAM from Q,K,V,m,ℓQ, K, V, m, \ell. That adds the entire cost of recomputing SS. The whole thing still comes out ahead, because what was added is desk work and what was removed is warehouse trips. In a bandwidth-bound regime, FLOPs are not the currency. Bytes are.

The paper reports figures such as roughly 3× on GPT-2 training (sequence length 1K) and a 15% wall-clock reduction on BERT-large (sequence length 512) against the MLPerf 1.1 training speed record. How much you gain depends heavily on sequence length, head dimension, and GPU generation, so measure on your own workload.

Later versions, and what changes at inference time

FlashAttention-2 (2023) cut the non-matmul work, extended parallelism along the sequence-length axis, and reduced shared-memory traffic between warps. FlashAttention-3 (2024) exploits Hopper-era asynchrony — overlapping data movement with matrix multiplies — and adds techniques for holding accuracy at low precision such as FP8.

Decoding changes the picture again. During generation the query is a single token, so parallelizing along sequence length no longer works and the cores go idle. The fix is to split the K,VK, V side, compute partial results in parallel, and combine them at the end. The combining step is exactly the three equations above — bring each partition's m,ℓ,O~m, \ell, \tilde{O} together, put them on a common footing, and add; splitting the work does not change the answer.

One clarification worth internalizing: what shrinks here is the score matrix, not the KV cache. When you run out of memory on long contexts, the culprit is usually the KV cache, and The KV Cache from Scratch covers how to size it. People conflate these two constantly.

How this shows up in practice

Who touches it, and when. Engineers running LLM training or inference infrastructure, teams shipping long-context products, and researchers implementing custom mask shapes or positional biases. The first two are "users of the kernel," the last is "the person whose change makes the fast kernel ineligible" — different failure modes.

Names you will actually type. In PyTorch the entry point is torch.nn.functional.scaled_dot_product_attention (SDPA), which dispatches among MATH, FLASH_ATTENTION, EFFICIENT_ATTENTION, and CUDNN_ATTENTION backends. To pin and verify which one runs, use torch.nn.attention.sdpa_kernel. More directly there is the flash-attn package with flash_attn_func and, for ragged batches, flash_attn_varlen_func. Serving stacks like vLLM and TensorRT-LLM call into these under the hood.

Pitfalls that turn into incidents.

What design reviews ask. "Why is it faster when it does more FLOPs?" — because the regime is bandwidth-bound, the currency is bytes, and recomputation in SRAM is cheaper than a round trip to HBM. "What does the forward pass save?" — the output OO plus per-row mm and ℓ\ell, a quantity that scales with NN. Answering those two in your own words is the difference between "we use it because it's fast" and "we use it because we know why it works."

Summary

🎬 Watch this explained (Manim, captioned)

References

  1. FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. arXiv:2205.14135Paper page·PDF
  2. FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning. arXiv:2307.08691Paper page·PDF
  3. FlashAttention-3: Fast and Accurate Attention with Asynchrony and Low-precision. arXiv:2407.08608Paper page·PDF
  4. Online normalizer calculation for softmax. arXiv:1805.02867Paper page·PDF

This article is written from the source paper above. Where they differ, the original is authoritative.

Published by TechAthletes · Editorial policy · How we cite · Corrections

Comments

Sign in to comment