NVIDIA-NeMo / NVIDIA-NeMo/Automodel

Masked CE losses overwrite the caller's labels in place, zeroing a reused batch's loss

Open
#3,917 0 comments 0 reactions 1 assignee View on GitHub

@akoumpa is already working on this.

Since Sep 16, 2026.

community-request waiting-on-maintainers
Dominant language
Python
Stars
960
Forks
316
Avg merge
3d 20h
Merged PRs (30d)
143

Description

Describe the bug

MaskedCrossEntropy, ChunkedCrossEntropy and TEParallelCrossEntropy all apply their
optional mask with an in-place labels.masked_fill_:

logits = logits.view(-1, logits.size(-1))
labels = labels.view(-1)
if mask is not None:
    with torch.no_grad():
        ...
        labels.masked_fill_(mask.view(-1) == 0, self.ignore_index)

labels.view(-1) does not copy — it aliases the caller's storage. So the fill writes
ignore_index (-100) directly into the caller's label tensor. In
TEParallelCrossEntropy the aliasing is worse: labels has already been through
labels.to_local(), and that local shard aliases the DTensor's storage, so the write
lands in the caller's distributed tensor.

Nothing in the signature or (until now) the docstring says the argument is consumed.
calculate_loss, the wrapper most callers go through, documents the opposite:
"The caller's mapping and tensors are not mutated."

file line
nemo_automodel/components/loss/masked_ce.py 72
nemo_automodel/components/loss/chunked_ce.py 197
nemo_automodel/components/loss/te_parallel_ce.py 174

Steps/Code to reproduce bug

import torch, torch.nn.functional as F
from nemo_automodel.components.loss.masked_ce import MaskedCrossEntropy

torch.manual_seed(0)
logits = torch.randn(6, 5)
labels = torch.randint(0, 5, (6,))

mask_a = torch.tensor([1, 1, 1, 0, 0, 0])   # first half supervised
mask_b = torch.tensor([0, 0, 0, 1, 1, 1])   # second half supervised

def reference(mask):
    t = labels.clone(); t[mask == 0] = -100
    return F.cross_entropy(logits, t, reduction="sum")

loss_fn = MaskedCrossEntropy(reduction="sum")
shared = labels.clone()
a = loss_fn(logits, shared, mask=mask_a)
b = loss_fn(logits, shared, mask=mask_b)    # same tensor reused

print(a.item(), reference(mask_a).item())   # 4.717452 4.717452
print(b.item(), reference(mask_b).item())   # 0.000000 3.509581   <-- wrong
print(shared.tolist())                      # [-100, -100, -100, -100, -100, -100]

Expected behavior

Each call scores the positions its own mask selects, and the caller's labels tensor is
unchanged afterwards.

Actual behavior

The first call destroys the supervision. The second call sees a tensor that is already
all ignore_index, so every position is ignored and the loss is exactly 0.0
which backprops a zero gradient. There is no exception and no warning; the step simply
contributes nothing.

Any second consumer of the same tensor is affected the same way: another loss term over
the same batch, a token-accuracy or perplexity metric computed after the loss, a cached
or memory-mapped dataset that hands back the same underlying tensor on the next epoch.

Why it has gone unnoticed

No recipe in-tree currently passes mask= (calculate_loss forwards only logits,
labels and num_label_tokens), so today this is a latent defect on a public API rather
than an active training-corruption bug. It is reachable by anyone calling these loss
modules directly, which the mask parameter exists to support.

The existing coverage cannot catch it. test_masked_cross_entropy_with_mask builds its
reference after calling the loss:

loss_custom = MaskedCrossEntropy()(logits, targets, mask=mask)
targets_ref = targets.clone()      # already mutated to -100 here
targets_ref[mask == 0] = -100      # re-applies an applied mask: a no-op

so it re-applies a mask that is already applied and passes either way.

Environment overview

  • Reproduced on CPU, torch 2.x, current main (4a334b644). No GPU or distributed
    setup required.

Proposed fix

Use the out-of-place masked_fill in all three losses, document that labels is left
intact, and snapshot targets before the call in the existing test so it can observe a
regression. PR to follow.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.