system_ml / 24 · numerical stability lesson 24 / 26

Numerical stability & convergence

Parts I–V made the run fast. This lesson keeps it from diverging. At 70B / trillions-of-tokens / three weeks, a single loss spike can torch days of compute — so convergence is active defensive engineering, not luck.

The thing you are actually afraid of

Lesson 23 taught you to diagnose a run that is slow. This one teaches you to survive a run that goes divergent. They are different emergencies. A slow run wastes money at a steady rate; a divergent run can throw away every step since the last good checkpoint in a single update. On a 1024-GPU job that has been grinding for a week, "the loss went to NaN at 3am" is not a curiosity — it is the most expensive event in the lifecycle of the run.

Here is the shape of it. You are watching the loss tick down — 2.41, 2.40, 2.40 — and then one step posts 2.40 → 11.7, or 2.40 → NaN. Two outcomes follow. Sometimes the loss recovers over the next few hundred steps and rejoins its old trajectory, as if nothing happened (a survivable spike). Sometimes it never comes back: the loss pins flat-high, or every subsequent step is NaN because a NaN in the weights poisons every matmul forever (a fatal spike). The whole job is now generating garbage gradients on garbage weights, and it will keep doing so, burning $X00/hour, until a human notices.

What causes a spike? It is almost always one of a small set of dynamic-range failures:

The rest of the lesson is the standard kit of defenses, each aimed at one of these. None of them is exotic. The expensive mistake is not knowing which one you forgot to turn on — and, in a distributed run, getting one of them subtly wrong so that every rank does something slightly different.

Defense 1 · Global gradient-norm clipping

The workhorse. Before the optimizer step, measure the L2 norm of the entire gradient vector and, if it exceeds a threshold c, scale the whole vector down so its norm is exactly c:

g  ←  g · min(1,  c / ‖g‖₂)

with c ≈ 1.0 for most LM pretraining. Two properties make this the right tool. First, it caps the worst case: no single step can move the weights further than η · c in norm, no matter how violent the batch. Second, it preserves direction — it scales the entire gradient by one scalar, so the relative sizes of per-parameter updates are untouched. You are throttling the magnitude of one bad step, not reshaping it.

The word doing all the work is global. ‖g‖₂ is the norm over all parameters at once:

‖g‖₂  =  sqrt( Σp Σi gp,i² )

— one number for the whole model, summed across every parameter p and every element i within it.

The systems trap
Under FSDP (lesson 05) and tensor parallel (lesson 06), the parameters — and therefore the gradients — are sharded across ranks. No single rank holds the full gradient vector, so no single rank can compute ‖g‖₂ by itself. The global norm is the square root of a sum that lives on every rank. Computing it is therefore itself a collective: each rank sums the squares of its shard, and those partial sums are AllReduced (lesson 02).

Written out, the correct distributed clip is a three-line dance:

local_sq  =  Σi ∈ shard gi²
global_norm  =  sqrt( AllReducesum(local_sq) )
gi  ←  gi · min(1,  c / global_norm)

