Skip to content

test(harness): pin f32 GEMMs to full precision, and accept minijinja's Python-style bools - #1260

Merged
inureyes merged 1 commit into
mainfrom
test/issue-1259-tf32-test-pin
Aug 20, 2026
Merged

inureyes merged 1 commit into
mainfrom
test/issue-1259-tf32-test-pin

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

Eighteen tests were red on Apple GPU generation 17 hosts and nowhere else, and every recent PR test plan on this machine has been diffing its failure set against main to prove it added nothing. Both causes are identified in #1259; this PR fixes them, and both suites run green on an M5 Max with no env vars set (mlxcel-core 1513 passed, root lib 5711 passed).

Related issues

Closes #1259. Refs #1088 (same mechanism on the CUDA gate; see below).

Type of change

  • test

Cause 1: MLX runs f32 GEMMs at TF32-class precision on NAX hardware (17 tests)

MLX's matmul dispatch is is_nax_available() && (enable_tf32() || dtype != float32), and MLX_ENABLE_TF32 defaults to 1 in mlx/utils.h. On generation 17 that routes f32 GEMMs through the reduced-precision NAX kernel, so the suite's algorithm-equivalence tests (chunked vs sequential GLA, prefill vs the single-token chain, absorbed vs decompressed MLA, incremental vs full decode) stop agreeing to their pinned tolerances: measured 2.2e-4 against a 1e-4 bound on the MLA up projection, 1.4e-3 against 1e-3 on a klear position-0 logit. MLXCEL_METAL4_ATTENTION=0 changes nothing, because the mechanism is the matmul dispatch, not the attention route.

