~/satyajit

Time-bomb loss spikes: Adam drifts the LM head, BF16 pulls the trigger

mdjsonmcp

2026-11-09 · 39 min · training · optimization · low-precision · numerics

Why read this

Essentialtop 10%

Drift, rounding, fix, injected drift and lead time measured in a CPU toy of the head, plus a code read; the 28M-1.4B runs are reported, not re-run.

  • Interactive explanations
  • Original analysis
  • Widely used

Training & RLNeeds datacenter GPUsMITPractitioner paper

How this was scored
Is it new?
2 of 3: A real new idea, method or capability
Can I trust it?
2 of 3: Measures key facts from files, code or configs
Can I run it?
2 of 3: Open code or weights with real limits
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?
2 of 3: A reference for a year or more
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 75 of 100, ranked 66 of 701 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored

A pretraining run that goes wrong in the first thousand steps is cheap. One that looks healthy for most of its budget and then starts climbing, and never comes back down, is the expensive kind. The Role of Quantization in Loss Spikes (Zaiqian Chen, Aditya Somasundaram, Sophie Li, Sean McLeish, John Kirchenbauer, Tom Goldstein and Micah Goldblum; Columbia, MBZUAI, Maryland and the Vector Institute; arXiv, v2 of 2 November 2026) calls these time-bomb spikes and gives them a cause in two parts. Adam makes the mean logit of the LM head drift. BF16 then cannot represent the differences between large logits.

Goldblum's thread on X put the puzzle in one line: "Adding the same constant to every logit changes neither the softmax distribution nor the cross entropy gradients in exact arithmetic. So why should this mean logit drift cause training to fail?" The answer the paper gives is finite precision, and the fix it proposes is small: "swap AdamW for momentum SGD only on the LM head, and keep AdamW everywhere else."

The code is public under MIT, a JAX and Flax training stack forked from Martin Marek's:

tingtang2/quantization-loss-spikes@76153f0 · snapshot 2026-11-10
tracked files
64
license
MIT
branch
main
tests
1 file
source
364.2 kB
commit date
2026-11-02
source by language
Python359.9 kB(27)Shell4.3 kB(5)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

local clone, 2026-11-10 at 76153f0 — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount

shallow clone: counts describe the pinned tree, not the history

The paper trains on TPUs and B200s, which I don't have. What I can do on a CPU is isolate the LM head, make it drift, round it the way a BF16 GEMM does, and swap the optimizer. So the evidence here comes in three labels. Measured: I ran it, in NumPy 2.5.3 on a shared 4-core cloud VM, with BF16 simulated by rounding fp32 bits to nearest even. Reported: the paper's figures and the repository's configs, read and not re-run. Reasoned: my arithmetic on the other two.

Two kinds of spike

The paper separates two failure shapes (reported). Fast spikes jump and recover within a few hundred steps. In their runs these mostly come from RMSNorm layers without a learnable gain: across five matched seed pairs at 162M parameters, adding the gain suppresses them along with the logit swings that come with them. Skipping the batch that triggered one only postpones the next, and masking as little as 0.2% of the highest-loss tokens hides them but slows convergence.

Time-bomb spikes survive the learnable gains. Two 404M-parameter models trained for 96B tokens, about 240 tokens per parameter or roughly 12 times the Chinchilla budget, follow the same loss curve for most of training. Then the higher-learning-rate run stops improving and its evaluation loss rises monotonically while its output logit norm accelerates (reported, Figure 3). Longer horizons make it worse: at 28M parameters, runs of 4B, 8B, 12B and 16B tokens show progressively larger late logit growth (reported, Figure 17). That matters because training far past compute-optimal is now the normal case.

Three panels against training tokens for 28M-parameter models trained to 4B, 8B, 12B and 16B tokens. Left, eval loss: all four fall to about 4.1; the 12B run turns up near 10B tokens and the 16B run shoots past 5 near 15B. Middle, output logit norm on a log scale, rising from about 5e5 to 1e7 for the longer runs and to about 3e8 for the 16B run. Right, output logit mean: near zero for most of training, then falling to about -650 for the 12B run and past -1000 for the 16B run.
The longer the horizon, the further the mean logit falls and the later, harder the failure: the shorter runs end before the bomb goes off. 28M-parameter models at peak learning rate 0.1 (paper, Figure 17).

The right panel is worth a second look: the real 28M model's mean logit reaches about −1,000-1{,}000 at 16B tokens. That is the same scale my toy drifts to, and the scale at which BF16 neighbours are 4 to 8 apart (reported figure; the comparison is mine).

The experiments use QK-Norm everywhere, so attention-logit growth is ruled out as a cause. What is left is the LM head.

The model, with shapes

