Skip to content

Make a non-finite training step visible in log_history - #6862

Closed
behroozazarkhalili wants to merge 24 commits into
mainfrom
fix/6702-nonfinite-loss-visibility
Closed

behroozazarkhalili wants to merge 24 commits into
mainfrom
fix/6702-nonfinite-loss-visibility

Conversation

@behroozazarkhalili

@behroozazarkhalili behroozazarkhalili commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator

The training logger could hide a non-finite loss while backward still used it. I added a gathered frac_nonfinite_loss metric, warnings, and agreement across ranks for selected failure paths to prevent peers from hanging. Regression tests expose the missing metric on main, and distributed GPU checks pass for the forward-failure agreement, while local async tests remain blocked by an unavailable kernel build.
Resolves #6702.


Note

Medium Risk
Touches every trainer’s hot loss path with new collectives and cross-rank failure agreement; incorrect placement could cause deadlocks or change logging semantics, though behavior is heavily tested.

Overview
Adds frac_nonfinite_loss across TRL trainers so NaN/Inf steps are visible in logs even when Transformers’ default logging_nan_inf_filter keeps the reported loss finite. Each loss path records a rank-gathered fraction of non-finite micro-batch losses, emits train/eval warning_once messages, and documents the metric on trainer pages.

Also fixes eval logging: metrics use the caller’s metric_key_prefix (not a hardcoded eval_) and evaluate/predict open a clean eval metrics window so predict() batches do not leak into the next eval average. SFT evaluate on a dict of splits prepares each split once instead of double-preparing via nested Trainer.evaluate.

For DPO, KTO, and SFT, forward failures are agreed across ranks before metric gathers so one rank’s exception does not hang peers in collectives; distributed child tests cover gathered frac_nonfinite_loss and peer-failure raises. Broad unit tests poison losses (NaN/Inf where applicable) and assert frac_nonfinite_loss behavior.

Reviewed by Cursor Bugbot for commit f096119. Bugbot is set up for automated code reviews on this repo. Configure here.

`transformers` replaces a NaN or Inf loss with the average of the
previously logged losses before logging it (`logging_nan_inf_filter`,
on by default), but `training_step` has already run and
`optimizer.step()` still runs on the non-finite value. The reported
loss curve stays plausible while the weights are being corrupted,
and nothing in `log_history` points at the step that went wrong.

