Skip to content

fix: validate sparse_categorical_crossentropy class indices off CPU for Torch backend - #23669

Open
maitry63 wants to merge 1 commit into
keras-team:masterfrom
maitry63:fix_torch_sparse_bounds
Open

maitry63 wants to merge 1 commit into
keras-team:masterfrom
maitry63:fix_torch_sparse_bounds

Conversation

@maitry63

@maitry63 maitry63 commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

This PR fixes , keras.ops.sparse_categorical_crossentropy only checks the shape of the inputs. It does not check that the label values are valid. It passes them straight to F.cross_entropy / F.nll_loss on the Torch backend.

Those PyTorch functions only check label values on CPU. So the same bad label behaves differently on each device:

Device What happens when a label is not in [0, num_classes)
CPU Raises IndexError: Target 5 is out of bounds.
MPS No check. Reads memory out of range and returns a wrong loss.
CUDA Crashes with a device-side assert. You have to restart the process.

Example with 3 classes, where label 5 is invalid:

output = torch.tensor([[0.9, 0.05, 0.05], [0.1, 0.8, 0.1]])  # 3 classes
target = torch.tensor([0, 5])                                # 5 is invalid

with keras.device("cpu"):
    ops.sparse_categorical_crossentropy(target, output)
    # IndexError: Target 5 is out of bounds.

with keras.device("mps"):
    ops.sparse_categorical_crossentropy(target, output)
    # tensor([0.1054, -0.0000], device='mps:0')   <- no error, wrong loss

This is a real problem. If your labels and your number of classes do not match, training on MPS keeps running and slowly learns the wrong thing. Nothing tells you something is wrong.

The fix

On non-CPU devices, bad labels are changed to 0 so the lookup stays in range. Then the loss for those rows is set to NaN:

def _mask_invalid_class_indices(target, num_classes):
    if target.device.type == "cpu":
        return target, None  # torch already raises here
    invalid = ~((target == -100) | ((target >= 0) & (target < num_classes)))
    return target.masked_fill(invalid, 0), invalid
    # The kernels below only validate class indices on CPU.
    target, invalid = _mask_invalid_class_indices(target, output.shape[1])
    ...
    if invalid is not None:
        result = result.masked_fill(invalid, float("nan"))

This runs after the class axis is moved to position 1. So output.shape[1] is always the number of classes, no matter the rank or the axis value. One call covers both the logits path and the probabilities path.

Result for target=[0, 5] with 3 classes:

Backend Before After
torch / CPU IndexError IndexError (no change)
torch / MPS [0.1054, -0.0000], no warning [0.1054, nan]
torch / CUDA crash […, nan]
tensorflow / CPU InvalidArgumentError no change
tensorflow / GPU NaN no change

Why not just raise an error everywhere

To raise an error, we have to copy the labels from the GPU back to the CPU and look at them. That makes the GPU wait, on every training step.

I measured this on an M4 with MPS, batch=4096, vocab=32000:

Version ms per call Difference
master (no check) 23.33
this PR 22.98 −1.5%, so basically free
version that raises an error 24.07 +0.74 ms (+3.2%)

The new check costs nothing. The error version does cost something, on every step.

The check also has no if statement that depends on the data. That means torch.compile can trace it with fullgraph=True, and it also works on meta tensors during shape inference.

One small difference from TensorFlow

TensorFlow makes both the loss and the gradients NaN. Here, only the loss is NaN. masked_fill blocks the gradient, so the gradient for a bad row is 0.

This means a bad label cannot push the weights toward the wrong class, and it cannot turn the weights into NaN. You see nan in the loss right away, but the model is still usable. This is written in the docstring and tested by test_invalid_target_contributes_no_gradient.

Testing

Tested on an Apple M4 with a real MPS device, torch 2.12.1:

  • The bug happens on master and is fixed on this branch.
  • It works through the public API too: keras.losses.SparseCategoricalCrossentropy() returns nan, and model.fit shows loss: nan instead of a normal-looking number.
  • keras/src/backend/torch/nn_test.py — 30 passed (12 on real MPS).
  • Same tests with MPS and CUDA turned off, to copy CI — 13 passed, 1 skipped.
  • keras/src/losses/losses_test.py -k sparse — 19 passed, nothing broken.
  • ruff check and ruff format are clean.

Fixes: #23664

Contributor Agreement

Please review our AI-Assisted Contribution Policy and check all boxes below before submitting your PR for review:

  • I am a human, and not a bot.
  • I will be responsible for responding to review comments in a timely manner.
  • I will work with the maintainers to push this PR forward until submission.

