0.10 — Numerical stability: log-sum-exp, fp16, and bf16
Goal: Compute log-sum-exp and softmax in a numerically stable way, and know when to choose fp16 vs bf16 for deep-learning workloads.
1. Why this session exists
Most of what we've derived so far is mathematically clean and numerically dangerous. The cross-entropy of a softmax (0.7), the KL divergence (0.6), the gradient of the log-likelihood (0.7) — every one of them is the composition
which overflows the moment any single logit gets large. We need a small trick (log-sum-exp) and a small amount of floating-point fluency (fp32 vs fp16 vs bf16) to make this work at the scale of a real network.
This is the last session of Chapter 0. After it, every loss we write will use the stable form, and we'll know why training in bf16 became standard.[1, 2]
2. The two failure modes: overflow and underflow
A floating-point number stores a sign, an exponent, and a mantissa. Two characteristic numbers matter:
- Max finite value — anything larger gets clamped to .
- Min positive normal value — anything smaller positive underflows (to a subnormal, eventually to ).
For our three formats:
- fp32 (single precision): max , min positive normal , relative precision .
- fp16 (IEEE half precision): max , min positive normal , relative precision .
- bf16 (brain float): max , min positive normal , relative precision .
So in fp32, overflows for . In fp16 it overflows already for . Underflow of happens at in fp32 and in fp16.
Both directions bite during training. A logit of value inside a classifier — completely ordinary, just a confident prediction — overflows fp16 if you naively exponentiate it.
3. The log-sum-exp identity
We want to compute
robustly. The naive recipe (exponentiate, sum, log) is the dangerous one. The fix is one line of algebra.
Let . Then
Taking the log,
Why this is stable:
- Every shifted exponent is , so — no overflow.
- At least one term has , contributing to the sum, so the argument of the outer log is — no .
- The only "large" number, the global maximum , is added back outside the log, where it stays in the safe linear regime.
Algebraically this is the same expression we started with. Numerically it is night and day. Always compute log-sum-exp this way.
4. Stable softmax and the fused softmax + cross-entropy
Softmax. Defined entry-wise as
A small but important invariance:
Lemma (shift invariance). For any scalar , .
Proof.
This lets us pick with , giving the numerically stable softmax:
Now every exponent in numerator and denominator is in , and the denominator is .
Cross-entropy on logits. For target class and logits , the per-example cross-entropy loss is
Substituting log-sum-exp,
This is what F.cross_entropy(logits, target) in PyTorch computes — as a single fused op that takes logits, not probabilities, and never forms the softmax explicitly. Doing so by hand (probabilities first, then .log()) is the most common cause of NaNs in custom losses.
Gradient sanity check. From 0.7 we already know
This expression is itself stable: it's a difference of two values in . The implementation needs the stable softmax above.
5. Floating-point formats compared
Three formats, all 32 or 16 bits, all IEEE-flavoured but distributed differently.
fp32. sign + exponent + mantissa bits. The reliable baseline. Big enough dynamic range that overflow rarely happens at training time; precise enough that accumulating gradients over millions of steps doesn't drift.
fp16. . Half the bits, but only exponent bits — so the range shrinks dramatically: positive normals lie roughly between and . The mantissa is generous ( bits, relative precision) but the range is the limiting factor. Used heavily for inference; needs loss scaling for training.
bf16 (brain float). . Same exponent layout as fp32, so the same dynamic range. The mantissa is only bits, giving relative precision — noisier than fp16. But noise the optimiser can average over; underflow it cannot.
A cartoon comparison:
- fp32: precise, wide range.
- fp16: precise, narrow range. Underflows aggressively on small gradients.
- bf16: noisy, wide range. Almost a drop-in replacement for fp32 in deep nets.
For training large language models, bf16 is the modern default — TPUs from the start, NVIDIA Ampere (A100) and later, AMD MI200+, etc. The reason is the dynamic range: gradients during pre-training of a Transformer regularly span to , which fp16 cannot hold.
6. Mixed-precision training (a forward look)
Modern training rarely uses one precision throughout. The standard recipe ("mixed precision"):
- Keep a master copy of the weights in fp32.
- Cast a working copy to bf16 (or fp16) for the forward and backward passes.
- Accumulate the loss in fp32.
- Cast gradients back to fp32 for the optimiser step.
Memory and bandwidth halve for activations and weights during the forward/backward pass; the precision-sensitive optimiser state stays in fp32.
For fp16, an extra trick is needed: loss scaling. Multiply the scalar loss by some before calling .backward(). Every gradient is then times larger in fp16, lifted out of the underflow region. After backward, divide gradients by before stepping the optimiser. PyTorch's torch.cuda.amp.GradScaler automates this with adaptive scale selection.
bf16 usually does not need loss scaling — its dynamic range already covers what fp32 sees.
7. Takeaways
- Never compute naively. Subtract the max first.
- Never compute cross-entropy as in two steps; use the fused form on logits.
- fp16 for inference and for layers known to live in a small dynamic range. bf16 for training large models. fp32 for the optimiser state and the master weights.
- Mixed-precision training: fp32 master copy, bf16 (or fp16-with-loss-scaling) compute. Roughly throughput on modern GPUs at essentially the same final accuracy.
With this in hand, Chapter 0 is done. Chapter 1 starts with the perceptron, the linear classifier, and the first end-to-end optimisation problem we'll write down — and we'll use the stable softmax + cross-entropy from §4 the moment we generalise to multi-class.
(a) Let . Walk through naive exp(x), point out exactly which step overflows in fp32, then compute the stable softmax. Verify the result equals .
(b) Prove the shift-invariance lemma of §4 directly, and explain how it justifies the subtraction without changing the value of any downstream loss.
(c) Suppose you compute a cross-entropy in fp16 as log(softmax(x))[y] (two-step). One logit is . Trace what happens: (i) what is in fp16, (ii) what does the softmax over become numerically, (iii) what does log of the result return? Then redo it using the fused log-sum-exp form and show no overflow occurs.
(d) In fp16, the smallest positive normal is . If gradient magnitudes during a training step are typically , what fraction of them underflow to zero? Show that scaling the loss by before backward eliminates the underflow, and that dividing gradients by before the optimiser step recovers the correct update.
(e) (Stretch.) For , prove the identity
Use 0.7 if you'd like, or differentiate the stable form directly and check that the -dependence cancels.
References
- [1]
Higham, N. J. (2002). Accuracy and Stability of Numerical Algorithms (2nd ed.). SIAM. — Numerical-stability material in 0.10 (overflow, underflow, log-sum-exp).
↩ - [2]
bfloat16 specification. Wang & Kanwar (2019), "BFloat16: The secret to high performance on Cloud TPUs". cloud.google.com — Used in 0.10.
↩