Problem
Add 0.1 to itself 100,000 times in float32, one term at a time, and the answer is 9998.5566 rather than 10,000. Compute the variance of four readings near 10⁹ with the textbook shortcut E[x²] − E[x]² in float64 and it comes out negative. Evaluate a softmax over the logits 1000, 999, 998 and every probability is NaN. None of these is a bug in the code that ran: each is ordinary rounding, amplified by the way the formula was arranged.
Machine learning runs into all of them because it works at scale and at low precision. Sums run over millions of terms, logits and log-probabilities span huge ranges, statistics are accumulated over streams, and training increasingly happens in float16, bfloat16 and smaller, where each rounding is a thousand times coarser than in float32. Knowing which formulas lose accuracy, and what to use instead, is the difference between a model that trains and one that silently produces garbage.
Intuition
A floating-point number is a significand with a fixed number of bits times a power of two. Within one power of two (one binade) the representable numbers are evenly spaced, and the spacing doubles at every power of two. So the absolute gap between neighbours grows with the size of the number, while the relative gap stays roughly constant. That relative gap is the whole story: every operation is exact up to a relative error of at most half of it.
The numbers come from toy_floats in the page's helpers. float32 is the same picture
with 23 fraction bits instead of 2, so its relative gap is 2⁻²³ ≈ 1.2 × 10⁻⁷ instead of ¼.
With that picture, two distinct questions decide whether a computed answer can be trusted. Conditioning belongs to the problem: if the inputs move by a tiny relative amount, how far can the exact answer move? Stability belongs to the algorithm: does it add more error than the unavoidable rounding of its inputs would cause anyway? A stable algorithm on a well-conditioned problem gives an accurate answer. On an ill-conditioned problem even a perfect algorithm cannot, and an unstable algorithm can ruin a well-conditioned one.
Mechanics
The rounding model
IEEE 754 arithmetic rounds the exact result of each +, −, ×, ÷ and √ to the nearest representable number. With p significand bits (counting the implicit leading 1), that gives the standard model
fl(x ∘ y) = (x ∘ y)(1 + δ), |δ| ≤ u = 2−p,
where u is the unit roundoff: 2⁻²⁴ ≈ 6.0 × 10⁻⁸ for float32 and 2⁻⁵³ ≈
1.1 × 10⁻¹⁶ for float64. The machine epsilon that
torch.finfo(dtype).eps reports is the gap above 1.0, which is 2u. Two consequences
follow at once. Rounding is relative, so a quantity is only known to about log₁₀(1/u) significant
digits: 7 in float32, 16 in float64, 3 in float16 and 2 to 3 in bfloat16. And floating-point
addition is not associative: (0.1 + 0.2) + 0.3 = 0.6000000000000001 but 0.1 + (0.2 + 0.3) =
0.6 in float64. Any reduction whose order changes, across threads, GPU blocks
or devices, changes the last bits of the result, which is why bitwise
reproducibility needs a fixed
order.
Summation
Adding n numbers one at a time rounds after every addition, and each rounding is relative to the running total, not to the term. The error bound for this recursive summation is
|ŝ − s| ≤ (n − 1) u Σ|xi| + O(u²),
linear in n. Once the total is large, small terms lose most of their bits on the way in. Three standard fixes:
- Pairwise (cascade) summation adds neighbours, then sums of neighbours, in a
tree. Each term passes through only ⌈log₂ n⌉ additions, so the bound becomes
⌈log₂ n⌉ u Σ|xi|. PyTorch's CPU
sumdoes this: it accumulates blocks of at least 16 elements into four levels of partial sums so that only numbers of similar size are added together. - Compensated (Kahan) summation keeps a second variable holding the low-order bits each addition dropped and feeds them back into the next term. The bound is 2u Σ|xi| + O(nu²)Σ|xi|, essentially independent of n, for four operations per term instead of one.
- A wider accumulator: sum float16 or float32 inputs in float32 or float64.
scikit-learn's
_safe_accumulator_opruns sums and dot products in float64, and GPU kernels accumulate half-precision products in float32.
Both traces from f32_naive_sum and f32_kahan_sum, which round to float32
after every operation with struct. Between powers of two the total's gap is fixed,
so each addition of 0.1 rounds the same way and the error grows linearly; at each power of two
(8,192, 16,384, 32,768, 65,536) the gap doubles and the direction or size of the drift changes.
Catastrophic cancellation
Subtracting two nearly equal numbers is itself exact, but it removes their shared leading digits and leaves only the trailing ones, which are exactly the digits earlier roundings corrupted. If a and b each carry a relative error u, then a − b carries a relative error up to u(|a| + |b|)/|a − b|, which is enormous when a ≈ b. The textbook variance Σx²/n − (Σx/n)² is the classic case: both terms are about the square of the mean, their difference is the much smaller variance, and every digit of the variance smaller than u times the mean squared is lost. Subtract the mean first (two passes), or update the mean and the sum of squared deviations together as each point arrives (Welford's algorithm), and the large common part never enters a subtraction.
Conditioning and backward stability
The condition number of a problem is the worst-case ratio of the relative change in the answer to the relative change in the input. For solving Ax = b it is κ(A) = ‖A‖‖A⁻¹‖ = σmax/σmin, met on the linear algebra toolkit page. An algorithm is backward stable if its computed answer is the exact answer for a slightly perturbed input, with a relative perturbation of order u. Then the two ideas combine into the rule of numerical analysis:
forward error ≲ κ × backward error ≈ κ u.
LU with partial pivoting, Cholesky and Householder QR are backward stable in practice, so their error is as small as the problem allows: you lose about log₁₀ κ digits, and no algorithm can do better in that precision. Forming XᵀX for least squares squares κ before a solver ever sees it, which is why the QR route is preferred. The residual b − Ax̂ measures the backward error, so a tiny residual proves the algorithm did its job, not that x̂ is accurate.
Stable rewrites in machine learning
Most of the numerically delicate code in a deep learning framework is one of a few rewrites, each keeping a large intermediate from overflowing or a small one from vanishing:
- logsumexp and softmax: with m = maxi xi, log Σ exi = m + log Σ exi − m. Every shifted exponent is at most 0, so nothing overflows, and one term equals 1, so the sum is between 1 and n and the log never sees 0. Softmax is ex − m / Σ ex − m, and log-softmax is x − logsumexp(x), computed directly instead of as log(softmax(x)), whose small probabilities would underflow to log 0.
- log1p and expm1: for small z, 1 + z rounds away z's low digits before the log
sees them;
log1p(z)computes log(1 + z) without forming 1 + z. - softplus, log(1 + eˣ): overflows for large x and loses everything for very
negative x. The stable form switches regimes: eˣ when x is very negative, log1p(eˣ), then
x + e⁻ˣ, then just x. PyTorch's
Softplusreturns x once βx exceeds a threshold (20 by default). - losses on logits: binary cross-entropy through sigmoid and log loses
accuracy once σ(z) rounds to 0 or 1. On the logit z directly,
−log σ(z) = max(−z, 0) + log1p(e−|z|), which is what
BCEWithLogitsLossandcross_entropycompute. See entropy and KL and loss functions. - epsilons in denominators: normalization layers divide by √(var + ε) and Adam by √v̂ + ε, so a zero variance or second moment does not divide by zero. The ε must be representable in the dtype the division runs in: Adam's default 10⁻⁸ is below float16's smallest positive number, 6.0e-08, and rounds to 0.
The pieces of PyTorch and scikit-learn that implement these rules:
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
torch.logsumexp (reference) |
self, dim, keepdim |
Subtracts the amax along dim, with infinite maxima masked to 0 so all-−∞ rows give −∞ rather than NaN, then adds it back after the log. |
_refs/__init__.py |
torch.log_softmax (reference) |
a, dim, dtype |
x − logsumexp(x, keepdim=True), never log(softmax(x)). | _refs/__init__.py |
torch.logaddexp (reference) |
a, b |
max + log1p(exp(min − max)), the two-term logsumexp, with a mask for equal infinities. | _refs/__init__.py |
aten._softmax decomposition |
x, dim, half_to_float |
Upcasts low-precision input to its computation dtype, subtracts the max and normalizes; _log_softmax is the same with a log of the sum. |
decompositions.py |
log_sigmoid_forward |
self |
log σ(x) = min(x, 0) − log1p(exp(−|x|)): no exponential of a positive number. | decompositions.py |
binary_cross_entropy_with_logits |
self [logits], target, pos_weight |
(1 − y)·z − log σ(z), built on logsigmoid; the stable form of sigmoid followed by BCE. |
decompositions.py |
nn.BCELoss |
probabilities in, reduction |
Clamps each log term to ≥ −100 so a probability of exactly 0 or 1 gives a finite loss; BCEWithLogitsLoss, which takes logits, describes itself as the more numerically stable choice. |
loss.py |
nn.Softplus |
beta=1, threshold=20 |
(1/β)·log(1 + eβx), reverting to x when βx > threshold. | activation.py |
multi_row_sum, cascade_sum |
4 levels, blocks of ≥ 16 | The CPU sum kernel for floating types: tree-like accumulation so that only partial sums of similar size meet. |
SumKernel.cpp, cascade |
_DTYPE_PRECISIONS |
dtype → (rtol, atol) | The default tolerances of torch.testing.assert_close, scaled to each dtype's precision. |
_comparison.py |
log1pexp |
double x |
scikit-learn's softplus with cutoffs at −37, −2, 18 and 33.3; the comment notes that only 18 matters for accuracy. | _loss.pyx.tp |
sum_exp_minus_max |
one row of raw predictions | The shared max-shift for the multinomial loss, its gradient and its probabilities. | _loss.pyx.tp |
_incremental_mean_and_var |
X batch, running mean, variance, count |
Merges a batch into running statistics with the corrected two-pass update of Chan, Golub and LeVeque; behind StandardScaler.partial_fit. |
extmath.py |
_safe_accumulator_op |
op, x |
Runs a reduction with a float64 accumulator when x is a smaller float. |
extmath.py |
stable_cumsum |
arr, rtol, atol |
Cumulative sum in float64 that warns if its last element disagrees with sum; deprecated in 1.8 in favour of a float64 cumulative_sum. |
extmath.py |
Worked example
Summing 0.1, 100,000 times, in float32
float32(0.1) is 0.10000000149, so the exact sum of 100,000 copies is 10000.000149. With u = 2⁻²⁴:
| Method | float32 result | Error | Bound |
|---|---|---|---|
| One at a time | 9998.5566 | -1.444 | (n − 1)uΣ|x| = 59.6 |
| Pairwise, blocks of 8 | 10000.0000 | -1.5e-04 | ⌈log₂ n⌉uΣ|x| = 0.010 |
| Kahan | 10000.0000 | -1.5e-04 | 2uΣ|x| = 0.0012 |
The one-at-a-time sum is off in the fourth significant digit, far inside its loose bound but about 10,000 times worse than necessary. Pairwise and Kahan both return 10,000, the nearest float32 to the exact sum, so their only error is the final rounding.
A negative variance
Take the readings 4, 7, 13, 16, whose sample variance is 30, and add a constant c to all of them. The variance must not change. In float64:
| Shift c | (Σx² − (Σx)²/n)/(n − 1) | Two-pass | Welford |
|---|---|---|---|
| 0 | 30 | 30 | 30 |
| 10000 | 30 | 30 | 30 |
| 1e+08 | 29.3333 | 30 | 30 |
| 1e+09 | -170.667 | 30 | 30 |
At c = 10⁹, Σx² is about 4 × 10¹⁸, where adjacent float64 values are 512 apart. The difference the formula needs is 90, smaller than one gap, so it is pure rounding noise and comes out negative. Welford's update never forms those squares:
- x = 1000000004: mean = 1000000004.0, M₂ = 0
- x = 1000000007: mean = 1000000005.5, M₂ = 4.5
- x = 1000000013: mean = 1000000008.0, M₂ = 42
- x = 1000000016: mean = 1000000010.0, M₂ = 90
and M₂/(n − 1) = 30.
Softmax of large logits
For x = [1000, 999, 998], e¹⁰⁰⁰ overflows float64, so the direct logsumexp is inf and every softmax entry is ∞/∞ = NaN. Shifting by m = 1000 gives x − m = [0, -1, -2], and
logsumexp(x) = 1000 + log(1 + e⁻¹ + e⁻²) = 1000.407606, softmax(x) = [0.6652, 0.2447, 0.0900].
The same comparison for softplus and for the cross-entropy of a positive label, −log σ(z):
| z | log(1 + eᶻ) | stable softplus | −log(sigmoid(z)) | on the logit |
|---|---|---|---|---|
| -800 | 0 | 0 | inf | 800 |
| -40 | 0 | 4.248e-18 | 40 | 40 |
| -1 | 0.3133 | 0.3133 | 1.313 | 1.313 |
| 20 | 20 | 20 | 2.061e-09 | 2.061e-09 |
| 40 | 40 | 40 | 0 | 4.248e-18 |
| 800 | inf | 800 | 0 | 0 |
At z = −40, 1 + e⁻⁴⁰ rounds to exactly 1 and the direct softplus returns 0, losing the whole answer; at z = −800 the sigmoid underflows to 0 and the loss becomes infinite, so the gradient is useless. The logit forms are accurate everywhere.
Conditioning: Hilbert systems
The Hilbert matrix Hij = 1/(i + j − 1) is famously ill-conditioned. Solve Hx = b with b chosen so that x = [1, …, 1], using LU with partial pivoting in float64 (u ≈ 1.1 × 10⁻¹⁶):
| n | κ(H) | Forward error ‖x̂ − x‖/‖x‖ | κu | Backward error ‖b − Hx̂‖/‖b‖ |
|---|---|---|---|---|
| 4 | 1.6e+04 | 1.4e-13 | 1.7e-12 | 9.1e-17 |
| 5 | 4.8e+05 | 3.7e-13 | 5.3e-11 | 1.6e-16 |
| 6 | 1.5e+07 | 2.5e-10 | 1.7e-09 | 9.5e-17 |
| 7 | 4.8e+08 | 1.3e-08 | 5.3e-08 | 1.6e-16 |
| 8 | 1.5e+10 | 3.6e-07 | 1.7e-06 | 2.4e-16 |
| 9 | 4.9e+11 | 8.6e-07 | 5.5e-05 | 1.5e-16 |
| 10 | 1.6e+13 | 7.2e-05 | 1.8e-03 | 1.7e-16 |
The residual stays at the level of u for every n: LU is backward stable. The forward error tracks κu, and by n = 10 only about four correct digits are left. Nothing about the algorithm can fix that; only a better-conditioned formulation or more precision can.
Computed by hilbert_solve with the page's lu_factor and
lu_solve; κ from its singular values. The gap between forward and backward error is
the condition number at work.
Implementation
import math
def logsumexp(xs):
m = max(xs)
if math.isinf(m):
return m # all -inf, or an inf present
return m + math.log(sum(math.exp(x - m) for x in xs))
def softmax(xs):
m = max(xs)
exps = [math.exp(x - m) for x in xs]
total = sum(exps)
return [v / total for v in exps]
def softplus(x): # log(1 + e^x), cutoffs as in scikit-learn
if x <= -37: return math.exp(x)
if x <= -2: return math.log1p(math.exp(x))
if x <= 18: return math.log(1 + math.exp(x))
if x <= 33.3: return x + math.exp(-x)
return x
def bce_with_logits(z, y): # -[y log s(z) + (1 - y) log(1 - s(z))]
return (1 - y) * z - min(z, 0.0) + math.log1p(math.exp(-abs(z)))
def kahan_sum(xs):
total = carry = 0.0
for x in xs:
y = x - carry
t = total + y
carry = (t - total) - y # the low bits that did not fit in t
total = t
return total
def welford(xs):
n = mean = m2 = 0
for x in xs:
n += 1
delta = x - mean
mean += delta / n
m2 += delta * (x - mean)
return mean, m2 / (n - 1)
welford([1e9 + 4, 1e9 + 7, 1e9 + 13, 1e9 + 16]) # (1000000010.0, 30.0)
The same habits in PyTorch and scikit-learn:
import torch
import torch.nn.functional as F
logits = torch.tensor([1000., 999., 998.])
torch.logsumexp(logits, dim=0) # 1000.4076, not inf
F.log_softmax(logits, dim=0) # not softmax(...).log()
F.binary_cross_entropy_with_logits(z, y) # not F.binary_cross_entropy(torch.sigmoid(z), y)
F.cross_entropy(logits_batch, labels) # takes logits, applies log_softmax inside
x = torch.randn(10_000_000, dtype=torch.float16)
x.sum(dtype=torch.float32) # accumulate in a wider type
torch.finfo(torch.float16).eps, torch.finfo(torch.bfloat16).eps # 0.000977, 0.0078
torch.testing.assert_close(actual, expected) # per-dtype rtol/atol defaults
torch.testing.assert_close(a, b, rtol=1e-4, atol=1e-6) # or explicit ones
torch.linalg.cond(A) # κ before trusting a solve
torch.linalg.lstsq(X, y) # QR/SVD based, not solve(X.T @ X, X.T @ y)
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
for batch in batches: # streaming mean and variance, float64 accumulators
scaler.partial_fit(batch)
Tradeoffs
- Precision against speed and memory. float64 makes most of these problems disappear and costs 2× the memory and, on GPUs, often 30× or more the time. Deep learning instead runs low precision where errors average out (matrix products with wide accumulators) and keeps float32 for the parts that accumulate: reductions, normalization statistics, optimizer state and the loss.
-
Compensated sums cost operations. Kahan summation does four floating-point
operations per term and a serial dependency; pairwise summation is nearly free and parallel,
which is why libraries default to it. Compilers allowed to reassociate (
-ffast-math) can optimize the compensation away entirely. -
Stable rewrites change the graph. A fused
log_softmaxor a loss on logits is both faster and more accurate than composing the pieces, but only if the model exposes logits; putting a sigmoid or softmax inside the model and a log in the loss throws the stability away. - Determinism against throughput. Fixing the reduction order makes results bitwise reproducible, but rules out atomics and some fast kernels. Differences in the last few bits between runs are expected; differences larger than the tolerances below are bugs.
-
Tolerances must match the dtype. Compare with a relative tolerance a few times
the dtype's epsilon plus a small absolute floor for values near zero.
assert_close's defaults:
| dtype | Fraction bits | eps = 2⁻ᵇⁱᵗˢ | default rtol | default atol |
|---|---|---|---|---|
float16 | 10 | 9.8e-04 | 0.001 | 1e-05 |
bfloat16 | 7 | 7.8e-03 | 0.016 | 1e-05 |
float32 | 23 | 1.2e-07 | 1.3e-06 | 1e-05 |
float64 | 52 | 2.2e-16 | 1e-07 | 1e-07 |
Check yourself
Why does float32 sum 0.1 accurately for the first few thousand terms but not for a hundred thousand?
Each addition rounds relative to the running total. While the total is small its gap is tiny compared with 0.1; once it passes 8,192 the gap is 2⁻¹⁰ ≈ 0.001, and past 65,536 it is 2⁻⁷ ≈ 0.008, so every 0.1 is rounded by up to a few percent of itself, the same way for thousands of terms in a row. Pairwise summation keeps both operands of each addition small.
A model outputs probabilities through a sigmoid and is trained with BCELoss. Some losses are pinned at 100. What happened, and what is the fix?
The sigmoid saturated to exactly 0 or 1 for confidently wrong examples, the log became −∞, and BCELoss clamped it to −100, which also zeroes the gradient. Output logits and use BCEWithLogitsLoss, which computes the loss from the logit with log1p and never rounds σ(z) to 0.
A linear solve returns a residual of 10⁻¹⁵ relative to ‖b‖. Is the answer accurate to 15 digits?
Only if the matrix is well-conditioned. The residual is the backward error; the forward error can be up to κ times larger. With κ = 10¹⁰ expect about 5 or 6 correct digits, as in the n = 8 Hilbert row.
Why subtract the maximum in softmax, rather than, say, the mean?
Subtracting the max makes every exponent ≤ 0, so nothing overflows, and makes one term exactly 1, so the denominator is at least 1 and the log of it can never be log 0. Subtracting the mean still lets a logit far above the mean overflow.
Connections
Read first
Read next
No page builds on this one yet.
References
- Goldberg. What Every Computer Scientist Should Know About Floating-Point Arithmetic. ACM Computing Surveys 23(1), 5–48, 1991.
- Higham. Accuracy and Stability of Numerical Algorithms, 2nd ed. SIAM, 2002: chapters 2 (the rounding model), 4 (summation) and 7–9 (conditioning and LU).
- Trefethen and Bau. Numerical Linear Algebra. SIAM, 1997: lectures 12–15 on conditioning and backward stability.
- Kahan. Further Remarks on Reducing Truncation Errors. Communications of the ACM 8(1), 40, 1965.
- Welford. Note on a Method for Calculating Corrected Sums of Squares and Products. Technometrics 4(3), 419–420, 1962.
- Chan, Golub and LeVeque. Algorithms for Computing the Sample Variance: Analysis and Recommendations. The American Statistician 37(3), 242–247, 1983.
- Mächler. Accurately Computing log(1 − exp(−|a|)), Assessed by the Rmpfr Package. 2012, the note scikit-learn's
log1pexpfollows. - Blanchard, Higham and Higham. Accurately Computing the Log-Sum-Exp and Softmax Functions. IMA Journal of Numerical Analysis 41(4), 2311–2330, 2021.
- IEEE. 754-2019, Standard for Floating-Point Arithmetic. 2019.
- PyTorch v2.15.0:
logsumexp,_log_softmax,BCEWithLogitsLoss,cascade_sum,_DTYPE_PRECISIONS. - scikit-learn 1.9.1:
log1pexp,_incremental_mean_and_var,_safe_accumulator_op.