AI Knowledge Tree

Mixed-precision arithmetic

Compare fp32, fp16, and bf16, and use loss scaling to compute stably in low precision.

GPUs and TPUs run several times faster on 16-bit floating point than on 32-bit, and modern hardware has dedicated tensor cores for it. Mixed-precision uses 16-bit storage and compute where possible and falls back to 32-bit only where stability demands it[1]. Knowing which 16-bit format to use and where the failure modes hide is the difference between a 2x speedup and silent training divergence.

Three formats

FormatTotal bitsExponent bitsMantissa bitsRangeRelative precision
fp3232823±1038\pm 10^{38}∼10−7\sim 10^{-7}
fp1616510±65,504\pm 65{,}504∼10−3\sim 10^{-3}
bf161687±1038\pm 10^{38}∼10−2\sim 10^{-2}

fp16 (IEEE-754 half precision) has a tiny dynamic range: values above 65,504 or below about 6×10−86 \times 10^{-8} saturate. Gradients in deep networks routinely escape this range, so they need rescaling.

bf16 (bfloat16) keeps fp32's 8-bit exponent and gives up precision in the mantissa. It has the same range as fp32, so it does not overflow during training, at the cost of about three significant decimal digits of precision. This range advantage made bf16 the default for transformer training.

Where each format fails

Low precision (~10−310^{-3} for fp16, 10−210^{-2} for bf16) shows up in three characteristic ways:

  • Vanishing updates. When the weight magnitude ∣w∣|w| is much larger than the update magnitude ∣ηg∣|\eta g|, the sum w+ηgw + \eta g rounds back to ww in low precision, and the update is lost. The standard fix is keeping a master copy of the weights in fp32, applying gradient updates to it, and casting to low precision only for the forward and backward passes.
  • Lossy reductions. Summing many small numbers in fp16 underestimates the true sum, because each addition rounds. Reductions like batch normalization statistics and softmax denominators should accumulate in fp32 even when inputs are fp16.
  • Overflow in fp16. Activations and gradients can exceed 65,504 (often the attention logits before softmax). Computing in bf16 sidesteps this; for fp16, loss scaling does.

Loss scaling

Loss scaling lifts small gradients out of the fp16 underflow zone before the backward pass. Multiply the loss by a large factor SS so that gradients are scaled by SS end-to-end (chain rule). After backprop, divide gradients by SS before the optimizer step:

∇θ(S⋅L)=S⋅∇θL,gused=1S fp16(S⋅∇θL).\nabla_\theta(S \cdot \mathcal{L}) = S \cdot \nabla_\theta \mathcal{L}, \qquad g_{\text{used}} = \frac{1}{S}\,\text{fp16}(S \cdot \nabla_\theta \mathcal{L}).

The intermediate fp16 values stay in the representable range; the final unscaled gradient is recovered exactly modulo rounding. Dynamic loss scaling monitors for gradient overflow (any inf or NaN) and halves SS when it occurs, doubles SS after a clean run of steps: an automatic search for the largest safe scale[2].

Check it on arrays

python · numpy
import numpy as np

print("fp16  eps:", np.finfo(np.float16).eps,  " max:", np.finfo(np.float16).max)
print("fp32  eps:", np.finfo(np.float32).eps,  " max:", np.finfo(np.float32).max)

# fp16 overflow: gradients of moderate logits can blow up
x = np.float16(1e4)
print("x * 10 in fp16:", x * np.float16(10))                # inf

# Vanishing update: tiny gradient added to a moderate weight is lost in fp16
w  = np.float16(1.0)
g  = np.float16(1e-4)
print("fp16: 1 + 1e-4 ==", float(w + g), "  lost ->", w + g == w)
print("fp32: 1 + 1e-4 ==", np.float32(1.0) + np.float32(1e-4))

# Loss scaling fix: scale gradient up, do reduction in fp16, then unscale
g_small = np.full(1000, 1e-5, dtype=np.float32)            # true gradient
naive = g_small.astype(np.float16).mean()                  # underflows to 0
print("naive fp16 mean:", float(naive))                    # 0.0
S = 1024.0
scaled = (S * g_small).astype(np.float16).mean() / S       # loss-scaled
print("loss-scaled fp16 mean:", float(scaled), "  truth:", g_small.mean())

