AI / ML Theory
Chapter 0 · 26 min

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

log⁡ (∑iexp⁡(something)),\log \,\Bigl(\sum_i \exp(\text{something})\Bigr),

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 ±∞\pm \infty.
  • Min positive normal value — anything smaller positive underflows (to a subnormal, eventually to 00).

For our three formats:

  • fp32 (single precision): max ≈3.4×1038\approx 3.4 \times 10^{38}, min positive normal ≈1.2×10−38\approx 1.2 \times 10^{-38}, relative precision ≈6×10−8\approx 6 \times 10^{-8}.
  • fp16 (IEEE half precision): max ≈65504\approx 65504, min positive normal ≈6.1×10−5\approx 6.1 \times 10^{-5}, relative precision ≈10−3\approx 10^{-3}.
  • bf16 (brain float): max ≈3.4×1038\approx 3.4 \times 10^{38}, min positive normal ≈1.2×10−38\approx 1.2 \times 10^{-38}, relative precision ≈8×10−3\approx 8 \times 10^{-3}.

So in fp32, exp⁡(x)\exp(x) overflows for x>ln⁡(3.4×1038)≈88.7x > \ln(3.4 \times 10^{38}) \approx 88.7. In fp16 it overflows already for x>ln⁡(65504)≈11.09x > \ln(65504) \approx 11.09. Underflow of exp⁡(−x)\exp(-x) happens at x>88.7x > 88.7 in fp32 and x>9.7x > 9.7 in fp16.

Both directions bite during training. A logit of value 2020 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

L(x)=log⁡∑i=1Nexi,x∈RN,L(x) = \log \sum_{i=1}^N e^{x_i}, \qquad x \in \mathbb{R}^N,

robustly. The naive recipe (exponentiate, sum, log) is the dangerous one. The fix is one line of algebra.

Let m=max⁡ixim = \max_i x_i. Then

∑iexi=∑iem+(xi−m)=em∑iexi−m.\sum_i e^{x_i} = \sum_i e^{m + (x_i - m)} = e^m \sum_i e^{x_i - m}.

Taking the log,

 log⁡∑iexi  =  max⁡ixi  +  log⁡∑iexi−max⁡ixi. \boxed{\ \log \sum_i e^{x_i} \;=\; \max_i x_i \;+\; \log \sum_i e^{x_i - \max_i x_i}. \ }

Why this is stable:

  • Every shifted exponent xi−mx_i - m is ≤0\le 0, so exi−m∈(0,1]e^{x_i - m} \in (0, 1] — no overflow.
  • At least one term has xi−m=0x_i - m = 0, contributing e0=1e^0 = 1 to the sum, so the argument of the outer log is ≥1\ge 1 — no log⁡0\log 0.
  • The only "large" number, the global maximum mm, 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

softmax⁡(x)i=exi∑jexj.\operatorname{softmax}(x)_i = \frac{e^{x_i}}{\sum_j e^{x_j}}.

A small but important invariance:

Lemma (shift invariance). For any scalar cc, softmax⁡(x+c1)=softmax⁡(x)\operatorname{softmax}(x + c \mathbf{1}) = \operatorname{softmax}(x).

Proof. exi+c∑jexj+c=ec exiec∑jexj=exi∑jexj.\dfrac{e^{x_i + c}}{\sum_j e^{x_j + c}} = \dfrac{e^c\, e^{x_i}}{e^c \sum_j e^{x_j}} = \dfrac{e^{x_i}}{\sum_j e^{x_j}}. ■\blacksquare

This lets us pick c=−mc = -m with m=max⁡ixim = \max_i x_i, giving the numerically stable softmax:

softmax⁡(x)i=exi−m∑jexj−m.\operatorname{softmax}(x)_i = \frac{e^{x_i - m}}{\sum_j e^{x_j - m}}.

Now every exponent in numerator and denominator is in (0,1](0, 1], and the denominator is ≥1\ge 1.

Cross-entropy on logits. For target class yy and logits x∈RNx \in \mathbb{R}^N, the per-example cross-entropy loss is

