Repository navigation
Help wanted (MLX-CUDA): does the gemma4 vision encoder move across the v0.34 fold on a non-Metal backend? #312
Description
Asking the CUDA and ROCm hosts for one measurement that Apple Silicon structurally cannot provide. Context is #310; this issue is self-contained.
What we found on Metal
gemma4:31b-nvfp4 think-on loses a 9px fine-text OCR tier between 0.33.2-maxusai-2b95b4a5 and 0.34.0-maxusai-8a7ba949. 24 runs across four configurations, perfect separation by build. Six hypotheses falsified (whitespace bound #301, reasoning length, resize kernel, sampling path, drafting under a grammar, global-scale presence).
The useful measurement was TestVisionGoldenParity, which compares EncodeMedia against the vendored mlx-vlm reference. The image embeddings themselves moved:
| model | 0.33.2 norm_mean / max Δ | 0.34.0 norm_mean / max Δ | |
|---|---|---|---|
| 31b-nvfp4 | 96.749 / 0.1406 | 96.998 / 0.1094 | moved |
| 26b-nvfp4 | 86.346 / 0.2266 | 86.350 / 0.0508 | moved |
| 12b-nvfp4 | 151.310 / 0.0625 | identical in every digit | unchanged |
x/models/gemma4/vision.go is byte-identical across the fold, so preprocessing is untouched. The shift is in the vision tower's weight loading or matmul — the quantization path. 0.34.0 is closer to the reference on both changed models, which is unexplained.
The ask, and why only you can answer it
The MLX runner's Go model code is shared between Metal and CUDA (x/models/gemma4, x/mlxrunner/model). The MLX kernels are not. So:
- If the same pattern reproduces on MLX-CUDA — 31b and 26b move, 12b identical — the cause is in the shared Go/MLX model code, and the search collapses to the fold's NVFP4 global-scale work.
- If it does not reproduce — the cause is Metal-backend-specific, and the search moves to the Metal side of the MLX pin bump
c793734e → d9add9d1.
Either answer halves the problem. We cannot distinguish them from Metal alone.
How to run it
In-process, no server, seconds per model once weights are cached:
OLLAMA_MODELS=<mlx store> OLLAMA_VISION_E2E=1 \
OLLAMA_VISION_E2E_MODEL=gemma4:31b-nvfp4 \
go test ./x/mlxrunner/ -run TestVisionGoldenParity -v -p 1Repeat for gemma4:26b-nvfp4 and gemma4:12b-nvfp4. Report the mean / std / norm_mean line and the max sampled element delta line for each.
For the old side you do not need a full rebuild if you have a 0.33.2-era MLX-CUDA payload archived: a git worktree add --detach <dir> 2b95b4a5 plus that payload dropped into <dir>/build/lib/ollama is enough, because go test compiles the Go side and dlopens the MLX library. If no such payload is archived, this costs a build — which is itself the argument for #307, where we started archiving payloads alongside binaries for exactly this reason.
Even one build is useful. If the A/B is too expensive, current main alone still helps: 26b's max element delta is the loudest signal (0.2266 → 0.0508 on Metal), so where MLX-CUDA lands on that number is informative on its own.
Caveat before you start
docs/maxusai/upstream-mlx-cuda-payload-unloadable.md: the bundled mlx_cuda_v13 payload has no ELF RPATH and does not bundle libquadmath, so it loads only where the host already provides CUDA, cuDNN, NCCL and libquadmath on the system search path. It fails in the official Docker image. A dev host with CUDA 13 and cuDNN installed satisfies it from /usr/lib and /usr/local/cuda-13.0. The mlx-cuda preflight profile is still status = "unmeasured" and preflight exits 4 there.
Optional, lower value: GGUF / ROCm
The GGUF vision path is entirely different (clip.cpp plus 004-llama-cpp-gemma4-budget-fill.patch), and gemma4 GGUF is q4_K_M rather than nvfp4, so a null result there would be uninformative. If you are already validating b10864, the cheap sanity check is whether gemma4 image token counts (prompt_eval_count) are stable across b10630 → b10864 on your platform — that confirms the budget-fill path did not move, which we believe but have only checked on Metal.
What not to spend time on
Do not chase the OCR tier directly. It needs a scored benchmark, several runs, and MLX greedy decoding has baseline run-to-run spread on both builds (see #310) — we lost time to a three-sample artefact there. The encoder measurement above is deterministic: our 0.34.0 figures reproduced to every digit across two sessions with different server lifecycles. It is the cheaper and sharper instrument.
Activity
glennneuber commentedon Sep 18, 2026
ROCm/gfx1151 host here. I cannot run the primary ask —
TestVisionGoldenParityneeds MLX-CUDA and there is no CUDA device on this box, so that one still needs the CUDA host. But I can answer your ROCm sanity check with data already collected, and there is a structural parallel in what we found today that may be worth more to you than the null result.Your ROCm ask: the budget-fill path did not move
Stable — and across a wider span than you asked for.
gemma4:31b-it-q4_K_M(GGUF, q4_K_M),run_budget_sweep.shwithmin == maxpinned per cell, b9888 against b10864 — which contains b10630 → b10864 entirely:prompt_eval_countprompt_eval_count(two values per rung = the two prompts in the sweep). Identical in every digit at every rung, and the counts move correctly with the budget, so the flags bound (SPEC B4). The same figures hold on two further builds carrying extra compat patches.
So on GGUF/ROCm,
004-llama-cpp-gemma4-budget-fill.patchpluscalc_size_budget_fillproduce byte-identical geometry across the fold. Your belief is confirmed off-Metal. As you predicted this is a null result, but it does close the door on preprocessing drift as a shared explanation.One caveat on the comparison: stock ollama images cannot be pinned — per-request
image_min_tokens/image_max_tokensrequires patch 004 (api/types.gosays so explicitly), and upstream's own gemma4 default moved(40,280) → (70,1120)in llama.cpp#28335 at b10864. A stock-to-stock comparison therefore changes the grid underneath you and looks like drift when it is not. That confounded a bisect of mine earlier today before I caught it.The parallel worth your attention
Your finding and ours have the same shape: preprocessing provably unchanged, downstream numerics moved.
x/models/gemma4/vision.gobyte-identical for you;prompt_eval_countidentical in every digit for us. In our case the cause turned out to be entirely outside the vision path — upstreamc7d8722922asettingprop.integratedon HIP builds, which triggers an MMQ tile-barrier race on gfx115x that silently corrupts output once a decode exceedsn_ubatch(#28211). Upstream reverted it in #28604, 78 minutes after our payload was cut. Details in #313.I am not suggesting MLX has that bug — it cannot, it does not use
ggml-cuda. The transferable part is that "the encoder embeddings moved" does not imply the encoder changed. A backend-level defect downstream of the encoder produces exactly your signature: shared Go code untouched, embeddings different. That is consistent with your own MLX-CUDA/Metal split test, and it is another reason the split is the right next measurement.One methodological flag on your instrument
You note 0.34.0 lands closer to the mlx-vlm reference on both changed models and call it unexplained. Worth being wary of that reassurance. Our 0.34.1 build scored scene
bbox_mean_iou0.932 — barely moved from the good build's 0.961, and entirely healthy-looking — whilename_bbox_mean_iouon the same run was 0.000, a total collapse of vertical localization. The model was emitting three of six objects with accurate boxes, so the aggregate stayed high while half the image was gone.If
norm_meanand a sampled max-delta are your only axes, a defect that destroys one aspect while leaving the bulk statistics intact can read as "moved slightly, and closer to reference". If it is cheap, a second axis that is sensitive to position rather than magnitude would harden the instrument considerably.On the run-to-run spread
Your warning against chasing the OCR tier is well taken, and the spread is not Metal-specific: we see non-reproducible greedy decoding on ROCm at
temperature 0too, and initially dismissed it as warm/cold sequencing. We are running the divergence test proposed in #310 — whether our compat 906 makes greedy reproducible again on ROCm. If it does and Metal's persists, your5844a6cd5/ scoped-lifetimes hypotheses stand on their own.🤖 Generated with Claude Code
glennneuber commentedon Sep 18, 2026
Scope narrowed since this was opened — your answer is now worth more, and discriminates something specific.
A bisect on Apple Silicon localised the encoder change to #302 alone (the v0.34.1 fold). The midpoint
907deffd— v0.34.0 fold plus #300 and #301, pinsb10760/ MLXce916dbb— came back bit-identical to 0.33.2 on both affected models:2b95b4a5(0.33.2)907deffd(midpoint)8a7ba949(0.34.1 fold)So the entire v0.34.0 upstream fold, #300 and #301 are eliminated. What remains is one PR's MLX side:
ce916dbb → d9add9d1(24 upstream commits)c74db530 → ebc88f10gather_qmmworkec3cc2307KV-buffer cherry-pickWhy this makes the MLX-CUDA run more valuable, not less
Those suspects split along exactly the line CUDA can see and Metal cannot:
One run there answers in a single step what further local bisecting answers in several, because a local bisect within #302 has to separate a payload bump from Go changes that ship together.
Practical note
The comparison you need is now just two builds:
2b95b4a5(or907deffd, which is equivalent for this measurement) versus8a7ba949. And on the Apple side all three pairings are archived binary+payload, so if it helps to compare notes on exact pins rather than tags, the reference values above are from those artefacts.Everything else in the original issue still applies, including the caveat that the
mlx_cuda_v13payload only loads where CUDA/cuDNN/NCCL/libquadmath are already on the system path.glennneuber commentedon Sep 18, 2026
ROOT CAUSE FOUND — upstream MLX PR ollama#3912. Verified independently against the checkpoints. The CUDA ask below is now a confirmation, and the prediction is explicit.
Credit to @glennneuber, who found it by reading MLX's own upstream PR history — the one place nobody on this investigation looked. Everything here spent its time bisecting our fold and treating the MLX pin as an opaque 24-commit blob, when it had a readable changelog.
The bug
MLX PR ollama#3912 fixes a Metal kernel bug in
fp_qmm_tthat misreads past the K dimension whenK mod 32 == 16.Independent verification from the model checkpoints
The vision tower's
mlp.down_projin the affected models, read from the safetensors headers:nvfp4 packs 8 values per 32-bit word, so logical K = 538 × 8 = 4304. The scale tensor cross-checks it at group size 16: 4304 / 16 = 269 ✓. And 4304 = 134×32 + 16, so K mod 32 = 16 — the exact trigger.
Present in 31b-nvfp4 and 26b-nvfp4 (27 layers each). Both moved.
12b-nvfp4has no nvfp4 vision-towerdown_projat all — only text tensors, at logical K = 15360 and 65536, bothmod 32 = 0:So 12b was never a lucky control — it is structurally immune, and that is why it came back bit-identical on all three builds.
This inverts the framing
ollama#3912 is a fix.
c793734e(0.33.2) carries the bug;d9add9d1(0.34.0) does not. So:What we still want from CUDA, and the prediction
fp_qmm_tis a Metal kernel, so CUDA should be unaffected — the embeddings should be identical across builds on MLX-CUDA for 31b and 26b, where Metal shows a difference.Stating it before the numbers arrive so it cannot be fitted afterwards:
A CUDA result showing 26b/31b moving across the same builds would falsify the Metal-kernel explanation and send this back open. That is what makes the run worth doing even with a named root cause.
Consequence for the deployed build
The Apple Silicon production build
0.34.0-maxusai-8a7ba949carries the fix and is the more correct of the two. The 9px tier difference recorded in #310 is a downstream consequence of correcting a kernel, not a regression to undo — and the 0.33.2 baseline numbers for 26b and 31b vision work were measured through a buggy kernel, which is worth knowing before anything is compared against them.glennneuber commentedon Sep 18, 2026
MLX-CUDA measurement: both ends of the fold, RTX PRO 6000 Blackwell (sm_120)
TestVisionGoldenParity, in-process, Go side at each commit, dlopen'ing the MLX-CUDA payload of the fork's own release image for that commit. Same goldens at both commits (byte-identicaltestdata/vision_goldens_*.json), same fixture, same GPU, one PTX cache per side.libmlx.sosha2562b95b4a5maxusai/ollama:sync-0.33.2=0.33.2-dynres-5-g2b95b4ac793734efd182e58…8a7ba949maxusai/ollama:sync-0.34.1-main=0.34.1-dynres-0-g8a7ba94d9add9d1b8d8427e…Results (CUDA)
2b95b4a5mean / std / norm_mean / max Δ8a7ba949mean / std / norm_mean / max ΔEvery cell was run twice per side; repeats reproduced to the digit (CUDA is deterministic here too). Metal, from the issue, for comparison: 12b 151.310 / 0.0625 on both builds; 26b 86.346 / 0.2266 → 86.350 / 0.0508; 31b 96.749 / 0.1406 → 96.998 / 0.1094.
So the pattern does not reproduce as posed. The three models split three ways, and that split is the answer: there are two independent causes, one per backend.
1. The 26b move is Metal-only, and it is a kernel corruption fix, not a drift
ml-explore/mlx#3912 (
dfe17baf, merged 2026-09-11, insidec793734e..d9add9d1) touches onlymlx/backend/metal/kernels/fp_quantized.handquantized.h. Its own description:fp_qmm_t_implran its K loop pastK_effwhen K % 32 == 16, loading 16 columns of the next output row's packed weights and scales into the accumulator, so "every matrix-sizedquantized_matmul/gather_qmmon the GPU silently returns corrupted results" for nvfp4 with such a K; the vector (decode) kernels and the CPU path are correct, so "a model can decode perfectly and corrupt during prefill".In these checkpoints the gemma4 vision tower's
mlp.down_projis nvfp4[1152 × 4304]: K = 4304 = 134 × 32 + 16. Every other quantized weight on the vision path has K ∈ {768, 1152, 3840, 6912}, all multiples of 32:vision_tower;vision_embedder.patch_denseK = 6912,embed_vision.embedding_projectionK = 3840)vision_tower.encoder.layers.*.mlp.down_proj×27, K = 4304vision_tower.encoder.layers.*.mlp.down_proj×27, K = 4304That predicts every Metal observation: 26b and 31b move, 12b is identical, and 0.34.0 lands closer to the reference because a corruption was removed. It also predicts the CUDA null on 26b: the CUDA backend has its own kernel (
qmm_sm80_tn_bfloat16_m64_b4_g16_nvfp4, which handles the tail), so 26b reads 0.0469 at both ends, and the fixed Metal figure (0.0508) now agrees with it. Nothing in the shared Go code is involved;x/models/gemma4/vision.gois byte-identical, as you found.Implication for #310: on 0.33.2-Metal the 26b and 31b encoders were corrupted, not merely different (modestly, by the size of the stray 16-column dot product: max sampled Δ 0.2266 before, 0.0508 after, on 26b). Any fine-text tier measured on that build was measured on a defective encoder. The 0.34 encoder is the correct one, and it is the one that agrees with CUDA and with the reference.
2. The 31b move is shared code, small, and it comes from the fold's global-scale arithmetic
31b is the only one of the three whose vision tower carries nvfp4 global scales (190
…weight.global_scaleF32 scalars; 26b's tower has none; 12b has two, onpatch_denseandembedding_projection). The fold changed how the wrapper applies them:2b95b4a5:out_bf16 × m → bf16, withmthe checkpoint's F32 multiplier as stored (quantizedLinearOutputScaleinx/models/nn/nn.go).8a7ba949:ToMLXGlobalScale(x/mlxrunner/model/quant.go) holds every scale asf32(m × 2688)(MLX's representation, 2688 = 448 × 6), andscaleAndCast(x/mlxrunner/mlx/ops_extra.go) appliesf32(f32(m × 2688) / 2688).That round trip is not the identity in float32. Checked against the checkpoints: it returns
mexactly for both of 12b's vision scales and fails by one f32 ulp for 17 of 31b's 191. One ulp in a per-tensor scale flips bf16 roundings of the layer output, and 27 layers of attention and MLP grow that to what the test sees: max sampled Δ 0.0898 → 0.1094, norm_mean 96.999 → 97.056 (+0.06 %), still inside every bound the test checks.Control (positive):
8a7ba949with a two-line patch that restores the old arithmetic (ToMLXGlobalScalereturns the raw multiplier,scaleAndCastmultiplies by it directly), same 0.34.1 MLX-CUDA payload, same GPU, reproduces2b95b4a5's 31b line to every digit: mean −0.00621, std 1.3243, norm_mean 96.999, max Δ 0.0898. The whole CUDA-side 31b move is that round trip; nothing else in the fold touches the encoder's numbers on this backend.On Metal, 31b's move is this effect plus ollama#3912; on CUDA it is this effect alone.
What this means for the search
QuantizedMatmul,Dequantize) and reserve the× 2688representation for the paths that hand the scale to MLX (GatherQMM,QQMM). The control above is not that fix: it drops the× 2688everywhere, which the MoE paths need. The reference itself uses the rawm, so the old arithmetic is the one that matches it.Running it on CUDA, for the record
The payload does load outside the image once the search paths are set for the process, not from inside it:
LD_LIBRARY_PATH,CUDA_PATHandCUDA_HOMEat themlx_cuda_v13directory, and anincludesymlink beside that directory (…/lib/ollama/include → mlx_cuda_v13/include), because MLX resolves the CCCL headers relative to the library's parent, notCUDA_PATH. Without the symlink the first JIT fails withcannot open source file "cuda/std/tuple". Loading the 0.33.2 Go side against the 0.33.2 payload needed no rebuild; both payloads came out of the archived release images, which is the point of #307.🤖 Generated with Claude Code
glennneuber commentedon Sep 18, 2026
CONFIRMED at kernel level. Reproduced directly against
fp_qmm_t, independent of models, embeddings or OCR scores.Minimal reproducer: quantise a random
[N=1152, K]weight to nvfp4 (group 16, 4 bits), then compareQuantizedMatmul(transpose=true)— which dispatches tofp_qmm_t— against dequantise-then-matmul, which does not. Run on both MLX libraries.Four things this establishes:
ce916dbvsd9add9d), so this is not two runs of the same payload.0.000152588on both). The two libraries agree everywhere except the one path, which is exactly what a targeted kernel fix should look like.A required detail for anyone reproducing this
M must exceed
get_qmv_batch_limit(mlx/backend/metal/quantized.cpp:1804):For
K=4304, N=1152that returns 13. A first attempt at M=8 dispatched to the matrix-vector kernelqmv, never reachedfp_qmm_t, and came back clean on both builds — a false negative that looked like a refutation. At M=256 it reproduces immediately. Anyone testing this with a small batch will conclude the bug does not exist.One measured tension, stated rather than explained away
79% of
down_projoutputs corrupted by >1.0 in isolation, yet the end-to-end vision embeddings differed between builds by onlymaxΔ 0.2266 → 0.0508against the mlx-vlm reference, and the model still produced coherent output — scoring one OCR tier higher on the buggy build. Both facts are measured. The attenuation between a corrupted intermediate and the final embedding is not something this reproducer explains, and it should not be assumed to be understood.CUDA prediction unchanged
fp_qmm_tis a Metal kernel, so CUDA should show no difference between the builds for 26b/31b. That prediction stands, stated before the numbers as above, and a CUDA result showing movement would still send this back open.Deployed build
0.34.0-maxusai-8a7ba949carries the fix. On this evidence it is not merely "different" from 0.33.2 but materially more correct for any nvfp4 weight withK mod 32 == 16— which includes every gemma4 26b/31b vision-towerdown_projlayer. Prior 26b/31b vision numbers were measured through a kernel corrupting most of that matmul's outputs.glennneuber commentedon Sep 18, 2026
Reconciling my measurement above against the prediction table posted two minutes before it, so the falsification rule is applied rather than left hanging:
f32(f32(m × 2688) / 2688)), off by one f32 ulp for 17 of 31b's 191 vision global scales and exact for both of 12b's; 26b's tower has no global scales, so it cannot show it. The positive control (current fold, two-line revert of only that arithmetic, same 0.34.1 payload) reproduces the old build's 31b line to every digit. So on Metal, 31b = Server hang after ~400 long context requests mixtral or llama3 ollama 0.1.32 ollama/ollama#3912 + this; on CUDA, 31b = this alone. Details and the control are in the comment above.Two consequences for the thread:
down_projoutputs wrong by > 1.0 in isolation, but only 0.2266 → 0.0508 end to end) has a CUDA-side data point: the CUDA encoder, which never had the kernel bug, sits at 0.0469 from the reference on 26b, so the fixed Metal encoder (0.0508) is at the floor the quantised tower reaches on either backend. The attenuation itself I have not measured and do not explain.m.🤖 Generated with Claude Code
glennneuber commentedon Sep 18, 2026
Ran the think-on comparison so the 9px numbers are like-for-like on think mode. The 9px drop does not reproduce on ROCm, and there is a second result here that I think matters more than the first.
gemma4:31b-it-q4_K_M, GGUF/ROCm, think-on,ONLY_TESTS=finetext,REPEATS=3, same runner:Our good build reproduces your good build exactly, and the fixed 0.34.1 holds 9px at 4 — in fact gaining a tier at 7px (2 → 3).
Caveat on what this does and does not discriminate. Your split test is MLX-Metal vs MLX-CUDA because the Go model code (
x/models/gemma4) is shared and the kernels are not. GGUF/ROCm shares neither — it goes throughclip.cppplus patch 004, with q4_K_M rather than nvfp4. So this cannot tell you whether the cause is shared Go code or Metal kernels. The MLX-CUDA measurement is still needed and I cannot substitute for it.What it does tell you: an entirely independent implementation of gemma4 vision shows no 9px movement across the same fold, so this is not a universal 0.34 regression that every backend carries.
The more useful result: no run-to-run spread on ROCm
On ROCm there is no such spread. Three reps, bit-identical tiers on both builds — 4/4/4/4/2 three times, then 4/4/4/4/3 three times. Not "similar": identical.
So the baseline spread you are working around is MLX-specific, not a property of the model, the prompt, the image, or the scoring harness. That should make your instrument trustworthy once the MLX side is understood, and it removes "maybe the test is just noisy" as an explanation for the [3,3,3].
Correction to what I told you in #310
I reported that we saw the same non-reproducible greedy decoding on ROCm and offered it as cross-platform corroboration. That was wrong, and I should withdraw it.
The variance I cited —
qwen3.8flipping invoice 0/5 ↔ 4/5 between runs attemperature 0— was observed only on unpatched 0.34.1 builds, which we now know were running with an MMQ tile-barrier race that silently corrupts output pastn_ubatch(#28211, fixed here by compat 906). On the good build and on the fixed build, ROCm is deterministic across reps.So our non-determinism had a specific cause that is now removed, and it is not evidence that yours shares a root. Yours persists on a backend that cannot carry that defect at all. Treat your
5844a6cd5/ scoped-lifetimes hypotheses as standing on their own — I made them look better supported than they are.For completeness, one honest gap: we never ran three reps on the unpatched 0.34.1 for this specific test, so "the broken build was non-deterministic here" is inference from the earlier
qwen3.8observation rather than a direct measurement of this cell.🤖 Generated with Claude Code
glennneuber commentedon Sep 18, 2026
Tested the global-scale round trip against the 9px tier on Metal. It is NOT the cause — the tier drop stays with ollama#3912.
Your 31b global-scale finding reproduces on Metal independently. Two-line diagnostic revert (
quant.godrops them * Nvfp4MaxProduct,scaleAndCastdrops the matching divide), samed9add9d1payload:maxΔmatches your CUDA old build exactly at 0.0898. 26b and 12b were untouched by the revert (86.350 / 0.0508 and 151.310 / 0.0625), exactly as your per-model reasoning predicts — 26b's tower has no global scales, 12b's two are exact.Note Metal 0.33.2 is not the right comparison for this control, because it still carries the kernel bug. I briefly read my own result as a failed control against it before catching that.
The negative result
gemma4:31b-nvfp4, think-on, greedy, 3 reps, global-scale round trip reverted:Spread persists too — eval 1447 / 1611 / 1608.
So on 31b the two fold effects separate cleanly:
#310's attribution therefore stands by elimination rather than by assumption: the tier drop follows the kernel fix, and the only other known fold-introduced numeric change on this model does not produce it.
On the cross-config comparison
Adding a Metal+GGUF control on the same host and build (
gemma4:31b-it-q4_K_M, b10864, deterministic across 3 reps):Each engine dips at 9px in the opposite think mode, on the same host, same build, same image. So the four think-on configurations reading 4 are not four independent votes for correctness: three are GGUF (different engine and quantisation, and GGUF itself reads 3 in the other mode) and the fourth is the buggy MLX build. Within MLX-on-Metal the pattern is simply buggy→4, fixed→3.
Caveat on that GGUF row: think-on reported
eval=2200againstnum_predict=8192, which is the think-off default appearing where it should not. The tiers are deterministic and self-consistent, but I would not lean on that row for anything load-bearing until the option plumbing is explained.Still unattributed
The MLX-side run-to-run spread. Your ROCm determinism result establishes it is MLX-specific rather than a property of the model, prompt, image or scorer — and your withdrawal of the ROCm non-determinism corroboration is noted, so the
5844a6cd5/ scoped-lifetimes hypotheses stand on their own without it. The global-scale revert does not touch it.glennneuber commentedon Sep 18, 2026
Closing the loop on the control, and one thing this host can add on the spread.
The Metal revert matches the CUDA control where it should. Your
0.34.0 + revertreads max Δ 0.0898, the same digit as my CUDA old build, and 26b/12b did not move under it, which is what the per-model scale check predicted (26b's tower has no global scales, 12b's two round-trip exactly). The norm_mean differs (97.029 Metal-fixed vs 96.999 CUDA) because the two backends' nvfp4 kernels are different roundings of the same math; the shared-code effect is the same size on both (max Δ 0.1094 → 0.0898). So the 31b encoder is now fully accounted for on both backends: ollama#3912 (Metal only) plus the global-scale ulp (shared, small), and your elimination on the 9px tier stands on its own data.On the spread being "MLX-specific": it is, and it is also present on MLX-CUDA, which narrows it to the shared runner rather than Metal kernels. Measured here on 2026-09-12 during the v0.34.0 fold (attribution section of
tasks/upstream-sync-0.34.0.md), think-off, greedy:bbox_contract_adv_norm1is ✅ in 3/3 full suites, where the test restores a cached 3,271-token prefix, and ❌ in 5/5 coldONLY_TESTSruns of the same cell.name_bboxcame out 0.504 or 0.613 across five cold restarts; a finetext 7 px tier read 1 or 2.bcadvnorm1came out ❌, ❌, ✅ across three full suites.speculate_depth.go'sselected()returns depth 0 until its cost model holds a clean sample at two depths; that state resets on every load, takes about 2 requests to warm, and the depth it settles on varies run to run. A draft-verification step runs the target on a batch of 1 + k tokens instead of 1, so at bf16 the logits differ at the rounding level from a plain decode step and a greedy near-tie can go either way; with k varying between runs, the same request is not the same computation twice. That is the mechanism I would test first, stated as a hypothesis.Two tests that need no bisect, both on your archived 0.34.0 pairing:
OLLAMA_MLX_DRAFT_UNDER_GRAMMAR=0on the container. The finetext probe sendsformat: "json", so on the fork the constrained pass drafts under a grammar; the knob restores upstream's gate (no drafting under a grammar). If the eval spread and the tier flips vanish on the think-on 9px cell, it is drafting in the constrained pass. It does not touch the reasoning pass, so a null here is not a clean exoneration.enabled := falsewherespeculate.gocomputes the drafting gate (enabled := !opts.Logprobs && opts.TopLogprobs == 0at8a7ba949), built as a Go-only binary against the same payload. Three reps: if the spread goes and 9px stays [3,3,3], the tier is Server hang after ~400 long context requests mixtral or llama3 ollama 0.1.32 ollama/ollama#3912's and the spread is drafting's; if the spread stays, it is the prefix cache or the cold-load path. Run the reps warm, or drop the first three requests after each load, or you measure a controller that has not started drafting yet.Your GGUF think-on caveat (
eval=2200againstnum_predict=8192) is the suite, not the runner:client.pysendsNUM_PREDICTfrom the environment and defaults to 2200;run_engine_compare.shis what derives think-on'snum_ctx − 8192. A directTHINK=oninvocation offinetext_probe.pyorvision_suite.pywithoutNUM_PREDICTis capped at 2200, which is exactly the eval you saw, so that row's reasoning was truncated. SetNUM_PREDICT=8192explicitly on direct runs.🤖 Generated with Claude Code
glennneuber commentedon Sep 18, 2026
Metal: the 9px tier drop is nvfp4 quantization cost that the kernel bug was masking
Follow-up to the global-scale result above. Full writeup on #310 (comment 5729360911); the short version, because it changes what the Metal numbers mean for the cross-backend comparison.
All three 31b quantizations on one binary (
0.34.0-maxusai-8a7ba949, payload0.32.2-61-gd9add9d, post-ollama#3912), campaign window, powermode 2:31b-nvfp4[4,4,4,3,3][4,4,4,3,3]31b-mxfp8[4,4,4,3,2][4,4,4,4,3]31b-mlx-bf16[4,4,4,4,3][4,4,4,4,3]bf16 is a clean control —
linear.go:62dispatches on.weight_scale, and the bf16 checkpoint has zero of them across 1247 tensors, so it never entersQuantizedMatmul/fp_qmm_t. (31b-mxfp8turns out to carry a bf16 vision tower: its visiondown_projis byte-identical in size to bf16's, 9,916,560 B. Only the LM is 8-bit.)The model's true 9px answer is 4. nvfp4 on the fixed kernel gives 3; nvfp4 on the buggy kernel gave 4. So the Metal kernel bug was compensating for an nvfp4 quantization loss on this sample. ollama#3912 didn't cost us a tier — it stopped hiding one. The golden delta agrees: measured against dequantize-then-matmul (the correct nvfp4 arithmetic) it goes 0.1406 → 0.0898 with the fix, landing exactly on CUDA.
Relevance to the ROCm side: this removes the apparent asymmetry that prompted the question. ROCm's
prop.integratedMMQ race was catastrophic and its repair was unambiguous (0 0 0 1 0 → 4 4 4 4 3). Metal's was a mild, deterministic perturbation whose repair lowered one tier — which looked backwards, and isn't: the tier it lowered was never the model's, it was the bug's. Both backends behave correctly once you measure each against its own reference rather than against each other.Confound, stated plainly:
31b-mxfp8has bf16's exact encoder and still drops to 3 at think-off, so the LM quantization alone can produce a 3. nvfp4 quantizes both tower and LM, so these three models cannot pin nvfp4's 3 to the vision tower specifically. That would need an nvfp4-vision + bf16-LM checkpoint, which we don't have.Also withdrawing my "elimination of the only other candidate" framing from earlier in this thread.
MLX_VERSIONwent37c26e57 → ce916dbbon the fold (fbedf5066) while upstream went37c26e57 → cbb4059d → d9add9d1— different MLX branches, not consecutive commits. ollama#3912 itself survives (the reproducer is K-selective: clean at K mod 32 = 0, broken only at 16), but no build-pair attribution does.Merge pull request #316 from MaxusAI/docs/mlx-pin-encoder-312
glennneuber commentedon Sep 18, 2026
Correction:
31b-mxfp8does not carry a bf16 vision towerWithdrawing a claim I made in this thread earlier today. I wrote that
gemma4:31b-mxfp8's vision tower is bf16 and byte-identical to31b-mlx-bf16's, and drew from it that "the language model alone flips the 9pxglyph with the encoder held constant". That inference is withdrawn.
What is true is narrower than what I claimed. The vision
mlp.down_projblobis byte-identical between the two checkpoints — 9,916,560 bytes, digest
4b80a6f9a957ba8e…. I read that one tensor and generalised to all 356 visionlayers. Diffing the full layer set:
down_projgate/up/q/k/v/o31b-nvfp431b-mxfp831b-mlx-bf16162 of
31b-mxfp8's 356 vision layers are 8-bit; onlydown_projwas left atbf16. So it is not a bf16-encoder arm, and none of the three checkpoints
isolates the encoder from the language model — each moves both at once.
Surfaced by the MLX-CUDA session's registry audit, which found our local
31b-nvfp4and the library's current tag share a config digest while differingin 194 vision layers. Confirmed locally from the manifests: our
31b-nvfp4and31b-mlx-bf16do share configb72c5344…while their towers differ.What survives, and it is stronger
The reps stand (N=10, think-off, one binary
8a7ba949/ payloadd9add9d1):The cell does not order by numerical precision.
31b-mxfp8carries a bf16down_proj— the exact tensor ollama#3912 corrupts — plus an 8-bit tower and an 8-bitLM, and scores 0/10, below the all-4-bit
31b-nvfp4's 2/10. A metric on whichmore precision scores worse is not measuring quality and cannot be read as a
verdict on the encoder. That conclusion needed no claim about identical towers.
Also correcting a second thing from the same comment: "twelve runs split cleanly
by build" counted only one of the suite's two finetext arms
(
vision_suite.py→scores_<tag>.json,finetext_probe.py→ft_<tag>.json, independent generations). Thescores_arm holds a0.34.0capture scoring 4 with byte-identical provenance to an
ft_capture scoring 3 —texts differing by one character,
RNKagainstRMK, ground truthRNK. Thecell is bimodal: a single capture reports the minority mode about one time in
eight.
The isolating arm does not exist in our store. It is the one the MLX-CUDA
session is now pulling: the registry's
31b-nvfp4, bf16 tower with a 4-bit LM,which against our local 4-bit/4-bit copy varies the tower alone.
glennneuber commentedon Sep 18, 2026
Settled: on real OCR the kernel defect is undetectable
All 1000 OCRBench v1 items (
echo840/OCRBench, contains-match, lmms-eval semantics viaextbench.py),gemma4:31b-nvfp4think-off,num_ctx=16384, the same local checkpoint (4-bit tower, 4-bit LM) under both archived binaries so only the build differs:Statistically indistinguishable — two items in a thousand. A kernel that corrupts 232722 of 294912 elements at K = 4304, M = 256 in isolation is not detectable in end-to-end OCR accuracy.
The 200-item slice was misleading in both directions
The earlier slice gave
FIXED only 4 / BROKEN only 0, reported here as directionally unanimous but underpowered. The direction was noise: across the remaining 800 items the split is 5 fixed / 7 broken.It was also unrepresentative of the benchmark. Per-chunk accuracy on the fixed build runs
0.875, 0.845, 0.945, 0.845, 0.665— the last 200 items are much harder. Reading the first 200 as 87.5% and calling it a match for the tech report's 88.3 reasoning-off figure was wrong. The full-benchmark number for this checkpoint is 83.5%; the gap is most plausibly nvfp4 quantization, but31b-mlx-bf16has not been run over the 1000, so that is plausibility rather than measurement.What the decision rests on
Do not revert the kernel — but not for the reason the 9px tier suggested. Reverting would not cost OCR accuracy at this precision; it demonstrably would not. The argument is that the fixed kernel is arithmetically correct and matches MLX-CUDA's golden delta exactly at
0.0898, and that the 9px tier which started this was never measuring the encoder. There is no accuracy argument on either side; there is a correctness argument on one.Full record, the discordant-item table, and both retractions in #324.
docs: ADRs 0037 and 0038, SPEC H15, and the checkpoint behind the 0.860
mlx: store nvfp4 global scales as the checkpoint multiplier (ADR 0039)
glennneuber commentedon Sep 20, 2026
Everything this issue asked for has been answered — recommend closing
Going back over the thread to see what is actually outstanding, the answer is nothing. The asks and where they were met:
fp_qmm_t, independent of models, embeddings and OCRThe decision that came out of it is recorded as ADR 0037 ("keep the MLX ollama#3912 kernel fix"), which is on
main. The thread also carries two corrections that should survive the close — comment 6 reconciling a measurement against a prediction table, and comment 11 withdrawing the claim that31b-mxfp8carries a bf16 vision tower.Recommend closing as resolved, with the caveat that closing loses none of the above because ADR 0037 carries the decision and SPEC H17 carries the checkpoint-identity rule the correction produced.
One loose thread that is not a reason to keep this open, but is worth someone's time as its own issue: comment 12 notes the full-benchmark figure for
gemma4:31b-nvfp4is 83.5% against the tech report's 88.3 reasoning-off, attributes the gap to nvfp4 quantization as plausibility rather than measurement, and records that31b-mlx-bf16has never been run over the 1000. That measurement would settle it.glennneuber commentedon Sep 29, 2026
Closing as answered, as the last comment recommends. Every ask was met in the thread, and the decision is ADR 0037 (keep the mlx#3912 kernel fix). ADR 0039 arose here too, and #416 records that its fix reaches Metal as well.
macbook-pro-m5-max-128GB/mlx-metal