Skip to content

fix(phi,stablelm): port the upstream f32 attention guard #1710

Closed

Description

@inureyes

Upstream promotes the attention inputs to f32 in phi, phixtral and stablelm; mlxcel had ported that guard only into phixtral.rs. phi.rs and stablelm.rs ran their scores at the input dtype.

Without it phi-2-4bit goes NaN at layer 29 of 32 in f16, and every token after that is the argmax of a NaN row, which decodes as !. It is reproducible in bare MLX with no mlxcel in the loop: the same forward with a f32 residual stays finite through all 32 layers and picks ' a' for "Paris is", and with a f16 residual it goes NaN. phixtral.rs's own comment already records the same failure ("NaN from layer 30 of 32"), so the family had been diagnosed once and only one of its files was fixed.

Nothing caught it because a second defect was masking it. gelu_approx in the cxx bridge returned f32 for a half-precision input, which widened the whole residual stream from the first MLP onward and kept the scores out of f16 range by accident. Fixing that exposed this.

Upstream reference: https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/models/phi.py and https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/models/stablelm.py, queries.astype(mx.float32) into the attention call and .astype(values.dtype) out of it; stablelm widens the keys as well.

The guard costs throughput, and the honest accounting is that phi-2-4bit's faster number was the speed of a broken model. On M1 Ultra it reads 164.10 tok/s with the widened attention removed and 129.93 with it, against 128.55 before any of this work. stablelm-1.6b-4bit goes 245.40 to 209.32, which is 98% of mlx-lm on the same host, the reference paying the same cost; its previous 115% was the guard not being paid. Measured headroom on stablelm is 11.8x against f16's ceiling on a 16-token prompt, worst |q·k^T| 5534.5, so its guard is precautionary. Phi's is not.

Two follow-ups. The other families upstream guards this way should be checked against their mlxcel ports: mlx-vlm applies it in minimax_m3_vl, paligemma, phi, phixtral and nemotron_labs_diffusion. And a f16 range assertion would catch the next one, since the symptom is a silent NaN and every parity check passes right up to the layer that overflows.

Update the affected rows in benchmarks/metal_m1ultra_2026-09-06.csv and the M5 Max equivalent when this lands, and regenerate the per-hardware docs. phi-2-4bit and stablelm-1.6b-4bit both move.