ℓ(x,y)=−log⁡softmax⁡(x)y=−xy+log⁡∑jexj.\ell(x, y) = -\log \operatorname{softmax}(x)_y = -x_y + \log \sum_j e^{x_j}.

Substituting log-sum-exp,

 ℓ(x,y)  =  −xy  +  m  +  log⁡∑jexj−m,m=max⁡jxj. \boxed{\ \ell(x, y) \;=\; -x_y \;+\; m \;+\; \log \sum_j e^{x_j - m}, \quad m = \max_j x_j. \ }

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

∂ℓ∂xi=softmax⁡(x)i−1[i=y].\frac{\partial \ell}{\partial x_i} = \operatorname{softmax}(x)_i - \mathbb{1}[i = y].

This expression is itself stable: it's a difference of two values in [0,1][0, 1]. 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. 11 sign + 88 exponent + 2323 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. 1+5+101 + 5 + 10. Half the bits, but only 55 exponent bits — so the range shrinks dramatically: positive normals lie roughly between 6×10−56 \times 10^{-5} and 6.5×1046.5 \times 10^4. The mantissa is generous (1010 bits, ∼10−3\sim 10^{-3} relative precision) but the range is the limiting factor. Used heavily for inference; needs loss scaling for training.

bf16 (brain float). 1+8+71 + 8 + 7. Same exponent layout as fp32, so the same dynamic range. The mantissa is only 77 bits, giving ∼8×10−3\sim 8 \times 10^{-3} 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 10−1010^{-10} to 10110^{1}, which fp16 cannot hold.


6. Mixed-precision training (a forward look)

Modern training rarely uses one precision throughout. The standard recipe ("mixed precision"):

  1. Keep a master copy of the weights in fp32.
  2. Cast a working copy to bf16 (or fp16) for the forward and backward passes.
  3. Accumulate the loss in fp32.
  4. 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 S>1S > 1 before calling .backward(). Every gradient is then SS times larger in fp16, lifted out of the underflow region. After backward, divide gradients by SS 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

  1. Never compute log⁡∑iexi\log \sum_i e^{x_i} naively. Subtract the max first.
  2. Never compute cross-entropy as −log⁡softmax⁡(x)y-\log \operatorname{softmax}(x)_y in two steps; use the fused form on logits.
  3. 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.
  4. Mixed-precision training: fp32 master copy, bf16 (or fp16-with-loss-scaling) compute. Roughly 2×2\times 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.


Self-test

(a) Let x=(1000,1001,1002)x = (1000, 1001, 1002). Walk through naive exp(x), point out exactly which step overflows in fp32, then compute the stable softmax. Verify the result equals softmax⁡((0,1,2))=(11+e+e2, e1+e+e2, e21+e+e2)\operatorname{softmax}\bigl((0, 1, 2)\bigr) = \bigl(\tfrac{1}{1 + e + e^2},\, \tfrac{e}{1 + e + e^2},\, \tfrac{e^2}{1 + e + e^2}\bigr).

(b) Prove the shift-invariance lemma of §4 directly, and explain how it justifies the −m-m 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 xi=20x_i = 20. Trace what happens: (i) what is e20e^{20} in fp16, (ii) what does the softmax over (20,0,0)(20, 0, 0) 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 ∼6×10−5\sim 6 \times 10^{-5}. If gradient magnitudes during a training step are typically ∼10−7\sim 10^{-7}, what fraction of them underflow to zero? Show that scaling the loss by S=216≈6.5×104S = 2^{16} \approx 6.5 \times 10^4 before backward eliminates the underflow, and that dividing gradients by SS before the optimiser step recovers the correct update.

(e) (Stretch.) For x∈RNx \in \mathbb{R}^N, prove the identity

∇xlog⁡∑jexj=softmax⁡(x).\nabla_x \log \sum_j e^{x_j} = \operatorname{softmax}(x).

Use 0.7 if you'd like, or differentiate the stable form m+log⁡∑jexj−mm + \log \sum_j e^{x_j - m} directly and check that the mm-dependence cancels.


References

  1. [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. [2]

    bfloat16 specification. Wang & Kanwar (2019), "BFloat16: The secret to high performance on Cloud TPUs". cloud.google.com — Used in 0.10.

    ↩