2026-10-24 · 24 min · training · theory · optimization · distillation
A 1:51 narrated explainer, drawn in code. Every number and picture in it is this article's own; the sources are below.
› transcript
Hi, I'm Coaming! Standard cross-entropy has a gradient that can silently round to zero. Here is how, and the fix. The standard code adds a tiny number to one, and it vanishes. The fix never adds the one. Here m is the margin: how far the right logit sits above the others. The usual form adds e to the minus m to one. In float thirty-two, past a margin near sixteen point six, that sum is one, and the gradient zero. The fixed form keeps e to the minus m on top, accurate until the exponent runs out. After memorising, margins grow past the cliff, and the target gradients go to zero. With almost no gradient, Adam's running average of squared gradients decays. Weight decay pulls a few examples back, and their gradients switch on again. Adam divides them by that tiny average. The step is huge, the loss spikes, and it repeats. Bug two is in distillation. Student and teacher are rounded separately, then subtracted, so even a perfect match leaves a gradient. The fix starts from the logit gap, in log space, so equal logits give exactly zero. In the paper's runs, the standard margin saws up to the cliff and falls back. The fixed loss crosses smoothly. On a shared cloud CPU, standard cross-entropy slingshot six times in every seed, the fixed loss never. But in my runs the fix did not speed up grokking. If the number you need is tiny, compute it directly, never as one minus something close to one. A gradient that rounds to zero, Adam turning it into slingshots, and a rewrite. Every source is in the full article. I'm Coaming. Bye!
Why read this
Essentialtop 10%Both bugs reproduced in PyTorch with printed values, the fix ported and checked, and a CPU grokking run: slingshots gone, but no speed-up here.
- Checked against the source
- Runs on a laptop CPU
- Interactive explanations
Training & RLPractitioner paper
How this was scored
- Is it new?
- 2 of 3: A real new idea, method or capability
- Can I trust it?
- 3 of 3: Reproduces the headline result, or shows from primary files it is wrong
- Can I run it?
- 3 of 3: Open, permissive, runs on reader hardware with instructions
- Will I understand it?
- 3 of 3: Mechanism carried by interactives built from real code or data
- Can I act on it?
- 2 of 3: A concrete recipe, numbers or comparison
- Will it last?
- 3 of 3: Evergreen fundamentals
- Does it affect many?
- 2 of 3: A widely used model, tool or lab release
- Only here?
- 2 of 3: A teardown or measurement few others did
Score 88 of 100, ranked 7 of 701 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored
A Fix for Softmax and Cross-Entropy (Jonathan T. Barron, Dan Gnanapragasam, Michael Tschannen, Google DeepMind, arXiv 22 October 2026) makes a claim that sounds like it cannot be true: the softmax and cross-entropy in JAX, PyTorch and the textbooks are wrong in two places. Not unstable in some exotic regime. Wrong, in the sense that they return a different gradient from the one the formula defines, in a regime that ordinary training reaches.
Barron announced it on X on 23 October: "all existing implementations of softmax and cross-entropy (that's JAX, PyTorch, textbooks, etc) have a subtle bug --- two bugs, actually. A simple rewrite fixes both, and eliminates 'slingshots' when grokking." The HTML version of the paper carries the code, and there is an interactive demo.
I wanted to see the numbers myself, so this page has three kinds of evidence, each labelled. Measured: I ran it, in PyTorch 2.14.1 (CPU build) and NumPy 2.5.4 on a shared 4-core cloud VM, with no GPU. Reported: the paper's figure, not re-run. Reasoned: my arithmetic on the other two.
Softmax as everyone writes it
Take logits . Computing directly overflows once a logit passes about 88 in fp32, so every framework first subtracts the largest logit:
Cross-entropy against a target distribution is , and its gradient is the cleanest result in deep learning: . and each sum to one, so the gradient sums to zero. Raising one logit has to lower the others. Keep that invariant in mind; both bugs break it.
Bug one: Softmax Collapse
Call the target class and suppose the model is right and confident, so . The paper measures confidence with the logit margin, how far the target logit sits above the log-sum-exp of all the others:
After the max shift, the target's own term in the denominator is and the other terms add up to exactly . So the denominator is , and the hard-target loss and gradient are
The two forms of are equal on paper. In floating point they are not. Floats are dense near zero and sparse near one: fp32 can hold numbers as small as about , but the gap between 1.0 and the next float is . Add anything at or below to 1.0 and the sum rounds back to exactly 1.0. That happens once , which is (reasoned). The paper calls this the precision cliff and gives for bf16, for fp32 and for fp64 (reported; and match, reasoned).
Past the cliff the standard path computes , subtracts 1, and gets a target gradient of exactly 0.0. The loss is as well. But the non-target gradients are each divided by 1.0. That numerator was never added to 1, so it stays a perfectly good small positive number all the way down to exponent underflow near . The row of gradients now sums to . The invariant is gone.
What PyTorch prints
Here is the paper's own example, with target 0, so , run through torch.nn.functional.cross_entropy and through my PyTorch port of the fix (measured):
-- torch.float32
torch.log_softmax(z) [ 0.0000e+00, -1.8000e+01, -1.8000e+01]
log_softmax_fixed(z) [-3.0460e-08, -1.8000e+01, -1.8000e+01]
F.cross_entropy loss=0.0000e+00 grad=[ 0.0000e+00, 1.5230e-08, 1.5230e-08] sum=3.0460e-08
cross_entropy_fixed loss=3.0460e-08 grad=[-3.0460e-08, 1.5230e-08, 1.5230e-08] sum=0.0000e+00
-- torch.float64
F.cross_entropy loss=3.0460e-08 grad=[-3.0460e-08, 1.5230e-08, 1.5230e-08]The fp32 standard gradient is the correct gradient with its first entry deleted. In fp64 the same call is right, because 17.31 is below fp64's cliff. The paper reports the identical numbers from jax.nn and optax (reported), and a NumPy textbook version, q = e / e.sum(); g = q; g[t] -= 1, prints the same [0.000e+00 1.523e-08 1.523e-08] (measured).
bf16 hits it at a margin anyone reaches in minutes. With , (measured):
F.cross_entropy bf16 loss=0.0000e+00 grad=[ 0.0000e+00, 9.1171e-04, 9.1171e-04]
fixed bf16 loss=1.8082e-03 grad=[-1.8234e-03, 9.1171e-04, 9.1171e-04]
exact fp64 grad [-1.8204e-03, 9.1022e-04, 9.1022e-04]The widget below does the same arithmetic in your browser, one rounding at a time, for three classes. Drag the margin across the cliff and watch the standard target gradient fall off a wall while the fixed one keeps decaying as .
The cliff is a little earlier than 16.6
One thing the paper's single number hides (measured). I swept in steps of 0.05 with one target logit and equal others, and recorded where F.cross_entropy's target gradient first became exactly zero:
| classes | fp32 | bf16 | fp64 |
|---|---|---|---|
| 2 | 16.65 | 5.55 | 36.75 |
| 3 | 15.95 | 5.55 | 36.05 |
| 10 | 14.45 | 5.55 | 35.25 |
| 100 | 16.0 | 5.6 | 34.75 |
| 1000 | 15.95 | 5.6 | 34.7 |
| 32000 | 15.95 | 5.55 | 34.7 |
With two classes it is the textbook . With more, the small terms are not summed before they meet the 1.0; each one is added to a running total near 1.0 and rounded on its own, so each is lost once it alone falls below half a step. With each term is , and the cliff moves to , exactly what PyTorch shows (reasoned from measured). Where it lands for larger depends on how the kernel vectorises the sum. bf16 does not move, because PyTorch's CPU kernel accumulates in fp32 and the cliff comes from rounding to bf16 afterwards. The paper's claim is unaffected; the cliff is, if anything, closer than it says.
The fix
Never add to 1. Take the max logit out of the sum entirely, compute the margin from the others with a log-sum-exp, and let a numerically stable softplus, , do the rest:
Because the target slot is the constant 0 rather than , reaches the loss only through the differences, so autodiff gives each non-target and the target exactly minus their sum. The paper ships it as nine lines of JAX. My PyTorch port of the hard-target version:
import torch, torch.nn.functional as F
def cross_entropy_fixed(z, t):
z_t = z.gather(-1, t[..., None]) # target logit
is_t = F.one_hot(t, z.shape[-1]).bool() # target mask
others = (z - z_t).masked_fill(is_t, float("-inf")) # exclude target from the sum
m = -torch.logsumexp(others, -1) # logit margin
return F.softplus(-m).mean() # = -log_softmax(z)_tlog_softmax gets the same treatment with the argmax standing in for . Across the whole sweep above, the port's gradient row summed to zero to within one rounding step in every format, and its target entry matched fp64 (measured).
Why a missing matters: Adam
A gradient of that should have been there is not obviously a problem. It becomes one because Adam does not care how big a gradient is. It divides the running mean of each parameter's gradient by the square root of the running mean of its square:
While , that normalised step is about 1 whatever the size of . So the paper's account of the slingshot (Thilak et al., 2022), the recurring loss spikes after a grokking network memorises its training set, runs like this (reported):
- After memorisation the margins grow, because the loss keeps pushing toward 1.
- Once a batch sits past the cliff, the target gradients are zero and what is left is the small, lopsided push from the non-targets. The gradient almost vanishes, and decays toward zero with it.
- Weight decay and that residual push drag a few examples back below the cliff. Their target gradients switch back on at about .
- Adam divides those restored gradients by a that has just decayed. The step is enormous, the loss spikes, resets, and the cycle starts again.
With the fixed loss, fades smoothly through the cliff, and decay together, and there is nothing to snap back.
The paper adds a second result that I find the more useful one. At a stationary point the update is zero, so . Once drops below the normalised step shrinks like , and since , weight decay stops the margin near
For PyTorch's default that is 18.42, just past fp32's 16.64 (reasoned). That is the whole reason the default triggers slingshots: training wants to settle at a margin the standard loss cannot represent. At the equilibrium is 16.12, below the cliff, and the paper notes standard cross-entropy then settles without spiking (reported). Barron's thread puts it as "the default eps=1e-8 happens to be juuust past where this bug kicks in for fp32 (this may be why some think a small eps is dangerous)."

