Describe the bug
MaskedCrossEntropy guards against a zero num_label_tokens; the other three
losses that normalise the same way do not, so a batch with no supervised tokens
produces NaN instead of 0.0. NaN propagates through backward() into every
parameter, so the run keeps going with a destroyed model rather than failing.
nemo_automodel/components/loss/masked_ce.py L84-88 has the guard:
if num_label_tokens is not None:
assert self.reduction == "sum", "num_label_tokens is only supported when reduction is 'sum'"
if num_label_tokens == 0:
return loss * 0.0
loss = loss / num_label_tokens
The other three divide unconditionally:
| Loss |
Line |
Guard |
masked_ce.py MaskedCrossEntropy |
86-88 |
yes |
linear_ce.py FusedLinearCrossEntropy |
264 |
no |
chunked_ce.py ChunkedCrossEntropy |
219 |
no |
te_parallel_ce.py |
188 |
no |
The recipe already treats zero as a real case on the validation path
(recipes/llm/train_ft.py L1382):
val_loss = total_loss / max(total_num_label_tokens, 1e-8)
so the training path is the inconsistent one.
Why the count can be zero
num_label_tokens is the global, DP-reduced number of non-ignored labels
(train_ft.py L1210-1213):
num_label_tokens = torch.tensor(
sum((batch["labels"] != -100).sum().item() for batch in batches), dtype=torch.long
)
num_label_tokens = self._dp_allreduce(num_label_tokens).item()
It reaches zero when every label in the global batch is -100 — for example a
gradient-accumulation window where truncation cut the answer off every sample
(answer_only_loss_mask with seq_length shorter than the prompts), or a bad
shard. The existing guard in MaskedCrossEntropy and the max(..., 1e-8) on the
validation path suggest this has been hit before.
Consequence: which loss you configure decides whether the step survives.
MaskedCrossEntropy contributes 0.0; the other three poison the model. The
recipe can also swap between them on its own — _maybe_downgrade_loss_fn
(train_ft.py L159) replaces a fused loss with MaskedCrossEntropy when the
model does not declare logits_to_keep — so the same config can behave
differently on two models.
Steps/Code to reproduce bug
CPU only, no GPU or checkpoint needed:
import torch
from nemo_automodel.components.loss.masked_ce import MaskedCrossEntropy
from nemo_automodel.components.loss.chunked_ce import ChunkedCrossEntropy
B, S, V = 2, 8, 32
torch.manual_seed(0)
logits = torch.randn(B, S, V)
labels = torch.full((B, S), -100) # every position ignored
num_label_tokens = int((labels != -100).sum()) # 0, as the recipe computes it
print(MaskedCrossEntropy()(logits, labels.clone(), num_label_tokens=num_label_tokens))
print(ChunkedCrossEntropy()(logits.clone(), labels.clone(), num_label_tokens=num_label_tokens))
The unguarded branch is 0.0 / 0 — the sum-reduced loss over zero supervised
tokens is 0.0, and dividing that by 0 gives NaN rather than 0.0.
Expected behavior
All four losses agree: a batch with no supervised tokens contributes 0.0,
matching MaskedCrossEntropy and the validation path, so an empty
gradient-accumulation window is a no-op step rather than a silent model wipe.
Environment overview
main at 3ddef9b, CPU only. ChunkedCrossEntropy reproduces directly;
FusedLinearCrossEntropy needs cut_cross_entropy and te_parallel_ce needs
Transformer Engine, but both perform the identical unguarded division.
Additional context
Happy to send a PR adding the same num_label_tokens == 0 guard to the three
losses, plus a CPU unit test per loss. Alternatively the normalisation could be
factored into one shared helper so the four cannot drift again — say which you
prefer and I will follow that shape.
Describe the bug
MaskedCrossEntropyguards against a zeronum_label_tokens; the other threelosses that normalise the same way do not, so a batch with no supervised tokens
produces
NaNinstead of0.0.NaNpropagates throughbackward()into everyparameter, so the run keeps going with a destroyed model rather than failing.
nemo_automodel/components/loss/masked_ce.pyL84-88 has the guard:The other three divide unconditionally:
masked_ce.pyMaskedCrossEntropylinear_ce.pyFusedLinearCrossEntropychunked_ce.pyChunkedCrossEntropyte_parallel_ce.pyThe recipe already treats zero as a real case on the validation path
(
recipes/llm/train_ft.pyL1382):so the training path is the inconsistent one.
Why the count can be zero
num_label_tokensis the global, DP-reduced number of non-ignored labels(
train_ft.pyL1210-1213):It reaches zero when every label in the global batch is
-100— for example agradient-accumulation window where truncation cut the answer off every sample
(
answer_only_loss_maskwithseq_lengthshorter than the prompts), or a badshard. The existing guard in
MaskedCrossEntropyand themax(..., 1e-8)on thevalidation path suggest this has been hit before.
Consequence: which loss you configure decides whether the step survives.
MaskedCrossEntropycontributes0.0; the other three poison the model. Therecipe can also swap between them on its own —
_maybe_downgrade_loss_fn(
train_ft.pyL159) replaces a fused loss withMaskedCrossEntropywhen themodel does not declare
logits_to_keep— so the same config can behavedifferently on two models.
Steps/Code to reproduce bug
CPU only, no GPU or checkpoint needed:
The unguarded branch is
0.0 / 0— the sum-reduced loss over zero supervisedtokens is
0.0, and dividing that by0givesNaNrather than0.0.Expected behavior
All four losses agree: a batch with no supervised tokens contributes
0.0,matching
MaskedCrossEntropyand the validation path, so an emptygradient-accumulation window is a no-op step rather than a silent model wipe.
Environment overview
mainat 3ddef9b, CPU only.ChunkedCrossEntropyreproduces directly;FusedLinearCrossEntropyneedscut_cross_entropyandte_parallel_ceneedsTransformer Engine, but both perform the identical unguarded division.
Additional context
Happy to send a PR adding the same
num_label_tokens == 0guard to the threelosses, plus a CPU unit test per loss. Alternatively the normalisation could be
factored into one shared helper so the four cannot drift again — say which you
prefer and I will follow that shape.