Skip to content

Fix fast.cross_entropy VJP on non-contiguous Metal inputs - #4702

Open
StockerMC wants to merge 2 commits into
ml-explore:mainfrom
StockerMC:fix-cross-entropy-vjp-strides
Open

StockerMC wants to merge 2 commits into
ml-explore:mainfrom
StockerMC:fix-cross-entropy-vjp-strides

Conversation

@StockerMC

@StockerMC StockerMC commented Oct 15, 2026 •

Copy link
Copy Markdown
  • ☑️ I understand it is strictly prohibited to use AI to write PR description
  • AI usage disclosure: I used Claude Code to understand the relevant code paths, help verify the root cause, and write the fix and test. I reviewed, rebuilt and tested everything myself.

fixes #4686

On Metal, the gradient of mx.fast.cross_entropy can be wrong for a non-row-contiguous view like logits[:, :-1] once the input is big enough that the command buffer gets committed right after the VJP. It's then possible for the next buffer to read the gradient before it's written, so calls sometimes fail.

As was mentioned in the issue, ensure_row_contiguous registers the input copy as a temporary, and the VJP then donates that copy to the output. end_encoding drops temporaries from the encoder's outputs, so the gradient buffer isn't tracked by the fence and the next command buffer doesn't wait for it to be written.

This fix no longer registers the input copy as a temporary, since the output shares its buffer, matching the CUDA VJP and LayerNormVJP.

I added a regression test with an input big enough so that the command buffer always splits after the VJP, and I built and tested on an M3 Pro.

@utsav1033 utsav1033 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.

the script showed that it computed gradient thrice and printed its size, correct being 34.352, which i got right everytime. and that wasnt the case on main as it was 0.0 at first and 6218.577 later.
the new test proved through a test added by this pr, the bug test failed on main and passed on this pr.
nothing else broke when i ran the related test files(test_fast, test_losses, test_autograd, test_vmap)
my setup is apple m4 on macOS 27.0.1

@StockerMC
StockerMC force-pushed the fix-cross-entropy-vjp-strides branch from 8514faa to 46087d0 Compare October 19, 2026 17:35

@ArupKantiDas ArupKantiDas 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.

Thanks for the fix. I read it against current main and tested it on an M5 Pro, and I think it's right.

The root cause checks out in mlx/backend/metal/device.cpp. end_encoding erases each temporary's buffer from all_outputs_ before recording prev_ce_outputs_[out] = fence_. The gradient lives in the temporary copy's buffer, so it never gets a fence entry, and the next command buffer reads it without a waitForFence. You only see this when needs_commit() closes the buffer right after the VJP, hence the large input.

Dropping the add_temporary is the same thing RMSNormVJP and LayerNormVJP do (their check_input hands back the copy without registering it), and the CUDA CrossEntropyVJP does it too. in_copied always implies donate_in, so out always ends up owning the copy and nothing leaks. I also looked at the forward CrossEntropy::eval_gpu: it still registers its copy as a temporary, but never donates it, so it doesn't need the same change.

What I ran (M5 Pro, macOS 26.6):

Build Issue repro, grad norms (expected 34.352) New test body, repeated
0.32.4 from PyPI 34.352, 6218.577, 6218.577 10/10 runs fail
This PR (46087d0) 34.352, 34.352, 34.352 0/20 runs fail
This PR with only cross_entropy.cpp reverted not run 10/10 runs fail (30/40 gradient calls wrong)

Full suite on the PR build: 994 tests pass, 56 skipped. pre-commit is clean on both files.

A small nit on the test comment, which says "the 50 Mi element command buffer limit". That limit is max_mb_per_buffer_. Device::Device() picks it from the last letter of the GPU architecture name (50 for s and d, 40 for everything else; my M5 Pro reports applegpu_g17s, so it gets 50), and it gets compared against buffer_sizes_, which counts elements, not MB. The slice plus its copy is at least 64 Mi elements, so the test is over the limit on every GPU either way. Maybe "over the per-buffer limit (40 or 50 Mi elements depending on the GPU)"?

I used Claude Code to help trace the encoder code and run the builds and tests above; I read through the results myself before posting.

This branch has not been deployed

No deployments
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.

[BUG] fast.cross_entropy VJP returns wrong gradients for large non-contiguous inputs on Metal (0.32.3 regression)

3 participants