The AllReduce in the middle is the whole point. Each rank contributes a single scalar (its local sum of squares), gets back the global sum, takes the square root, and now every rank has bit-identical global_norm and applies bit-identical scaling to its own shard. Skip the collective and clip on the local norm instead, and every rank computes a different scale factor. The "model" the optimizer then steps is no longer a single coherent model — each shard has been throttled by a different amount. Nothing crashes. The loss just quietly stops converging, or converges to something worse, and you spend two days bisecting your config before you find it. This is one of the most common silent bugs in a from-scratch trainer; the framework helpers (torch.nn.utils.clip_grad_norm_ under FSDP, Megatron's clip) exist precisely to get the collective right for you.

Cost-wise the clip is cheap: one AllReduce of a single scalar per step. Latency-bound (lesson 02's α term), negligible bytes. You will not see it in the MFU budget. You will absolutely see its absence in the loss curve.

Watching one spike happen, in numbers

Make it concrete. Say the model has been training with a healthy gradient norm hovering around ‖g‖ ≈ 0.8 and a learning rate η = 3 × 10⁻⁴. Each step therefore nudges the weights by roughly η · ‖g‖ ≈ 2.4 × 10⁻⁴ in norm — small, steady, exactly what convergence wants. Now an outlier batch arrives and produces ‖g‖ = 40 (a 50× spike — well inside the tail you will actually see over millions of steps).

Without clipping, that one step moves the weights by η · 40 ≈ 1.2 × 10⁻² in norm — fifty times the usual jump, in a direction set by a single pathological batch. That is enough to fling the parameters out of the basin the optimizer had been carefully descending. The next forward pass runs on those displaced weights, produces an even larger loss, and therefore an even larger gradient; the step after that is larger still. This is the positive-feedback loop that turns one bad batch into a runaway: big step → worse weights → bigger gradient → bigger step. A few iterations of that and an activation overflows to inf, the inf contaminates a matmul, and the loss is NaN for the rest of the run.

With clipping at c = 1.0, the same spike is scaled by min(1, 1.0/40) = 0.025 before the step. The update norm becomes η · 1.0 = 3 × 10⁻⁴ — back in the normal range. The bad batch still nudges the weights in its (bad) direction, but only by a normal-sized amount, which the next few good batches easily absorb. The spike shows up as a one-step blip in the loss and then vanishes. The clip converted a fatal event into a non-event by capping a single scalar. That is the entire value proposition, and it is why c is one of the few hyperparameters you should set conservatively (≈ 1.0) and rarely touch.

Note what clipping does not do: it does not change the direction of the update, only its length. All parameters are scaled by the same 0.025, so the relative shape of the step is identical to the unclipped step — you are stepping the same way, just less far. That direction-preservation is why global-norm clipping is gentle enough to leave on for the entire run without hurting convergence, unlike per-element gradient value clipping (clamping each gi to [−c, c] independently), which distorts the direction and is rarely used for large LMs.

Defense 2 · The learning-rate schedule

A constant learning rate is a stability liability at both ends of training, for two different reasons. The schedule — warmup, then decay — fixes both.

Warmup: why the first thousand steps are dangerous

Early in training two things are simultaneously true. The model is at a random initialization, far from any good basin, so the gradient direction is noisy and frequently bad. And Adam's second-moment estimate v — the running average of squared gradients that sits in the denominator of the update — has only seen a few batches, so it is a poor estimate of the true gradient scale. A full learning rate multiplied by a confidently-wrong direction with a mis-calibrated adaptive scale is exactly the recipe for a step that launches the weights out of the sensible region and never recovers.

Warmup ramps the LR linearly from (near) zero up to its peak over the first few hundred to few thousand steps:

η(t)  =  ηpeak · min(1,  t / twarmup)    for   t ≤ twarmup

This buys time. The early steps are tiny, so even bad directions barely move the weights; by the time the LR reaches its peak, the second moments have stabilized and the model sits somewhere reasonable. Typical warmup is 1–3% of total steps (a 500B-token run might warm up over ~2000 steps). Skipping warmup is one of the classic ways to make a large model diverge in the first 100 steps — it is not a luxury, it is load-bearing.

Decay: cosine vs WSD

Late in training you want the opposite: a small LR so the optimizer settles into a minimum instead of bouncing around it. Two schedules dominate.

Connection to lesson 11 (gradient accumulation)
The peak LR is coupled to the global batch size. The familiar rule — scale LR linearly with batch — is an SGD result and only weakly true for AdamW, where the dependence is closer to a square-root and the constants are recipe-specific. Two further wrinkles bite in practice: the largest batch you can use efficiently (the critical batch size Bcrit) grows as the loss falls, so a batch that is fine at the end may be too large at the start; and a bigger batch averages out gradient noise, which makes spikes rarer but each surviving spike larger. Treat LR and batch as one jointly-tuned knob, not two independent ones.

Defense 3 · Bounding the logits

The softmax is the single most overflow-prone operation in a transformer, and it appears in two load-bearing places: the attention scores and the final vocabulary projection. Both involve an exponential of a dot product, and an exponential of an unbounded input is an overflow waiting to happen.

z-loss: pin the softmax normalizer

Cross-entropy over a vocabulary computes log Z = log Σv exv, the log of the softmax normalizer. Mathematically the loss is invariant to adding a constant to all logits, so nothing in the objective stops log Z from drifting — the logits can all creep upward together over training while the predicted distribution stays the same. That drift is harmless in exact arithmetic and dangerous in low precision: a large Z summed over 128k+ vocabulary entries in bf16 (with its 8-bit exponent but only 7 mantissa bits) loses precision in the sum, and in the worst case exv overflows outright.

z-loss adds a tiny auxiliary penalty that gives the model a reason to keep log Z near zero:

L  =  LCE  +  α · (log Z)²

with α small (≈ 10⁻⁴). It barely touches the loss value, but it actively pulls the normalizer back to a sane magnitude every step, which keeps the exponentials in range. This is standard in large-vocab LM heads and is essential for the MoE router (lesson 09): the router is a tiny softmax over experts whose logits, if left unconstrained, drift until routing collapses or overflows. A router z-loss is one of the first things you add when MoE training gets unstable.

Why large vocab + bf16 is the specific risk
bf16 keeps fp32's range (it won't overflow as easily as fp16) but has only 7 mantissa bits. The danger over a 128k-vocab softmax is not a single overflow so much as precision loss in the reduction: summing 128k exponentials of wildly different magnitudes in 7-bit-mantissa arithmetic, the small terms vanish and log Z is computed inaccurately. Best practice is to compute the LM-head logits and the softmax/cross-entropy in fp32 (lesson 17's "keep reductions in higher precision" rule) and add z-loss to stop the drift in the first place. Belt and suspenders.

Attention soft-capping and QK-norm

For the attention logits, two related stabilizers cap the dot products before the softmax sees them:

All three — z-loss, soft-capping, QK-norm — are the same idea applied in different spots: do not let a quantity that gets exponentiated grow without bound.

Defense 4 · The mixed-precision interaction

Lesson 17 was about the formats. Here is how each one behaves under the stability lens, because precision choice and spike-proneness are the same conversation.

FormatSpike profileRequired defense
bf16Mostly fine — full fp32 8-bit exponent, so gradients rarely overflow or underflowKeep reductions (norm, softmax, LM head) in fp32; no scaler needed
fp16Underflow-prone (5-bit exponent); small gradients flush to zeroDynamic loss scaling (below) — non-optional
fp8Most spike-prone — tiny range; weights/grads can overflow per-tensorPer-tensor / per-block scaling; keep norms, router, LM head in higher precision

fp16 → dynamic loss scaling. Multiply the loss by a scale S (start ~2¹⁵) before backward so gradients land in fp16's healthy range instead of underflowing; unscale by S before the optimizer step. The "dynamic" part is itself a stability mechanism: if any gradient overflows to inf, GradScaler detects it, skips the entire step, and halves S; after many clean steps it doubles S back up. That "skip the step on overflow" reflex is the same recovery instinct as Defense 5 — fp16 just bakes it into the scaler.

bf16 → mostly nothing. Its 8-bit exponent matches fp32, so the underflow problem that motivates the scaler disappears. This is why bf16 became the default: you delete the entire loss-scaler apparatus. You still keep the reduction-heavy ops in fp32.

fp8 → the careful one. Smallest range, so it is the most likely to overflow and the hardest to keep stable. You keep per-tensor or per-block scale factors (lesson 17), and you deliberately leave the most numerically sensitive components — the normalization layers, the MoE router, the embedding and LM-head — in bf16 or fp32. fp8 is for the bulk matmuls, not for the parts where a small error becomes a spike.

And underneath all three, the fp32 gradient accumulation rule from lesson 11: when you accumulate gradients over many micro-batches, accumulate in fp32. The accumulator is summed over potentially hundreds of micro-batches; doing that sum in bf16 loses the small contributions exactly as a bf16 vocabulary sum does. The unifying principle across this whole section is one sentence:

The mixed-precision stability principle
Keep the reduction-heavy and large-dynamic-range operations in higher precision — gradient norm, softmax/cross-entropy, layer norms, gradient accumulation, the LM head — and let the low precision live in the bulk matmuls. Spikes are born in the reductions; defend the reductions.

Defense 5 · Detection and recovery

Even with every static defense in place, the heavy tail eventually produces a spike. So the final layer is operational, and it ties directly to lesson 26 (fault tolerance): watch every step, and react automatically.

Monitor two scalars on every optimizer step — they cost nothing because you already computed them:

The grad-norm is the better early-warning signal because it spikes before the loss does — a bad batch shows up as an anomalous norm one step before that step's update corrupts the loss. The standard trigger compares the current norm to a running statistic:

spike  ⟺  ‖g‖  >  k · medianrecent(‖g‖)

with k ≈ 3–10. A median (or other robust statistic) is used rather than a mean precisely because the mean is contaminated by the spikes you are trying to detect. On a trigger, two responses, in increasing order of severity:

  1. Skip the step. Throw away this batch's gradient entirely — do not call optimizer.step() — and continue from the same weights with the next batch. The offending data is gone, the weights are untouched, and you have lost one batch of compute. This handles the common case (a single outlier batch) almost for free.
  2. Roll back. If the loss has already gone NaN (a step slipped through, or several bad steps compounded), the in-memory weights are poisoned and skipping won't help. Reload the last good checkpoint, and — crucially — skip past the data shard that caused it so you don't immediately re-trigger on the same poison batch. This is exactly lesson 26's checkpoint-and-resume machinery, repurposed from "a GPU died" to "the math died."
The economics
Logging two scalars and a skip-on-spike rule is a few lines of code and zero measurable runtime. The downside it insures against is days of wasted compute on a 1000-GPU cluster — a run that quietly NaN'd at 3am and was discovered at 9am has burned six hours × 1000 GPUs for nothing. Detection-and-skip is the highest return-on-effort defense in this entire lesson. Build it before you scale up, not after your first all-nighter.

Widget · the loss-spike sandbox

A simulated 70B training run. The blue curve is a smooth loss decay; every so often an outlier batch injects a spike. Toggle the three defenses and move the sliders to feel how they interact. With all defenses off and a healthy LR, a single big spike sends the run to NaN and it flatlines — that flat red line is days of compute gone. Turn on clipping (caps the step), warmup (survives the fragile early phase), and z-loss (tames the spike magnitude), and watch the same spikes become survivable wobbles. Try raising the LR with defenses off, then turning them on.

Loss-spike sandbox · defenses on vs off
Smooth decay + stochastic spikes. Clipping bounds each step; warmup protects the early steps; z-loss shrinks spike magnitude. With everything off and a high LR the run diverges (flat-high / NaN). The same seed is used so you compare like-for-like.
final loss
spikes survived
fatal spike
verdict

Widget · global-norm clipping as a collective

Per-step gradient norms drawn as bars over a stretch of training. Most steps are well-behaved; a few are outliers (sampled deterministically from the step index, so the picture is stable as you drag). The dashed line is the clip threshold c. Bars above it are clipped down to c (the darker cap shows how much was shaved); bars below pass through untouched. The annotation is the load-bearing systems fact: that single threshold comparison requires every rank to first agree on ‖g‖ via an AllReduce.

Global gradient-norm clipping · ‖g‖ = sqrt(AllReduce(Σ local‖g‖²))
Bars = per-step global gradient norm. Dashed line = clip threshold c. Above-threshold steps are scaled down to c (you keep the direction, lose the magnitude). Drag c and watch how many steps get clipped. The norm itself is a collective — a single AllReduced scalar per step.
steps clipped
max norm (before)
max norm (after)
collective / step
1 AllReduce

Initialization & residual scaling — divergence you can prevent before step 1

Every defense so far reacts to a spike during the run. Initialization is the one lever that decides whether the run is stable before step 0. Init sets the variance of activations and gradients at the very first forward/backward pass; get it wrong and the model diverges before warmup (Defense 2) ever has a chance to save it. The baseline rule is variance-preserving: draw weights with std ∝ 1/√(fan_in) (Xavier/Kaiming). That keeps the activation variance ≈ 1 as a signal passes through a layer, so neither activations nor gradients shrink toward zero or blow up toward overflow as they propagate.

The depth problem: a growing residual stream

One layer being well-conditioned is not enough. In a residual network the residual stream variance grows with depth L, because every block adds its output back into the stream — x ← x + f(x), repeated L times. Variances of independent contributions add, so after L blocks the stream variance scales like L. The fix is to scale each residual-branch output down by 1/√(2L) (the GPT-2 trick) or use DeepNorm, so the accumulated stream variance stays bounded as L grows.

Failure mode
Skip the residual scaling and a deep model (80+ layers) sees its activations grow geometrically up the stack. The activations at the top layers overflow the format's range, the loss spikes, and the run typically dies in the first hundred steps — before warmup has even reached peak LR. This is a pure depth effect: the same recipe is perfectly stable at 12 layers and divergent at 96.

μP: hyperparameters that transfer across width

μP (maximal update parametrization) goes one step further. It parametrizes the init and the per-layer learning rates so that the optimal hyperparameters are invariant to width. You tune LR and init on a small, narrow proxy model, then transfer those exact values to the full-width model without re-tuning. This is how large runs avoid an LR sweep at full scale — sweeping a 70B model is the expense you cannot afford, so you sweep a cheap proxy and let μP carry the result across.

LeverDivergence it prevents
Init std ∝ 1/√(fan_in)Per-layer activation/gradient blowup (or vanish) at step 0
Residual scale 1/√(2L) / DeepNormDepth-driven residual-stream blowup in deep (80+ layer) models
μPLR-transfer mistuning — wrong LR/init when scaling width up

These are the cheapest defenses of all: they cost zero runtime and are decided once, in the config, before the GPUs (lesson 01) ever spin up. A run that is initialized right rarely needs the reactive defenses to fire at all.

Putting the defenses in order

If you are bringing up a new large-model run, this is the order to add the defenses and the failure each one removes:

DefenseRemovesSystems cost
Global grad-norm clip (c≈1)Single-step blowups from outlier batches/grads1 scalar AllReduce/step
LR warmupEarly-training divergence (cold Adam state)Free
LR decay (cosine / WSD)Late-training bouncing around the minimumFree
z-lossSoftmax-normalizer drift / overflow (vocab, router)One extra scalar in the loss
Logit soft-cap / QK-normAttention-logit blowupA few elementwise ops
fp32 reductions + (fp16) loss scalerPrecision overflow/underflow in reductionsFormat-dependent
Grad-norm monitor + skip/rollbackWhatever the static defenses missTwo logged scalars

Notice how cheap the column on the right is. Almost none of this shows up in your MFU. Convergence engineering is not a throughput tax — it is insurance with a near-zero premium and a payout measured in saved GPU-days.

Takeaway
Convergence at 70B scale is defensive engineering, not luck. Clip the global gradient norm — and remember that the norm is a collective (an AllReduce of each rank's local sum-of-squares, lesson 02), so clip on the global value or every rank diverges silently. Warm up the LR through the fragile early phase, then decay it (cosine or WSD). Bound the logits with z-loss and soft-cap/QK-norm so nothing that gets exponentiated runs away. Keep the spike-prone reductions in higher precision (lesson 17). And monitor loss + grad-norm every step so you can skip a bad batch or roll back a poisoned checkpoint. The next lesson keeps the GPUs fed (25 · the data pipeline); lesson 26 lets you recover when the hardware itself dies.