The paper's Table 1 puts numbers on the top row: on at , fp32 fixed cross-entropy reaches 100.0 ± 0.0% test accuracy against 94.3 ± 5.4% for standard (reported). The bottom row is the dramatic one: with no weight decay and , one spike at around step 1,250 drops standard cross-entropy from 61% to 23%, while the fixed loss continues to 85% (reported).
I ran the grokking experiment
The paper's Section 6 setup is a 1-layer, width-64 ReLU Transformer on with a 50% training split, full-batch AdamW at learning rate , betas 0.9 and 0.98, weight decay 1, , 10,000 epochs. The paper does not give head count, MLP width, initialisation or normalisation, so I chose: 4 heads, an MLP of width 256, PyTorch default initialisation, no LayerNorm, inputs a b =, read out at the last position. That leaves 480 training pairs and 481 held out. Each run is one process, one thread, nice -n 19, and took 2.5 to 11 minutes on the shared CPU, depending on load (measured). The only difference between a standard and a fixed run is the loss function: same seed, same split, same initialisation.
What I saw (measured, three seeds at learning rate ):
- The slingshots are real and the fix removes them. Standard cross-entropy produced 6, 6 and 6 slingshots in 10,000 epochs (a mean-margin drop of more than 3 within 100 epochs); the fixed loss produced 0, 0 and 0. The standard runs climb to a mean margin of 16.05 to 16.24, the moment a quarter to a third of training pairs are past the 16.64 cliff, and fall back to between 4.75 and 8.14.
- The fixed margin settles below the predicted equilibrium. It flattens at 16.51 to 16.83 by epoch 10,000, not at the 18.42 that predicts or the 17.9 the paper's Table 1 reports for fp32. My guess is that the approximation drops constants, such as the weight norm, that differ in my model (reasoned).
- Neither loss grokked in 10,000 epochs. Final test accuracy was 46.2, 47.6 and 50.7% for standard against 43.7, 46.2 and 48.6% for fixed. My model groks slower than the paper's, so 10,000 epochs is not enough to test the speed claim.
So I ran the same seeds at a learning rate of , which makes weight decay act three times faster per epoch (switch to it in the widget).
The result was the opposite of the paper's (measured, three seeds):
| learning rate | seed 0 | seed 1 | seed 2 |
|---|---|---|---|
| standard: slingshots | 7 | 7 | 7 |
| fixed: slingshots | 0 | 0 | 0 |
| standard: first epoch at 90% test | 5,890 | 5,470 | 4,090 |
| fixed: first epoch at 90% test | not reached | not reached | 4,680 |
| standard: test accuracy at 10,000 | 97.9% | 100.0% | 100.0% |
| fixed: test accuracy at 10,000 | 60.1% | 47.2% | 100.0% |
Standard cross-entropy grokked on all three seeds; the fixed loss grokked on one, later than standard did on the same seed, and on seed 0 it was just starting to climb when the run ended. In the standard runs, test accuracy climbs between slingshots: on seed 0 it goes from 50.5% at epoch 4,800, just after one, to 90.9% at epoch 6,000, just after the next. The fixed margins sat at 16.60 to 17.17 at the end, again under 18.42.
That is not what the paper reports, and I do not think it refutes it: three seeds of one architecture I had to partly guess, against five seeds and four benchmarks. The paper itself says that whether spikes help or hurt depends on how the weight norm is controlled, and that with LayerNorm and no weight decay each spike "acts as an accidental learning-rate decay" (reported). My model has weight decay and no LayerNorm, the case where the paper says spikes only hurt. What my runs do support without reservation is the mechanism: every sawtooth tops out just as a quarter or more of the training pairs cross the cliff, and the fixed loss has no sawtooth at all.