This costs more in GRPO than in plain SFT, because several GRPO paths
reach a NaN from data rather than from divergence: an unscorable
token logprob returned by vLLM (#6166), a degenerate reward group,
an overflowing `exp` of a KL log-ratio (#3015). A user has no reason
to suspect the learning rate in those cases, and a single poisoned
group or token is not something a loss curve can point at.

`compute_loss` now appends `frac_nonfinite_loss` and warns once. The
indicator is gathered across ranks before it is recorded, since a NaN
confined to one rank would otherwise read as 0.0 from the main process,
which reproduces the same invisibility one layer up. The warning text
carries no step index on purpose: `accelerate`'s `warning_once` is an
`lru_cache` keyed on its arguments, so an interpolated step number
misses the cache and warns on every step.

`compute_loss` is the single funnel for both the Liger
and the non-Liger path, so one site per trainer covers
both. `GRPOWithReplayBufferTrainer` and the `gspo_token` variant
subclass `GRPOTrainer` and override only `_compute_loss`, so they
inherit the change.

The regression tests poison the second of two steps and leave the first
as the finite control, then assert on `log_history`: 0.0 then 1.0 for
the new metric, with the reported `loss` still finite for the poisoned
step. Both fail on main with `KeyError: 'frac_nonfinite_loss'`.

Resolves #6702
@bot-ci-comment

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

Checkpoint so the work survives. Not the final message.

`transformers` swaps a non-finite loss for a substitute value before logging it, while the
backward pass still runs on the real one, so a diverging run keeps a plausible loss curve. Each
trainer now gathers the non-finite indicator across ranks at the end of its loss method, records
`frac_nonfinite_loss` where a metrics dict exists, and warns once.

Every block is emitted from one generator so the copies cannot drift; all 20 are byte-identical
modulo indent. grpo, rloo and async_grpo are re-synced onto the same text.

Known open, from a full-diff review: the warning's account of what transformers substitutes is
wrong (it reads the tr_loss accumulator, not logged history), the fp16/bf16 sentence has
counterexamples on CPU and DeepSpeed, the filter claim does not hold on XLA, the metric ignores
metric_key_prefix, and predict() leaks into the next eval window. None of those are fixed here.
`transformers` substitutes a non-finite training loss before logging it
(`logging_nan_inf_filter`, on by default) while the backward pass still
runs on the non-finite value. The substitution is not an average of
previously logged losses, as the option's name and the surrounding
comment suggest: it adds a fraction of the current accumulator to itself
and never reads logged history. The reported curve therefore stays
plausible while a run degrades, and nothing in `log_history` identifies
the step that went wrong.

Each trainer's loss path now appends `frac_nonfinite_loss` and warns
once. The indicator is gathered across ranks first, since a NaN confined
to one rank would otherwise read as zero from the main process and
reproduce the same invisibility one layer up. The warning text carries
no step index because `warning_once` caches on its arguments, so an
interpolated step number would warn on every step instead of once.

Trainers here are self-contained by design, so the block is duplicated
rather than abstracted, and kept byte-identical across every copy apart
from indentation and the four metric mechanisms the trainers already
use.

Two pre-existing defects surfaced while adding it, both fixed because
the new metric is the first whose value a reader would notice is wrong.
Eval metrics were filed under a hardcoded `eval_` prefix rather than the
caller's `metric_key_prefix`, which is `test` for `predict()`. And
`predict()` never calls `log()`, so the batches it scored survived into
the next evaluation window and skewed its averages.

Adding a collective to the loss path also turned any rank-local `raise`
upstream of it into a hang rather than a clean failure, because the
raising rank leaves while its peers block in the gather until the NCCL
timeout. Twelve such raises across eight trainers now agree across ranks
before they fire. The per-rank predicate stays inside the pure loss
helpers, which are unit-tested on objects that have no accelerator.

That agreement has to precede the operation it guards. In CPO and ORPO
it sat below the cross-entropy that raises on the very shape mismatch it
checks for, so a rank failing there never reached the gather and its
peers waited on it anyway.

Verifying the gather needs more than one process: with a single rank it
is the identity, so removing it entirely leaves every single-process
test green. `tests/distributed/nonfinite_loss.py` poisons one rank and
asserts on all of them that the logged fraction covers the whole world,
and `test_distributed.py` launches it. Four of the per-trainer tests
inject an infinity rather than a NaN, which an implementation checking
`isnan` in place of `isfinite` does not catch.

Resolves #6702.
…ecks

`iw_opd` and `server_distillation` each tested `missing_teacher[:, 1]`
twice in a row and raised in both branches. The first raise always fires
when the condition holds, so the second was dead from the moment it was
written: it survived a rewrite that added the count-carrying message
above it without removing the flag-only one below.

An AST sweep over `trl/` now reports no other `if` that raises on a
condition a preceding sibling already tested, and it finds exactly these
two when run against the previous revision.

While counting for that sweep, the rank-agreeing raise total in the
previous commit message proved wrong. Measured against `origin/main`,
the branch takes that count from 6 to 22, so it adds 16 rather than the
twelve claimed there. The count of eight trainers is right.
Comment thread trl/experimental/async_grpo/async_grpo_trainer.py Outdated
Every non-finite loss test injected NaN only, so an `isnan` implementation of
the guard passed the whole suite while the guard itself tests `~isfinite`. The
18 tests whose poison lands on the loss scalar now run for both NaN and Inf.
That set now includes a2po: mutating its guard to `isnan` fails the Inf case
and to `isinf` fails the NaN case, so both values reach its loss distinctly.
The other 7 poison logits or log-probabilities, where an injected Inf arrives
at the loss as NaN, so they keep injecting NaN and record why.

Four comments described code that does something else. The stated reason for
the NaN-only limit named a log-softmax the poison does not pass through. The
GRPO and RLOO comments still called the injection multiplicative after it
became additive, and claimed the poisoned step corrupts the weights, which
adding a constant does not. The distributed test claimed a rank-local result
differs from the expected fraction by at least 0.5, which is true at two ranks
and false above them.

`_open_eval_window` is defined in 16 trainers and no test touched it. A new
test evaluates under three different `metric_key_prefix` values and asserts the
metric arrives under each. Hardcoding the prefix back to "eval" fails two of
the three.
…alse claims

The two branches of the distillation-loss metric in SDFT and SDPO gathered different dtypes. One
gathered `torch.zeros(())`, which is always float32; the other gathered `mean_distill_loss`, which
follows the autocast dtype and so is bfloat16 or float16 under mixed precision. `accelerator.gather`
requires every rank to contribute the same dtype and NCCL does not promote, so a run where one rank
took each branch could not complete the collective. Measured under a bfloat16 autocast: the peer's
value arrives as bfloat16 while the zero stays float32. Both sides are now pinned to float32. The
mismatch was introduced by 13a0666 on this branch and is not present on main.

The docstring in tests/distributed/nonfinite_loss.py claimed the assertion "would be false" with a
single rank. It would not. With one rank the sole rank is also the poisoned one, so the assertion
reads 1.0 against an expected 1.0 and passes whether or not the gather is there, which is exactly
the blindness the file exists to remove. The docstring now says that.

Five doc pages head the metric list with "While training and evaluating", but CPO and ORPO record
`frac_nonfinite_loss` with `train_eval="train"`, and NashMD, OnlineDPO and XPO append it inside
`training_step`. Each of those five bullets now states that the trainer records the metric on the
training path only, so readers do not look for it among the evaluation metrics.
`PRMTrainer` defines no loss of its own, so it inherits `transformers.Trainer.compute_loss` and the standard
training loop, which is exactly where `logging_nan_inf_filter` applies. It was left out of this PR on the
reasoning that it owns no loss site, but owning one and being subject to the filter are different questions.
Measured before the fix: with a NaN injected on step 2, `log_history` reported `loss: 0.0` and carried no
`frac_nonfinite_loss` key at all, which is the symptom this PR exists to remove. `PPOTrainer` stays exempt for
a reason that does hold: it writes its own `train()` and never enters that loop. PRM now carries the same guard
block as the other 25, byte-identical, and a test parametrized over NaN and Inf. Both mutants die: neutering
the guard fails both cases, and swapping `~isfinite` for `isnan` fails the Inf case alone.

The warning was asserted by none of the 27 non-finite tests, so deleting every `warning_once` call left the
suite green. The regression that permits is specific: `warning_once` keys its cache on the message text, so
collapsing the train and eval messages into one lets an evaluation warning suppress every later training
warning. A test now drives both paths and asserts both messages. It clears the cache first, because that cache
is process-wide and an earlier test emitting the same text would otherwise suppress the messages here and make
the result depend on test order.

The metric is documented as a rate averaged over every loss computation in the window, but all 27 tests ran at
`gradient_accumulation_steps=1`, where each window holds exactly one computation and the averaging is
invisible. Changing `sum(val) / len(val)` to `max(val)` passed everything. A test now poisons one micro-batch
of two and asserts the metric reads 0.5.

Two comments asserted mechanisms that are not true. `test_distributed.py` said the child's assertion "would be
false" under one rank; with one rank the sole rank is also the poisoned one, so it reads 1.0 against an
expected 1.0 and passes, which is the blindness the file exists to remove and what its own sibling docstring
already said. The async-GRPO test explained its NaN-only injection by `inf - inf`, but only the model's
log-probabilities are poisoned and they are differenced against rollout-time values no forward pass touches,
so an injected Inf gives `exp(-inf)`, a finite 0.0, and never reaches the guard at all.

`sdft` spelled the eval-prefix comprehension with `k` and `v` where the other fifteen copies use `key` and
`val`. Same behaviour, but the duplicated blocks are required to match, so it is now spelled like its siblings.
The guard reduced the loss with `loss.detach().mean()`. A low-precision dtype has no `mean` kernel,
so a `float8_e5m2` loss raised `NotImplementedError: "sum_cpu" not implemented for 'Float8_e5m2'`
rather than reporting a rate. That turned a diagnostic into a crash on the dtypes most likely to
produce a non-finite loss in the first place. Casting to float32 before the reduction fixes both
float8 variants and leaves the finiteness of every other dtype unchanged. Applied at all 31 sites.

The guard also ends each loss path in a collective, and ten raises upstream of it ran on one rank
only. A rank that raised left its peers waiting inside a gather that never completed: measured on
two ranks, the peer blocked 3 times out of 3. Those ten now agree across ranks before raising, so
the run fails together instead of hanging. Four config-time raises stay rank-local on purpose,
because a gather inside a branch only the raising rank enters would create the hang it prevents
everywhere else.

BCO stored the metric under `is_main_process`, discarding every other rank's contribution to a
value the gather had already averaged across ranks. Its store line now matches CPO's and ORPO's.

Two tests were passing against a mutation. The SFT rate test split one non-finite micro-batch
against one finite one, so an inverted `~torch.isfinite` still averaged to 0.5 and the assertion
could not tell the two apart; it now uses four micro-batches and asserts 0.25. The DPO Liger loss
path had no test at all, so deleting its guard changed nothing observable. Both mutations now fail.

The 15 doc pages said the filter substitutes a finite value unconditionally. It does that only when
`is_torch_xla_available()` is false; under XLA the step is dropped instead.
Comment thread tests/test_dpo_trainer.py Outdated
The `doc-builder-style` pre-commit hook wraps docstring prose to fill `--max_len 119`, and its scope
is `^(trl|tests|docs/source)/`, so the three docstrings added with these tests were short of the
column it packs to and CI's code-quality job rejected them. This is the hook's own output rather
than a hand rewrap, so a later run is a no-op. No wording changes, only line breaks.
The guard reduced the loss with `loss.detach().float().mean()`. Narrowing first is lossy: a finite
`float64` loss above `float32`'s maximum becomes `inf`, so the guard reported a healthy step as
non-finite and emitted the warning. Measured over 13 dtype and edge cases, the narrowing form is right
in 11 of them and `.double()` in all 13. Widening also keeps both `float8` dtypes working, where
testing finiteness elementwise instead raises `NotImplementedError: "isfinite" not implemented for
'Float8_e4m3fn'`. The DPO test added here fails against the old cast and passes against this one.

DPO and KTO translate an image-token mismatch into an actionable `max_length` message, and that raise
ran on one rank only. The guard's gather sits downstream of it, so a rank whose own batch failed left
its peers inside a collective it never entered: measured on two ranks, the survivor blocked for the
full collective timeout instead of failing with the message. SFT already agreed across ranks before
raising; these two now do too.

Their guard moves out of the two loss methods and into `compute_loss`, which is where the agreement can
sit upstream of the guard's gather. Keeping the guard inside those methods instead pairs one rank's
guard with another rank's failure flag: measured, the survivor read the peer's flag as its own loss
value and then blocked anyway. That placement differs from the other 24 trainers, which have no
rank-local raise upstream of their guard.

Guard sites drop from 31 to 29, because DPO and KTO each had one per loss method and now have one per
trainer. Coverage is unchanged at 26 trainers.
Comment thread trl/trainer/dpo_trainer.py Outdated
0cab401 put the failure agreement in `compute_loss`, downstream of the sixteen metric gathers that
`_compute_loss` runs after its forward. A rank whose forward failed never reached those gathers, so
the survivor's first gather paired with the failing rank's agreement flag and the run blocked in the
next collective. The agreement now sits inside each loss method, directly after the forward and
before any collective. KTO's reference forward gets an agreement of its own because it runs below
the batch-size check's gather. Both `compute_loss` methods return to plain delegation and the
non-finite guard moves into the callees, so it stays the last collective on every path.

The Liger DPO test hooked the causal-LM wrapper's `logits`, but `_compute_loss_liger` reads
`last_hidden_state` from the backbone, so the hook never fired and three CI lanes failed on it.
The test now poisons the fused loss itself and covers Inf as well as NaN.

The distributed check fails the forward from a pre-hook rather than raising in an override: an
override that raised before delegating would skip the agreement it exists to test. It refuses to
run on one rank, where every assertion passes whether or not the ranks agree, and its watchdog is a
daemon thread: a stalled rank sits inside a C-level collective and never returns to Python
bytecode, so a `SIGALRM` handler is delivered but never runs. Measured on two CPU ranks over MPI:
the shipped code passes, the code with both agreements removed ends at the watchdog.

GOLD's missing-field check agrees across ranks and so reads `accelerator`; the bare-trainer test
stubs it, as its sibling in the same file already does. At fifteen guard sites the `mode`
assignment had split the guard's comment in two; it moves above the comment.
The forward-failure agreement caught only `ValueError`, the class it
translates into the image-placeholder message. A `RuntimeError` or an
out-of-memory error in one rank's forward, the plainest rank-local
failure there is, bypassed the agreement and left the peers waiting in
the first metric gather. A probe with a failing pre-hook shows it:
`ValueError` reached the agreement gather, `RuntimeError` and
`OutOfMemoryError` reached none. Every agreement now catches
`Exception` and keeps the `ValueError` translation as a special case.

KTO's Liger path called the fused loss after its only agreement, so a
rank-local failure in the kernel skipped every metric gather that
follows. DPO keeps its fused loss inside the forward's `try`; KTO's
depends on the KL gather in between, so it gets an agreement of its
own, the same shape as the one around its reference forward. The tails
of all six agreements are byte-identical.

The DPO test for a finite loss above `float32`'s maximum proved
nothing: it replaced the loss after `_compute_loss` returned, so the
guard had already run on the real value, and mutating the guard's cast
to `float32` left it green. The loss is now produced inside the
trainer, with the model in `float64` and the IPO loss at `beta=1e-20`,
about `2.5e39` whatever the log-ratios are. The `float32` mutant fails
it.

The distributed child's watchdog comment records the measured margin:
the healthy two-rank CPU run finishes in under 90 seconds against a
300 second deadline.

On the two-rank MPI harness the shipped code passes, and the code
with the agreements removed stalls until the watchdog ends it.
The previous commit widened the DPO and KTO agreements from `ValueError`
to `Exception`, so a `RuntimeError` or an out-of-memory error on one rank
reaches the same gather as a failed batch. SFT's agreement, the one GOLD,
SDFT, SSD and the other SFT subclasses inherit, still caught `ValueError`
only, so any other exception on one rank skipped the agreement and left
the peers waiting in the next collective.

The catch block is now the same in all seven sites (SFT, DPO x2, KTO x4).
… dataset comments

`SFTTrainer.evaluate` prepared each split of a dict and then handed the
whole dict to `Trainer.evaluate`, which calls `self.evaluate` again per
split and so re-entered the override on data it had just prepared. The
tokenizing steps skip an already-tokenized split, but `packing` packs it
a second time. The override now runs `super().evaluate` once per split
itself, opening the eval window with that split's prefix, the same shape
DPO's `evaluate` already has in this branch. A test counts one
`_prepare_dataset` call per split and checks the per-split metric keys.

The `evaluate` comments in DPO, KTO and TPO said `_prepare_dataset` is
idempotent and skips already-tokenized data. None of the three has such
a skip: each tokenizes from the text columns, and TPO refuses input
without them. The comments now say the method has to receive the raw
dataset, which is why a `str` is left untouched.
# Conflicts:
#	tests/experimental/test_async_distillation_trainer.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4db11db. Configure here.

Comment thread trl/trainer/sft_trainer.py
…ing count

The distributed child now takes a case name and injects a rank-local
failure at each of the seven agreement sites across DPO, KTO and SFT
(standard and Liger paths, ValueError, RuntimeError and OutOfMemoryError).
Before, only one DPO site was reached. Failure hooks register on the
module DDP wraps, so the DDP buffer broadcast completes before the hook
raises; a hook on the wrapper itself raises ahead of that collective.

The non-finite warning test asserted message presence only, which an
uncached warning satisfies; it now requires exactly one training and one
evaluation warning.
@qgallouedec

Copy link
Copy Markdown
Member

Thanks @behroozazarkhalili but I don't think I get the purpose here: we're adding a metric that only lights up once the run is already dead? In what real training did you end up finding this metric really useful?
Also the description is way too long and reads as AI generated, It would benefit from being cut it to a few sentences

@behroozazarkhalili

Copy link
Copy Markdown
Collaborator Author

You are right on both points.

There was no real run. This came from the regression test for #6166. An assertion on log_history passed with the fix removed, because the NaN step had been replaced by the running mean. I have not seen it in an actual training job, and every NaN in this PR's tests is injected.

I also agree on the timing. Without a grad scaler, a NaN loss still takes an optimizer step, so the run is already dead by the time anything is logged. grad_norm is logged unfiltered (trainer.py:2081), so the next log line shows nan there anyway. The metric adds little on top of that. It only tells you that the finite loss column is no longer real. The one case where it could tell you something grad_norm does not is fp16 with a grad scaler, where the step is skipped and training continues. I have not measured that case, so I will not rely on it.

Given that, 70 files is far more than this is worth, and I am fine closing it. If you see value in a small version, it would be a warning on the first filtered step in GRPO and RLOO only. It would have to be a new PR from my fork, because this branch now conflicts with #7136 and I can no longer push to it. It is your decision.

Noted on the description. I will keep descriptions to a few sentences from now on.

@qgallouedec

Copy link
Copy Markdown
Member

Closing, I think it's important to work from real training. And fix real issue. The amount of edge / unreachable cases that we can find from just reading the codebase is infinite.

@behroozazarkhalili

behroozazarkhalili commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator Author

That's a fair bar, and I agree with it.

Six of my other open PRs had the same origin. I found them by reading the code, not because a run broke or a user reported a problem. I've closed all six (#6977, #7033, #7042, #7044, #7045, #7053) so they're out of your queue. I also closed the issues I had filed for them.

From here on, I'll only open a PR when there's a failing run or a user report behind it, and the description will start with that evidence. I appreciate the direct feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GRPO: a NaN training step is invisible in log_history, so a corrupted run reports a clean loss curve

2 participants