fix(vision): stop VLM wrappers re-enabling a padded prefill their backbone refuses - #1202
Merged
Merged
Conversation
…kbone refuses LanguageModel::supports_padded_prefill defaults to true. Every hybrid and recurrent text model overrides it to false, and the comments say why: a tile-aligned prefill appends up to 31 pad positions, and while the causal mask and trim_caches_to_actual_len undo their effect on the KV caches, a Mamba / GatedDeltaNet / RWKV / DeltaCache state that has already absorbed them cannot be rewound. Qwen35VLModel and MiniCPMV46VLModel both hold a Qwen35Model, which answers false, and both forwarded four other capability predicates while leaving these two defaulted. So on Neural Accelerator hardware a text-only run through either wrapper padded the prompt to a 32-token tile and corrupted the backbone's recurrent state. Nothing failed. Greedy output just changed, and only when the prompt length was not already a multiple of 32. Measured on M5 Max with qwen3.8-27b-4bit, whose architectures field routes it through the VLM wrapper. A 75-token prompt padded to 96 and produced a different 120-token completion than the same prompt with padding disabled. After this change the padded and unpadded outputs agree, and so does speculative MTP decode, which never padded and was therefore right all along. That disagreement is how this was found (#1201); the classic path was the wrong one. The guard is a source-level test rather than a runtime one, because constructing every wrapper needs weights and the property is about which methods each impl block carries. It derives the refusing-backbone set by scanning src/models rather than hard-coding it, so a new hybrid family is covered on the day it lands. Verified to fail by removing one override. Fixes #1201
4 tasks
inureyes
added a commit
that referenced
this pull request
Aug 17, 2026
…are alone (#1205) execute_full_prefill and both chunked-prefill paths padded the prompt to a 32-token Neural Accelerator tile whenever the hardware had one, without asking whether the model tolerates it. Every hybrid and recurrent family answers supports_padded_prefill() == false, because pad positions are absorbed by a conv / SSM / GatedDeltaNet state that trimming the KV caches afterwards does not rewind. So every such model served on an M5-class host had its recurrent state corrupted whenever a prompt or chunk was not already tile-aligned. The same file knows the hazard. The boundary-snapshot path declines to pad and says why in a comment, and the batched path reads the predicate into can_pad_prefill. These three sites simply never asked. Measured on M5 Max with qwen3.8-27b-4bit, whose backbone answers false: the same /v1/completions request at temperature 0 returns different text before and after this change, diverging at character 74 of the completion. The after side is the correct one. This is the server-side twin of #1201, and wider: that one needed a VLM wrapper to lose the predicate by not delegating it, while this one reaches every hybrid model directly, VLM-wrapped or not. The guard test added with #1202 grows a second case for the second way to lose a guard. The first covers a type that answers the trait default instead of forwarding; this covers a call site that never asks. It accepts a local bound to the predicate as the guard, since the batched path hoists it fourteen lines up, and skips the definition and its own unit tests. Verified to fail by removing one of the three guards.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
LanguageModel::supports_padded_prefilldefaults totrue. Every hybrid and recurrent text model in this tree overrides it tofalse, with the reason in the comment:Qwen35Modelis one of them.Qwen35VLModelandMiniCPMV46VLModeleach hold one in atext_modelfield and each already forward four other capability predicates (supports_batching,supports_batched_prefill,supports_paged_decode_backend,supports_snapshot_reuse) while leaving these two defaulted.The result is a wrapper that answers
truewhere its own backbone answersfalse, re-enabling an optimization the backbone disabled for correctness. The causal mask andtrim_caches_to_actual_lenundo the pad positions' effect on the KV caches; a GatedDeltaNet state that has already absorbed them cannot be rewound.Impact
Silent. It compiles, it runs, and greedy output changes, on:
should_align_prefill()requireshas_neural_accelerator && macos_supports_na), so M5-class and not M1 Ultra, andalign_to_na_tileis otherwise a no-op.qwen3.8-27b-4bitis affected because itsarchitecturesfield isQwen3_5ForConditionalGeneration, sodetect_text_or_vlmroutes even a text-only run throughQwen35VLModel.Measured on M5 Max, 75-token prompt,
--temp 0 -n 120:MLXCEL_NO_PADDED_PREFILL=1With a 1312-token prompt (41 x 32) every arm agrees before and after, which is the control that isolates tile alignment as the variable.
How it was found
#1201, from a disagreement rather than from a crash. MTP's
prefill_and_seedforwards the raw prompt length, classic padded it, and their temperature-0 outputs diverged at one token. The first four hypotheses were all wrong (the #1199qmv_widesplit, block width, the GDN chain-parity kernel, nondeterminism); each was ruled out by a control before this one was found. The instrumented build settled it:supports_padded=trueon a model whose own impl returnsfalseis the whole bug in one line.The guard
tests/vlm_wrapper_capability_delegation.rsfails when a wrapper insrc/vision/holds a backbone that refuses padded prefill and does not forward the predicate.It is source-level rather than runtime because constructing every wrapper needs weights, and the property is about which methods an
implblock carries, which the source states directly. Two things keep it from rotting:src/modelsfor impls that answer a barefalse, not hard-coded, so a hybrid family added next month is covered the day it lands.Verified to fail: removing the
Qwen35VLModeloverride reproduces the exact diagnostic naming the file, the wrapper, and the backbone.Test plan
-p mlxcel --libfails the same 13 tests over two runs, matching the baseline;-p mlxcel-core --libis 1472 passed / 4 failed. All are the pre-existing M5 reduced-precision failures of fix(core): f32 matmul and single-query SDPA lose ~fp16 precision on M5, breaking four numeric tests #1065.cargo fmt --checkandcargo clippy --all-targets --features metal,accelerate -- -D warningsclean.Not covered
Only the two wrappers the scan flags are changed. Every other wrapper in
src/vision/holds a plain transformer, where thetruedefault is correct, and the guard now says so mechanically rather than by inspection.Whether the server path has the same gap is not checked here:
src/server/batch/scheduler.rshas its ownshould_align_prefill()and fouralign_to_na_tilesites, and whether those consult the model predicate at all is a separate question worth its own look.Fixes #1201