Activity

  1. added
    type:bugBug fixes, error corrections, or issue resolutions
    area:modelsModel architectures, weights, loading, metadata
    on Sep 8, 2026
  2. inureyes commented on Sep 8, 2026

    @inureyes
    MemberAuthor

    Implemented in commit f4ecc92 on branch bench/0.7.0-refresh (PR #1617, which is not for merge), awaiting the benchmark refresh on both hosts before it lands. Both phi-2-4bit and stablelm-1.6b-4bit move, so their rows are re-measured as part of that refresh.

  3. inureyes commented on Sep 12, 2026

    @inureyes
    MemberAuthor

    Status, so this does not read as untouched work: the guard itself landed in 3576d734 (src/models/phi.rs and src/models/stablelm.rs now widen exactly what upstream widens), and the M1 Ultra rows were re-measured after it in benchmarks/metal_m1ultra_2026-09-08.csv: phi-2-4bit 128.76 and stablelm-1.6b-4bit 211.05, against 128.55 and 245.39 before. Both move in the direction this issue predicted, and the old stablelm number was the speed of a model running its scores in f16.

    What is left is the M5 Max half. benchmarks/metal_m5max_2026-09-03.csv still holds the pre-fix rows (phi-2-4bit 106.08 with the early-EOS flag, stablelm-1.6b-4bit 428.36), and docs/benchmark_results/model_tests_m5max.md still quotes them. That measurement needs the M5 Max host and is now with the session that owns it; it will also record whether the early-EOS flag on phi-2-4bit survives the guard.

    The two follow-ups named in the description, checking the other families upstream guards this way and adding an f16 range assertion, have no issue of their own yet.

  4. inureyes commented on Sep 12, 2026

    @inureyes
    MemberAuthor

    Correcting my previous comment: the M5 Max measurement already existed, and I missed it.

    benchmarks/metal_m5max_2026-09-06.csv carries phi-2-hf-4bit-mlx at 173.07 and stablelm-2-1_6b-chat-4bit at 283.30, both dated 2026-09-08 and both stamped mlxcel_commit=3576d734, which is the commit that added the guard. So the M5 Max numbers are post-guard, and against the M1 Ultra values in metal_m1ultra_2026-09-08.csv they read 1.34x on both models.

    I looked for the M1 Ultra directory names (phi-2-4bit, stablelm-1.6b-4bit), and the M5 Max host stores those checkpoints under different directory names, so my grep returned nothing and I read that as "not measured". A directory name is a host-local artifact, which docs/benchmarks.md says in as many words. Joining across hosts has to go through the checkpoint identity in docs/model-catalog.tsv, not the directory name.

    What was actually stale is docs/benchmark_results/model_tests_m5max.md, which still quoted the pre-guard 181.80 and 394.89. #1827 aligns it with the committed rows and also records that the old early-EOS flag on phi-2 does not reproduce: measured with --no-ignore-eos at 39c597b0, it generates 14 tokens on the old 7-token chat prompt and the full 128 at pp512, so that flag was the pre-guard NaN behaviour rather than an EOS defect.

  5. added a commit that references this issue on Sep 12, 2026
  6. inureyes commented on Sep 12, 2026

    @inureyes
    MemberAuthor

    Done on both hosts. Closing.

    The guard landed in 3576d734: src/models/phi.rs and src/models/stablelm.rs now widen exactly what upstream widens, queries into the attention call and the result back out, with stablelm also widening the keys.

    Measurements, all taken at a commit that contains the guard. M1 Ultra, in benchmarks/metal_m1ultra_2026-09-08.csv: phi-2-4bit 128.76 against 128.55 before, stablelm-1.6b-4bit 211.05 against 245.39. M5 Max, in benchmarks/metal_m5max_2026-09-06.csv under that host's directory names: phi-2-hf-4bit-mlx 173.07 and stablelm-2-1_6b-chat-4bit 283.30, both stamped mlxcel_commit=3576d734, which is 1.34x the M1 Ultra figure on both models. #1827 carried those numbers into docs/benchmark_results/model_tests_m5max.md, which had still been quoting the pre-guard 181.80 and 394.89.

    The early-EOS flag this issue's numbers carried for phi-2-4bit does not survive the guard: re-measured with --no-ignore-eos, the checkpoint generates 14 tokens on the old 7-token chat prompt and the full 128 at pp512, so that flag was the pre-guard NaN behaviour rather than an EOS defect. The M5 Max table now records the model as passing.

    Two things named in the description are not done and have no issue of their own yet: checking the other families upstream guards this way (minimax_m3_vl, paligemma, phi, phixtral, nemotron_labs_diffusion in mlx-vlm), and adding an f16 range assertion so the next silent NaN is caught by a test rather than by a decode that prints !. Separately, a re-measurement of phi-2 on current main is reading lower than the committed row; that is being repeated on a quiet host before anyone calls it a regression, and it will get its own issue if it holds up, since the MLX pin also moved in between.

  7. inureyes commented on Sep 12, 2026

    @inureyes
    MemberAuthor

    Closing the loose end the previous comment left open: the lower phi-2 reading on current main does not hold up, so no regression issue is needed.

    Repeated on the M5 Max host under an idle gate, phi-2-hf-4bit-mlx measures 173.23 tok/s at pp512 with prefill 6843.44, against the committed 173.07 and 6802.24. The immediately preceding repetition of the same script read 117.56, and the stablelm pair in the same session read 284.65 and 198.02. The spread was contention: mlxcel is developed from several checkouts on that host, and cargo clippy --features metal,accelerate and cargo test --workspace were running in another one during the low samples. The MLX pin hypothesis closes with it, since 173.23 was measured at pin 81ba1c6a while the committed row is at 9a795735.

    One thing worth recording for the next benchmark campaign. The contention hit phi-2 and missed stablelm inside the same window, so a control model reproducing its baseline does not certify the measurement taken beside it. The suspect model has to be repeated itself.

  8. inureyes commented on Sep 12, 2026

    @inureyes
    MemberAuthor

    One correction to my closing comment, because the wrong version of it would send the next reader at the wrong commit.

    I wrote that the guard landed in 3576d734. It landed in f4ecc9269, the #1709 commit, in a wider form than upstream: phi.rs widened q, cache_k and cache_v through an f32_dtype binding. 3576d734 then narrowed it to what upstream actually widens, queries only for phi and queries plus keys for stablelm.

    That distinction matters for anyone writing the regression test in #1830: reverting 3576d734 restores the wider guard and leaves the model finite, so a test would still pass. The change that removes the protection is deleting the single astype(&q, mlxcel_core::dtype::FLOAT32) at src/models/phi.rs:199.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:modelsModel architectures, weights, loading, metadatapriority:highHigh prioritystatus:doneCompletedtype:bugBug fixes, error corrections, or issue resolutions

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions