import torch
def softmax_textbook(x):
e = torch.exp(x)
return e / e.sum(-1, keepdim=True)
print(softmax_textbook(torch.tensor([1000., 1001., 1002.])).tolist())[nan, nan, nan]
July 19, 2026

Here is a softmax — the most ordinary operation in deep learning, written exactly as the definition says — and here it is returning nan on inputs that are perfectly ordinary logits:
[nan, nan, nan]
Nothing is wrong with the math. The formula is correct in the real numbers, the inputs are three ordinary logits, and yet the answer is garbage — three nans where three probabilities should be. The reason is the single fact this entire post is built around, so it’s worth stating before anything else:
A floating-point number can only represent values inside a finite window.
fp32tops out around \(3.4 \times 10^{38}\);fp16, still common in mixed-precision workloads, tops out at just65504. Step outside the window and arithmetic doesn’t wrap or warn — it saturates toinf, or collapses to exactly0. Andexpwalks you outside that window terrifyingly fast:exp(89)already overflowsfp32, andexp(12)overflowsfp16.
exp(1002) is inf. Then inf / inf is nan, and the whole vector is poisoned. That’s the bug above, and once you see the window, the fix writes itself: before calling exp, shift the numbers back inside the window. That move — pull the values into the safe range before the dangerous operation, in a way that provably doesn’t change the mathematical answer — is the dominant theme of this post, and five of its six cases are variations on it. But keeping numbers inside the window is not the whole story of numerical stability, and the sixth case is the reason: sometimes every intermediate is perfectly representable and you still get a catastrophically wrong answer, because subtracting two nearly-equal numbers destroys the precision of the result. That second failure mode — cancellation — comes from the same finite-precision world but has nothing to do with the window’s edges, and it produces the most dangerous bug in the post.
TL;DR — Floats live in a finite window (
fp16overflows aboveexp(11),fp32aboveexp(89)), and outside it arithmetic saturates toinfor collapses to0silently. The softmax max-trick,logsumexp,log_softmax, the two-branch sigmoid, and logit-based cross-entropy are all the same idea: rewrite the computation so intermediate values stay inside the window without changing the real-valued result. Then there’s a bug from the same world in different clothing — the standard vectorized distance formula subtracts two nearly-equal large numbers and returns negative squared distances, whichsqrtturns intonan— and which corrupts distance magnitudes even where the sign survives.
Before the fixes, look at the window itself, because its size is the whole reason fp16 is scary and bf16 is not:
fp16: largest value ~6.55e+04 | exp(x) overflow boundary near x = 11.1
bf16: largest value ~3.39e+38 | exp(x) overflow boundary near x = 88.7
fp32: largest value ~3.40e+38 | exp(x) overflow boundary near x = 88.7
Read the fp16 row and feel the danger: an activation of 12 — a completely unremarkable value — overflows exp in half precision. This is also, in one line, why bf16 is usually the safer choice for training: it has the same exponent range as fp32 (same overflow threshold, 88.7), so it’s far less prone to the range-related overflow and underflow failures that plague fp16 — at the cost of fewer precision bits, which makes rounding and cancellation worse. That trade is why fp16 typically needs loss-scaling to keep small gradients from underflowing while bf16 usually doesn’t, though stability always remains workload- and hardware-dependent. The through-line for this post: range failures dominate anything built on exponentials and probabilities, while significand precision dominates the cancellation-sensitive computations we’ll meet later — which is why this post needs both halves.
With the window in view, here are the six places you keep your numbers inside it — and the one place the window’s grid spacing, rather than its edges, is what bites.
The bug from the opening, and its fix, side by side:
def softmax_stable(x):
e = torch.exp(x - x.max(-1, keepdim=True).values) # subtract the max: shift into the window
return e / e.sum(-1, keepdim=True)
logits = torch.tensor([1000., 1001., 1002.])
print("textbook softmax:", softmax_textbook(logits).tolist())
print("stable softmax :", [round(v, 6) for v in softmax_stable(logits).tolist()])
print("torch.softmax :", [round(v, 6) for v in torch.softmax(logits, -1).tolist()])
torch.testing.assert_close(softmax_stable(logits), torch.softmax(logits, -1))textbook softmax: [nan, nan, nan]
stable softmax : [0.090031, 0.244728, 0.665241]
torch.softmax : [0.090031, 0.244728, 0.665241]
The same trick also rescues the other edge of the window, which people forget exists — very negative inputs, where the failure looks identical but the cause is the opposite:
naive softmax : [nan, nan, nan] <- exp underflows to 0, then 0/0
stable softmax: [0.665241, 0.244728, 0.090031]
Subtracting the max fixes both, because it maps the largest logit to exactly 0 and every other to something ≤ 0. The largest term is always exactly 1, so the denominator can never underflow to zero, and nothing can overflow. This argument holds for a nonempty row of finite logits. A row containing +inf gives inf - inf = nan; a row that is entirely -inf (every position masked) gives 0/0; a nan anywhere poisons the max. Those aren’t hypothetical — the fully-masked row is a whole section below.
Softmax is shift-invariant. For any constant c:
\[\frac{e^{x_i - c}}{\sum_j e^{x_j - c}} = \frac{e^{-c}\,e^{x_i}}{e^{-c}\sum_j e^{x_j}} = \frac{e^{x_i}}{\sum_j e^{x_j}}\]
The e^{-c} cancels. This is not an approximation; it’s an identity. So why subtract the max specifically, rather than the mean, or 100, or anything convenient?
Because shift-invariance holds in ℝ, and we are not in ℝ:
c = 0 : max abs diff = 0.000e+00 allclose? True
c = 100 : max abs diff = 8.941e-07 allclose? True
c = 1000 : max abs diff = 5.841e-06 allclose? False
c = 10000 : max abs diff = 1.806e-05 allclose? False
c = 100000 : max abs diff = 3.771e-04 allclose? False
Adding a large c destroys the precision of x itself — fp32 holds ~7 significant digits, so 100000.0 + 0.001 rounds to 100000.0 and the information in the low bits of your logits is simply gone. The mathematical identity survives; the float doesn’t.
So the max is not an arbitrary choice among many valid shifts. Other shifts can keep you safe — any shift that maps the logits below the overflow threshold without underflowing the denominator would do — but the max is the canonical one because it buys two guarantees at once: every shifted logit is non-positive (so no exponential can overflow) and at least one is exactly zero (so the denominator is ≥ 1 and cannot underflow). It also doesn’t push the whole vector further below the largest entry than necessary. That’s a real argument for preferring it — though not a proof that it universally minimizes floating-point error, which would need more than this hand-waving. The best shift, not the only workable one, and a satisfying amount of design packed into one .max() call.
log(softmax(x)) failsSoftmax lives inside the window now. But the moment you take the log of a softmax — which you do constantly, because log-probabilities are what losses are built from — you can fall out of it again, from the other direction: not by overflowing to inf, but by underflowing to exactly 0 and then taking log(0) = -inf. The fix is the same philosophy — never leave the window — applied in log-space.
softmax(x) : [1.0, 1.9287498933537385e-22, 3.783505853677006e-44, 0.0]
log(softmax(x)) : [0.0, -50.0, -99.98309326171875, -inf]
F.log_softmax(x): [0.0, -50.0, -100.0, -800.0]
Two failures in one line, and they’re different failures.
At -100, the probability is 3.78e-44 — a subnormal float, right at the edge of fp32’s range, where precision has already degraded. log of it gives -99.983, not -100. We’ve lost three digits.
At -800, the probability underflowed to exactly zero. log(0) = -inf. The information is not degraded — it is destroyed. No amount of downstream care recovers it, and if that -inf contributes to a loss it can produce an infinite loss or non-finite gradients, depending on what the surrounding operation does with it.
log_softmax never makes this mistake because it never materializes the probability:
\[\log \text{softmax}(x)_i = x_i - \log\sum_j e^{x_j} = x_i - \text{logsumexp}(x)\]
It computes the log directly, in log-space, staying in the range where fp32 is comfortable. Which brings us to the function everything above is secretly built on:
x = torch.tensor([1000., 1001., 1002.])
m = x.max()
manual = m + torch.log(torch.exp(x - m).sum()) # the logsumexp identity
print(f"naive log(sum(exp(x))) = {torch.log(torch.exp(x).sum()).item()}")
print(f"m + log(sum(exp(x - m))) = {manual.item():.6f}")
print(f"torch.logsumexp(x, -1) = {torch.logsumexp(x, -1).item():.6f}")
torch.testing.assert_close(manual, torch.logsumexp(x, -1))naive log(sum(exp(x))) = inf
m + log(sum(exp(x - m))) = 1002.407593
torch.logsumexp(x, -1) = 1002.407593
\[\log\sum_j e^{x_j} = m + \log\sum_j e^{x_j - m}, \quad m = \max_j x_j\]
That identity is the load-bearing beam of this entire subject. It’s the same trick FlashAttention’s online softmax generalizes to a streaming setting, and it’s why torch.logsumexp exists as a primitive at all.
The same window governs sigmoid, and the fix here is a small twist on “shift into the window”: use a different algebraic form on each side, each one chosen so exp only ever sees a safe argument.
\[\sigma(x) = \frac{1}{1 + e^{-x}}\]
Perfectly fine — for x ≥ 0. For x very negative, e^{-x} explodes:
naive forward : [0.0, 0.006692850962281227, 0.5, 0.9933071732521057, 1.0]
naive gradient: [nan, 0.0066480571404099464, 0.25, 0.0066480571404099464, 0.0]
Look carefully at those two lines, because the failure is not where you’d expect. The forward pass is correct — exp(1000) = inf, then 1/(1+inf) = 0, which is the right limit.
The gradient is nan. The derivative of the naive form involves the same inf intermediate, and inf/inf² is nan. So your loss curve looks healthy, your forward values are right, and your weights quietly fill with nan from the backward pass. This is the recurring lesson of the whole post: checking the forward value is not checking numerical stability.
The repair is to never let exp see a positive argument in the first place, by using a different — algebraically identical — form on each side of zero:
\[\sigma(x) = \frac{1}{1 + e^{-x}} \quad (x \ge 0), \qquad \sigma(x) = \frac{e^{x}}{1 + e^{x}} \quad (x < 0)\]
Both are the same function; each branch keeps its exponential’s argument non-positive, so the exponential lands in (0, 1], the denominator lands in [1, 2], and for finite inputs no intermediate can overflow in any precision. In practice you should just call torch.sigmoid, which is finite in both directions:
Every failure so far came from the edges of the window — values too big or too small to represent. This one is different, and it’s my favourite, because there’s no exp anywhere and it comes from the window’s grid spacing instead of its edges: floats near a large value are spaced far apart, so subtracting two large nearly-equal numbers throws away all the meaningful digits. It can silently corrupt retrieval, geometry, and loss computations.
The textbook vectorized distance uses the expansion \(\|a-b\|^2 = \|a\|^2 + \|b\|^2 - 2a^\top b\), which lets you compute an entire (N, N) distance matrix with one matmul instead of materializing an (N, N, D) intermediate. This is a common vectorized formulation because it replaces an (N,N,D) difference tensor with a matrix multiplication.
torch.manual_seed(1)
D = 128
X = torch.randn(500, D) * 10 + 100 # not centered — that's the trigger
def dist_expanded(X):
sq = (X ** 2).sum(-1)
return sq[:, None] + sq[None, :] - 2.0 * (X @ X.T)
d = dist_expanded(X)
print(f"negative squared distances : {(d < 0).sum().item()} of {d.numel()}")
print(f"most negative value : {d.min().item():.4f}")
print(f"d(x, x) should be 0; worst : {d.diagonal().abs().max().item():.4f}")
print(f"NaNs after sqrt : {torch.sqrt(d).isnan().sum().item()}")negative squared distances : 186 of 250000
most negative value : -1.5000
d(x, x) should be 0; worst : 1.5000
NaNs after sqrt : 186
A squared distance. Negative. And sqrt of it is NaN, which then propagates into your loss, your gradients, and your weekend.
Where do the negatives live?
negatives ON the diagonal : 186 (of 500 diagonal entries)
negatives OFF the diagonal: 0
All of them are on the diagonal — for this data. Self-distances are the most vulnerable entries: d(x,x) is the one place whose true value is exactly zero, so even a tiny absolute error is enormous relative to the result, and roughly half the time it lands on the negative side.
The mechanism is catastrophic cancellation. With ‖x‖² ≈ 1.29e6 and 2x·x ≈ 1.29e6, you are subtracting two large, nearly equal numbers to get something near 0. fp32 carries ~7 significant digits, so the top ~7 digits cancel exactly and what survives is rounding noise from the inputs, amplified into the output. The absolute error here is ±1.5 — which is nothing next to 1.29e6, and catastrophic next to 0.
I thought so. It doesn’t — and this is the more interesting result:
Xc = X - X.mean(0, keepdim=True)
dc = dist_expanded(Xc)
d_direct = ((X[:, None, :] - X[None, :, :]) ** 2).sum(-1)
dc_direct = ((Xc[:, None, :] - Xc[None, :, :]) ** 2).sum(-1)
print(f"uncentered: ||x||^2 ~ {(X**2).sum(-1).mean():.0f} | max error {(d - d_direct).abs().max():.4f}")
print(f"centered : ||x||^2 ~ {(Xc**2).sum(-1).mean():.0f} | max error {(dc - dc_direct).abs().max():.4f}")
print(f"\ncentered negatives: {(dc < 0).sum().item()} — still all on the diagonal")uncentered: ||x||^2 ~ 1292773 | max error 1.9004
centered : ||x||^2 ~ 12866 | max error 0.0156
centered negatives: 200 — still all on the diagonal
Centering shrinks the error, which genuinely matters for the off-diagonal entries. But shrinking the error scale does not guarantee the diagonal comes out non-negative, because its true value is exactly zero and the remaining noise still has a sign. Scaling arguments help; they don’t resolve structural ones.
Here’s the deterministic counterexample that kills the “just fix the diagonal” mental model. Two distinct points, far from the origin, separated by 0.01 in one coordinate:
direct : 6.103516e-05
expanded : -3.27680e+04
The true squared distance is about 6e-5. The expanded formula reports negative thirty-two thousand — an off-diagonal entry, between two different points. The mechanism is the same cancellation, just fed bigger numbers: ‖x‖² ≈ 1.6×10¹¹, and fp32’s spacing between representable values at 3.2×10¹¹ is about 3.3×10⁴. The entire answer lives below one unit in the last place of the intermediates. The -32768 isn’t even an error estimate — it’s literally one grid step of the float lattice.
Two smaller cuts from the same experiment, both worth a beat:
direct says 6.1e-05, not the 1e-04 you’d compute by hand from 0.01². That’s because the perturbation itself got rounded on storage. Try += 0.001 and the two points become bitwise identical — your “distinct” test case silently collapses to a duplicate.raw = dist_expanded(X)
clamped = raw.clamp_min(0) # out-of-place: keep `raw` around to inspect
print("1. clamp :", f"{(clamped < 0).sum().item()} negatives, "
f"{clamped.sqrt().isnan().sum().item()} NaNs")
print("2. cdist :", f"{(torch.cdist(X, X) < 0).sum().item()} negatives, "
f"{torch.cdist(X, X).isnan().sum().item()} NaNs")
print("3. float64 :", f"{(dist_expanded(X.double()) < 0).sum().item()} negatives, "
f"worst diagonal {dist_expanded(X.double()).diagonal().min().item():.2e}")1. clamp : 0 negatives, 0 NaNs
2. cdist : 0 negatives, 0 NaNs
3. float64 : 0 negatives, worst diagonal 2.33e-09
.clamp_min(0) — the one-line patch, when your application’s contract needs a real non-negative result. Small negatives from expected rounding can be clamped before sqrt; large negatives (like our -32768) should be read as evidence that the formulation or precision is inadequate, not quietly clamped away. Either way, be honest about what clamp buys: it restores the invariant (squared distances non-negative, sqrt won’t nan), not the accuracy. If near-duplicate geometry is what you care about, use the direct form (chunked over blocks if memory forces it) or fp64 for that region.torch.cdist — the maintained high-level starting point, provided you check which compute mode you’re actually getting on numerically awkward data. Do not file it under “handled.” cdist chooses between a direct implementation and the same expanded-matmul form we just indicted, based on its compute_mode argument — and the default heuristic switches to the matmul path once either input has more than 25 points. On our own counterexample that is not a subtle difference:import platform
print(f"[python {platform.python_version()} | torch {torch.__version__} | "
f"{'cuda' if torch.cuda.is_available() else 'cpu'}] <- these exact values are build-specific\n")
close = torch.full((2, 16), 100_000.0)
close[1, 0] += 0.01
direct = ((close[0] - close[1]) ** 2).sum().sqrt()
# note: 0.0078 is the distance between the STORED fp32 values — the requested +0.01
# increment is itself rounded on storage at this magnitude (see the counterexample above).
print(f"direct distance : {direct.item():.6f}")
for mode in ["use_mm_for_euclid_dist_if_necessary",
"donot_use_mm_for_euclid_dist",
"use_mm_for_euclid_dist"]:
d = torch.cdist(close, close, compute_mode=mode)[0, 1]
print(f"cdist [{mode:35s}]: {d.item():.6f}")[python 3.9.6 | torch 2.8.0 | cpu] <- these exact values are build-specific
direct distance : 0.007812
cdist [use_mm_for_euclid_dist_if_necessary]: 0.007812
cdist [donot_use_mm_for_euclid_dist ]: 0.007812
cdist [use_mm_for_euclid_dist ]: 0.000000
If you take one thing from this section: when you use the expanded form, check the scale of the negatives before you clamp. Small ones are ordinary rounding and clamp_min(0) before sqrt is a reasonable repair; large ones — like our -32768 — are telling you the formulation or the precision is wrong for this data, and clamping them is hiding the evidence rather than fixing the bug.
0 × inf = nan and unsafe maskingdata = torch.tensor([1., 2., float('inf'), 4.])
mask = torch.tensor([True, True, False, True]) # mask OUT the inf
mul = (data * data * mask.float()).sum()
where = torch.where(mask, data * data, torch.zeros_like(data)).sum()
print(f"multiply-by-mask : {mul.item()}")
print(f"torch.where : {where.item()}")multiply-by-mask : nan
torch.where : 21.0
You masked the inf out. It poisoned the sum anyway, because inf × 0 = nan and nan + anything = nan.
torch.where selects — the discarded value never enters the sum. But before you file this under “solved,” here’s the part that most treatments (including an earlier draft of this one) stop short of:
torch.where is eager, and the backward pass knows itwhere is a tensor selector, not control flow. By the time it is called, the tensors supplied as its two branches have already been computed. In the forward pass that was fine — inf² = inf is a legal value being discarded. Watch the backward:
forward : 21.0
gradient: [2.0, 4.0, nan, 8.0]
Finite forward, NaN gradient. The chain rule for the masked slot multiplies the incoming gradient (0, correctly, from where) by the local derivative of the square (2 · data = inf) — and 0 × inf = nan is back, one level down, where your loss curve looks perfectly healthy and your weights quietly rot. This is a long-standing, well-documented autograd sharp edge (search “torch.where NaN gradient”).
The genuinely safe pattern: sanitize the input before the dangerous op, not the output after —
data2 = torch.tensor([1., 2., float('inf'), 4.], requires_grad=True)
safe = torch.where(mask, data2, torch.zeros_like(data2)) # inf never enters the square
loss2 = (safe * safe).sum()
loss2.backward()
print("forward :", loss2.item())
print("gradient:", data2.grad.tolist())
assert torch.isfinite(data2.grad).all() # the whole point of rung 3forward : 21.0
gradient: [2.0, 4.0, 0.0, 8.0]
So the full ladder, worst to best:
(x*x*mask).sum() — forward NaN (0 × inf in the sum).where(mask, x*x, 0).sum() — forward fine, backward NaN (0 × inf in the chain rule).where(mask, x, 0) then square — forward fine, backward fine. The garbage value never touches an op whose derivative can amplify it.torch.manual_seed(0)
scores = torch.randn(1, 4)
allmask = torch.ones(1, 4, dtype=torch.bool) # every key masked
print("with -inf:", torch.softmax(scores.masked_fill(allmask, float('-inf')), -1).tolist())
print("with -1e9:", [round(v, 4) for v in
torch.softmax(scores.masked_fill(allmask, -1e9), -1)[0].tolist()])with -inf: [[nan, nan, nan, nan]]
with -1e9: [0.25, 0.25, 0.25, 0.25]
Every score is -inf → every exp is 0 → the denominator is 0 → 0/0 = nan.
“But a row is never fully masked!” It is: a zero-length sequence that slipped through your collate function, a query attending only to a padded region, a document with all tokens filtered. It happens in production and it happens at 3am.
-1e9 is not the fix — it’s an fp32 habitThe classic remedy — BERT-era code is full of it — is a large finite negative like -1e9. Now put your model in half precision, where the maximum representable magnitude is 65,504:
--------------------------------------------------------------------------- RuntimeError Traceback (most recent call last) Cell In[42], line 2 1 scores16 = torch.randn(1, 4, dtype=torch.float16) ----> 2 scores16.masked_fill(allmask, -1e9) RuntimeError: value cannot be converted to type at::Half without overflow
That’s the lucky failure — a loud one. The additive-mask idiom fails silently:
additive -1e9 in fp16: [[-inf, -inf, -inf, -inf]]
softmax of that : [[nan, nan, nan, nan]]
Your carefully chosen finite sentinel round-trips through fp16 and comes out as the exact -inf you were avoiding. The dtype-aware fix is one call:
[[0.25, 0.25, 0.25, 0.25]]
One step further: the uniform row is a patch, not a solution. A fully masked query now assigns equal probability to positions explicitly marked invalid, so its output becomes an arbitrary average of their value vectors. That may be acceptable only if the row is guaranteed to be discarded before any downstream computation. Otherwise, the implementation has merely replaced a loud NaN with a finite but semantically invalid result—a quiet failure that is much harder to detect.

