← All posts

Model development

Post 12 of 26

Efficient execution: IO-aware attention, mixed precision and FP8

Posts in this series · 26

The GPU post ended with a diagnosis: much of training is limited not by arithmetic but by moving data between memory and the compute units. This post covers the two most important responses. The first changes how an operation is scheduled so it moves less data, without changing its result; the second uses fewer bits per number. Both are now default in serious training, and both come with assumptions worth knowing.

Attention without the score matrix

Standard attention computes, for each head, the score matrix S=QK⊤/dS = QK^{\top}/\sqrt{d} of size N×NN \times N for a sequence of NN tokens, writes it to GPU memory, reads it back to apply softmax, writes the probabilities, and reads them again to multiply by VV. For long sequences that matrix is huge and the operation is memory-bound: the arithmetic is cheap, the round trips through memory are not. It is also the 5as2b5as^2b term that dominated activation memory in the GPU post.

FlashAttention computes exactly the same result without ever storing SS (Dao et al., 2022, §3.1). It loads blocks of queries, keys and values into the GPU’s small, fast on-chip memory, computes their part of the attention there, and accumulates the output. The difficulty is softmax: normalizing a row needs the maximum and the sum over all its scores, but the blocks arrive one at a time.

The trick is an online softmax: keep a running maximum mm and running sum ℓ\ell, and rescale what has been accumulated whenever a larger maximum appears. Take one query whose scores against four keys are (1,3,2,0)(1, 3, 2, 0), with values (10,20,30,40)(10, 20, 30, 40), processed in two blocks:

That is exactly the full softmax-weighted average, 22.14. If a later block had a larger maximum, the running sums would be multiplied by emold−mnewe^{m_{\text{old}} - m_{\text{new}}} first. The same procedure on random 256×64256 \times 64 matrices in blocks of 32 keys agrees with ordinary attention to within 5×10−75 \times 10^{-7}, and the 256×256256 \times 256 score matrix never exists at once.

For the backward pass FlashAttention stores only the output and the per-row statistics mm and ℓ\ell, and recomputes the scores block by block, the recomputation idea applied inside one operation. The paper’s benchmark for GPT-2 medium’s attention on an A100 shows the trade clearly (Fig. 2):

ArithmeticMemory trafficRuntime
Standard attention66.6 GFLOPs40.3 GB41.7 ms
FlashAttention75.2 GFLOPs4.4 GB7.3 ms

Thirteen percent more arithmetic, nine times less memory traffic, 5.7 times faster. The paper proves that standard attention needs Θ(Nd+N2)\Theta(Nd + N^2) memory accesses and FlashAttention Θ(N2d2/M)\Theta(N^2 d^2 / M) for on-chip memory of size MM, which is many times fewer for typical sizes (Theorem 2). In PyTorch, scaled_dot_product_attention dispatches to such fused kernels where available; the small training run used it.

The general lesson reaches beyond attention: when an operation is memory-bound, fusing its steps into one kernel that keeps intermediate results on chip can beat a “more efficient” algorithm that moves more data.

Lower precision

Fewer bits per number means less memory, less traffic and faster matrix units. Each format trades range (the largest and smallest representable magnitudes) against precision (how finely values are spaced):

FormatBitsLargest valueSpacing just above 1
FP32323.4×10383.4 \times 10^{38}1.2×10−71.2 \times 10^{-7}
FP161665,5040.00098
BF16163.4×10383.4 \times 10^{38}0.0078
FP8 E4M384480.125
FP8 E5M2857,3440.25

The name E4M3 covers two variants, which is a common source of confusion: the OCP format (PyTorch’s float8_e4m3fn) reaches 448, while the “fnuz” variant (float8_e4m3fnuz), which has no negative zero, reaches 240. Some course notes quote 240; check which variant your hardware and library use before computing scales.

FP16 and loss scaling. FP16 has decent precision but a narrow range. Gradients are often tiny: in FP16 anything below about 2−24≈6×10−82^{-24} \approx 6 \times 10^{-8} becomes zero. Micikevicius et al. found about 5% of weight-gradient values in that region for one speech model, and much of an object-detection network’s activation-gradient histogram below FP16’s range (Micikevicius et al., 2018, §§3.1–3.2). Their fix was loss scaling: multiply the loss by a large factor before the backward pass, so gradients are shifted into FP16’s range, and divide by the same factor before the update. A gradient of 2−252^{-25} is zero in FP16, but scaled by 1,024 it survives and is recovered exactly. They also kept an FP32 master copy of the weights, so small updates are not lost (§3.1).

BF16 has the same range as FP32, with less precision. Gradients do not underflow, so loss scaling is unnecessary, and BF16 has largely replaced FP16 for training. Its coarse precision is why accumulation still happens in FP32: as the small training run showed, 1+0.0011 + 0.001 is exactly 1 in BF16. In practice, matrix multiplications take BF16 inputs and accumulate in FP32; optimizer state and often master weights stay in FP32.

FP8 and the outlier problem

FP8 halves memory and traffic again and doubles matrix throughput on hardware that supports it, but its range is tiny: E4M3, the more precise variant, tops out at 448. To use it, each tensor is divided by a scale chosen so its largest value fits, converted to FP8, and multiplied back after the matrix multiplication.

A single scale per tensor breaks when the tensor contains outliers, which activations often do. Take 1,024 values of typical size 0.001 plus one of 100. Scaling so that 100 maps to 448 squeezes the small values into FP8’s coarsest region: converting them flushes 16% of them to zero and leaves a median relative error of 14%. Giving each block of 128 values its own scale isolates the outlier to its block, and the median error elsewhere falls to 2.1%.

That is the approach DeepSeek-V3 used to train in FP8: scales per 1×1281 \times 128 tile of activations and per 128×128128 \times 128 block of weights, E4M3 for all tensors, and higher-precision accumulation for the matrix products. Compared with BF16 training, its loss differed by less than 0.25% relative (DeepSeek-V3, §3.3). The assumptions to check in any FP8 recipe are where the scales are computed, which operations stay in higher precision (accumulation, normalization, softmax, optimizer state), and whether the loss tracks a BF16 baseline over a long run, because small biases compound.

Everyday picture: one ruler for a whole drawing that includes a skyscraper and an ant. Marked to fit the skyscraper, the ruler cannot measure the ant at all. Give each corner of the drawing its own ruler and both can be measured.

Try it: will it underflow? A gradient of 3×10−93 \times 10^{-9} is computed in FP16. Does it survive? With a loss scale of 4,096? In BF16?

Answer. In FP16 it is below about 6×10−86 \times 10^{-8} and becomes zero. Scaled by 4,096 it becomes about 1.2×10−51.2 \times 10^{-5}, which FP16 represents (as a subnormal, with reduced precision), so it survives and is divided back after the backward pass. In BF16, with FP32’s range, it survives without any scaling, though only to about two or three significant digits.

What to remember

Sources

Further reading