Note: Failing to adhere to this agreement may result in your future PRs no longer being reviewed.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces explicit bounds checking for target class indices in the PyTorch backend's sparse_categorical_crossentropy implementation to ensure consistent error behavior across CPU, GPU, and MPS devices, along with comprehensive unit tests. The review feedback highlights two critical issues: first, the check will crash with a RuntimeError on the 'meta' device during symbolic tracing, which can be resolved by returning early for 'meta' tensors; second, performing synchronous host-device transfers on GPU/MPS tensors introduces a severe performance bottleneck, and a non-synchronous device-side assertion should be considered instead.

Comment thread keras/src/backend/torch/nn.py Outdated
Comment on lines +965 to +966
if target.device.type == "cpu" or target.numel() == 0:
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

During symbolic tracing or shape inference (e.g., inside 'compute_output_spec'), Keras runs operations eagerly using the 'meta' device. Since 'meta' tensors do not contain actual data, calling 'bool(torch.any(invalid))' on line 972 will raise a 'RuntimeError' (e.g., 'RuntimeError: Ky_meta_tensor cannot be cast to boolean'). To prevent crashes during symbolic tracing, we should return early if the target is on the 'meta' device.

Suggested change
if target.device.type == "cpu" or target.numel() == 0:
return
if target.device.type in ("cpu", "meta") or target.numel() == 0:
return

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The old RuntimeError was caused by bool() on a meta tensor.
With the new approach, there is no bool() or host sync, so meta tensors work normally with masked_fill.
I also added a test for meta-device shape inference to make sure this doesn't regress.
I intentionally removed the "meta" special case since it is no longer needed.

Comment thread keras/src/backend/torch/nn.py Outdated
Comment on lines +971 to +973
invalid = ~((target == -100) | ((target >= 0) & (target < num_classes)))
if bool(torch.any(invalid)):
raise IndexError(f"Target {int(target[invalid][0])} is out of bounds.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Calling 'bool(torch.any(invalid))' and 'int(target[invalid][0])' on a GPU/MPS tensor forces a synchronous host-device transfer (GPU-to-CPU copy). This stalls the GPU pipeline and can introduce a severe performance bottleneck during training, as 'sparse_categorical_crossentropy' is called at every training step. While message parity with the CPU kernel is nice, forcing a synchronous barrier on every batch on GPU/MPS is highly discouraged in high-performance deep learning. Consider using a non-synchronous device-side assertion like 'torch._assert' with a static error message, or only performing this check when a debug/validation mode is explicitly enabled.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I tested this on MPS, and bool() adds 0.42 ms because it waits for the GPU, causing a overhead on a small train step.
torch._assert has the same issue because it also calls bool().
So I removed the host sync and kept the check on the device. Invalid labels are safely changed to 0, and their losses are set to NaN.
The overhead is now reduced depending on the workload. CPU behavior is unchanged, and it also works with torch.compile.
I don't think this should be behind a debug flag because otherwise invalid labels can still silently produce a wrong loss.

@codecov-commenter

codecov-commenter commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.41%. Comparing base (8ddf4b0) to head (673793e).

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #23669      +/-   ##
==========================================
- Coverage   86.96%   84.41%   -2.56%     
==========================================
  Files         484      484              
  Lines       71754    71762       +8     
  Branches    11840    11842       +2     
==========================================
- Hits        62400    60576    -1824     
- Misses       6224     8181    +1957     
+ Partials     3130     3005     -125     
Flag Coverage Δ
keras 84.24% <100.00%> (-2.54%) ⬇️
keras-cpu 84.24% <100.00%> (-1.90%) ⬇️
keras-gpu ?
keras-jax 58.27% <12.50%> (-2.16%) ⬇️
keras-numpy 54.05% <12.50%> (-0.16%) ⬇️
keras-openvino 59.79% <12.50%> (-0.16%) ⬇️
keras-tensorflow 59.94% <12.50%> (-2.09%) ⬇️
keras-torch 59.54% <100.00%> (-2.19%) ⬇️
keras-tpu ?
keras.applications ?
keras.applications-jax ?
keras.applications-numpy ?
keras.applications-openvino ?
keras.applications-tensorflow ?
keras.applications-torch ?
keras.wrappers ?
keras.wrappers-jax ?
keras.wrappers-numpy ?
keras.wrappers-openvino ?
keras.wrappers-tensorflow ?
keras.wrappers-torch ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@maitry63
maitry63 force-pushed the fix_torch_sparse_bounds branch from 2518a55 to 673793e Compare September 18, 2026 08:10
@keerthanakadiri keerthanakadiri added the stat:awaiting keras-eng Awaiting response from Keras engineer label Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M stat:awaiting keras-eng Awaiting response from Keras engineer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

With Torch backend sparse_categorical_crossentropy silently accepts out-of-range class indices on MPS

4 participants