Floating-point numbers live in a finite window, and stepping outside it doesn’t raise — it may saturate to inf or collapse to 0, and your correct-on-paper formula returns nan. Most of the standard stability techniques are one move against that fact, with cancellation as the separate second problem:
logsumexp, log_softmax, the two-branch sigmoid, logit-based cross-entropy are all the same idea — rewrite the computation so intermediate values stay inside the window, in a way that provably doesn’t change the real-valued answer. Subtract the max; work in log-space; pick the algebraic form whose exp argument is safe. The stable version is never a hack bolted onto the math; it is the math, done carefully before converting to floats.bf16 is usually the safer training dtype for exactly this reason: its fp32-like exponent range makes range-related overflow and underflow far less likely than in fp16, while its smaller significand can make rounding and cancellation worse. Loss scaling is usually less central for bf16, but the right dtype stays workload- and hardware-dependent.sqrt turns into nan. It corrupts distance magnitudes, and can disturb rankings when the error approaches the gap between neighbours; in the experiment here the top-5 sets happened to survive, which is a fact about that data, not a guarantee. Clamping restores the invariant, not the accuracy.0 × inf = nan means masking by multiplication is unsafe, and torch.where selects between already-evaluated branches — it doesn’t prevent unsafe arithmetic in the branch it later discards, so if gradients flow, sanitize the input before the dangerous op (the order is: sanitize → operate → reduce).torch.logsumexp, torch.cdist — note cdist’s compute_mode argument.