docs: OCRBench quantisation ladder for gemma4:31b, and a store/registry audit - #323
glennneuber wants to merge 7 commits into
Conversation
…ry audit A published tag is not a fixed artifact. gemma4:31b-nvfp4 in this store and gemma4:31b-nvfp4 in the library today differ in 194 of 1248 layers, all on the vision path and each ~3.5x larger upstream: the library re-published the tag with a bf16 vision tower while our copy keeps the nvfp4 one. The config blob is unchanged, so `ollama show` surfaces nothing; 26b-nvfp4 and 12b-nvfp4 moved the same way and qwen3.5:0.8b-mlx had its tensors renamed. Every gemma4 vision measurement in this repo that names an nvfp4 tag was taken against the quantised tower, so a host that pulls today will not reproduce them. store_audit.py finds this on any host: it diffs the store's manifests against the registry and prints what moved, where, and the size ratio. --digests prints the manifest digest to cite instead of the tag. The ladder doc carries the method, the CUDA rows as they land, and an empty mlx-metal section for the Metal session, which should run the audit first so its number can be placed. ocrbench_table.py renders the tables from extbench.py score files, paired with an exact McNemar test, because a 200-item slice cannot resolve the 3-item gaps the accuracy column shows. Tests: test_store_audit.py (9), test_ocrbench_table.py (7). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…GGUF row Two 200-item arms died when a DNS blip made the /rows fetch fail: extbench re-asked the datasets server for the same fixed slice at the start of every arm, so every arm depended on that service resolving. It now caches the slice beside the images and retries with backoff; REFRESH_ROWS=1 re-fetches, and a short slice is never cached so a truncated fetch cannot poison a later arm. Also records the first llama.cpp row: q4_K_M 171/200 against mlx-cuda nvfp4's 172/200, five discordant items in both directions, p = 1.0 — one engine is not reading these images better than the other. Tests: test_extbench.py (8). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… rows 0–200 are The slice is not a sample of OCRBench. The dataset is ordered by task, so rows 0–200 are four of its ten categories at 50 items each — the text-recognition half — with no VQA, key-information extraction, digit strings or handwritten maths. Every number here, and the Metal 0.875, is an accuracy on that half and must not be called an OCRBench score or set beside a published one. Still the right slice for this comparison, since the arms answer identical items. GGUF results, both arms repeated: q4_K_M 0.855 twice and q8_0 0.850 twice, with zero items changing verdict between runs. That determinism is the control the ladder needs. Paired, q4 and q8 differ on one item out of 200 in q4's favour, so q8 buys nothing here at the same seconds per item. ocrbench_table.py gains --categories, which reads the cached row slice (no network) and splits each arm by question type: regular and artistic text are at ceiling for every arm, all the headroom is handwriting and irregular text, and no arm leads another by more than one item in any category. Tests: test_ocrbench_table.py now 10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e per image Three MLX arms, each run twice, zero items flipping in any of them. The two nvfp4 rows share a language model and differ only in the tower: ours is nvfp4, the library's is bf16, and the bf16 one answers in 1.4 s against 2.3 s on identical images at the same 1115-token prefill. The bf16 tower is 3.5x the bytes, so this is kernel throughput and not bandwidth — MLX's quantised matmul on sm_120 runs at about half the dense bf16 rate, as the qqmm bench measured directly. No accuracy difference survives the paired test: tower precision p = 0.625, language-model precision (nvfp4 vs mxfp8 over the same tower) one item in 200. On this text-recognition slice the ladder is flat and only the clock moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bf16 scores 171/200 twice, the same as q4_K_M, and q8_0 sits one item below both. Paired, q4 and bf16 differ on four items, two each way; nothing resolves. Seconds per item are identical across the three, because the workload is prefill-bound with an eight-token answer, so tripling the weights costs no clock. All 61 layers of the bf16 model fit on the GPU (58 GiB weights, 1.8 GiB KV at num_ctx 8192), leaving 19 GB free on a shared card, so no arm ran partly on CPU. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ADR 0037 (proposed): a measured model is identified by its manifest digest, not its tag. gemma4:31b-nvfp4 was re-published with a bf16 vision tower — 194 layers different, config blob unchanged, ollama show silent — and the old artifact cannot be fetched back, so the store is the archive. Records cite digests, benchmark pulls go to a separate store, and a cross-host comparison runs the audit on both hosts first. ADR 0038 (proposed): nvfp4 global scales are stored as the checkpoint multiplier and converted to MLX's m x 2688 form only at the call sites that hand the scale to MLX. The stored-MLX-form round trip f32(f32(m x 2688)/2688) misses m by one ulp for 17 of 31b's 191 vision scales, which is the whole of the CUDA-side encoder move in #312; the three wrappers that apply the scale themselves — QuantizedMatmul, Dequantize, GatherQMM off Metal — all pay it. #287's prefill dequantisation runs through Dequantize, so it inherits the fix; it stays held on its other grounds. SPEC vision-harness-reuse H15: a reported model is named by its manifest digest, and a slice is named by what it contains. The survey's claim that a 200-item OCRBench slice is comparable to a model card is corrected in place: the set is ordered by task, so rows 0-200 are four of ten categories. The ladder doc now states which checkpoint produced the mlx-cuda 0.860 (this store's 4-bit-tower copy, pulled 2026-08-17, digest 637cc0ff1570), what makes the Metal 0.875 comparable to it, and the one command that settles it. The MLX bf16 arm is recorded as refused by admission — 75.0 GiB asked against 62.7 GiB available with the 16 GiB reserve — not forced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ROCm/gfx1151 arms for the GGUF ladder — and one correction to the "What is measured" tableI ran the same three GGUF arms on the ROCm host, one run each, before finding this PR. Overlapping work; consolidating here rather than shipping a second renderer. #325 should be folded into this PR — see the end. The numbers
Paired, exact McNemar:
|
|
Consolidated into #327, which merges this PR with the other two OCRBench streams (#323 CUDA, #324 mlx-metal, #325 ROCm/GGUF). It contains every commit from all three, so merging #327 closes them together and there is no three-way conflict to resolve later. What moved, so nothing is lost:
All three runs agree: no quantisation difference resolves on any host, and OCRBench cannot separate the kernels where the goldens can. 🤖 Generated with Claude Code |
main took 0037 for "keep the MLX ollama#3912 kernel fix" when #324 merged, so this branch's 0037 and main's are different decisions under one number. The merge shows CLEAN because the filenames differ -- git has no reason to object, and the collision would only surface when someone cites "ADR 0037" and gets the wrong document. Mechanical: two git mv plus the six references in README.md, ocrbench-quantisation-ladder.md and spec/vision-harness-reuse.md. Patterns are anchored to "ADR 003x" and the full filenames, because a bare 0037 also matches the 1.003776 JPEG constant in synthetic-triggers/README.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed one mechanical commit to this branch: renumbered this branch's ADRs 0037/0038 → 0038/0039.
Two Revert the commit if you would rather renumber the other way. |
Consolidating the Metal side — and one collision that git will not flagMetal session here. Taking this PR as the consolidation point for the OCRBench test and results format rather than opening a parallel one. 1. #324 took ADR 0037 and SPEC H15 while this was open
Fixed in the stacked PR below, as its own commit so you can drop it if you'd rather renumber differently:
Every reference moved with them (count-asserted): three links in the ladder doc plus its H15 mention, the SPEC's rule, conformance row and ADR citation, the benchmark survey, and the README deviations row. Left for whoever merges, because it needs both trees at once: main's H17 ("a checkpoint is its manifest digest, not its tag and not its config digest") and your H19 overlap on the manifest-digest half. H19's enforcement is the stronger one — H17's conformance row says only "partly", while yours names 2. Which checkpoint produced the 0.875 — answeredLocal manifest digest Two caveats on that 0.875 before it goes on the ladder: it was rows 0–200 at 3.
|
| arm | engine | model | correct / scored | accuracy | ±1 s.e. |
|---|---|---|---|---|---|
| 0.34.0 fixed kernel | mlx-metal | gemma4:31b-nvfp4 |
835 / 1000 | 0.835 | 0.012 |
| 0.33.2 defect kernel | mlx-metal | gemma4:31b-nvfp4 |
833 / 1000 | 0.833 | 0.012 |
Paired: b = 9, c = 7, p = 0.804, unresolved. The MLX ollama#3912 kernel defect is undetectable end to end (ADR 0037).
--categories adds something the totals hide — where the two differ:
| question type | n | fixed | defect |
|---|---|---|---|
| Regular Text Recognition | 50 | 50 | 49 |
| Irregular Text Recognition | 50 | 41 | 39 |
| Handwriting Recognition | 50 | 35 | 34 |
| Scene Text-centric VQA | 200 | 186 | 185 |
| Handwritten Mathematical Expression Recognition | 100 | 37 | 39 |
| Non-Semantic Text Recognition | 50 | 46 | 47 |
The fixed kernel's gains sit in text recognition, where character fidelity is what's scored — consistent with the flips I inspected (Davidson/Davison, CORONADO/CORONA). The defect's two wins sit in handwritten maths, where the flips were LaTeX formatting under a contains-match scorer (OB - OD, an inserted \cdot). Not significant either way; just no longer arbitrary.
Consolidates #323, #324 and #325 into one OCRBench test, renderer and format. #325 was closed unmerged, so this carries the only copy of the ROCm ladder and its summarizer. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
Superseded by #327, which merged as 88c34c8 and carries every commit from this branch alongside #324's and #325's. Main now has the consolidated result: ADRs 0037 (kernel), 0038 (manifest-digest identity) and 0039 (nvfp4 global scales); one renderer in 🤖 Generated with Claude Code |
The maintainer asked for the Metal side's OCRBench slice to be repeated on
mlx-cudaand on the llama.cpp GGUF path across the gemma4:31b quantisation ladder. This is the record, with the Metal section left empty for that session to fill, plus the two decisions the measurement forced.Results
mlx-cuda (each arm run twice, zero items flipping in any run)
llama.cpp GGUF (each arm run twice, zero flips)
No pair resolves under an exact McNemar test on the shared items. The ladder is flat from 4 bits to bf16 on both engines for this slice. What does move is the clock: quantising the vision tower costs 39 % of the time per image — the two nvfp4 rows share a language model and differ only in the tower, 1.4 s against 2.3 s on identical images at the same 1115-token prefill. The bf16 tower is 3.5× the bytes, so this is kernel throughput, not bandwidth: the encode is compute-bound and MLX's quantised matmul on sm_120 runs at about half the dense rate, as the qqmm bench measured directly.
Two things the measurement forced
A published tag is not a fixed artifact.
gemma4:31b-nvfp4here and in the library differ in 194 of 1248 layers, all on the vision path, with the config blob unchanged soollama showsays nothing.26b-nvfp4,12b-nvfp4andqwen3.5:0.8b-mlxmoved too. Every gemma4 vision result in this repo was taken against the quantised tower, and the old artifact cannot be fetched back. ADR 0037 (proposed) makes the manifest digest the identity in any record, sends benchmark pulls to a separate store, and requires the audit on both hosts before a cross-host comparison. SPECvision-harness-reuseH15 carries the reporting half, including that aLIMIT/OFFSETwindow is a stratum: rows 0–200 are four of OCRBench's ten categories, and the survey's claim that such a slice is comparable to a model card is corrected in place.ADR 0038 (proposed) settles the nvfp4 global-scale representation that #312 turned up and that #287 runs through: store the checkpoint multiplier and convert to MLX's
m × 2688form only where MLX consumes it. The stored-MLX-form round trip missesmby one ulp for 17 of 31b's 191 vision scales, which is the entire CUDA-side encoder move across the fold, and all three wrappers that apply the scale themselves pay it. #287's prefill dequantisation inherits the fix; it stays held on its other grounds.Tools (with tests)
store_audit.py— diffs any store against the registry: what moved, which tensor group, the size ratio (≈3.55× reads as nvfp4 → bf16).--digestsprints the digest to cite. Runs on the Metal host too.ocrbench_table.py— renders the ladder fromextbench.pyscore files with repeats, exact McNemar pairs, and--categoriesfor the question-type split.extbench.pynow caches the row slice and retries fetches: two arms died when a DNS blip made it re-ask the datasets server for a fixed set of rows at the start of every arm.34 tests across the three.
For the Metal session
The empty section names the four steps. The one that matters first: run
store_audit.py gemma4on that host. The CUDA 0.860 came from a 4-bit tower (digest637cc0ff1570…, pulled 2026-08-17), so the Metal 0.875 sits on the same row only if that host also holds a 4-bit tower; if its store matches the registry, the number belongs on the bf16-tower row, where CUDA scored 0.850.🤖 Generated with Claude Code