The fix is a ctor in each lib test binary that sets MLX_ENABLE_TF32=0 before MLX latches the value into its process-wide static, with an explicit operator setting winning over the pin. The policy this encodes: unit tests verify algorithm equivalence at full f32; shipped numerics stay on MLX defaults and are covered by the runtime exactness probes (#1189, #1188).

Verified three ways: all 17 pass individually under MLX_ENABLE_TF32=0 before the change; both full suites pass after it; and MLX_ENABLE_TF32=1 still reproduces the red, so the override is real.

Cause 2: minijinja 2.24 renders Jinja2 bools Python-style (1 test)

prepare_chat_request_renders_tool_call_arguments_as_mapping pinned ARG:recursive=true; and the #1162 bump to minijinja 2.24 renders ARG:recursive=True;, which is what HF transformers (Python Jinja2) produces for the same template. The expectation moves to the Python-style form with the rationale recorded; the test's actual subject, the bool surviving argument-mapping normalization as a bool rather than a string, is unchanged.

The CUDA gate (#1088)

The pin lives in the test binaries, so a CUDA build's test run gets it too, and #1088's "the gate leaves MLX TF32 enabled" is the same default this pin overrides. Validation needs the CUDA runner; this PR does not claim it.

Test plan

  • cargo fmt --check
  • cargo clippy --workspace --all-targets --features metal,accelerate -- -D warnings
  • cargo test --release -p mlxcel-core --features metal,accelerate: 1513 passed, 0 failed
  • cargo test --release -p mlxcel --lib --features metal,accelerate: 5711 passed, 0 failed
  • MLX_ENABLE_TF32=1 reproduces the pre-fix failure (operator override wins)

…s Python-style bools

Eighteen tests were red on Apple GPU generation 17 hosts and nowhere
else, for two unrelated reasons (#1259).

Seventeen assert full-f32 algorithm equivalence (chunked vs sequential,
prefill vs the single-token chain, absorbed vs decompressed), and MLX
routes f32 GEMMs to its reduced-precision NAX kernel whenever
MLX_ENABLE_TF32 is unset: the dispatch is 'is_nax_available() &&
(enable_tf32() || dtype != float32)' with the env defaulting to 1, so on
generation 17 the two computation shapes stop agreeing to the pinned
tolerances (measured 2.2e-4 against a 1e-4 bound on the MLA up
projection). A ctor in each lib test binary now sets MLX_ENABLE_TF32=0
before MLX latches the value, with an explicit operator setting winning;
verified that all seventeen pass under the pin and that the override
still reproduces the red. Shipped numerics stay on MLX defaults and are
covered by the runtime exactness probes. The same mechanism is the open
CUDA report #1088; this pin applies to those test binaries too but has
only been validated on Metal.

The eighteenth expected 'ARG:recursive=true;' from a rendered chat
template. minijinja 2.24 (bumped in #1162) renders a Jinja2 bool
Python-style, as HF transformers does, so the expectation moves to
'ARG:recursive=True;'; the test's actual subject, the bool surviving
argument-mapping normalization as a bool, is unchanged.

Both suites are green on this M5 Max with no env vars set: mlxcel-core
1513 passed, root lib 5711 passed.

Closes #1259
Refs #1088
@inureyes inureyes added status:review Under review type:test Test related changes priority:medium Medium priority area:core mlxcel-core: MLX FFI, primitives, KV cache, layers labels Aug 20, 2026
@inureyes
inureyes merged commit 670512c into main Aug 20, 2026
8 checks passed
@inureyes
inureyes deleted the test/issue-1259-tf32-test-pin branch August 20, 2026 08:04
inureyes added a commit that referenced this pull request Aug 20, 2026
…on (#1262)

## Summary

#1088's second acceptance criterion asked that whichever fix was chosen be documented at the gate's own definition, so the next person does not rediscover the mechanism from a confusing failure set. #1260 fixed the mechanism but documented it only in the ctor doc comments inside `src/lib.rs` and `src/lib/mlxcel-core/src/lib.rs`. Somebody starting from the `Makefile` and asking "why is this gate green now, and what am I allowed to change" finds nothing. This adds the missing note as a third bullet in the `verify-test-cuda` comment block. Comment-only, no recipe change.

## Related issues

Refs #1088. Refs #1259, #1260 (the pin this documents).

## Type of change

- [x] `docs`

## What the note records

Four things a future reader needs, in the place they will actually look:

- Neither gate setting `MLX_ENABLE_TF32` is deliberate, not an oversight. The pin lives in the test binaries so it covers every gate running them without the macOS and CUDA command lines diverging from each other.
- MLX defaults the variable to 1 in `mlx/utils.h`, which routes f32 GEMMs through a reduced-precision kernel on both backends: TF32 on CUDA, NAX on Apple GPU generation 17. That is why #1088 and #1259 are the same bug on different hardware.
- The encoded policy: algorithm-equivalence unit tests run at full f32, while shipped numerics stay on MLX defaults and are covered by the runtime exactness probes (#1188, #1189).
- The trap to avoid: do not answer a future red here by widening a parity tolerance before checking whether the run had TF32 on.

## Validation

The evidence cited in the note is from the CUDA-runner validation of #1088, run on GB10 (sm_121, Linux aarch64, driver 580.173.02) at `670512c2` with MLX pin `9a795735`:

- `make verify-test-cuda`, unmodified: **8167 passed, 0 failed, 310 ignored** across 101 test binaries. No `FAILED`, no `panicked`, no `SIGABRT`/`SIGSEGV`, no compile errors.
- `MLX_ENABLE_TF32=1` at the same commit still reds three of the four tests #1088 listed (bailing GLA, deepseek_v2 MLA, florence2), confirming the pin is load-bearing rather than decorative. florence2 reproduces at `max abs diff 0.00014263391`, identical to the value recorded in #1088 at `0f727572`.
- klear is the one that no longer reproduces under TF32; its margin was the thinnest of the four (1.1e-3 measured against a 1e-3 tolerance). It was checked instead by deliberately perturbing `src/models/klear.rs:617` to skip the causal kernel, which fails the test by 0.28, roughly 280x its tolerance. The perturbation was reverted.

## Test plan

- [x] `make -n verify-test-cuda` emits the same recipe as before the change
- [x] Comment-only diff, no Rust sources touched, so no `fmt`/`clippy` surface
inureyes added a commit that referenced this pull request Aug 20, 2026
## What was actually wrong

The run-to-run variance is a **test fixture defect**, not a backend property. `filled_weights` walks a `WeightMap` and advances one LCG seed per key. `WeightMap` is `HashMap<String, UniquePtr<MlxArray>>`, and Rust's `RandomState` randomizes iteration order per process, so every run handed a different noise block to a different tensor: **each process built a different random model.** The klear causality test then measured a different model's numerics each time, which is why its max logit delta wandered over 5.9e-4 to 2.0e-3 and straddled its own 1e-3 bound.

The fix is to sort the key walk. The same fixture bug was present in three sibling files, so all four are fixed here.

## Two hypotheses in the issue are refuted, with measurements

**"The prime suspect is accumulation order in the quantized MoE path (GatherQMM / grouped GEMM)."** The fixture is not quantized. `synthetic_weights` emits only `.weight` for the expert stacks and no `.scales`, and `SwitchLinear::from_stacked_parts` selects the non-quantized `Regular` path when scales are absent. Probed directly through the public API on GB10: `SwitchGLU::forward_fused_kernel(..).is_some()` is **false**, so neither the fused decode-MoE kernel nor the quantized `gather_qmm` path runs in this test at all.

**"The variance exists at both settings, so TF32 is an amplifier and not the cause."** Both halves needed correcting. Once the fixture is deterministic there is no variance at either setting, and the pin's effect on this test is exactly what it is on the other three in #1088.

Measured on GB10 (CUDA sm_121, MLX pin `9a795735`), 10 processes per mode after the fix, max abs logit delta between the single-token forward and row 0 of the 4-token prefill:

| | `MLX_ENABLE_TF32=0` (the #1260 pin) | `MLX_ENABLE_TF32=1` (MLX default) |
| --- | --- | --- |
| delta | 5.364418e-7 | 1.0485351e-3 |
| distinct values over 10 runs | **1** | **1** |
| verdict over 10 runs | 10 passed, 0 failed | 0 passed, 10 failed |

Before the fix the same test was 8 failures in 10 with a moving value. It is now bit-identical run to run in both modes, so klear stops being the exception among the four tests #1088 listed and behaves like the other three: a deterministic magnitude that clears the bound under the pin and misses it without.

## The backend is not implicated on this path

Repeated identical forwards inside one process are bit-identical at both precision settings, measured separately for each arm (`0e0` for both the single-token and the 4-token prefill arm, at `MLX_ENABLE_TF32=0` and `=1`). Scoped claim on purpose: this says nothing about the quantized MoE accumulation-order nondeterminism recorded in #629 / PR #726, which is a different path that this fixture never reaches.

## What the tolerance is

The 1e-3 bound is a precision bound, not a causality bound, and the test now says so. Measured in the same binary by handing the prefill an all-zero additive mask, a genuinely bidirectional prefill moves row 0 by about **1.6**, roughly 1.5e3x the default-precision delta and 3e6x the pinned one. The assertion keeps its full power over the property it names, so the tolerance is left alone rather than widened.

## Acceptance criteria

- [x] The source of the run-to-run variance is identified: per-process `HashMap` iteration order in `filled_weights`.
- [x] A decision is recorded: it was neither a backend defect nor an accepted backend property, but a fixture defect, and it is fixed.
- [x] Nothing to document for users: the premise (nondeterministic greedy decode for quantized MoE on CUDA, inferred from this test) is refuted, so no such note is added. The corrected reasoning is recorded at the `verify-test-cuda` gate definition in the `Makefile`, replacing the claims #1264 left there.
- [x] The runtime exactness probes (#1188, #1189) are checked. See below.
- [x] Re-running the klear test 10 times on GB10 gives a stated, reproducible verdict.

## On the exactness probes

The probes compare a verify block against a single-token chain byte for byte, within one process, so run-to-run variance would make their verdict noise-determined. The in-process repeat measurement above shows forwards on this path are bit-reproducible, so the probes are robust on that axis.

One asymmetry surfaced while checking and is recorded rather than changed here, since it is not a nondeterminism problem: the Qwen 3.5 gate short-circuits on `!mlxcel_core::metal_is_available()` in both the server path (`models/qwen3_5.rs`) and the offline CLI path (`commands/generate.rs`), while the three Gemma 4 arms have no such guard and therefore do run the probe on CUDA. Not exercised against a real Gemma 4 checkpoint on CUDA here.

## Verification

- `models::klear` 22 passed, `models::afmoe` 23 passed, `models::phixtral` 18 passed, `models::bailing_moe_linear` 35 passed; 98 tests, 0 failed.
- `cargo fmt --all -- --check` clean; `cargo clippy --lib --tests --features cuda -- -D warnings` clean.
- The klear causality test run 10x per precision mode as tabulated above.

Closes #1265
@inureyes inureyes self-assigned this Aug 27, 2026
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 6, 2026
inureyes added a commit that referenced this pull request Oct 6, 2026
Issue #1065 was filed as an M5 precision defect. The cause is MLX's default MLX_ENABLE_TF32=1, which runs f32 GEMMs with 10-bit mantissa inputs on CUDA Ampere and later (cuBLAS FAST_TF32) as well as on Apple GPU generation 17 (NAX). #1260 already pins the variable to 0 in both lib test binaries. This change closes what was left.

- Add tf32_pin_tests, an f32 GEMM (16x512x16, off MLX's gemv path) checked against an f64 host reference. On GB10 it passes with the pin and fails at 5.8e-3 against a 1e-4 bound under MLX_ENABLE_TF32=1. Before this, nothing in mlxcel-core went red on CUDA when the pin was lost: the four tests #1065 named stay green there under TF32 because their shapes use gemv and SDPA, not cuBLAS.
- Correct both ctor doc comments, which said the breakage was Apple-only.
- Print the measured divergence in the two remaining chunked-SDPA assertions.
- Document MLX_ENABLE_TF32 in docs/environment-variables.md with the GB10 decode A/B, and stop naming the M5 attention kill switch as #1065's suspect.

Decode A/B on GB10, greedy, 256 tokens: the f32 checkpoint plamo-2-1b changed output on 1 of 5 prompts, deterministically in both arms over 5 runs, at unchanged throughput. bf16, f16 and 4-bit checkpoints showed no difference attributable to the variable. Shipped numerics stay on the MLX default.

Closes #1065
inureyes added a commit that referenced this pull request Oct 6, 2026
## Summary

#1065's title and "Hardware dependence" section are wrong: the failures are not M5-specific. The cause is MLX's default `MLX_ENABLE_TF32=1`, which runs f32 GEMMs with 10-bit mantissa inputs on CUDA Ampere+ (cuBLAS `CUBLAS_COMPUTE_32F_FAST_TF32`) and Apple GPU generation 17 (NAX). The M1 Ultra passed only because it has no TF32-class path. The main fix already landed: #1260 pins `MLX_ENABLE_TF32=0` in both lib test binaries, #1262/#1263 document it at the `verify-test-cuda` gate, #1196 added the attention kill switch (which does not affect these tests). This PR closes what remained:

- `mlxcel-core/src/tf32_pin_tests.rs`: an f32 GEMM (16x512x16, off MLX's gemv path) against an f64 host reference. Nothing in `mlxcel-core` went red on CUDA when the pin was lost, because the four tests #1065 named pass on CUDA even under TF32 (their shapes use gemv/SDPA, not cuBLAS).
- Ctor doc comments in `src/lib.rs` and `mlxcel-core/src/lib.rs` no longer say "Apple only".
- The two remaining chunked-SDPA assertions print the measured divergence (the two #1065 named already did).
- `docs/environment-variables.md`: new `MLX_ENABLE_TF32` row with the measured decode impact; the `MLXCEL_METAL4_ATTENTION` row no longer names the route as #1065's suspect.

Tolerances are unchanged. Shipped numerics stay on the MLX default, per the existing policy (unit tests verify full-f32 algorithm equivalence; TF32 output is not f32 evidence).

## Decision on production decode

GB10, greedy, 256 tokens, default vs `MLX_ENABLE_TF32=0`, arms alternated:

| checkpoint | weights | result |
|---|---|---|
| plamo-2-1b | f32 | 1 of 5 prompts diverges (after ~40 words), deterministic in both arms, 5/5 runs each; 38 tok/s both arms |
| llama-3.1-8b-bf16 | f16 | identical, 3 prompts x 2 runs |
| qwen2.5-0.5b-bf16 | bf16 | run-to-run variation in both arms alike, not attributable |
| qwen3-0.6b | 4-bit | run-to-run variation in both arms alike, not attributable |

TF32 changes greedy output only where dense f32 GEMMs exist, which in practice means f32 checkpoints. On CUDA, MLX sends unbiased single-row matmuls to a gemv kernel the setting does not reach, so prefill-sized GEMMs are the likely site; that split is read from MLX source, not measured. The default stays; the docs tell operators to set `MLX_ENABLE_TF32=0` for f32-exact output.

## Test plan

- [x] At origin/main `5211f58d`, CUDA, `--test-threads=1`: the 4 `mlxcel-core` tests from #1065 pass by default and under `MLX_ENABLE_TF32=1` (3 runs each). The 3 root-lib tests from the 2026-08-17 comment pass by default and fail 3/3 under `MLX_ENABLE_TF32=1` (deepseek_v2 2.70e-4, florence2 1.43e-4)
- [x] New sentinel: pass by default and with `=0`, fail 3/3 under `=1` (5.84e-3 vs 1e-4 bound)
- [x] After the change: `layers::tests::chunked_`, `mla::decode::decode_tests`, `tf32_pin_tests` all pass on CUDA
- [x] `cargo fmt --check`, `cargo clippy -p mlxcel-core --lib --tests --features cuda -- -D warnings`

## Not verified on this host

- The four tests on M5 Max, and the M1 Ultra nightly runner: no Metal hardware here. #1065's 2026-08-17 comment already showed all four pass on M5 under `MLX_ENABLE_TF32=0`, and #1260 reported the suite green on M5 with the pin.
- Sentinel failing under `MLX_ENABLE_TF32=1` on M5 (expected from the NAX dispatch, not run).
- M5 vs M1 decode quality on real checkpoints.

Closes #1065
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core mlxcel-core: MLX FFI, primitives, KV cache, layers priority:medium Medium priority status:done Completed type:test Test related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test: the suite is red on generation-17 hosts (MLX TF32 default) and on current minijinja

1 participant