# Reduction precision: sum many fp16 values directly vs accumulating in fp32
v = np.full(10_000, 1e-3, dtype=np.float16)
direct = v.sum(dtype=np.float16)                           # lossy
upcast = v.astype(np.float32).sum()                        # accurate
print("fp16 reduction:", float(direct), "  fp32 accumulator:", float(upcast))

fp16 overflows above 65,504 and silently drops tiny updates, loss scaling rescues the underflowing gradients, and accumulating sums in fp32 fixes lossy reductions.

Exercises

Exercise 1. fp16 has 1010 mantissa bits, so its relative precision near 11 is about 2−102^{-10}. Argue that an update w+ηgw + \eta g with w=1w = 1 is lost in fp16 once ∣ηg∣|\eta g| falls below roughly 2−112^{-11}, and state the standard fix.

Solution

Step 1. Near 11 the gap between consecutive fp16 values is one unit in the last place, 2−102^{-10} (one full eps). The midpoint between 11 and the next float is 2−112^{-11} above 11.

Step 2. Round-to-nearest sends any value within 2−112^{-11} of 11 back to 11. So if ∣ηg∣<2−11|\eta g| < 2^{-11}, then fl(1+ηg)=1\text{fl}(1 + \eta g) = 1: the update rounds away and the weight does not move.

Step 3. The fix is a master copy of the weights in fp32. The optimizer adds ηg\eta g to the fp32 weight, where the gap near 11 is 2−232^{-23} and the small update survives; only a cast to fp16 for the forward and backward passes uses the low precision. The update accumulates in the high-precision copy across steps even when each individual update is below the fp16 gap.

Exercise 2. Loss scaling multiplies the loss by SS before backprop and divides gradients by SS after. Show that the recovered gradient equals the true gradient (modulo rounding), and explain what range constraint picks the largest safe SS.

Solution

Step 1. By linearity of differentiation, scaling the loss scales every gradient by the same factor:

∇θ(S⋅L)=S⋅∇θL.\nabla_\theta (S \cdot \mathcal{L}) = S \cdot \nabla_\theta \mathcal{L}.

The chain rule carries the constant SS through every layer unchanged.

Step 2. The backward pass computes and stores these scaled gradients in fp16. Dividing by SS afterward recovers gused=1S fp16(S⋅∇θL)≈∇θLg_{\text{used}} = \tfrac{1}{S}\,\text{fp16}(S \cdot \nabla_\theta \mathcal{L}) \approx \nabla_\theta \mathcal{L}, exact apart from the single fp16 rounding of the scaled value.

Step 3. The constraint is that S⋅∇θLS \cdot \nabla_\theta \mathcal{L} must stay below the fp16 overflow threshold (≈65,504\approx 65{,}504) while lifting the smallest gradients above the underflow floor. The largest safe SS is the biggest power of two whose scaled gradients do not overflow, which is exactly what dynamic loss scaling searches for by halving on overflow and doubling after clean runs.

Exercise 3 (code). Demonstrate a lossy fp16 reduction: accumulate 50005000 ones one at a time in an fp16 register and show the running sum stalls at 20482048, while an fp32 accumulator reaches 50005000.

Solution
python · numpy
import numpy as np

acc16 = np.float16(0.0)
for _ in range(5000):
    acc16 = np.float16(acc16 + np.float16(1.0))

acc32 = np.float32(0.0)
for _ in range(5000):
    acc32 = np.float32(acc32 + np.float32(1.0))

# Once the fp16 running sum reaches 2048, the gap to the next float is 2,
# so adding 1 rounds back and the accumulator freezes.
assert float(acc16) == 2048.0
assert float(acc32) == 5000.0
print("fp16 sequential sum:", float(acc16), "  fp32 sequential sum:", float(acc32))

At 2048=2112048 = 2^{11} the fp16 spacing is 22, so each +1+1 lands below the midpoint and rounds away, freezing the sum; the fp32 accumulator has a spacing of 2−122^{-12} there and keeps counting, which is why reductions accumulate in fp32 even with fp16 inputs.

This closes the A4 numerical-methods track; A5 turns from floating-point math to the data structures and algorithms the runtime is built on.


References

  1. [1]Micikevicius, P., et al. (2018). "Mixed precision training". ICLR. fp16 storage with fp32 master weights and tensor-core compute (arXiv:1710.03740).↩
  2. [2]Higham, N. J. (2002). Accuracy and Stability of Numerical Algorithms (2nd ed.). SIAM. Scaling, low-precision rounding, and accurate reductions (doi:10.1137/1.9780898718027).↩