docs(vision): SOLVED — the 0.34.0 gemma4 vision shift is upstream MLX #3912 - #310
Conversation
The 0.34.0 campaign found one moved quality cell: gemma4:31b-nvfp4 think-on drops a 9px fine-text OCR tier against 0.33.2. 18 runs across three conditions separate cleanly by build, so it is not sampling luck. Records four falsified hypotheses with the evidence that killed each — #301's whitespace bound, reasoning length, the resize kernel and the sampling path — because they are the four explanations anyone reaches for first and each cost a run. The more useful finding is incidental to the last of those: under forced greedy decoding 0.33.2 reproduces bit-identically three times while 0.34.0 produces three different outputs. Greedy decoding should be reproducible. That is a sharper thing to bisect than a one-tier recall difference, and it is where the next person should start. Think-off is unaffected across all five nvfp4 models, including under a json schema, so the deployed build's headline behaviour is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
CUDA-side context for the bisect, from the v0.34.1 fold session — evidence only, no conclusion to inherit.
🤖 Generated with Claude Code |
|
Corroboration from the other side of the estate, plus one scope note on hypothesis 3 and a list of what is already refuted so nobody re-runs it. "Greedy decoding is not reproducible on 0.34.0" reproduces on ROCm/gfx1151This is the part of your write-up that transfers. I hit the same thing today on a completely different backend and wrote it off as warm/cold sequencing — wrongly, I think, now that I see your numbers. Two runs of the same 0.34.1 binary,
And the tell that killed my own explanation: stock Worth noting for anyone reading both threads: this means single-run cells on 0.34.x are weaker evidence than usual. (The regression below is safe on that count — 0.000 vs 0.728, reproduced across separate runs on both stock and fork.) Scope note on hypothesis 3 (the resize kernel)Your kill is "b10630 already set For what it is worth, I have now killed it in my window too, by a different route: a compat patch reverting the flip for GEMMA4V/GEMMA4UV and QWEN3VL, verified present in-binary (output hash differs at identical The ROCm-side regression, for cross-referenceDifferent symptom from yours (think-off, bbox rather than think-on 9px OCR) but the same era. Already refuted here, so you do not have to
On your two suspects
🤖 Generated with Claude Code |
|
Downstream follow-up: the nondeterminism has a real consequence, and it is not the one I expected. A document-extraction pipeline running against this build on the same host audited its own dependence on byte-identical re-extraction, from its code rather than from memory. It found none:
So no behaviour breaks. But it did find one thing: its retry path deliberately warms the temperature, justified by the comment that "a temperature-0 retry is the same computation and stalls again." That premise held on 0.33.x and is false on 0.34.0. The behaviour stays defensible — arguably a cold retry now has independent value it did not have before — but the stated reason silently stopped being true underneath it. Those comments have been corrected in that tree, dated and scoped to the build, with the warming kept. Recording it here because it is the first concrete consequence outside a benchmark, and it is the shape I would expect others to take: not a crash or a wrong answer, but a design rationale that quietly stopped holding. Anywhere in this fork or its consumers that reasons "temperature 0 means the same computation" is now reasoning from a premise that needs re-checking on 0.34.0. That also slightly raises the priority of the bisect this PR defers. A one-tier OCR difference is easy to live with; an invalidated determinism assumption is the kind of thing that is cheap to fix now and expensive to discover later. |
An earlier revision claimed 0.34.0 is nondeterministic under greedy while 0.33.2 is deterministic, and offered that as the better bisect target. Three further runs refute it: 0.33.2 produced an outlier in the identical configuration where it had given three identical runs an hour earlier, and 0.34.0 with drafting disabled gave two of three identical. Pooled, 0.33.2 is 5-of-6 identical and 0.34.0 is 2-of-3 with drafting off and 0-of-3 with it on. So MLX greedy has baseline spread on both builds and drafting under a grammar amplifies it — which the fork's own comment already said, 'within the run-to-run spread MLX already has'. The claim that the fold introduced nondeterminism was a three-sample artefact. The withdrawn claim is kept in the document rather than deleted. It is the same small-sample error this repository retracted a throughput regression for, and the correction is the part worth reading. The tier drop is untouched by any of it: 9px is 3 on 0.34.0 and 4 on 0.33.2 in twelve runs each across four configurations, 24 runs with perfect separation. That is the finding to bisect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Withdrawing the determinism headline from this PR's original body — it was a three-sample artefact. The doc has been corrected in the latest commit; this comment records what changed and why, since the original framing is in the thread above. What I claimedThat greedy decoding is nondeterministic on 0.34.0 and reproducible on 0.33.2, and that this was a better bisect target than the OCR tier. That rested on three greedy runs of 0.33.2 coming up bit-identical. What further runs showTesting The knob is a no-op on 0.33.2 — Pooled across every greedy run:
What actually holds
What is untouchedThe tier drop. 9px is 3 on 0.34.0 and 4 on 0.33.2 in twelve runs each — constrained, unconstrained, greedy, and greedy with drafting disabled. 24 runs, perfect separation, no configuration moves it. That remains the thing to bisect, and it is now the only open item here. The withdrawn claim is kept in the doc rather than deleted. It is the same small-sample error that produced this repo's retracted throughput regression, made while I was actively citing that retraction as a caution — which seems worth leaving visible rather than tidying away. |
|
Progress: the tier drop is partitioned. The vision encoder moved — the image tokens themselves differ between builds. Prompted by two hypotheses from @glennneuber: measure whether the image tokens differ, and check for a dtype change along the pipeline. The first had a test already written. Measurement
The encoder is deterministic — 0.34.0's figures reproduced to every digit across two sessions with different server lifecycles. Unlike the generation spread discussed above, these differences are real. What it narrows to
Two things deliberately not concluded0.34.0 is closer to the reference, not further — 26b's max element delta improves four-fold. So the encoder became more faithful while the OCR tier got worse. That pairing is unexplained and is not being explained away here. A predicted mechanism failed. I expected the checkpoints carrying NVFP4 NextCheapest causal test, no new code: run the think-on Injection — feeding 0.33.2's embeddings to 0.34.0's decoder — is now clearly worth building if that test is ambiguous, since the decoder demonstrably receives different tokens. It needs real plumbing; there is no API path for precomputed embeddings. |
|
Follow-up to my earlier comment: the ROCm-side regression is root-caused and fixed, and one piece of it bears on your open lead. Cause: upstream Relevant to your "greedy is not reproducible on 0.34.0" lead: that was the one finding of yours that transferred to ROCm, and it now has a possible shared explanation — a race that silently corrupts output past So the useful experiment is a divergence test, and we will run our half: does 906 make greedy reproducible again on ROCm?
Full detail and the cross-platform asks are in #313. Also recorded there: the things already refuted by experiment here, so nobody re-runs them — fp16 accumulation in the vision tower, multi-sub-batch image decode, the b10864 resize-algo flip, the gemma4 budget bump, and One correction to my earlier comment while I am here: I wrote that the resize-algo flip and the token-budget bump were "the same hunk". They are not — separate upstream PRs twelve days apart, #27594 (2026-08-23) and #28335 (2026-09-04). Neither is the cause. 🤖 Generated with Claude Code |
|
Hypothesis 7 (integrated-GPU allocator clamp) falsified — twice. And a withdrawn claim comes back in a narrower, better-supported form. Prompted by the ROCm result in the sibling investigation: upstream setting It fails on two independent grounds1. The clamp never binds on this host. Pinning Identical 2. Equal budgets do not converge the tier. With both pinned to the same 48 GiB: It could never have explained the encoder anywayRaised as a question by the consuming-pipeline session before the result landed, and verified: Checking that also surfaced that Tier status30 runs, 15 per build, five configurations, no overlap. Constrained, unconstrained, greedy, greedy-without-drafting, and greedy-with-pinned-budget. Nothing moves it. Revisiting the withdrawn determinism claimEarlier in this thread I claimed 0.34.0 broke greedy determinism, then withdrew it as a three-sample artefact. With nine greedy runs per build, a narrower version survives:
Both builds drift — the withdrawal was correct on that point, and "0.33.2 is deterministic" stays wrong. But 0.34.0's spread is roughly two orders of magnitude wider (4 tokens vs 815). The accurate claim is "both drift; 0.34.0 drifts far more", which the original 3-run samples could not have established in either direction. |
|
Bisect step 1: the encoder change is in #302 (the v0.34.1 fold), not v0.34.0. Switched the bisect target from the OCR tier to the encoder measurement. The tier needs a scored benchmark and sits on a quantity that drifts 815 tokens between runs on 0.34.0; the encoder measurement is deterministic, reproduces to every digit across sessions, runs in seconds with no server, and answers one question per build. Midpoint:
The midpoint is bit-identical to 0.33.2 — every digit of mean, std, norm_mean and max delta, on both affected models. 26b and 31b agree, so this is not two changes straddling the midpoint. The 12b control holds across all three builds. What this eliminatesThe entire v0.34.0 upstream fold (#297), plus #300 and #301. The whitespace bound is now exonerated twice independently. What remainsOnly #302's contents:
Method notesTwo outcomes were allowed for and did not occur, flagged in advance by the consuming-pipeline session: a third value at the midpoint (meaning a change on each side, splitting the bisect in two), and 26b and 31b disagreeing about which endpoint the midpoint matches (also two changes). Recording that they were anticipated rather than ruled out after the fact. The midpoint build is archived, binary and payload, as Bearing on #312This sharpens the MLX-CUDA ask rather than replacing it. The remaining suspects split cleanly: MLX Metal kernels (which CUDA would not reproduce) versus shared Go/MLX-C layers (which it would). One run there still answers in one step what further local bisecting answers in several. |
|
Bisect step 2: the tier drop is in #302 as well. All three observables co-localise in one PR.
Midpoint think-on, greedy, 3 reps: Three independent observables flip at the same commit boundary — the image embeddings, the OCR tier, and the run-to-run spread. All are clean on the midpoint; all move in #302. This does not prove a single cause. What it does remove is the possibility that these are separate defects scattered across the fold requiring separate hunts. The whole remaining search is one PR's MLX side. Credit: the midpoint tier run was proposed by the consuming-pipeline session, with both readings stated before the measurement existed — that the tier scoring 4 would co-localise the findings, and scoring 3 would prove them independent and exonerate the encoder for the tier. Until then the two findings were being carried as probably-related on nothing but a shared endpoint pair, which is the same unforced inference behind the retractions earlier in this thread. A separation route that does NOT workThe MLX pin and the Go changes ship together in #302, so no bisect separates them. Pairing them deliberately — The new Go calls It also means payload/binary skew on this path fails loudly rather than silently, which is a stronger guarantee than Remaining suspects (all within #302)
All three pairings are archived binary+payload, so any further step between them is a copy rather than a build. |
Bisected on the encoder rather than the tier: the encoder measurement is deterministic and answers in seconds, while the tier needs a scored benchmark on a quantity that drifts hundreds of tokens between runs. Midpoint 907deff (v0.34.0 fold + #300 + #301, pins b10760 / MLX ce916dbb) is bit-identical to 0.33.2 on the encoder for both affected models, scores 4 on the think-on 9px tier, and has a greedy eval range of one token. So the image embeddings, the OCR tier and the run-to-run spread all flip at the same commit boundary, and all of them are in #302. That eliminates the whole v0.34.0 upstream fold, #300 and #301. It does not prove one cause; it rules out the findings being scattered and needing separate hunts. Also records a separation route that does NOT work: pairing 8a7ba94's Go with ce916dbb's MLX payload segfaults at package init, because the new Go calls mlx_install_capture_handler and the old MLX-C lacks the symbol. The halves are hard-coupled and cannot be split by swapping payloads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root cause found by reading MLX's own upstream PR history, which this investigation never did: ollama#3912 fixed a Metal kernel bug in fp_qmm_t that reads past the K dimension when K mod 32 == 16. The v0.34.1 fold crossed that fix when the MLX pin moved ce916dbb -> d9add9d1. K = 4304 is the gemma4 26b/31b vision tower's mlp.down_proj contraction dimension, stored packed [1152, 538] at 8 values per word and cross-checked by the group-16 scale tensor at [1152, 269]. 12b carries no nvfp4 vision down_proj at all, so it was structurally immune rather than a lucky control. Confirmed at kernel level (guard test in #315): on ce916dbb, K=4304 puts 232722 of 294912 output elements more than 1.0 away from dequantize+matmul, max error 24.05; on d9add9d1 it is zero and 0.138. The K=4288 control is bit-identical across both libraries. This inverts the document. The tier drop is a downstream consequence of CORRECTING a kernel, not a defect to undo: the 9px score of 4 was measured on a build corrupting most of that matmul, and 3 is what the correct kernel produces. It also resolves what the document carried as an unexplained paradox — a more faithful encoder scoring worse — which is exactly what a correctness fix looks like from a probe that happened to favour the broken output. Prior gemma4 26b/31b vision baselines were measured through that kernel and should be re-measured rather than compared against. Renamed: the file is no longer about an open regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Resolved. The doc is rewritten and renamed; this PR is no longer about an open regression. Root cause: upstream MLX ollama#3912 — a Metal kernel bug in
Confirmed at kernel level, model-free (guard test in #315): This inverts the PREverything above is now reframed. The tier drop is a downstream consequence of correcting a kernel, not a defect to undo. The 9px score of 4 was measured on a build corrupting 79% of that matmul's outputs; 3 is what the correct kernel produces. It also resolves what this thread carried for most of its length as an unexplained paradox — a more faithful encoder scoring worse. That is exactly what a correctness fix looks like from a scored probe that happened to favour the broken output. I flagged it repeatedly as not-to-be-explained-away, and it turned out to be the clue. Consequence worth acting on: prior gemma4 26b/31b vision numbers were measured through that kernel. Anything compared against those baselines should be re-measured, not trusted. What the seven falsified hypotheses cost, and what actually workedThe bisect was correct — it localised all three observables to #302, which is where the pin crosses ollama#3912 — but it could never have named the cause, because it treated a 24-commit upstream pin as an opaque blob. Reading the dependency's changelog would have been faster than every measurement in this thread combined. Both root causes found on this codebase today, this one and the ROCm |
|
Withdrawing the cross-platform corroboration I offered earlier in this thread. I reported that ROCm shows the same non-reproducible greedy decoding you measured on 0.34.0, citing That variance was observed only on unpatched 0.34.1 builds. Those were running with the HIP Measured since,
No spread on either. So ROCm's non-determinism had a specific, now-removed cause, and it is not evidence that your MLX spread shares a root — particularly since Metal cannot carry that defect at all. Net effect for you: your Full numbers and the think-on 9px comparison are in #312. 🤖 Generated with Claude Code |
The 9px tier drop is nvfp4 quantization cost, not a ollama#3912 regression — this PR's doc needs amendingRan all three 31b quantizations on one binary (
Both nvfp4 rows reproduce the campaign exactly (positive control, eval=263 / 1363). bf16 is a clean control
|
… bug masked The doc attributed the tier drop to ollama#3912 as a consequence of the fix, and said "the score of 3 is what the correct kernel produces". The unquantized control refutes that: gemma4:31b-mlx-bf16, which never enters QuantizedMatmul at all, scores 9px = 4 in both think modes. So 4 is the model's answer, nvfp4 on the fixed kernel costs a tier, and the broken kernel happened to compensate for that loss on this sample. The fix did not cost a tier, it stopped hiding one. Records the full control table so these arms are never re-run: all three 31b quantizations on one binary and window, the 31b tier history across four builds, and 26b/12b unchanged throughout. The suite gitignores ft_*.json, so the doc is their durable form. Two things found while controlling: - gemma4:31b-mxfp8 carries a bf16 vision tower, not an mxfp8 one — its vision down_proj blob is byte-identical in size to bf16's (9,916,560 = 1152*4304*2 + 144). It is a second bf16-encoder arm, not an independent vision quant. It still drops to 3 at think-off, so LM quantization alone moves the tier and these checkpoints cannot pin nvfp4's 3 to the vision tower. - MLX_VERSION never moved ce916dbb -> d9add9d1 along one line: fbedf50 took the fold 37c26e57 -> ce916dbb while upstream went 37c26e57 -> cbb4059d -> d9add9d1. The two builds sit on different MLX branches. ollama#3912 survives that because the reproducer is selected by K, not by build pairing; no purely build-pair attribution does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correction:
|
| checkpoint | vision down_proj |
vision gate/up/q/k/v/o |
LM |
|---|---|---|---|
31b-nvfp4 |
4-bit | 4-bit | 4-bit |
31b-mxfp8 |
bf16 | 8-bit | 8-bit |
31b-mlx-bf16 |
bf16 | bf16 | bf16 |
162 of 31b-mxfp8's 356 vision layers are 8-bit; only down_proj was left at
bf16. 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-nvfp4 and the library's current tag share a config digest while differing
in 194 vision layers. Confirmed locally from the manifests: our 31b-nvfp4 and
31b-mlx-bf16 do share config b72c5344… while their towers differ.
What survives, and it is stronger
The reps stand (N=10, think-off, one binary 8a7ba949 / payload d9add9d1):
31b-mlx-bf16 [4,4,4,4,4,4,4,4,4,4] 10/10
31b-mxfp8 [3,3,3,3,3,3,3,3,3,3] 0/10
31b-nvfp4 [4,4,3,3,3,3,3,3,3,3] 2/10
The cell does not order by numerical precision. 31b-mxfp8 carries a bf16
down_proj — the exact tensor ollama#3912 corrupts — plus an 8-bit tower and an 8-bit
LM, and scores 0/10, below the all-4-bit 31b-nvfp4's 2/10. A metric on which
more 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). The scores_ arm holds a 0.34.0
capture scoring 4 with byte-identical provenance to an ft_ capture scoring 3 —
texts differing by one character, RNK against RMK, ground truth RNK. The
cell 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.
Settled: on real OCR the kernel defect is undetectableAll 1000 OCRBench v1 items ( 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 directionsThe earlier slice gave It was also unrepresentative of the benchmark. Per-chunk accuracy on the fixed build runs What the decision rests onDo 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 Full record, the discordant-item table, and both retractions in #324. |
The 0.34.0 campaign found exactly one moved quality cell. This records it, the four hypotheses that turned out to be wrong, and the sharper symptom that surfaced while killing the last one.
The finding
gemma4:31b-nvfp4think-on drops a 9px fine-text OCR tier, 4 → 3, between0.33.2-maxusai-2b95b4a5and0.34.0-maxusai-8a7ba949. 18 runs, three conditions, no overlap:Think-off is unaffected across all five nvfp4 models — every tier, count, field and verdict identical, including under a json schema. Only scene IoU moved, third decimal, upward on four of five.
Four falsified hypotheses
max_whitespace_cntRESIZE_ALGO_BICUBIC; identical on both sidestemperature 0, top_k 1Each cost a run. They're recorded because they're the four explanations anyone reaches for first — #301 in particular looked compelling, being present on one build and absent on the other and acting only on constrained output.
The better lead
Killing hypothesis 4 surfaced something sharper:
Greedy decoding is not reproducible on 0.34.0. That is a better thing to bisect than a one-tier recall difference: it reproduces in three runs without a scored benchmark, and nobody has to argue about whether it matters.
Two fold changes touch memory reuse and are worth testing first —
5844a6cd5(KV buffer release on a 256-token boundary, upstreamec3cc2307) and the scoped-array-lifetimes refactor. Stated as hypotheses to check, deliberately not as a conclusion to inherit.Not done
Splitting the fold at
907deffdvs8a7ba949halves the search but needs a rebuild — only the two endpoints are archived. The doc says so, and says archiving907deffdwould have been right since it was a distinct Go/MLX pairing.Reproduction notes
Both pairings are archived binary and payload, so neither needs rebuilding. The doc also records the trap that nearly produced a wrong answer here:
serve-apple-mlx.shkills only its own$BIN, so switching builds leaves the previous one holding the port and the readiness check is answered by the process that was supposed to be replaced — measuring the wrong build under the right label. Free the port by port, then assert/api/versionbefore measuring.🤖 Generated with Claude Code