NaN detection¶
NaNDetector
watches for NaN / Inf in the loss and the gradient norm every step. When it fires, the
training loop zeros gradients, skips the optimizer step, and increments
a consecutive-NaN counter. The skipped step still counts as a step (see
What a skipped step still does). If
too many consecutive NaNs pile up, the loop stops so a human can roll
back to an earlier checkpoint.
Wiring¶
# training/entry.py::run_training
nan_detector = NaNDetector(action="warn", max_consecutive=10)
...
# Inside the training loop, after backward:
if not nan_detector.check_loss(result.loss, step, grad_norm=result.grad_norm):
optimizer.zero_grad()
if nan_detector.should_rollback:
logger.error("Too many consecutive NaNs — stopping")
break
_advance_lr_schedule(session) # scheduler.step(), then the phase lr_scale
step += 1
_apply_step_transitions(session, step) # FreezeStages and data phases due at step
if _shutdown_if_requested( # saves only finite weights
session, step, tokens_seen, saved_this_step=False, nan_skipped=True
):
break
continue
Two things to note:
action="warn"andmax_consecutive=10are hardcoded intraining/entry.py— not exposed as TOML config. If you want"skip"or"raise"behavior, edit the script or construct the detector yourself.The gradient norm is checked with the loss. A finite loss can come with non-finite gradients: a micro-batch whose labels are all
-100has an exact zero cross-entropy loss whatever its activations, and without the norm check its NaN gradients would reachoptimizer.step(). The norm goes into the same all-reduced flag as the loss, so every rank skips the step together even when a non-finite gradient reaches only some ranks’ norms.check_gradientsis not called by the training loop. It returns a rank-local verdict; the gradient norm check above covers non-finite gradients.
What a skipped step still does¶
A skipped step has consumed its batch, so it counts as a step: the step counter advances, and the LR schedule and the step-keyed transitions move with it. The update, the per-step reporting and every periodic tick except the shutdown check are dropped:
Work |
On a skipped step |
|---|---|
Optimizer update |
Skipped. Gradients are zeroed; weights and optimizer state stay as they were, except that the sigmoid router updates its |
|
Skipped. |
LR schedule |
Advanced: |
|
Applied. A |
Shutdown check |
Run. A pending SIGTERM / SIGUSR1 writes the emergency checkpoint at the new step, unless the weights are not finite (see A shutdown after a bad update), then the loop stops. |
Eval, NCCL health check, scheduled checkpoint |
Skipped until their next interval. |
Profiler step |
Skipped; the profiler steps on the next finite step. |
The checkpoint holds the weights as the skipped step left them, and the schedule, freeze state, data phase and data position are all those of the new step, so resuming from that emergency checkpoint continues as the uninterrupted run would have, with two exceptions:
Partway through a run of NaN steps. The consecutive-NaN count is not checkpointed, so the resumed run counts from zero and allows a full
max_consecutivemore before it stops. Tracked in #480.Before the run’s first optimizer update. The checkpoint holds no optimizer state, so the resumed optimizer keeps the LR the scheduler set at step 0 (0 under the default warmup) for its first update. That update still advances the optimizer’s moments and step count, so the resumed run does not rejoin the uninterrupted one, though its LR follows the schedule from the next step on. Tracked in #481.
Two edge cases:
The NaN that trips the stop.
should_rollbackis checked first, so that step ends the run without a checkpoint, even with a shutdown pending (see Rollback recommendation).A fresh run whose first step is skipped. PyTorch warns “Detected call of
lr_scheduler.step()beforeoptimizer.step()”. That is expected: the skipped step uses up its LR value like any other step.
A shutdown after a bad update¶
A run of NaN steps can start with a bad update: an optimizer step that
writes NaN / Inf into the weights although its loss and gradient norm
were finite, so the NaN check let it through, and every loss after it
is NaN. Non-finite gradients never get that far: they make the gradient
norm non-finite, and the check skips that step. The steps that follow
are skipped, but the weights they leave are not finite, and an
emergency checkpoint of one of them would become latest and count
toward checkpoint.keep_last_n. With keep_last_n = 1 it would prune
the last good checkpoint, and the requeued run would resume from NaN
weights.
So before it saves a skipped step, the shutdown check calls
params_are_finite (kempnerforge/resilience/health.py): each rank
checks the parameters it holds, and one all-reduce gives every rank the
same answer. The check runs only when a shutdown is pending on a
skipped step. If any parameter is not finite, the loop logs an ERROR,
writes no checkpoint, finishes the shutdown and stops: latest still
names the last checkpoint saved, and no older one is pruned to make
room. A skipped step whose weights are finite, such as one whose NaN
came from the data, writes its emergency checkpoint as before. Like any
save, that checkpoint becomes latest and counts toward
checkpoint.keep_last_n, so with keep_last_n = 1 it replaces the
checkpoint saved before the NaN run (see
Rollback recommendation).
The bad update’s own step passed the NaN check, so a shutdown or a scheduled save on that step is not checked and writes the weights it produced.
Three actions¶
# kempnerforge/resilience/health.py — NaNDetector.__init__
if action not in ("warn", "skip", "raise"):
raise ValueError(f"Invalid NaN action: {action!r} (expected warn/skip/raise)")
Action |
Behavior |
When to pick it |
|---|---|---|
|
Log a warning, return |
Default. NaN-tolerant training where a single bad step shouldn’t kill the run. |
|
Same as |
Same as warn; the two are nearly equivalent since the caller already skips the optimizer step on |
|
Raise |
Early development when you want the run to die loudly on first NaN. |
In the shipped training loop, "warn" and "skip" produce the same
outcome (the caller already zeros grads and advances the step). The
distinction exists for callers that only call check_loss and let the
return value drive their own logic.
Cross-rank sync¶
The critical detail. On a distributed run, a NaN on one rank must stop every rank — otherwise one rank zeros grads while the others keep optimizing and FSDP gets an inconsistent view of the parameter sharding on the next step.
# kempnerforge/resilience/health.py — check_loss
bad_loss = not _is_finite(loss)
bad_grad_norm = grad_norm is not None and not _is_finite(grad_norm)
local_nan = bad_loss or bad_grad_norm
if dist.is_initialized():
nan_flag = torch.tensor([1.0 if local_nan else 0.0], device="cuda")
dist.all_reduce(nan_flag)
any_nan = nan_flag.item() > 0
else:
any_nan = local_nan
Four bytes per step — dwarfed by the gradient all-reduce. The
all_reduce is a SUM (the default) — any rank with NaN lifts the
flag above zero on every rank.
If any_nan is true but local_nan is false, the log line mentions
“detected on another rank” so you can correlate which rank blew up from
per-rank logs.
State tracking¶
@dataclass
class NaNState:
consecutive_nans: int = 0 # reset on a good step
total_nans: int = 0 # monotonic across the run
last_good_loss: float = inf # last finite loss value
last_good_step: int = 0
nan_steps: list[int] = [] # capped at max_history (default 100)
consecutive_nans resets on any finite step. It’s the one that drives
rollback: when it reaches max_consecutive (default 5 in the class, 10
in the shipped config), should_rollback flips to True.
nan_steps is a diagnostic — a post-hoc “which steps actually failed”
list. Capped at 100 entries to bound memory on pathological runs.
Rollback recommendation¶
@property
def should_rollback(self) -> bool:
return self.state.consecutive_nans >= self.max_consecutive
When this trips, the training loop stops — it doesn’t roll back automatically:
# training/loop.py::run_training_loop
if nan_detector.should_rollback:
logger.error("Too many consecutive NaNs — stopping")
break
Rolling back is manual: resubmit with a new, empty checkpoint.dir and
checkpoint.load_path pointing at an earlier step_N directory; with
exclude_from_loading empty this restores its full state. Its
train_state.pt restores the scheduler’s base LR whatever
exclude_from_loading says, so a lower optimizer.lr in the resubmitted
config does not take effect; schedule settings such as
scheduler.warmup_steps do, from the restored step on. Keep the old
checkpoint.dir and the run does not roll back: a checkpoint there takes
precedence over load_path, so it resumes from the newest one, and rank 0
warns that load_path is not used. The reason it’s not automatic is that
the source of the NaN determines what’s safe:
LR spike — lower the LR and restart from an earlier checkpoint. A rollback through
load_pathbrings back the saved base LR, so a loweroptimizer.lrdoes not take effect there. A longerscheduler.warmup_stepsdoes, but it can raise the LR as well as lower it. One that ends before the restored step starts the decay later, which never lowers the LR there. One that extends past it scales the saved base LR bystep / warmup_steps(with the defaultwarmup_start_factor = 0), so right after the restored step it lowers the LR only when that ratio falls below the old schedule’s factor there (its LR at that step over the base LR). Restoring a 1000-step cosine run (optimizer.lr = 3e-4,min_lr_ratio = 0.1, 10 warmup steps) at step 500 with a warmup of 100 or 800 raises the LR at step 501 from 1.67e-4 to 1.88e-4; only a warmup longer than about 900 steps lowers it.Bad data — skip the offending shard, restart from the same checkpoint.
FP8 overflow — reduce
distributed.fp8_intervalor disable FP8 for sensitive layers, restart.
A rule-of-thumb: if you hit should_rollback, don’t resume from the
most recent checkpoint. It holds the weights from just before the NaN
storm, so whatever state caused the explosion is baked in. That also
holds for an emergency checkpoint a shutdown wrote during the storm:
skipped steps leave the weights as they were (the sigmoid router’s
expert_bias aside), so it carries the pre-storm weights under a later
step. A storm that a bad update started leaves no such checkpoint (see
A shutdown after a bad update).
Manual use¶
Call the detector outside the training loop for ad-hoc checks:
from kempnerforge.resilience import NaNDetector
det = NaNDetector(action="raise", max_consecutive=1) # fail fast
for step, batch in enumerate(loader):
loss = model(batch).item()
det.check_loss(loss, step) # raises on first NaN
check_gradients(model, step) does the same but walks
model.named_parameters() and returns False on the first NaN grad.
The action="raise" mode raises RuntimeError instead of returning
False (the warning case still returns False).
params_are_finite(model, device) from
kempnerforge.resilience.health checks the parameters themselves and
returns False if any rank holds a NaN / Inf one. When running
distributed it all-reduces a flag on device (CUDA under NCCL), so
every rank must call it.
Limitations¶
NaN action isn’t in config. Fixed at
"warn"/max_consecutive=10inrun_training(kempnerforge/training/entry.py). Change the source if you need something different.No gradient scan in the hot path.
check_gradientsexists but isn’t wired in. Add it if you’re hunting a specific gradient pathology; expect a small per-step cost (oneisfinite+.all()per parameter).Loss is already a CPU scalar.
check_lossgets a Python float, so the distributed sync creates a new tensor on CUDA and all-reduces it — a negligible one-off each step but not free. If you optimize this path, aggregate the NaN flag into the existing grad-norm all-reduce.
See also¶
SLURM preemption — the other “stop cleanly” mechanism; both rely on the training loop polling a flag between steps.
GPU health — coarser health check; run it at startup and after any NCCL failure.
Checkpointing § Auto-resume — how to roll back to an earlier checkpoint.