Every number in this section is read from the repository's Hydra configs (configs/model/*.yaml, configs/experiment/*.yaml, configs/base.yaml) at commit 76153f0, and the arithmetic on them is mine (reasoned).

The architecture is a modernised GPT-2: rotary positions, GELU, RMSNorm, QK-Norm, untied input and output embeddings, no biases. The GPT-2 tokenizer gives V=50,257V = 50{,}257. Four sizes appear:

configwidth DDlayerscontexthead EE shapehead parameters
lm28m256351250,257 × 25612.9M
lm162m768121,02450,257 × 76838.6M
lm404m1,024241,02450,257 × 1,02451.5M
lm1p4b2,048242,04850,257 × 2,048102.9M

At 28M, where the precision experiments run, the head is close to half the model: three blocks at 12D212D^2 come to about 2.4M and the input embedding to another 12.9M. Per position, the final RMSNorm produces h∈R256h \in \mathbb{R}^{256} and the head computes z=Eh∈R50,257z = Eh \in \mathbb{R}^{50{,}257}. In the bf16_mixed config that GEMM takes BF16 inputs and returns BF16 logits (lm_head_dtype: bfloat16, lm_head_param_dtype: float32), and the loss casts them to fp32 only afterwards. By the time anything runs in fp32, the logits have already been rounded once.

