docs/maxusai: document the qwen2.5vl-3b poison-image serving bug - #214
Conversation
One corpus image (md5 02c9d7e1563a7c6089f688ddff8ad590) makes qwen2.5vl:3b return done_reason=null + '???' garbage in every quant (q4_K_M/q8_0/fp16) and permanently poisons the resident runner until reload. Reproduces on STOCK ollama/ollama:0.32.15 => upstream bug, not a fork regression. 7B is unaffected. Full fresh-instance evidence matrix, launch flags, minimal repro, and downstream impact in the doc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Reviewing as consolidator. The isolation here is the strongest part and it is what makes this filable upstream rather than an internal curiosity. Four variables eliminated, each by a distinct test rather than by argument:
And the 7B processing the same image cleanly across a 2,496-request run is the control that turns "this model family is fragile" into "this is the 3B's path". That is a much narrower claim and a much more useful one. The persistence is the part I would put first
That is the detail that changes how everyone else should treat this. A one-off garbage response is a bad cell; a runner that stays poisoned means every subsequent request in that process is suspect, including ones that look fine. Any campaign that touched this image and kept serving has cells after it that cannot be trusted, and nothing in a score file would show it. It is also the same hazard class the suite's cold-server-per-cell rule exists for — the upstream cross-request leakage (ollama#17459 / ollama#17475) that One thing worth adding while the repro is freshThe suspect — "the 3B (2048-embed) mmproj/merger translation path emitting NaN/garbage projector output" — is testable one step short of a fix: if the projector output is NaN, that is visible in the runner at debug/trace level without touching the model. Confirming or eliminating NaN would sharpen the upstream report considerably, since "garbage decode" and "NaN in the projector" invite very different investigations from a maintainer. Not asking for it here — this is explicitly a report, and stopping at the boundary of what was measured is right. FilingSince issues are disabled here, this doc is the artifact, same as #201 and #210. Worth noting in the file whether the upstream issue has been opened and its number once it is, so the two do not drift — a local bug report that outlives its upstream counterpart is how a fixed defect stays documented as open. |
|
Upstream prior art found (2026-08-26 search):
Neither upstream issue has the quant-independence, pixel-content-trigger, poison-until-reload, or 7B-immunity evidence from this report — an upstream filing should consolidate all three. 🤖 Generated with Claude Code |
|
Architecture A/B (same build, store shared ro via OLLAMA_MODELS, container pinned per GPU):
Architecture-independent — the fault tracks the 3B model/projector, not the GPU generation. Upstream ollama#17687's GPU-vs-CPU split is either a different trigger or misattributed; a CPU-inference run of this repro remains the one untested cell. 🤖 Generated with Claude Code |
|
CPU cell run (decisive), and one retraction:
The bug is CUDA-path-specific and architecture-independent within CUDA — same blob, same image: CPU answers correctly, every CUDA GPU garbages and poisons the slot. Retracting my previous comment's aside: ollama#17687's GPU-vs-CPU split is consistent with this bug, not misattributed. Refined suspect: a CUDA kernel in the qwen2.5vl-3B (2048-embed) vision/projector path producing NaN/garbage for specific inputs. 🤖 Generated with Claude Code |
Runtime knob matrix: the garbage survives FA-off, f32/bf16 KV, and MMQ — it is generated inside the shared vision-encoder computeFollow-up to the CPU-healthy result. We swept every runtime precision/kernel lever the build exposes, fresh container per config (same store, read-only), 3-request probe good → poison → good-after (H = healthy, X = garbage):
Three conclusions:
Workaround status: none reachable via environment. Remaining fix candidates are build/model-side: force F32 precision on the clip graph's mm ops, or ship the vision-tower/mmproj tensors as f32/bf16 in the GGUF so the CUDA GEMMs leave the fp16-accumulate path. Probe harness: 3-request H/X pattern scripts ( |
Known-good implementation found:
|
| Runtime | Pattern | GPU residency |
|---|---|---|
| 0.7.1, RTX 2080 Ti (Turing) | HHH | size_vram=6.89GB/6.89GB, 37/37 layers |
| 0.7.1, RTX PRO 6000 (Blackwell) | HHH | same — its CUDA v12 payload compiles sm_120 (ARCHS=…,1200), so this is real Blackwell CUDA, not a CPU fallback |
0.7.1 even describes the poison image sensibly and consistently on both GPUs ("Worn and Damaged Wood: the wooden shelf…"). It serves qwen2.5vl via ollama runner --ollama-engine — the Go engine's own vision implementation — not the llama-server/mtmd-clip path current builds use. Bonus: its dynamic-resolution preprocessing is pixel-exact for our smart-resized inputs (980 measured image tokens vs (H/28)*(W/28)=972 expected +8 markers), so it is a like-for-like healthy server, not a downscaling one.
Bisect boundary
The healthy Go implementation and the broken clip-path routing swap in a single commit:
9db4bdba— runner: Remove CGO engines, use llama-server exclusively for GGML models (ollama#16031), 2026-05-29
It deletes model/models/qwen25vl/ (the Go vision encoder that handles this image correctly) and routes the arch to llama-server + the compat mmproj translation. Release mapping: last release without it = v0.24.0 (no stable 0.25–0.29 exists; numbering jumps to 0.30), first release with it = v0.30.0. We are pulling ollama/ollama:0.24.0 and :0.30.0 for the release-level A/B:
0.24.0H +0.30.0X → the regression is the engine switch itself (the clip.cpp graph / mmproj translation numerics), with a working reference implementation sitting in git history at9db4bdba~1.0.24.0X → the Go era broke earlier (ggml kernel drift between 0.7.1 and 0.24.0); bisect that window instead.0.30.0H → introduced after the switch (0.30 → 0.32 ggml/clip changes).
Combined with the knob matrix (previous comment): the overflow lives in vision-encoder compute shared by the FA and non-FA clip paths, while the deleted Go implementation of the same math on the same hardware is numerically fine — strongly suggesting the clip.cpp graph's precision choices (f16-weight matmuls without GGML_PREC_F32, including the commented-out KQ override) rather than a GGML-kernel-level defect.
…t vision matmuls - every runtime knob ruled out (FA on/off, KV f32/bf16, FORCE_MMQ): garbage in all serving CUDA configs; CPU healthy => fp16-accumulate GEMM of the vision tower/merger is the one stage unique to the failing path - known-good implementation found: ollama/ollama:0.7.1 Go engine, same blob, same GPUs, pixel-exact; regression boundary = 9db4bdb (v0.24.0 -> v0.30.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ets disjoint
- 04431b0d (1288x616) garbles a fresh 0.7.1 slot on request 1 ('!'x31, sticky),
while the original poison decodes fine there; cross-probe: 04431b0d healthy on
the 0.32.x clip path => disjoint per-implementation trigger sets, same class
- fix guidance narrowed: clip-only precision patch just moves the trigger set;
keep vision GEMMs out of fp16 accumulate in whichever engine serves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correction to my previous comment: 0.7.1 is NOT a clean implementation — it has its own poison image; the trigger sets are disjointRunning the full production val fold through
Key facts:
Interpretation: the overflow class is latent in the shared CUDA fp16 vision compute of both implementations; each graph's op order decides which activation patterns cross the fp16 cliff, giving each engine its own poison set. This strengthens the f16-weight-matmul localization and narrows the fix guidance: a clip.cpp-only precision patch would merely move the trigger set — the durable fix keeps the vision tower/merger GEMMs out of fp16 accumulation (F32 prec on the mm ops, or f32/bf16 vision tensors in the GGUF) in whichever engine serves. The |
- clip-path trigger present since 0.30.0 (first llama-server release); Go engine had its own set at 0.7.1; 0.24.0 passes both known triggers full-GPU - mmproj offload is memory-conditional => exposure varies with free VRAM at load - workaround: options.num_gpu = layers-1 => --no-mmproj-offload => vision on CPU fp32, HHHH on both triggers; prefix cache implicated in the sticky-slot half Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Release matrix complete + operational workaround (
|
… kill-switch found - GGML_CUDA_CUBLAS_COMPUTE_TYPE=f32|bf16 heals both trigger images at full GPU residency on the current stack; explicit f16 reproduces garbage (positive control) - corrects the earlier FORCE_MMQ row (env inert in b10488) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause CONFIRMED: fp16 cuBLAS accumulation — and there is a runtime kill-switchGlenn asked whether
One env var flips the failure on and off at full GPU residency — the poison class is fp16 accumulation in the cuBLAS GEMM path, exactly as the elimination matrix predicted. Also correcting the earlier knob matrix: the |
Latest-release status: the class is ALIVE in 0.33.0 — and the kill-switch works there too. Typhoon (ollama#17687) closure.New cells on stock images (fresh container per cell, all fully GPU-resident on Blackwell):
Two refinements:
Upstream-ready summary: deterministic single-image repro on the CURRENT release, mechanism pinned to fp16 cuBLAS accumulation with a three-way runtime A/B ( |
…ve activations - bf16-hooked vision tower: good image peaks 37,376 at blocks.31.mlp (0.57x fp16 max); both triggers peak 50,688-55,808 (0.77-0.85x) => fp16 GEMM partial sums cross 65,504 and NaN; measurement script committed alongside Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Why a poison image poisons — measured: final-block massive activations at 0.77–0.85× the fp16 ceilingWe hooked every stage of the HF
The 3B vision tower carries a massive-activation outlier structure in its final block that sits above half of fp16's ceiling for ordinary images — every input walks the cliff edge. A "poison" image is one whose patch statistics resonate with those outlier channels and push them ~50% higher: the stored tensors still fit in fp16, but the partial sums inside the next fp16-accumulate GEMM (block-31's MLP projection / the merger consuming it) transiently exceed 65,504 before cancellation, overflow to inf, and cascade to NaN → the 31-glyph degenerate decode. This one mechanism accounts for every behavior in this PR's matrix: deterministic per-image failure; rare-but-inevitable triggers across a large corpus (the fine→poison margin is only ~40%); per-implementation trigger sets (different engines tile GEMMs differently, and fp16 overflow depends on summation order, not just final magnitudes); fine-tuned checkpoints failing harder (typhoon-ocr / fp16 bitsandbytes training shift the outlier scales so every input crosses); the 7B's relative immunity (lower final-block outliers against the same ceiling); and the immunity of CPU, Fix guidance, final form: the multimodal matmuls of this family cannot safely run fp16 accumulation — the margin is structurally too thin. |
- 56px checkerboard at 1350x1800 (3-line generator + PNG committed) garbles stock qwen2.5vl:3b on stock ollama 0.32.9 and is healed by COMPUTE_TYPE=f32: no client data needed to reproduce the class end to end - corrects the earlier 'synthetic triggers do not transfer': adversarial noise does not, ordinary high-contrast patterns at large working size do - image SIZE is a trigger axis: upscaling 1008->1800px raises peaks ~1.6x and turns a safe image into a trigger; longer visual sequences overflow partial sums even below the stored-value ceiling Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shareable synthetic repro — no client data needed — plus: image SIZE is a trigger axisTwo updates, one of which corrects my earlier claim in this thread. 1. A three-line generator reproduces the bug on stock everything
That is the whole bug — trigger, mechanism, and fix — reproducible by anyone in under a minute with no corpus image and no fine-tuned checkpoint. 2. Correction: synthetic triggers DO transfer — I tested the wrong kindEarlier I reported that a gradient-ascent image reaching 2.53× the fp16 ceiling decoded cleanly, and concluded synthetic triggers don't transfer. The right conclusion was narrower: adversarially-optimized noise doesn't transfer (it is brittle to weight quantization), while ordinary high-contrast patterns at a large working size do. 3. Image size is a first-class trigger axisMax |activation| at
Two compounding mechanisms: upscaling the same content raises the activation peaks (~1.6× from 1008→1800 px), and longer visual sequences lengthen the GEMM reductions so partial sums overflow even when stored maxima stay under the ceiling (synthetic rings garble at 0.77×, while a natural image at 0.95× does not). Consequence for clients: the larger the image you send, the more exposed you are. Any olmOCR-style pipeline that renders pages at 1800–2048 px sits in the worst part of this space — including 4. Cross-model status
|
A run-once, profile-keyed defect-class canary (#214 / PR #215): render the synthetic 1.06x-fp16-ceiling checkerboard (pixel-identical to docs/maxusai/make_poison_repro_image.py — stdlib PNG writer, verified pixel-equal against the PIL render), send it to qwen2.5vl:3b-q4_K_M on a fresh slot, require a healthy decode, then a text follow-up on the same slot to require no poisoning residue. Profiles without a [poison.<profile>] entry SKIP; recorded for cuda-dynres-903 with provenance. Runs even when --arch narrows the run, since it loads its own model. is_degenerate_decode keys on the measured fingerprint (done_reason null or one repeated glyph) and deliberately NOT on short/empty responses, which the num_predict trap produces for unrelated reasons. Validated both ways on live builds: PASS inside a full harness run against maxusai/ollama:pr215-gate-rc0 (gate live; VERDICT PASS), FAIL with the recorded fingerprint against ungated sync-0.32.15. test_verdicts.py: 50 tests OK (7 new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The checkerboard was measured on stock 0.32.9 only; the corpus triggers reproduce on stock 0.30.0/0.32.9/0.32.15/0.33.0; pre-0.30 Go engine has its own disjoint trigger set (0.24.0 was never shown clean — the known-good-0.7.1 claim was falsified same-day in #214). Body and the branch commit (amended to 5a739d5) now state exactly what was measured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All 29 corpus images at the poison's exact geometry (1288x616, 0.79 MP, identical token count and reduction lengths) run against 0.7.1: 1 X / 29, only 04431b0d itself. Size is a risk multiplier (measured positively on the clip path) but within a fixed size only specific pixel content resonates with the final block's outlier channels -- consistent with #214's lossless-re-save observation. Rough base-rate floor for this engine at this geometry: ~1 in 29. Descriptions redacted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l, quantify the decoder effect, and show peak is not a sufficient objective Hooked the real HF tower. THREE results, one of which corrects me: 1. FALSIFIED: ffn_down's INPUT peaks at 4,128 for the trigger, 0/3420 channels over their product threshold, indistinguishable from healthy images. The 50,688 from #214 is the module OUTPUT. No single product overflows; the mechanism is accumulation, whose order is engine-specific -- which is itself why trigger sets are disjoint. The fp16 audit is a screen, not a diagnosis. 2. QUANTIFIED: Go's decode lifts 04431b0d's block-31 peak from 50,688 to 60,672 (+20%), which is why the decode path flips the verdict. Every corpus image sits at 70-97% of the fp16 ceiling. 3. LIMITING: peak magnitude is NOT a sufficient objective for synthesis. 39823be1 reaches 63,744 (97.3%) -- higher than the 60,672 image that DOES trip 0.7.1 -- and is healthy there. Hand-designed families plateau at 83.2% (239 candidates, waves 1-6). A sufficient objective would need to simulate the engine's GEMM accumulation order. Ships measure_channel_thresholds.py and screen_activation_peak.py (deterministic, verified 3x bit-identical). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fixes it Six 3B-class vision models x seven triggers at stock precision: typhoon-ocr1.5-3b HHHHHXX (ead2a6c7 + checkerboard) qwen2.5vl:3b XXXXXXX qwen3-vl:2b/4b HHHHHHH qwen3.5:2b/4b HHHHHHH This overturns the earlier "typhoon does not reproduce on this estate" conclusion (PR #214 comment 5421905441): that sweep used photos and the two qwen corpus triggers, never ead2a6c7 or the synthetic checkerboard, both of which garble it. typhoon-ocr1.5-3b is upstream ollama#17687's model, which therefore now has a reproducer needing no private data. The shipped gate covers it: ollama resolves the model to arch qwen25vl, so applyArchServerEnvs fires, the runner env shows f32, and both failing images decode correctly. Recorded caution: its CLIP metadata reports qwen2vl after mmproj translation, which is not the string the gate keys on -- read ollama's model architecture, not that. Narrower trigger set than the base model (2/7 vs 7/7), consistent with a fine-tune shifting activation scales rather than removing the fragility. Posted to ollama#18070 (comment 5439106009). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tudy Investigation of the qwen2.5-vl fp16-accumulate class (#214/#216, fixed by #215 and filed upstream as ollama#18070). Squashed to a single commit deliberately: earlier revisions of this branch contained model-generated descriptions of client corpus imagery, and squashing ensures no revision reachable from this branch ever did. Every result file here carries verdicts, token counts and fingerprints only; image content is redacted, and no client image is committed. Contents: - Synthetic triggers: generators (waves 1-5, 199 candidates) and the two shareable artifacts -- trigger_checker56 (clip path, 0.30+) and trigger_stripes56 (spans 0.24.0 and the clip path). Both are generated black/white patterns. - DTYPE-TRACE.md: stage-by-stage dtype validation of 0.7.1's Go engine (preprocessing conforming; [0,1] and CLIP bounds verified empirically), the CPU/GPU split, decoder-divergence and perturbation studies, and the node localisation on the clip path -- ffn_down-31, 3 inf of 15.7M. - Tooling: fp16_audit.py (GGUF-only exposure screen), clip-nan-tracer.patch (non-finite eval callback), screen_activation_peak.py, measure_channel_thresholds.py, simulate_fp16_accum.py, repeat_sweep.sh. - Blast radius: six vision models screened and probed; gemma4 and granite carry no F16 vision matmuls at all; 105 repeat observations under forced fp16 on the models the gate excludes, with a positive control. - Corrections recorded in place: single-product overflow, the 256/255 luma gain as causal, peak activation as a sufficient objective, and the typhoon checkerboard claim -- each falsified by measurement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tudy Investigation of the qwen2.5-vl fp16-accumulate class (#214/#216, fixed by #215 and filed upstream as ollama#18070). Squashed to a single commit deliberately, and result files carry NO image descriptions. Probes in this study ran against client corpus photos and their derivatives, and the model's answers describe client property; every `head` field of descriptive text has been removed, leaving verdicts, token counts, done_reason and -- where the model produced one -- the degenerate single-glyph fingerprint, which is not image content. No client image is committed; the only images here are two generated black/white patterns. Contents: - Synthetic triggers: generators (waves 1-5, 199 candidates) and the two shareable artifacts -- trigger_checker56 (clip path, 0.30+) and trigger_stripes56 (spans 0.24.0 and the clip path). - DTYPE-TRACE.md: stage-by-stage dtype validation of 0.7.1's Go engine (preprocessing conforming; [0,1] and CLIP bounds verified empirically), the CPU/GPU split, decoder-divergence and perturbation studies, and the node localisation on the clip path -- ffn_down-31, 3 inf of 15.7M. - Tooling: fp16_audit.py (GGUF-only exposure screen), clip-nan-tracer.patch (non-finite eval callback), screen_activation_peak.py, measure_channel_thresholds.py, simulate_fp16_accum.py, repeat_sweep.sh. - Blast radius: six vision models screened and probed; gemma4 and granite carry no F16 vision matmuls at all; 105 repeat observations under forced fp16 on the models the gate excludes, with a positive control. - Corrections recorded in place: single-product overflow, the 256/255 luma gain as causal, peak activation as a sufficient objective, and the typhoon checkerboard claim -- each falsified by measurement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bug REPORT only (issues are disabled on this repo) — no fix attempted.
One corpus image garbages qwen2.5vl:3b in every quant (q4_K_M / q8_0 / fp16) and permanently poisons the resident runner until
ollama stop. Reproduced fresh-instance on the vsuite build (sync-0.32.15), on 0.32.14-rc0-dynres, and — decisively — on STOCKollama/ollama:0.32.15with the same registry blob: this is an upstream ollama bug, not a fork regression, and should be filed upstream too. The 7B processed the same image cleanly across a 2,496-request run; a pixel-identical lossless re-save of the image still triggers it (pixel content, not container). Suspect: the 3B (2048-embed) mmproj/merger translation path emitting NaN/garbage projector output.Full minimal repro, the H/X evidence matrix across builds and quants, server launch flags, and downstream impact (experiment 00017.8's 3B ladder rungs blocked) are in the added doc:
docs/maxusai/qwen25vl-3b-poison-image-garbage-decode.md.🤖 Generated with Claude Code