Repository navigation
fix(qwen2_5_vl): image description drops an object that transformers reports (blue circle in a three-shape image) #1596
Description
Problem / Background
On the Qwen2.5-VL path, a synthetic three-object image (red filled square, blue filled circle, green filled triangle on a light gray background) is described as "The image contains a red square and a green triangle." The blue circle is dropped. transformers names all three objects for the same PNG and prompt under greedy decoding. Measured 2026-09-02 on main at 5b57a10 (the #1423 fix, PR #1582) and later, Apple M1 Ultra, release build with metal,accelerate.
The omission is not a quantization artifact. The raw HuggingFace export (Qwen/Qwen2.5-VL-3B-Instruct, bf16 weights, loaded through the loader fixed in #1423) produces the identical sentence as the mlx-community 4-bit conversion, so the raw and converted paths agree with each other and both disagree with transformers. It also predates #1423: the 4-bit output was byte-identical before and after PR #1582.
The repository's only image fixture, tests/fixtures/test_image.png, is a solid orange square. Every VLM test that uses it (tests/vlm_concurrency.rs, tests/smolvlm_parity.rs, tests/hunyuan_vl_parity.rs, the other tests/*_parity.rs, and src/commands/generate_tests.rs:47) can only catch "no output" or "non-finite output". None of them can catch an omitted object, so this defect class has no coverage today.
Evidence
| Runtime | Checkpoint | Output (greedy, 48 max tokens) |
|---|---|---|
mlxcel generate |
models/mlx/qwen2.5-vl-3b-4bit (mlx-community 4-bit, group_size 64) |
The image contains a red square and a green triangle. |
mlxcel generate |
models/mlx/qwen2.5-vl-3b-hf (Qwen/Qwen2.5-VL-3B-Instruct, bf16) |
The image contains a red square and a green triangle. |
transformers 4.x Qwen2_5_VLForConditionalGeneration, bf16 on MPS, AutoProcessor defaults |
Qwen/Qwen2.5-VL-3B-Instruct |
square, circle, triangle, red, blue, green |
Prompt for all three rows: What shapes and colors are in this image? Answer briefly. Same 448x448 PNG for all three rows.
Current Behavior
The image enters through Qwen2VLProcessor (src/vision/processors/qwen2_vl.rs). smart_resize (:248-279) rounds to multiples of 28 under the code defaults DEFAULT_MIN_PIXELS = 3136 and DEFAULT_MAX_PIXELS = 12845056 (:44-52); the loader builds the processor from those defaults and does not read preprocessor_config.json (src/loading/vlm.rs:775-786), which is harmless for this checkpoint because its preprocessor_config.json carries the same two values. A 448x448 input therefore stays 448x448 (no resample), gives grid_thw = (1, 32, 32), 1024 patches of 3 * 2 * 14 * 14 = 1176 features (preprocess_with_grid, :294-311, emits 2048 rows of 588 that PatchEmbed::forward regroups), and 256 <|image_pad|> tokens (id 151655) between <|vision_start|> (151652) and <|vision_end|>, inserted by insert_qwen_vl_image_tokens (src/multimodal/qwen_vl.rs:634). Normalization uses the OpenAI CLIP mean/std (:176-177), matching the checkpoint.
Qwen25VLModel::get_input_embeddings_with_cache (src/vision/qwen2_5_vl.rs:68-133) casts pixel_values to the text embedding dtype (:92-94), runs Qwen25VLVisionEncoder::forward_with_grid (src/vision/encoders/qwen2_5_vl.rs:755-826), scatters the result over the image-token slots with merge::merge_llava_any (:112-117), and computes MRoPE positions with compute_qwen_vl_mrope_position_ids (src/multimodal/qwen_vl.rs:828) plus rope_deltas = max_pos + 1 - seq_len (:120-130).
Inside the tower: PatchEmbed::forward (:235-257, a matmul against the Conv3d filter flattened in [out, T, C, H, W] order), rot_pos_emb (:560-607, built on VisionRotaryEmbedding::forward from src/vision/encoders/qwen2_vl.rs:204-218 and applied by apply_rotary_pos_emb_vision, qwen2_vl.rs:357), get_window_index (:614-732, vit_merger_window_size = 112 / 2 / 14 = 4, so a 16x16 merged grid becomes 16 windows of 16 merged tokens), 32 VisionBlocks with full attention at fullatt_block_indexes = [7, 15, 23, 31] and windowed attention elsewhere via per-segment attention_from_ptr (VisionAttention::forward, :289-380), then PatchMerger::forward (:489-495, RMSNorm, reshape to 5120, GELU MLP to 2048) and the un-reorder at :805-823.
On Apple Silicon, finish_vlm_weights_common (src/loading/vlm.rs:290-313) converts every bf16 tensor of a non-quantized checkpoint to f16 through convert_bf16_weights (src/models/sanitize.rs:1872), so the bf16 export runs its whole vision tower in f16. The 4-bit checkpoint runs the tower through the quantized linears that inherit_qwen_vision_quantization selects (src/loading/vlm_qwen.rs:255) with bf16 scales.
Reproduction
Generate the image (Pillow):
from PIL import Image, ImageDraw
img = Image.new("RGB", (448, 448), (245, 245, 245))
d = ImageDraw.Draw(img)
d.rectangle([40, 60, 200, 220], fill=(220, 30, 30))
d.ellipse([250, 80, 410, 240], fill=(30, 60, 220))
d.polygon([(90, 400), (230, 260), (370, 400)], fill=(30, 170, 60))
img.save("shapes.png")mlxcel (greedy is the default, -t 0):
cargo build --release --features metal,accelerate
PROMPT='What shapes and colors are in this image? Answer briefly.'
./target/release/mlxcel generate -m models/mlx/qwen2.5-vl-3b-4bit --image shapes.png -p "$PROMPT" -n 48 -t 0
./target/release/mlxcel generate -m models/mlx/qwen2.5-vl-3b-hf --image shapes.png -p "$PROMPT" -n 48 -t 0Both print The image contains a red square and a green triangle. on M1 Ultra at 5b57a10.
transformers oracle (bf16, MPS, greedy):
import torch
from PIL import Image
from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
model_id = "Qwen/Qwen2.5-VL-3B-Instruct"
processor = AutoProcessor.from_pretrained(model_id)
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(model_id, torch_dtype=torch.bfloat16).to("mps")
messages = [{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": "What shapes and colors are in this image? Answer briefly."}]}]
text = processor.apply_chat_template(messages, add_generation_prompt=True)
inputs = processor(text=[text], images=[Image.open("shapes.png")], return_tensors="pt").to("mps")
out = model.generate(**inputs, max_new_tokens=48, do_sample=False)
print(processor.batch_decode(out[:, inputs.input_ids.shape[1]:], skip_special_tokens=True)[0])Prints square, circle, triangle, red, blue, green.
Proposed Solution
-
Reproduce with the snippet above on both local checkpoints and confirm the transformers output on the same PNG before changing anything. Record the exact strings.
-
Compare the vision-side pipeline against transformers stage by stage on this image and stop at the first stage that diverges. Tap points on the mlxcel side, with the transformers counterpart and the expected shape, are: (a) processor output,
preprocess_with_gridversusprocessor.image_processor(images=[img])["pixel_values"], expected[1024, 1176]after thePatchEmbedregroup,grid_thw = (1, 32, 32), and 256 image tokens in the tokenized prompt on both sides; (b)PatchEmbed::forwardversusmodel.visual.patch_embed,[1024, 1280]; (c)rot_pos_embversusmodel.visual.rot_pos_emb(grid_thw),[1024, 40], plusget_window_indexand bothcu_seqlenslists versusmodel.visual.get_window_index(grid_thw), which must be integer-identical; (d) the output of everyVisionBlock(forward hooks onmodel.visual.blocks[i]on the transformers side), reporting mean absolute difference per block in the window-reordered token order; (e)PatchMerger::forwardversusmodel.visual.merger,[256, 2048], after undoing the window order on both sides; (f) the merged input embeddings at the 256 image slots; (g)compute_qwen_vl_mrope_position_idsversusget_rope_index(input_ids, image_grid_thw),[3, 1, seq_len], integer-identical, andrope_deltas; (h) the prefill logits at the last position, whose argmax must agree before greedy decode can. Run the transformers side in f32 on CPU for the tap comparison so its own bf16 rounding does not mask a real gap; a mean absolute difference that jumps by an order of magnitude between two consecutive stages is the divergence. -
While mapping stage (e), the un-reorder in
forward_with_grid(src/vision/encoders/qwen2_5_vl.rs:805-823) was checked algebraically and by simulation: it sorts(value, index)pairs and writesreverse_indices[orig_idx] = rank, which for a permutation of0..Nreproduceswindow_indexitself rather thanargsort(window_index)as upstream does (https://github.com/Blaizzy/mlx-vlm/blob/main/mlx_vlm/models/qwen2_5_vl/vision.pyreverse_indices = mx.argsort(window_index, axis=0)). Applyingtake(h, window_index)twice is only the identity when the permutation is an involution, which happens to hold for a 16x16 merged grid with 4x4 windows (this issue's 448x448 image and any image whose merged width is 16), so it cannot explain the omission reported here. For other sizes it scrambles the merged tokens: simulating the sameget_window_indexgives 120 of 144 tokens misplaced at 336x336, 360 of 400 at 560x560, and 352 of 384 at 448x672. The correct construction isreverse_indices[rank] = orig_idx. Fix it in this issue because the stage comparison in step 2 must also be run at a non-involution size (336x336) to be meaningful, and confirm the effect on model output at that size. The identical construction insrc/vision/encoders/youtu_vl_window.rs:147-159(reverse_window_indices, used byyoutu_vl.rs:425) is a separate family and goes to a follow-up issue, linked from here, rather than into this PR. -
Check whether the f16 tower explains the 448x448 omission: keep every
vision_tower.*tensor in f32 for one forward (a keep predicate throughconvert_bf16_weights_with_keep,src/models/sanitize.rs:1882, thenastypeto f32, plus a matching f32 cast ofpvatsrc/vision/qwen2_5_vl.rs:93) on the bf16 export, and compare the merger output and the generated sentence against the f16 run and against transformers. If f32 restores the circle, locate the overflowing op with the per-block diffs from step 2(d) and upcast only that op (RMSNorm input, attention scores, or the SwiGLU intermediate), not the whole tower, and keep the quantized checkpoint's bf16 scales untouched per the Apple Silicon precision rules. If f32 does not restore it, the defect is structural and the stage diff from step 2 is the answer. -
Add a content-rich fixture and a parity test. Fixture:
tests/fixtures/test_image_shapes.png, the exact 448x448 image from the snippet, committed together withtests/fixtures/generate_test_image_shapes.py(same convention astests/fixtures/generate_inkling_dmel_numpy.py) so it is reproducible; a flat-color PNG of this kind is a few KB. Test: newtests/qwen2_5_vl_parity.rswithmod common;, resolving the checkpoint withrepo_model_dir("qwen2.5-vl-3b-4bit")exactly astests/vlm_concurrency.rs:41does and falling back torepo_model_dir("mlx/qwen2.5-vl-3b-4bit"), marked#[ignore = "requires the local qwen2.5-vl-3b-4bit checkpoint"](precedenttests/chat_template_kwargs.rs:122) and returning early with aneprintln!skip message when neither directory exists so-- --ignoredstays green on a machine without the model. The test spawns the built binary throughcommon::repo_binary_path("mlxcel")withgenerate -m <dir> --image tests/fixtures/test_image_shapes.png -p "<prompt>" -n 48 -t 0, so it exercises the same loader, processor, tower, merge and decode path as the CLI, and asserts case-insensitively that stdout contains all ofsquare,circle,triangle,red,blue,green. Also add a checkpoint-free unit test insrc/vision/encoders/qwen2_5_vl_tests.rsfor the extracted reverse-index helper: input[2, 0, 1]yields[1, 2, 0], and for the (1, 24, 24) grid the composed permutation is the identity.
Scope
In scope: src/vision/encoders/qwen2_5_vl.rs (tower, un-reorder, any precision fix found), src/vision/qwen2_5_vl.rs (pixel dtype cast if the fix lands there), src/vision/processors/qwen2_vl.rs only if stage (a) diverges, tests/fixtures/test_image_shapes.png, tests/fixtures/generate_test_image_shapes.py, tests/qwen2_5_vl_parity.rs, src/vision/encoders/qwen2_5_vl_tests.rs, and docs/supported-models.md only if the fix changes a documented limitation.
Out of scope: quantized-weight numerics (the raw bf16 export shows the same omission); the Youtu-VL copy of the reverse-index construction (src/vision/encoders/youtu_vl_window.rs:147-159, file a follow-up and link it here); the Lanczos3 versus bicubic resample mismatch (normalize_resized_frame, src/vision/processors/qwen2_vl.rs:452, versus the Qwen2VLImageProcessor default BICUBIC), which cannot act at 448x448 because no resample happens there, and which belongs in its own issue if stage (a) diverges at a non-multiple-of-28 size; reading min_pixels / max_pixels from preprocessor_config.json, which is a no-op for this checkpoint.
Implementation Notes
- Reuse: the stage taps on the mlxcel side already exist as public or crate-visible functions (
Qwen2VLProcessor::preprocess_with_grid,Qwen25VLVisionEncoder::forward_with_grid,compute_qwen_vl_mrope_position_ids); add anoutput_hidden_states-style per-block capture only if it is needed for the diff and keep it out of the hot path. Use transformers as the oracle for this issue (mlx-vlm shares the same un-reorder and window logic and would mask a shared mistake);docs/adding-models.md(Choosing and Using Reference Implementations) covers when each reference applies. - Shared code:
apply_rotary_pos_emb_vision,VisionRotaryEmbedding(src/vision/encoders/qwen2_vl.rs),Qwen2VLProcessor, andsrc/multimodal/qwen_vl.rsare used by Qwen2-VL, Qwen3-VL, and ColQwen2.5 as well. If the fix touches any of them, update the// Used by:comment perdocs/code-guidelines.mdand smoke-testqwen2-vl-2b-4biton the same PNG. - Constraints: quantized checkpoints keep bf16 scales and biases; do not promote them. Any f32 upcast must be scoped to the offending op and measured for decode and prefill cost on the 4-bit checkpoint (
scripts/bench_decode.sh), since a whole-tower f32 run is a diagnostic, not a fix. - Edge cases: the un-reorder fix must be validated with two images in one prompt (448x448 plus 224x448, where
window_index_idoffsets accumulate) and with a padded window grid (an image whose merged grid is not a multiple of 4, for example 1036x1036 giving 37x37), both against transformers. - Error handling: no new failure paths; the parity test skips with a printed reason when the checkpoint is absent and panics with the full stdout when an object is missing, as
tests/vlm_concurrency.rs:271-281does for the empty-output case.
Acceptance Criteria
-
mlxcel generate -m models/mlx/qwen2.5-vl-3b-4bit --image shapes.png -p "What shapes and colors are in this image? Answer briefly." -n 48 -t 0names the square, the circle, and the triangle with their colors, and the same command onmodels/mlx/qwen2.5-vl-3b-hfdoes too. - The stage-by-stage comparison is recorded in the PR: for each tap (a) through (h), the mean absolute difference or integer equality against transformers on the 448x448 image and on a 336x336 image, and the first diverging stage is named.
- The f16-versus-f32 tower experiment result on the bf16 export is recorded in the PR, with the conclusion (precision or structural) and, if precision, the single op that was upcast.
- The un-reorder in
forward_with_gridequalsargsort(window_index); the checkpoint-free unit test insrc/vision/encoders/qwen2_5_vl_tests.rscovers[2, 0, 1]and the (1, 24, 24) grid; a 336x336 image gives the same description on mlxcel and transformers. - A follow-up issue for
src/vision/encoders/youtu_vl_window.rs:147-159is filed and linked here. -
tests/fixtures/test_image_shapes.pngandtests/fixtures/generate_test_image_shapes.pyexist, the PNG is byte-identical to the script's output, andtests/qwen2_5_vl_parity.rspasses under-- --ignoredwith the checkpoint present and skips with a printed reason without it. - Integrated into the real code flow: the fix is on the path used by
mlxcel generate --imageand bymlxcel-serverchat completions with an image part, not in a test-only helper;tests/vlm_concurrency.rs::qwen2_5_vl_single_row_bench_prefill_succeeds_with_m5_warmupstill passes. -
qwen2-vl-2b-4biton the same PNG is unchanged or improved if any shared file was touched. -
cargo test --workspace --profile test-fast --features metal,accelerate,cargo clippy --workspace --all-targets --features metal,accelerate -- -D warnings, andcargo fmt --all -- --checkare green. (narrow scope run here: the module suites,--test qwen2_5_vl_parity,--test vlm_concurrency qwen2_5_vl,cargo clippy --profile test-fast --lib --tests --features metal,accelerate -- -D warningsandcargo fmt --all -- --checkare all green; the full workspace gate runs centrally)
Verification
cargo build --release --features metal,accelerate
PROMPT='What shapes and colors are in this image? Answer briefly.'
./target/release/mlxcel generate -m models/mlx/qwen2.5-vl-3b-4bit --image tests/fixtures/test_image_shapes.png -p "$PROMPT" -n 48 -t 0
./target/release/mlxcel generate -m models/mlx/qwen2.5-vl-3b-hf --image tests/fixtures/test_image_shapes.png -p "$PROMPT" -n 48 -t 0
python3 tests/fixtures/generate_test_image_shapes.py /tmp/regen.png && cmp /tmp/regen.png tests/fixtures/test_image_shapes.png
cargo test --profile test-fast --features metal,accelerate -p mlxcel qwen2_5_vl
cargo test --profile test-fast --features metal,accelerate --test qwen2_5_vl_parity -- --ignored --nocapture
cargo test --profile test-fast --features metal,accelerate --test vlm_concurrency qwen2_5_vl
cargo test --workspace --profile test-fast --features metal,accelerate
cargo clippy --workspace --all-targets --features metal,accelerate -- -D warnings
cargo fmt --all -- --checkPass looks like: both generate runs print a sentence containing square, circle, and triangle with red, blue, and green; cmp prints nothing; the ignored parity test reports 1 passed; the workspace gate, clippy, and fmt exit 0. Manual step: run the transformers snippet from the Reproduction section once on the final fixture and paste its output next to the mlxcel output in the PR.
Technical Considerations
Related: #1423 and PR #1582 (raw HF Conv3d patch-embed layout, the loader this issue's bf16 measurement depends on), #294 (earlier qwen2.5-vl-3b-4bit M5 warmup abort, unrelated to output content), #866 (XLA windowed vision attention for Qwen2.5-VL, which will need the corrected un-reorder). Upstream reference for the tower: https://github.com/Blaizzy/mlx-vlm/blob/main/mlx_vlm/models/qwen2_5_vl/vision.py.
Activity
fix(qwen2_5_vl): f32 tower norm and a correct window inverse (lablup#…
fix(youtu_vl): build the window inverse as argsort (#1603)