The time-bomb recipe at 28M (timebomb_28m.yaml): 131,072 tokens per step, 16B tokens, so about 122,000 steps (the 120k-step axis on the paper's figures); peak learning rate 0.1, 5% warmup, cosine to zero; AdamW with β1=0.9\beta_1 = 0.9, β2=0.95\beta_2 = 0.95, ϵ=10−8\epsilon = 10^{-8}; global-norm clipping at 1. The paper's setup says weight decay is 10−410^{-4} "unless otherwise specified"; the fast-spike configs set that, but both time-bomb configs set weight_decay: 0.0, which is what the argument below needs for the drift to be exactly invisible. One step's logits at 28M are about 6.6 billion numbers, 13 GB in BF16, which is why moving the head to FP32 is not free.

The gradient that sums to zero

Write the LM head as an output embedding matrix E∈RV×dE \in \mathbb{R}^{V \times d} acting on the final hidden state hh, so the logits are z=Ehz = Eh and p=softmax⁡(z)p = \operatorname{softmax}(z). The cross-entropy gradient with respect to EE is an outer product:

∂L∂E=(p−y) h⊤,∑i=1V∂L∂Eij=hj∑i=1V(pi−yi)=0.\frac{\partial \mathcal{L}}{\partial E} = (p - y)\,h^\top, \qquad \sum_{i=1}^{V} \frac{\partial \mathcal{L}}{\partial E_{ij}} = h_j \sum_{i=1}^{V} (p_i - y_i) = 0 .

Both pp and the one-hot yy sum to one, so every column of the gradient sums to zero over the vocabulary. Any optimizer that applies a fixed linear map to that gradient, the same for every row, keeps the zero sum. Plain SGD does. So does SGD with momentum, whose buffer is a running sum of zero-sum gradients. Under either, the mean output embedding μ=1V∑iei\mu = \frac{1}{V}\sum_i e_i never moves.

Adam does not. Its first moment is still zero-sum, but it divides each coordinate by its own v\sqrt{v}. Rows for rare tokens see small, mostly one-signed gradients (they are pushed down a little every time they are not the target) and so have a small vv. Dividing by it turns that trickle into a full-size step. Frequent tokens get large, two-signed gradients and a large vv. The weighted rows no longer cancel, so the mean embedding drifts, and with it the mean logit μ⊤h\mu^\top h. This part is not new: the paper credits Biś et al. (2021) and Stollenwerk and Stollenwerk (2025) for it.

I wanted to see the size of that imbalance in a real step, so I built a toy (measured). The trunk is frozen: 1,024 contexts, each a 64-wide RMS-normalised hidden state that shares one mean direction with the others, which is the anisotropy real final hidden states have. Targets come from a 2,048-token vocabulary with Zipfian frequencies and a context-dependent tilt. Only EE trains, at batch 128 with gradient clipping at 1, like the paper's runs. The widget below is one Adam step at step 2,000, split by token-frequency rank. Each bar is how far that bin of rows moves its logits for an average context.

The momentum buffer sums to −5×10−8-5 \times 10^{-8} over the vocabulary, which is fp32 noise. The Adam direction sums to 5,373 against an absolute sum of 9,595, so 56% of it is net push in one direction, and 87% of that net push comes from the 1,792 rows ranked past 256 (measured). At a learning rate of 0.01 that is a change of −0.026-0.026 in the mean logit per step. Over 40,000 steps the FP32 run below drifted to −1,002-1{,}002, which is the same rate (measured).

Adam's step, term by term

For one coordinate (i,j)(i, j) of EE, with gradient gij=(pi−yi)hjg_{ij} = (p_i - y_i) h_j averaged over the batch, Adam keeps

mij←β1mij+(1−β1)gij,vij←β2vij+(1−β2)gij2,ΔEij=−η m^ijv^ij+ϵ.m_{ij} \leftarrow \beta_1 m_{ij} + (1 - \beta_1) g_{ij}, \qquad v_{ij} \leftarrow \beta_2 v_{ij} + (1 - \beta_2) g_{ij}^2, \qquad \Delta E_{ij} = -\eta \, \frac{\hat m_{ij}}{\sqrt{\hat v_{ij}} + \epsilon}.

Take a rare token ii that is almost never the target in a batch. Then yi=0y_i = 0 at every position and gijg_{ij} is the batch average of pihjp_i h_j: a small number whose sign follows the mean hidden state's jj-th coordinate, batch after batch. A gradient with the same sign and roughly the same size every step has ∣m^∣≈v^|\hat m| \approx \sqrt{\hat v}, so the ratio is close to ±1\pm 1 whatever pip_i is. A token at probability 10−610^{-6} moves as fast as one at 10−210^{-2}. Every such row steps by about η\eta per coordinate, all lowering its logit.

A frequent token is the target often enough that gijg_{ij} flips sign between batches. Its m^\hat m averages the flips toward zero while v^\hat v keeps their squares, so the ratio stays well below one. Frequent rows move slowly and both ways; rare rows move fast and one way. Averaged over the vocabulary the rare rows win, which is the 87% in the ledger above (reasoned, matching the measured ledger).

Two predictions follow, tested below. Halving η\eta should roughly halve the drift. And the culprit is the per-row divisor, not the momentum. If every row in a column shares one second moment sjs_j, the update is ΔEij=−η m^ij/sj\Delta E_{ij} = -\eta\, \hat m_{ij} / s_j, and the column sums to −ηsj∑im^ij=0-\frac{\eta}{s_j} \sum_i \hat m_{ij} = 0 again. That is the idea behind Stollenwerk and Stollenwerk's coupled Adam, which the paper cites and does not run.

Why the drift should not matter

Here is the puzzle. Subtract the mean embedding from every row, E′=E−1μ⊤E' = E - \mathbf{1}\mu^\top. Every logit drops by the same scalar μ⊤h\mu^\top h, so pp is unchanged, so the gradient with respect to EE is unchanged. The gradient into the hidden state is unchanged too:

∂L∂h=E′⊤(p−y)=E⊤(p−y)−μ∑i(pi−yi)=E⊤(p−y).\frac{\partial \mathcal{L}}{\partial h} = E'^\top (p - y) = E^\top (p - y) - \mu \sum_{i} (p_i - y_i) = E^\top (p - y).

In exact arithmetic, without weight decay, the mean component is invisible to both passes (reasoned, following the paper's Section 3.4). A run with a drifting mean and a run without one should follow the same trajectory. My FP32 toy agrees: the Adam run drifted to a mean logit of −1,002-1{,}002, and its final loss of 5.038 matches, to within 0.001, the 5.037 of a run that removes the mean after every step, which never drifts at all (measured; that run is the μ-centering one below). So whatever breaks, it is not the drift itself.

BF16 has no room for differences between big numbers

BF16 keeps 8 bits of significand. Between 2e2^e and 2e+12^{e+1} the gap between neighbouring values is 2e−72^{e-7}: about 0.008 between 1 and 2, and 8 between 1,024 and 2,048. The paper's worked example (reported, Appendix C.1): logits (0,1)(0, 1) give probabilities of about (0.27,0.73)(0.27, 0.73). Shift both by −1025-1025 and both round to −1024-1024, so the softmax returns (0.5,0.5)(0.5, 0.5). I checked it in NumPy and got exactly that (measured). Subtracting the maximum before the exponential, which every framework does, can't help: the difference was lost when the GEMM wrote its output.

The relevant quantity is therefore the mean relative to the spread, r=∣μz∣/σzr = |\mu_z| / \sigma_z. While rr is small, rounding is small next to the differences that set the probabilities. Once the mean dominates, it isn't. Try it:

To get numbers at a realistic vocabulary size I shifted 64 rows of 50,257 Gaussian logits with standard deviation 2 and measured the KL divergence from the exact softmax after rounding (measured):

mean shiftBF16 RMS errorBF16 KLFP16 KL
00.00332.67e-54.12e-7
640.1142.78e-34.35e-5
2560.4564.38e-26.99e-4
5120.9100.1672.78e-3
1,0241.5450.5971.11e-2
2,0481.9302.234.38e-2

FP16 at a shift of 512 has the same KL as BF16 at 64: three more significand bits buy a factor of eight in headroom, and the table shows it to three digits. By a shift of 1,024 the BF16 RMS error is 1.5 logits on a spread of 2, and the distribution is mostly noise (measured).

And FP8?

The paper does not test FP8, so I extended the static test (measured). Here the drift lives in the weights, as in training: a centred 50,257 × 128 head plus a shared mean row sized to put the mean logit at the first column's value, applied to 32 RMS-normalised hidden states, logit spread 2. "E4M3" and "E5M2" quantise both GEMM inputs to FP8 with one per-tensor scale each and accumulate in fp32; "BF16 GEMM" rounds inputs and output to BF16; "E4M3 centred" quantises E−1μ⊤E - \mathbf{1}\mu^\top and adds the mean logit back in fp32.

mean logitBF16 GEMM, RMS / KLE4M3, RMS / KLE5M2, RMS / KLE4M3 centred, RMS / KL
00.006 / 3.8e-50.074 / 2.9e-30.150 / 1.2e-20.074 / 2.9e-3
−160.030 / 1.9e-40.130 / 4.2e-30.248 / 1.7e-20.074 / 2.9e-3
−640.113 / 3.8e-30.439 / 3.7e-20.823 / 0.1460.074 / 2.9e-3
−2560.433 / 8.1e-21.683 / 0.533.135 / 1.470.074 / 2.9e-3
−1,0241.780 / 1.005.917 / 5.3211.27 / 10.40.074 / 2.9e-3

E4M3 at zero drift already has an error BF16 reaches only between a mean of −16-16 and −64-64, and at −64-64, which the toy's Adam runs pass within about 3,000 steps, its KL is ten times BF16's. An FP8 head would have far less room. And the damage is entirely the mean: centring before quantising holds E4M3 at its zero-drift error at every shift, because otherwise one large shared component sets the per-tensor scale and leaves the per-token differences fewer levels. That is one more reason to keep the head out of FP8 (table measured, advice reasoned).

The paper's evidence

The cleanest test of the precision story is to hold everything fixed and change only the LM head's precision. The paper does that at 28M parameters, training 16B tokens (about 570 tokens per parameter, roughly 28 times Chinchilla) at peak learning rate 0.1, in three settings: FP32 (head weights and GEMM in fp32), BF16 mixed (fp32 master weights, BF16 GEMM) and BF16 true (head weights and optimizer state also stored in BF16). The rest of the network stays in BF16 mixed throughout.

Three panels over 120k steps. Left, eval loss: BF16 true diverges first, near 50k steps; BF16 mixed diverges near 100k; FP32 and BF16 mixed with SGD-M stay near 4.05. Middle, output logit norm on a log scale: every AdamW baseline grows, BF16 true fastest to above 1e8, FP32 to about 4e6; SGD-M stays flat near 2e5. Right, RMS of the BF16 minus FP32 logits on a log scale: the baselines rise from about 1e-2 to above 1, while SGD-M stays flat near 6e-3.
Lower LM-head precision brings the time bomb earlier; FP32 survives although its logits grow too, and SGD-M on the head keeps both the logit norm and the quantization error flat. 28M-parameter models at peak learning rate 0.1 (paper, Figure 1).

The ordering is the one the precision story predicts. BF16 true fails first, BF16 mixed later, FP32 not at all over the horizon, even though its logit norm grows too (reported). The error metric on the right is the one I copied for the toy: recompute the head in FP32 from the same hidden states and take RMS(zbf16−zfp32)\mathrm{RMS}(z_{\mathrm{bf16}} - z_{\mathrm{fp32}}). In the failing runs it grows by orders of magnitude before the loss turns.

Three panels over 120k steps with five seeds per precision. Left, eval loss: BF16 true runs fail earliest, BF16 mixed later, FP32 stays stable. Middle, RMS logit quantization error on a log scale rising to about 1 for both BF16 settings. Right, KL divergence between FP32 and BF16 predictive distributions rising by several orders of magnitude for BF16 runs.
Across five seeds, the order of failure matches the order of quantization error in both the logits and the predictive distribution. 28M-parameter models at peak learning rate 0.1 (paper, Figure 6).

Appendix C.2 extends it to the learning-rate sweep: lower precision triggers time bombs at smaller learning rates, and at the largest rate tested every BF16 run fails while FP32 stays stable (reported, Figure 19).

I reproduced it in a toy

Same toy as above, 40,000 steps per run, one seed, a constant learning rate after a 200-step warmup. "Mixed" rounds the GEMM's inputs and its output logits to BF16 and keeps fp32 master weights; "true" also stores EE and the optimizer state in BF16. The softmax and the backward run in float64 on the rounded logits, so the only precision loss is where the paper locates it. The irreducible loss of the task is 4.403 nats.

What the runs show (all measured):

Two things in the toy disagree with the paper, and I'd rather say so than tune them away. First, z-loss: at the paper's λ\lambda it only delays the failure here. At ten times that, λ=10−3\lambda = 10^{-3}, the drift stops near −97-97 and the RMS error holds at about 0.16, but the loss settles at 5.214, worse than the FP32 run's 5.038 (measured). The right strength is presumably set by how fast the drift is, which in my toy is fast. Second, BF16 true fails differently. Its drift stalls at −120-120 because, once a head weight is large, BF16 storage rounds away most of each Adam update; the loss rises early, to 5.314 by step 8,000, from lost updates rather than from logit rounding. The paper's BF16 true runs fail first too, but its figures show the quantization error leading. An SGD-M head under BF16 true is not perfectly zero-sum either, since rounding each stored weight is not: its mean logit crept to +10+10 and its error to 0.018, with the loss unchanged at 4.752 (measured).

The toy's SGD-M runs also reach a lower loss than any Adam run (4.752 against 4.992 for FP32 Adam). I would not read anything into that. My trunk is frozen and the head is a linear softmax regression, where SGD is well suited; the paper finds SGD-M "competitive", not better, on real models.

And one finding about my own set-up. I first gave the toy a learnable RMSNorm gain, trained with Adam. The gain absorbed the drift. In a full 40,000-step BF16 mixed run at learning rate 0.01, its norm shrank from 8.0 to about 1.6 while the mean embedding's norm grew to 393, and the mean logit stayed between −6-6 and −11-11. The RMS error reached only 0.027 and the loss ended at 5.107, with no climb (measured). That is why the runs above freeze the trunk. In a real network the hidden-state scale is set by much more than one gain vector, and the paper's runs, which do have learnable gains, still drift.

The fix: SGD with momentum on the head only

The proposal is to train EE with SGD with momentum and everything else with AdamW. Momentum keeps the zero sum, so the mean never drifts, and no explicit centering is needed. In the repository it is one config, configs/method/sgdm.yaml:

opt:
  lm_head_optimizer:
    type: sgd_momentum
    peak_lr: 0.3
    momentum: 0.99
    nesterov: false

training/optimizers.py builds it as optax.sgd masked onto the output embedding, chained with the AdamW for the rest of the model. The head's learning rate follows the trunk's schedule, scaled to its own peak. The paper tunes that peak separately and raises momentum from 0.9 to 0.99 (reported). For a fair comparison it gives the AdamW head the same tuning budget: tuning the head learning rate alone helps a little, raising the head's β1\beta_1 to 0.99 closes much of the gap, but every AdamW variant, with or without μ-centering or z-loss, still diverges or time-bombs at the largest trunk learning rate, where SGD-M stays stable (reported, Figure 5).

Three panels over 120k steps with five seeds per method, all in BF16 mixed. Left, eval loss: the baseline median rises late, with individual seeds spiking; mu-centering, z-loss and SGD-M stay near 4.05. Middle, RMS logit quantization error: the baseline rises to about 1, z-loss to about 1e-1, mu-centering to about 4e-2, SGD-M stays flat near 6e-3. Right, KL divergence: the baseline climbs to about 1e-1 while the mitigations stay near 1e-4 to 1e-3.
Three mitigations that act on different things, the loss, the embeddings and the optimizer, all keep the quantization error bounded, and all prevent the time bomb. 28M-parameter models in BF16 mixed at peak learning rate 0.1, five seeds (paper, Figure 7).

The 404M learning-rate sweep, 96B tokens each, is where SGD-M earns its place:

Two panels. Left, final eval loss against trunk peak learning rate from 3e-4 to 3e-1 for four methods: the AdamW baseline ranges 2.87 to 2.95 and time-bombs at 1e-2, 1e-1 and 3e-1; z-loss sits around 2.87 to 2.91; mu-centering around 2.86 to 2.89; LM head SGD-M lowest at about 2.81 to 2.83 until 3e-1, where it rises to about 2.87. Right, output logit norm over 700k steps on a log scale: the baseline grows from about 5e5 to above 1e7, z-loss holds near 1e6, mu-centering and SGD-M stay near 2e5.
Across trunk learning rates, SGD-M on the head gives the lowest final loss at every rate, and with μ-centering holds the logit norm down; the AdamW baseline time-bombs at three of seven rates. 404M-parameter models, 96B tokens (paper, Figure 4).

The table behind the fast-spike version of that sweep (reported, Table 2, final eval loss, † for a run that spiked and did not recover) makes the momentum point concrete:

404M, peak learning rate0.100.1450.210.30
AdamW3.2953.24811.006†28.241†
μ-centering3.1723.2215.144†7.115†
z-loss3.1613.2133.2896.588†
SGD-M head, β = 0.93.3353.3043.2683.244
SGD-M head, β = 0.993.1863.1803.1693.167

At the lowest rate μ-centering and z-loss edge out SGD-M; at the high end they fail and SGD-M does not. β = 0.9 is stable but costs 0.08 to 0.15 nats. The paper credits the momentum's 1/(1−β)1/(1-\beta) amplification, and my ablation below finds that in the toy this is the whole story.

That figure carries the paper's broader claim: z-loss, μ-centering and SGD-M work for the same reason, because each keeps the head in a range where BF16 can still tell logits apart. Their Figure 7 also shows the ordering my toy shows, with SGD-M's error lowest and flattest. At 1.4B parameters, single seed, SGD-M on the head reaches a lower final training loss than AdamW, μ-centering and z-loss, and a Muon trunk with an SGD-M head reaches the lowest of all (reported, Figure 15).

The paper is also careful about a neighbouring explanation. Barron, Gnanapragasam and Tschannen's fixed cross-entropy repairs a different rounding bug, in the loss rather than in the head's GEMM. Swapped in here, it delays but does not prevent time bombs under BF16 true; under BF16 mixed no fixed-softmax run fails within 16B tokens, but its logit norm and quantization error keep growing well above the z-loss runs (reported, Appendix C.4). It treats a symptom of large logits; it doesn't stop them getting large.

What the code does that the paper does not say

I read the training path at commit 76153f0, about 5,400 lines across training/, model.py and diagnostics/. Most of it matches the paper. What the PDF does not tell you:

The amplification hook multiplies whatever mean exists, so on an SGD-M head it does nothing. To run the causal test on a head that never drifts, I injected the drift additively instead.

Three more experiments

For these I rebuilt the toy from its description, since the first script did not survive: same vocabulary, widths, batch, clipping, warmup and 40,000 steps, NumPy 2.5.3 on CPU. The rebuild is close, not identical: irreducible loss 5.026 nats rather than 4.403, FP32 Adam drift −910-910 rather than −1,002-1{,}002. Compare numbers within this section. Every run is measured, one seed unless I say otherwise.

Inject the drift by hand

The paper's evidence for drift, then rounding error, then damage is that the three move together and that precision changes the outcome. A sharper test: take a head that does not drift, add drift and nothing else. The head here trains with SGD-M (momentum 0.99, learning rate 0.03), which keeps the mean at zero to print precision. From step 10,000, after every update, I add the same small vector to every row of EE, pointed along the mean hidden direction. In exact arithmetic that changes nothing; at the ×1 rate it moves the mean logit about −0.025-0.025 a step, the Adam runs' own rate. The metric is the gap: eval loss through the run's own forward pass minus eval loss of the same weights through an FP32 head, logged every 100 steps. Both terms see the same weights, so optimizer noise cancels.

What happened (all measured):

That collapse answers a question I left open in the first version of this article: when does growing error turn into damage? In the toy, at powers of two. Read the ×1 run's error against its mean logit and it is a staircase: about 0.15 for means between 64 and 128, 0.30 between 128 and 256, 0.60 between 256 and 512, 1.2 between 512 and 1,024. Each plateau is BF16's spacing at that magnitude (2e−72^{e-7}: 0.5, 1, 2, 4) divided by 12\sqrt{12}, the RMS of uniform rounding error, to two digits: 0.144, 0.289, 0.577, 1.155. The loss gap climbs the same stairs, roughly fourfold per step (about 0.01, 0.04, 0.15, 0.55), because the KL grows with the square of the spacing. The time bomb in this toy is a series of detonations, one per power of two, each about four times worse than the last. Where logits spread over several binades at once, as in a real model, I would expect the stairs to smear into a ramp; the Adam runs below, with wider logits, already look smoother (reasoned).

How much warning you get

The paper shows the quantization error growing by orders of magnitude before the loss turns. I wanted the number of steps and its spread across seeds, so I ran Adam on the head in BF16 mixed at two learning rates, three seeds each, plus one cosine-decay run, and recorded when each signal crossed a threshold (measured):

runRMS err > 0.1gap > 0.01gap > 0.05final lossFP32 head on same weights
lr 0.01, seed 02,500 (−61)3,500 (−84)7,500 (−174)6.1885.654
lr 0.01, seed 12,500 (−61)4,500 (−106)7,000 (−163)6.1955.629
lr 0.01, seed 22,500 (−61)3,500 (−83)10,000 (−231)6.2375.622
lr 0.005, seed 04,000 (−61)5,000 (−75)11,000 (−159)6.0175.482
lr 0.005, seed 14,000 (−61)5,000 (−75)16,500 (−237)5.9695.486
lr 0.005, seed 24,000 (−61)6,000 (−89)14,500 (−209)6.0045.501
lr 0.01, cosine to 10%2,500 (−61)5,500 (−128)9,500 (−214)5.9535.408
lr 0.01, FP16 mixed21,500 (−491)24,500 (−559)never5.6565.644

Steps at which each signal first crossed its threshold, with the mean logit at that moment in brackets, evaluated every 500 steps. "FP32 head" is the final weights evaluated through an FP32 forward pass.

Three things stand out. The first warning is not statistical: the RMS error crosses 0.1 at a mean logit of −61-61 in every BF16 run, whatever the seed, learning rate or schedule, because it is a property of the BF16 grid, not of the training. The first damage, a gap of 0.01 nats, follows 1,000 to 3,000 steps later (mean −75-75 to −128-128), and a gap of 0.05 comes 4,500 to 12,500 steps after the warning.

Second, the final weights are not ruined. Evaluated through an FP32 head, every BF16 run sits within 0.03 of the matching FP32 run (5.644 at learning rate 0.01, 5.473 at 0.005). The damage is almost all in the forward pass, so here you could recover by centring the head, without rewinding. Whether that holds in a real network, whose trunk has trained against wrong probabilities for thousands of steps, the paper does not test.

Third, the loss alone is a poor alarm. A detector in the spirit of the paper's (three consecutive evaluations 0.05 above the running best) fires on the FP32 runs too, because the toy's eval loss wanders that much. The gap is zero to four decimals in every FP32 run. The repository logs it as cross_entropy_difference_native_minus_fp32; it is what I would alert on.

The widget plots what the paper's Figure 18 plots: the error divided by the spread of the logits, against r=∣μz∣/σzr = |\mu_z| / \sigma_z. The dashed curves are one-shot rounding of Gaussian logits at the same rr, computed separately, not fitted.

Every run, Adam or injected, BF16 or FP16, lands on its format's rounding curve once rr passes about 10 (measured). At r≈90r \approx 90 the Adam runs sit at a normalised error of 0.125, and one-shot rounding of Gaussian logits at the same rr gives about 0.13; the injected ×1 run at r=389r = 389 sits at 0.645, on the same curve. Below r≈10r \approx 10 the runs sit above it, because there the rounding of the GEMM's inputs, which the curve leaves out, is the larger term. So the paper's Figure 18 relationship is not a trend to fit: above small rr it is the BF16 grid, and a run's error is predictable from its mean and spread alone. FP16 sits a factor of eight lower all along, which is why neither FP16 run's loss moved more than 0.02 from its FP32 twin.

What in the optimizer matters

The paper's tuning study (Figure 5 and Table 2) makes three claims about the head's optimizer: SGD-M needs momentum 0.99 rather than 0.9; raising AdamW's β1\beta_1 to 0.99 closes much of the gap; and the cause is Adam's per-coordinate scaling. The toy can test the shape of each, all in BF16 mixed:

head optimizerη\etaeffective step η/(1−β)\eta/(1-\beta)final mean logitfinal lossFP32 head on same weights
Adam (β1\beta_1 0.9, β2\beta_2 0.95)0.01n/a−8986.1885.654
Adam0.003n/a−3875.5535.426
Adam, β1\beta_1 0.990.01 / 0.003 / 0.001n/a−6,898 / −1,828 / −30548.4 / 30.2 / 14.4diverged
Adam, β1\beta_1 0.99, β2\beta_2 0.9990.01n/a−215.4095.410
coupled Adam (one vv per column)0.01n/a0.05.6165.615
Adam + μ-centering0.01n/a0.05.6455.644
SGD-M, β 0.90.030.30.05.1045.104
SGD-M, β 0.990.0110.05.1595.159
SGD-M, β 0.990.0330.05.4175.417
SGD-M, β 0.90.330.05.4825.483
SGD-M, β 0.990.1100.06.5706.571

All BF16 mixed, 40,000 steps, one seed, measured. For reference, FP32 Adam at 0.01 ends at 5.644 and coupled Adam in FP32 at 5.615.

What the table says, in order of how much I trust it:

  1. The per-row divisor is the cause. Coupled Adam keeps Adam's momentum, its bias correction and its normalisation, and changes one thing: every row in a column shares a second moment. The mean stays at zero to print precision and the BF16 run ends where the FP32 one does (5.616 against 5.615). This is the term-by-term prediction, and the experiment the paper did not run: you can keep an adaptive optimizer on the head if it stops adapting per token.
  2. For SGD-M, the momentum is a learning-rate multiplier and nothing else. Matched on η/(1−β)\eta/(1-\beta), momentum 0.9 and 0.99 land within 0.07 of each other (5.482 against 5.417 at an effective step of 3), and the best run of all is β 0.9 at an effective step of 0.3. Matched on η\eta, 0.99 looks far better than 0.9, which is how Table 2 compares them. The paper's own explanation is the 1/(1−β)1/(1-\beta) factor; in the toy, that factor is the whole effect. A real head's gradient is less stationary, but I would check the effective step before concluding that 0.99 is special.
  3. Adam with β1=0.99\beta_1 = 0.99 and β2=0.95\beta_2 = 0.95 diverged at every learning rate I tried, down to 0.001, and in FP32 too (a 400-step probe trained to 5.61 by step 200, then the mean logit fell from −3-3 to −42-42 in the next 200 steps). This is not a precision effect. When β1>β2\beta_1 > \sqrt{\beta_2} (here 0.99>0.9750.99 > 0.975), a gradient that is shrinking leaves the first moment remembering its old size while the second moment forgets it, and m^/v^\hat m / \sqrt{\hat v} grows by about 0.99/0.9750.99 / 0.975, 1.5%, per step. A rare token's gradient shrinks precisely because its logit is being pushed down, so the push feeds itself. With β2=0.999\beta_2 = 0.999 the same β1\beta_1 is stable and nearly stops the drift (−21-21 by the end). The paper reports that pair working on its head, with a tuned learning rate; my only explanation is that a real head's gradients do not decay as cleanly as a frozen-trunk toy's. I would not use it without a check.
  4. Learning rate. Halving it from 0.01 to 0.005 cut the drift to 63% (−565-565), not 50%, and 0.003 to 43% (−387-387). Lower rates slow the drift but less than proportionally, so they buy fewer steps than you might hope (measured; the exponent is a property of this toy).

What each fix costs

The paper's practical comparison (reported, Section 3.4), with my notes on scale (reasoned):

Gemma 2's logit soft-capping is mentioned in the related work and not tested.

Where this sits

The pieces are older than the paper, and it says so. Biś et al. (2021) documented a common shift in Transformer embeddings; Stollenwerk and Stollenwerk (2025) tied output-embedding drift to Adam's preconditioner and proposed coupled Adam; Stollenwerk et al. (2026) proposed μ-centering. Kunstner et al. (2024) argued that Adam beats gradient descent on language models largely through heavy-tailed class imbalance: the same per-row adaptivity that causes the drift, and the tension SGD-M pays for with β=0.99\beta = 0.99. z-loss comes from PaLM (Chowdhery et al., 2023); Wortsman et al. (2024) studied it against output-logit divergence with the elevated-learning-rate small models this paper borrows.

What is new is the link from a harmless drift to a failure through the forward GEMM's rounding, and the evidence that all three known mitigations work by bounding it. It joins a cluster of 2026 papers on rounding that breaks softmax invariants. Barron, Gnanapragasam and Tschannen and Liu et al. found the cross-entropy gradient rounding to zero at high confidence, behind grokking's slingshot spikes. Delta-Matching covers the attention case, where BF16 in the fused backward breaks the softmax gradient's row sum. This paper is the forward-pass, LM-head member, and its Appendix C.4 shows the members are distinct.

On this site, the Open-1B training config carries z-loss at 10−410^{-4}, the paper's value.

What I could not check

What I would do with this

If you train past compute-optimal in BF16, log three numbers you probably don't log today: the mean output logit, RMS(zbf16−zfp32)\mathrm{RMS}(z_{\mathrm{bf16}} - z_{\mathrm{fp32}}), and the loss through the BF16 head minus the loss through an FP32 copy of it, all on a held-out batch. The last two cost one extra FP32 GEMM per logging step. In both the paper's runs and mine the error climbs long before the loss does, and in mine the loss gap gave thousands of steps of warning without the false alarms the loss itself raises. Watch the mean against powers of two: in BF16, each one it crosses doubles the rounding error. If the mean is drifting, remove it: μ-centering is one subtraction per step, and SGD-M on the head stops the drift at its source. Raising the head's precision to FP32 also works, at a throughput cost.

For the optimizer side of the same story, see Muon, which the paper pairs with an SGD-M head at 1.4B. For another rounding error that breaks a sum-to-zero invariant, see Delta-Matching, where the softmax backward's row sum goes wrong in FP8 attention.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Time-bomb loss spikes: Adam drifts the LM head, BF16 pulls the trigger", ai.satyajit.si, November 2026.

bibtex
@misc{ghana2026lmheadlossspikes,
  author = {Satyajit Ghana},
  title  = {Time-bomb loss spikes: Adam drifts the LM head, BF16 pulls the trigger},
  url    = {https://ai.satyajit.si/articles/lm-head-loss-spikes},
  year   = {2026}
}
share