Bug two: Softmax Cancellation
The second bug lives in distillation, where the target is itself a softmax, of teacher logits. The standard code computes and separately, rounds both to probabilities, and subtracts. When the student is close to the teacher, that is the subtraction of two nearly equal numbers, and the small logit gap is gone before the subtraction happens. Worse, comes from softmax in the forward pass while comes from exp(log_softmax) in the backward pass, two different sequences of roundings, so even at , where the true gradient is zero, they do not cancel (reported).
The fix computes from directly, in log space, with expm1 and log1p so nothing is ever added to or subtracted from 1:
with and , a fallback to when , and a final projection so the row sums to zero.
My PyTorch reproduction of the paper's example, and in fp32 (measured). In fp32 that perturbation rounds to , so the exact gradient is of those rounded inputs:
exact (fp64 of the rounded inputs) [-4.5955e-08, 4.1442e-08, 4.5131e-09]
fp32 standard (PyTorch) [-5.9605e-08, 4.4703e-08, 7.4506e-09] sum=-7.451e-09
fp32 fixed [-4.5955e-08, 4.1442e-08, 4.5131e-09] sum= 1.776e-15PyTorch's standard gradient is wrong by 30% in the first entry and 65% in the third. The paper's JAX run of the same thing is wrong differently, [-5.960e-8, 1.452e-8, -1.123e-9], with the third sign flipped (reported). Different frameworks, different rounding sequences, different wrong answers, which is the point.
Then the clean test, suggested by more than one reply to Barron's thread: set the student equal to the teacher, where the gradient must be zero. 64 rows of 1,000 random logits each (measured):
| format | standard: max abs gradient | standard: entries non-zero | fixed: max abs gradient |
|---|---|---|---|
| fp64 | 6.985e-16 | 95.4% | 0 |
| fp32 | 2.778e-07 | 96.3% | 0 |
| bf16 | 2.747e-03 | 100.0% | 0 |
That residual is harmless in plain SGD. Under Adam it is not, for the same reason as before: any gradient larger than becomes a full-sized step. The paper initialises a student with its teacher's weights on and runs 2,000 Adam steps: the fp64 residual of about sits below and nothing moves, fp32's residual causes an initial KL spike and a floor near , and bf16's drives the student away from the teacher and costs test accuracy, while the fixed loss stays at KL 0 and 100% in every format (reported, Section 7). I did not re-run that experiment.

