Skip to content

Add fused Metal kernels for fast.cross_entropy - #4520

Open
zsun6 wants to merge 3 commits into
ml-explore:mainfrom
zsun6:feat/metal-fused-cross-entropy
Open

zsun6 wants to merge 3 commits into
ml-explore:mainfrom
zsun6:feat/metal-fused-cross-entropy

Conversation

@zsun6

@zsun6 zsun6 commented Sep 16, 2026

Copy link
Copy Markdown

Metal kernels for mx.fast.cross_entropy, same idea as the CUDA ones from #3947. On Metal this currently falls back to logsumexp - take_along_axis.

The fallback does the lse - x_t subtraction in the logits dtype and only then casts to float32, so with bf16 logits you lose bits right there. The kernel does everything in fp32. Max abs error vs float64 numpy on random (4, 7, 8192) logits:

bfloat16: fused 9.3e-07  unfused 1.0e-01
 float16: fused 1.0e-06  unfused 1.8e-02
 float32: fused 1.9e-06  unfused 1.8e-06

I tightened test_cross_entropy to 1e-3 for fp16/bf16 on GPU, it fails on main with Metal. I couldn't run it on CUDA, no hardware here, but that kernel accumulates in fp32 as well.

For the backward I use exp((x - x_t) - loss), which is the softmax, so there is no second reduction and I never build the one-hot. That is where the time and memory go. Times in ms on an M3 Pro, mean of 20, scripts at the bottom:

shape dtype fwd unfused fwd fused fwd+bwd unfused fwd+bwd fused peak unfused peak fused
2048 x 151936 bf16 5.46 5.10 45.8 14.8 1.87 GB 1.24 GB
2048 x 151936 fp32 10.73 9.90 93.4 29.8 3.73 GB 2.49 GB
2048 x 32000 bf16 1.20 1.16 8.9 3.5 0.39 GB 0.26 GB
8192 x 32000 bf16 4.28 4.22 34.9 13.5 1.57 GB 1.05 GB
8192 x 1024 bf16 0.37 0.35 1.4 0.7 <0.1 GB <0.1 GB

Forward is bandwidth bound either way and the differences there are noise. fwd+bwd is about 3x at the big vocab sizes and 2x at V=1024, peak memory ~1.5x lower.

I went with one threadgroup per row, ceil(V / N_READS) threads rounded up to a simd multiple and capped at 1024. So only the looped variant, I didn't write the block kernel that softmax has for small V. If you want it for the small V case I can add it.

Negative targets wrap, matching take_along_axis. The CUDA kernel doesn't check that and reads out of bounds.

One thing to flag: with a -inf target logit the gradient row is now NaN where the unfused path gave finite values, because the VJP reconstructs the softmax from the loss. As far as I can tell from reading the CUDA kernel it does the same, so I left it.

The second commit drops the mx.cuda.is_available() check in nn.losses.cross_entropy so Metal also goes through fast.cross_entropy. Also fixed the docstring example there, it showed float32 output for a bfloat16 input.

python/tests/run.py passes, also under MLX_METAL_JIT=ON.

  • ☑️ I understand it is strictly prohibited to use AI to write PR description
  • AI usage disclosure: used AI for Coding

zsun6 and others added 3 commits September 12, 2026 20:22
On Metal, CrossEntropy::use_fallback returned true and both eval_gpu
overloads threw NYI, so mx.fast.cross_entropy always ran the unfused
logsumexp - take_along_axis graph. Add the forward and VJP kernels and
enable them, following the CUDA implementation from ml-explore#3947.

Forward: one threadgroup per row, single-pass online logsumexp with
float32 accumulation for every input dtype. The loss is formed as
(max - x_t) + log(normalizer) so the two close values are subtracted
first. The host shrinks the threadgroup to ceil(V / N_READS) rounded to
a SIMD multiple, so short rows take one iteration of the same looped
kernel; the cross-SIMD reduction only reads the slots that were written.

VJP: exp((x - x_t) - loss) is softmax(x), so the backward pass needs no
reduction and the one-hot target is never materialized. When the logits
buffer can be donated the gradient is written in place, with a device
memory barrier between the reads of x_t and the first write.

Negative targets wrap, matching the take_along_axis fallback this
replaces. The JIT library name is passed explicitly because deriving it
from the kernel name would drop the "cross_" prefix.

The float16/bfloat16 tolerances in test_cross_entropy tighten to 1e-3 on
the GPU. The fallback fails this because its logsumexp runs in the input
dtype; the fused kernels are within ~4e-6 for all three dtypes. The CPU
path keeps the old tolerances.
nn.losses.cross_entropy only took the mx.fast.cross_entropy path when
CUDA was available. The Metal kernel exists now, so gate on the default
device being the GPU alone. The half precision example in the docstring
now shows what the fast path returns: the loss is cast back to the
logits dtype, so it is bfloat16, not float32.
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.

1 participant