Repository navigation
fix(core): f32 matmul and single-query SDPA lose ~fp16 precision on M5, breaking four numeric tests #1065
Description
Problem / Background
Four deterministic test failures occur on Apple M5 Max that do not occur on the M1 Ultra nightly runner. All four are numeric-agreement assertions on f32 computations. The observed error magnitudes cluster at fp16 epsilon scale (2^-10 = 9.77e-4), roughly three orders of magnitude larger than f32 epsilon, which indicates the f32 inputs are being computed at reduced internal precision on M5-class hardware.
The four failing tests
mlxcel-corelayers::tests::chunked_causal_attention_matches_fast_causalatsrc/lib/mlxcel-core/src/layers.rs:6344. Message:q_len=4 k_len=4 chunk=1 diverged from do_causal SDPA. Measuredmax_abs_diff= 0.0009678602 against a tolerance of 1e-5. Fails only atchunk=1; chunk 2 and 3 pass.mlxcel-corelayers::tests::chunked_query_attention_matches_unchunked_with_maskatsrc/lib/mlxcel-core/src/layers.rs:6288. Message:chunk=1 diverged from unchunked SDPA. Measuredmax_abs_diff= 0.0009678602 against a tolerance of 1e-5. Fails only atchunk=1; chunks 2, 4 and 5 pass.mlxcel-coremla::decode::decode_tests::absorbed_attention_respects_an_additive_causal_maskatsrc/lib/mlxcel-core/src/mla/decode_tests.rs:93. Message:masked absorbed attention drifted by 0.0014691837, againstF32_TOL = 2e-5(relative error).mlxcel-coremla::decode::decode_tests::expand_latent_reproduces_the_up_projectionatsrc/lib/mlxcel-core/src/mla/decode_tests.rs:132. Message:k_nope[0][0][1] 0.30357933044433594 != 0.3037950135765861, absolute tolerance 1e-4, observed absolute error 2.157e-4 (relative error about 7.1e-4).
Why this is a precision problem and not a test problem
- Test 4 compares the GPU result against a reference dot product accumulated in f64 in plain Rust, over
kv_lora_rank = 32elements (theTINYfixture isnum_heads: 4, kv_lora_rank: 32, qk_nope_head_dim: 16, qk_rope_head_dim: 8, v_head_dim: 12). A correctly executed f32 dot product of length 32 on values of order 1 should differ from the f64 reference by about 1e-6 or less. The observed 2.157e-4 is roughly two orders of magnitude larger than that, and sits at fp16 epsilon scale. The reference side is exact, so the GPU side is the imprecise one. - Tests 1 and 2 both report the identical divergence 0.0009678602, which is just below 2^-10 = 0.0009765625, that is fp16 epsilon. Both fail only in the
chunk=1configuration, which is the single-query path. - Test 4 is a plain up-projection matmul, not attention, so the reduced precision is not confined to SDPA kernels.
Hardware dependence, established
- Failing host: Apple M5 Max, macOS 26.6, toolchain 1.93.1 (also reproduced under 1.97.1 with byte-identical values).
- Passing host: the
self-hosted-macos-26-arm64nightly runner (M1 Ultra). In the last successfulnightly-verify.ymlrun (run id 30939291504) all four tests report... ok. - The failures are fully deterministic on M5: three consecutive reruns produced byte-identical values.
- The repository already carries M5-specific routing at
src/lib/mlxcel-core/src/lib.rs:3178, which dispatches tolayers::metal4_causal_attentionwhenhw.has_neural_accelerator && hw.macos_supports_na. There is currently no environment variable to disable that path, so the hypothesis could not be isolated by toggling it off. Adding such an override would make this class of problem diagnosable.
Why it matters beyond the test suite
chunk=1 is the single-query shape, which is what every decode step uses in production. If the single-query path on M5 computes at fp16-level precision while the multi-token prefill path computes at f32, then prefill and decode disagree numerically on M5 by about 1e-3 per step. Test 3's failure is on the MLA decode path specifically. This should be assessed for output-quality impact on real checkpoints, not only fixed at the tolerance level.
Suggested investigation
- Determine whether MLX selects a neural-accelerator (NAX) kernel for f32 matmul and single-query SDPA on M5, and whether that kernel accumulates at reduced internal precision.
- Decide whether the reduced precision is intended behavior on M5. If it is, the affected tolerances need per-hardware calibration rather than a blanket loosening, and the divergence must be documented. If it is not, the dispatch needs to select a full-precision kernel for these shapes.
- Measure decode output quality on at least two real checkpoints on M5 versus M1 to establish whether the numeric difference is observable in generated tokens.
- Add an environment override to disable the M5 neural-accelerator routing so this class of divergence can be bisected locally.
Related test-quality gap (fold into the fix)
The assertions in tests 1 and 2 do not print the measured difference; the message says only "diverged". The divergence magnitude had to be obtained by patching the assertion locally. Those two assertions should include the measured value, the way tests 3 and 4 already do.
Not part of this issue
A fifth test, tests/mllama_parity.rs::sub_max_real_tiles_keep_the_legacy_real_rows_byte_identical, also fails on M5 but has a different and benign root cause: it asserts exact f32 bit equality and the observed difference is 5.9604645e-8 (2^-24), which is 0.5 ULP of the output's largest element (1.1521907). That is the same class as issue #939 and is being fixed separately by applying a tolerance, following the precedent set by PR #953 for its sibling test in src/models/mllama/text.rs.
Acceptance Criteria
- Root cause established: confirmed whether MLX dispatches a reduced-precision (neural-accelerator) kernel for f32 matmul and single-query SDPA on M5, with evidence.
- A decision is recorded on whether the reduced precision is intended M5 behavior, along with the rationale.
- If unintended: the dispatch selects a full-precision kernel for these shapes, and all four tests pass on M5 with their original tolerances.
- If intended: tolerances are calibrated per hardware (not blanket-loosened), the M5 divergence is documented in the repository docs, and all four tests pass on both M5 and the M1 Ultra nightly runner.
- Decode output quality measured on at least two real checkpoints on M5 versus M1, with results reported in this issue.
- An environment override exists to disable the M5 neural-accelerator routing at
src/lib/mlxcel-core/src/lib.rs:3178, and it is documented. - The assertions in
chunked_causal_attention_matches_fast_causalandchunked_query_attention_matches_unchunked_with_maskprint the measured divergence in their failure messages. - The fix is integrated into the actual inference code path, not left as a standalone helper, and
cargo test --releasepasses on both M5 and the nightly runner.
Technical Considerations
- Affected files:
src/lib/mlxcel-core/src/layers.rs,src/lib/mlxcel-core/src/mla/decode.rsand its tests,src/lib/mlxcel-core/src/lib.rs(M5 routing at line 3178). - Per the project's Apple Silicon precision rules, M5-specific code paths gate on
hw.has_neural_accelerator && hw.macos_supports_na. Any new override must respect that gate rather than bypassing hardware detection. - Loosening tolerances alone would hide a real production-path divergence on the decode hot path, so it is not an acceptable standalone fix.
Activity
fix(tests): tolerate f32 reassociation in the mllama tile-selection p…
inureyes commentedon Aug 16, 2026
Narrowing from the #1186 investigation, plus the override this issue asked for
Two contributions from the block-vs-chain work on #1186 and #1188. The first narrows the suspect list; the second removes the obstacle that made testing it awkward.
The override now exists: PR #1196
Acceptance criterion 6 here asks for an environment override that disables the M5 neural-accelerator routing so this class of divergence can be bisected locally. There was none, and the M5 investigation on #1182 had to patch
should_use_metal4_attentionby hand and revert it afterwards.MLXCEL_METAL4_ATTENTION=0now forces the route off. Off-switch only, so it is inert on hardware that lacks the accelerator. It also fixes a drift the change exposed:lib.rs's causal-attention dispatch carried its own inline copy of the hardware test rather than calling the shared predicate, so a switch applied to one site only would have produced runs that look like the route is off and are not.Suggested use here: run the four tests this issue names with and without the variable and report whether the failures move. That is the evidence acceptance criterion 1 wants.
What the new diagnostic does and does not say about the SDPA half
tests/metal_block_vs_chain_op_parity.rs(inmainsince #1192) compares oneT = Kcall againstKsingle-position calls, byte for byte. On both an M1 Ultra and an M5 Max, at Qwen3.8-27B and Gemma 4 12B shapes:fast_rms_normfast_ropequantized_matmul(affine)Read this carefully, because it is easy to overclaim. The SDPA row says the single-query form and the
T = Kblock form agree with each other on f16 inputs. This issue's failures are f32 computations compared against a higher-precision reference. Those are different axes: agreement between two paths does not establish that either is accurate, and an f16 result says nothing directly about the f32 kernel.What it does contribute: on the block-vs-chain axis, the single-query SDPA path is not distinguishable from the multi-position one on either machine, so whatever separates M5 from M1 in tests 1 and 2 is not a
T = 1versusT > 1dispatch split in SDPA. Test 4 is a plain up-projection matmul rather than attention, and the matmul rows above are where every measured divergence lives, which is consistent with this issue's own observation that the reduced precision is not confined to SDPA kernels.A second lever worth trying, from the same investigation
MLX gates its NAX gemm on
metal::is_nax_available() && !complex && (env::enable_tf32() || a.dtype() != float32)(https://github.com/ml-explore/mlx/blob/main/mlx/backend/metal/matmul.cpp). For float32 specifically that last clause makesMLX_ENABLE_TF32=0a way to avoid the NAX gemm without touching our code at all, which is directly relevant to this issue's f32 matmul half. Measured on M5 Max during the #1182 investigation: withMLX_ENABLE_TF32=0the block arm's digest moved while the chain arm's did not, and the divergence shrank from 72% of bytes to 37%, so the toggle demonstrably reaches the kernel.Combining the two switches gives four arms on one machine: NA attention on/off crossed with TF32 on/off. Tests 1 to 3 should respond to the first, test 4 to the second, if the hypothesis in this issue is right.
Not addressed
Nothing here measures decode output quality on real checkpoints (acceptance criterion 5), and nothing here was run on M5, which is where the four failures are.
feat(core): add MLXCEL_METAL4_ATTENTION as a kill switch for the M5 N…
feat(core): add MLXCEL_METAL4_ATTENTION as a kill switch for the M5 N…
inureyes commentedon Aug 17, 2026
Root cause:
MLX_ENABLE_TF32, not the M5 neural-accelerator attention routeRan the cross this issue's suggested investigation asks for, now that #1196 has shipped the kill switch. Apple M5 Max, 40 GPU cores, 128 GB, macOS 26.6.1, Xcode 26.5,
mainat33951285,cargo test --release --features metal,accelerate -p mlxcel-core.MLXCEL_METAL4_ATTENTIONMLX_ENABLE_TF32All four tests respond to the second variable only.
MLXCEL_METAL4_ATTENTIONchanges nothing, in either direction, for any of them.The failure values are byte-identical across all three failing arms and match this issue's body exactly:
The default-environment arm matters as much as the cross: with neither variable set, all four fail. So TF32 is on by default on this host and the default state is exactly what this issue reports. With
MLX_ENABLE_TF32=0all four pass at their original tolerances, unchanged.On the first acceptance criterion
This is evidence that MLX dispatches a reduced-precision kernel for f32 matmul on M5 and that the selection is governed by TF32. It is consistent with every observation in the issue body: the fp16-epsilon-scale error, the identical 0.0009678602 in tests 1 and 2, and test 4 being a plain up-projection matmul rather than attention. A single switch explaining an attention test and a non-attention test together is what a matmul-level cause looks like.
What this does not establish, and please do not read it as such
MLXCEL_METAL4_ATTENTIONhaving no effect here is not a verification that the switch works, and it is not evidence that the NA attention route is well behaved. These four tests never reach it.The route is consulted at exactly three sites:
lib.rs:3207,layers::metal4_attentionandlayers::metal4_causal_attention. The failing tests callattention_dispatch,chunked_causal_attention,chunked_query_attention,ffi_fast_scaled_dot_product_attention_causaland the MLA decode paths directly, none of which pass through those three. Confirmed empirically as well: re-running test 1 withMLXCEL_LOG_NA_ATTENTION=allproduces zero dispatch log lines with the switch on and zero with it off.So this test set is out of the route's call path, the no-effect result is uninformative about the switch, and #1196's "effect not verified" stays open. Following up separately with a real-checkpoint decode A/B that does engage the route.
Suggested next step for the remaining criteria
The second criterion (is the reduced precision intended M5 behavior) is now a narrower question than the issue posed: it is about MLX's TF32 default on generation 17, not about our dispatch. The fourth (decode output quality on real checkpoints, M5 against M1) is unchanged and still needs measuring, and
MLX_ENABLE_TF32gives it a clean A/B on a single host.inureyes commentedon Aug 17, 2026
Follow-up: TF32 is invisible to decode on quantized checkpoints
Same host and build as the four-arm comment above. This is a partial answer to the fourth acceptance criterion, and it is partial in a way worth being explicit about.
96 tokens,
--temp 0, arms alternated, two reps, default environment againstMLX_ENABLE_TF32=0:llama-3.2-1b-4bitgemma-3-4b-it-4bitqwen2.5-7b-instruct-4bitByte-identical output on all three, throughput within noise.
Why this is not yet the criterion's answer
All three are 4-bit checkpoints, so their hot matmuls are
quantized_matmul, not f32 dense matmul, and TF32 governs the f32 arm. The M5 probe on #1182 saw this directly:MLX_ENABLE_TF32=0changed the dense f32 block digest and left the f16 arm alone. So this measurement mostly demonstrates that the production decode path does not go through the ops TF32 controls, rather than that reduced precision is harmless where it does apply.That is still useful, and it narrows the issue: the four failures are f32 fixtures, and a 4-bit checkpoint's decode does not reach that path. It is an argument that the blast radius is smaller than the issue's "prefill and decode disagree numerically on M5 by about 1e-3 per step" framing suggested, at least for quantized checkpoints.
Not measured, and needed to actually close the criterion: a checkpoint whose forward pass does f32 dense matmul, and the M1 comparison. This is a single-host result.
Also worth flagging for whoever picks this up:
--temp 0byte-identity over 96 tokens is a test for argmax flips, not for per-op precision. A quality assessment that matters would want perplexity or a longer generation, not a diff.inureyes commentedon Aug 17, 2026
This is not M5-specific. It reproduces on CUDA, from the same cause.
make verify-test-cudaatfa260a5don NVIDIA hardware fails three tests in-p mlxcel --lib:models::bailing_moe_linear::tests::chunked_gla_matches_the_sequential_recurrencemodels::deepseek_v2::tests::absorbed_mla_attention_matches_the_decompressed_block_step_for_stepmodels::florence2::florence2_tests::incremental_decode_matches_full_sequenceAll three are members of the M5 Max set, and all three pass under
MLX_ENABLE_TF32=0on CUDA, exactly as all seventeen do on M5:So the variable is
MLX_ENABLE_TF32on both backends. TF32 is on by default for f32 matmul on Ampere and later just as it is on an M5's neural accelerator, and this issue's tests are numeric-agreement assertions on f32.What that changes about this issue
The title and the "Hardware dependence, established" section are wrong, and in a way that matters. They read as "M5 computes at reduced precision and M1 Ultra does not", which frames it as an Apple generation 17 defect. The M1 Ultra is not the control it looks like: it passes because it has no TF32-equivalent path, not because the other hosts are broken. Two unrelated architectures failing the same assertions under the same switch is a property of the assertions and of MLX's default, not of a chip.
It also changes what a fix would cost. "Calibrate tolerances per hardware" now means per hardware class, at least Metal-NA and CUDA-Ampere-plus, and the set is open. The alternative, deciding whether TF32 should be on by default for these ops, is one decision rather than a growing table.
The subset is itself information
CUDA fails 3 where M5 fails 17. Whatever the other 14 touch on M5, CUDA either does not route through TF32 there or its kernels close the gap. Worth knowing before anyone loosens a tolerance: a tolerance wide enough for M5 would be far wider than CUDA needs on the same test.
Not a regression from the MLX pin bump
Established from both directions rather than assumed. On M5 the failure set is identical at pin
2c46b953and9a795735, deterministic over three isolated runs each (#1198). On CUDA the three failures vanish underMLX_ENABLE_TF32=0, which is a property of the ops and not of the pin.Recommend retitling this issue away from M5 and restating the acceptance criteria around the TF32 default, since "all four tests pass on M5 with their original tolerances" is now a subset of the actual problem in two directions: seventeen tests, two backends.
inureyes commentedon Oct 6, 2026
Close-out in #2132, and a correction to this issue's framing
The title and the "Hardware dependence, established" section are superseded by the 2026-08-17 comments: the cause is MLX's default
MLX_ENABLE_TF32=1(10-bit mantissa f32 GEMM inputs) on CUDA Ampere+ as well as Apple generation 17. It is not M5-specific, and theMLXCEL_METAL4_ATTENTIONroute is not involved. The body is left as filed; this comment is the current reading.MLX_ENABLE_TF32, four-arm cross on M5 (2026-08-17) plus CUDA reproductionMLXCEL_METAL4_ATTENTION)New from GB10 at
5211f58d: the fourmlxcel-coretests named here pass on CUDA even underMLX_ENABLE_TF32=1(3 runs each), because their shapes use MLX's gemv and SDPA kernels rather than cuBLAS. So nothing inmlxcel-corecaught a lost pin on CUDA; #2132 adds a sentinel GEMM test that fails at 5.84e-3 under=1.Greedy decode, 256 tokens, default vs
=0: the f32 checkpointplamo-2-1bdiverges on 1 of 5 prompts, deterministically in both arms (5/5 runs), same throughput. f16, bf16 and 4-bit checkpoints show nothing attributable to the variable. The default stays;docs/environment-variables.mdnow documentsMLX_ENABLE_TF32=0for f32-exact output.test(core): guard the TF32 test pin and record its decode impact