Who should care
The paper is candid that "the extreme margins and near-zero student–teacher gaps probed by our experiments are uncommon in standard large-scale training" (reported). I would not expect language-model pretraining over a 100,000-token vocabulary to sit at a margin of 16 on most tokens (reasoned). But four places do reach it.
- bf16 losses. The bf16 cliff is a margin of 5.55, a probability of about 0.996 on the right answer (reasoned). Most training code upcasts logits to fp32 before the loss for this reason among others; code that does not is collapsing on every easy token.
- Small-output problems trained long. Grokking, classifiers with a few classes, anything past memorisation with weight decay and default .
- Distillation from a close teacher, and KL penalties that start the policy as a copy of the reference, which is how most RL fine-tuning begins. One reply points at continual-learning penalties that start the new model as an exact copy of the old one.
- Attention. Attention is a softmax too. A reply to the thread from the authors of Broken Symmetry in BF16 Attention argues the same broken zero-sum shows up in FlashAttention's bf16 backward. That claim is their own; a second group has since made the stochastic-rounding version of it, and I measured that one in the 25 October section below.
Prior fixes treated symptoms: Liu et al. project the gradient to zero sum, StableMax (Prieto et al., 2025) replaces the exponential, and others cast to fp64. The paper's Table 1 finds zero-sum projection stalls the margin near 18 and drops test accuracy below 50% at , and StableMax lets margins run to about (reported). Liu replied that their main contribution is the mechanism, "Numerical Feature Inflation", and that the projection was a causal test.
What the fix costs: an argmax, a masked log-sum-exp and a softplus instead of one fused kernel. The paper says it runs "without meaningful slowdown" (reported). I did not benchmark it, and a reply asking about fused vocabulary-scale kernels has no public answer yet. My unfused port allocates a few extra temporaries, which at a large vocabulary is the memory a fused cross-entropy exists to avoid.
Two notes on provenance. Barron writes that the bug was "first found and fixed by Gemini Flash 3.7", that most experiments were run by Flash 3.8 and the first draft written by a Gemini model before he rewrote it (reported, from his thread); a reply points out the paper itself does not say so. And no code repository is published: the code is the listings on the project page, which is what I ported.
25 October: the same bug in FlashAttention's backward pass
A day after this page went up, Alexander Johansen (@AlexRoseJo) posted that "FlashAttention's backward pass has the same zero-sum bug Jon Barron just fixed in softmax. With FP4 stochastic rounding it's wrong on every single draw." The fix is in his ICLR 2027 submission, CroissAttn, a complete FP4 attention backward. The repository holds one file, the anonymous submission PDF (reported; no kernels are published). Everything below about the paper is from that PDF and his thread.
- branch
- main
- tests
- none found
- commit date
- 2026-10-25
local clone, 2026-10-26 at 6faeb97 — branch, commit, commitDate, fileCount, hasTests, shallow
shallow clone: counts describe the pinned tree, not the history
What the backward computes. For one query row, attention gives probabilities and output . The backward forms and then the softmax derivative
Because sums to one, the row sum of is , which is zero exactly when equals the -weighted mean of the row it is subtracted from. FlashAttention never stores , so it takes the second form of , the dot product of with the stored output . With rounded operands it is a different number from computed from the row actually used, and the gap is the row sum of (reasoned).
Where the property is lost. Two places. If is stored in bf16, its rounding error is a fixed offset per row. With stochastic rounding of and , is a random draw, while taken from is the average draw. The expected gradient is right, but each draw has a non-zero row sum, and the error lands on the key that holds most of the row: the attention sink. The submitted CroissAttn kernels use a high-precision auxiliary product for , which fixes the mean and not the individual draw (reported, PDF Eq. 4 and Appendix A.3).
What CroissAttn changes. Compute from the same rounded row: . Then on every draw, and the mean is unchanged because the rule is linear in . Johansen's thread says the per-draw rule was derived after the submitted kernels were built, and training results with it are "to follow" (reported). All the paper's training numbers, such as the 0.0439-nat gap at 500M parameters and the 1.5 to 2.2 times backward speedup on B200, were produced with the auxiliary version, so they do not test this claim.
Their evidence. In the PDF, an exact enumeration of E2M1 rounding on a two-key row gives a total squared error of 0.21037027 with the auxiliary rule and 0.01670725 with per-draw centering, and 0 against 0.03609975 on a one-key row (reported, Table 8). The variance ratio falls from 1.98 on peaky rows to 1.14 on diffuse ones (reported, Table 9). The thread adds that on one sink row, 150 draws land within 1 times the true gradient 1 time in 150 with the shortcut and 150 times in 150 with CroissAttn; that experiment is not in the PDF, so I could not check it against a table. The paper notes a catch: for some downstream maps, sampled centering gives a larger error than the auxiliary rule (reported, Appendix C.1, a toy with 1/16 against 0).
I ran the row sum
(measured) I rebuilt the claim in PyTorch 2.14.1 on the shared 4-core CPU, two threads. One attention row, 256 keys, head size 64, Gaussian tensors; a sink row adds +6 or +12 to the first key's logit and shrinks its value vector to 0.01 of the others. Gradient operands and are rounded to bf16, fp8 (E4M3) or fp4 (E2M1, blocks of 16 with an E4M3 scale), by round to nearest or by stochastic rounding I emulated myself; the stored is bf16. Each cell is 150 draws. Three choices of are compared, and the target is the exact-arithmetic gradient of the rounded forward. I did not run the authors' code, because none exists, so this is my reading of the rules, not their kernel.
- Stored output, fp4 with stochastic rounding, +6 sink row: the row sum of averaged in absolute value, from a mean of . That is zero on average and wrong on each draw, which is what the thread says.
- Per-draw rule, same cell: , which is fp32 rounding of and nothing else. It is or lower on diffuse rows and on the +12 sink row.
- bf16 with round to nearest gives a deterministic offset, not noise: on a diffuse row from the stored .
- The sink amplifies it. On the +12 row the sink holds 99.9% of the probability and its true is only . With fp4 and stochastic rounding the shortcut's sink gradient stayed within 1 times the true value in 16 of 150 draws and the per-draw rule in 150 of 150; with fp8 and stochastic rounding, 29 of 150 against 150 of 150. Mean relative error of was 7.4 for the shortcut against 0.11 for per-draw (fp4, stochastic).
- On diffuse rows the three rules have the same error in (0.138, 0.138 and 0.137 in fp4 with stochastic rounding), matching the thread's "about 1 times on diffuse rows".
Limits: one row and one seed per cell, Gaussian tensors rather than a trained model's, my own rounding emulation, no training. I did not check the speedup, the training gaps or a reply's claim that GProj fixes the bf16 side with a rank-one correction.
The takeaway
The bug is one addition: . Everything else follows from where floats are dense and where they are sparse. If a quantity you care about is tiny, compute it directly; never compute it as the difference between 1 and something close to 1. The same lesson is why log1p and expm1 exist, and it is the whole of both fixes. For a different, older problem with the same output layer, see the softmax bottleneck; for softmax inside attention, the attention explainer; for training natively in low precision, A.X-K2; for loss spikes caught by an optimizer that watches itself, reactive optimizers; and for distillation measured on a reasoning model, Inkling-Small.