Shift by the max in log_softmax - #4169
Open
ayaangazali wants to merge 1 commit into
Open
Conversation
zcbenz
approved these changes
Aug 12, 2026
zcbenz
left a comment
Member
There was a problem hiding this comment.
Thanks!
Cross-checked JAX and it is doing the same:
https://github.com/jax-ml/jax/blob/da2d20de08edc1041b1983d0800d12c733a51352/jax/_src/nn/functions.py#L569
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Proposed changes
nn.log_softmaxloses the normalizer once the logits get large. Two equal logits have to givelog(1/2)no matter their magnitude, but:pytorch returns
-0.6931471824645996for all four. It is already visibly off at1e4and reports0.0from1e8on, which claims each class has probability 1.The cause is the composition:
mx.logsumexpitself is fine.logsumexp([1e8, 1e8])is1e8 + log(2), and since the ulp of1e8in float32 is 8,1e8is the correctly rounded answer. The problem is that the normalizer is added to a large magnitude before it is subtracted off again, so it rounds away and never reaches the result.Shifting by the max first keeps the normalizer at a magnitude where it survives, which is what pytorch does:
After the change every scale from
1e0to1e36gives-0.693147, matching pytorch. On random inputs the two now agree exactly (max difference0) for scales of1e3and above, and both sit the same distance from a float64 reference, which is the float32 limit of the inputs rather than of the algorithm. Gradients are unchanged, max difference against pytorch0.0, andstop_gradienton the shift keeps the graph the same shift invariant function it was.Special values line up with pytorch better than before as a side effect:
log_softmax([inf, 1])was[nan, -inf]and is now[nan, nan].mx.softmaxwas never affected. It is a C++ op that already subtracts the max, and it returns[0.5, 0.5]at every scale.Checklist
Put an
xin the boxes that apply.pre-commit run --all-filesto format my code / installed pre-commit prior to committing changesCPU only build (
MLX_BUILD_METAL=OFF) at a076a63.test_nn.py,test_losses.py,test_optimizers.pyandtest_ops.pyall pass, and the new assertion fails on main from1e8up. Python only, so no rebuild is involved.freshman contributor, i lean on Claude Code while digging. found this feeding extreme values through the activations and comparing against pytorch. one thing i want to flag rather than hide: my first instinct was that
mx.logsumexpwas wrong, and it took working out the ulp at1e8to see that it returns the best float32 answer available and the fault is in how it gets combined here.