Repository navigation
feat(models): add InternVL (internvl_chat) VLM architecture - #37
Merged
Merged
Conversation
Add end-to-end support for the InternVL VLM family so `internvl3-1b` loads and runs in both text-only and image modes, wired into the real CLI and server VLM dispatch. Architecture (verified against the real config.json and safetensors tensor names, which override the "reuse the InternLM backbone" assumption): - The language model is Qwen2, not InternLM. `text_config.model_type == "qwen2"` (hidden 896, 24 layers, 14 heads, 2 KV heads, QKV bias, untied embeddings, rope_theta 1e6), so we reuse mlxcel's existing Qwen2 backbone (`models::Qwen2Model`) rather than reimplementing a transformer. The bogus top-level `llm_config` (vocab 151936, 32B-template dims) is ignored in favor of the real `text_config` (vocab 151674). - The InternViT vision tower (`vision_model.*`) is a standard SDPA encoder with fused QKV (+bias), per-layer layer-scale (ls1/ls2), LayerNorm, a Conv2d patch embed, a CLS token, and a learned position embedding. `qk_normalization` is false for InternVL3 so the q/k norms are skipped. `select_layer=-1` returns the last hidden state and the CLS token is stripped before the connector. Every tile is exactly 448x448, so the patch grid matches the trained position-embed grid and interpolation is the identity. - The connector `mlp1.*` applies pixel_shuffle(downsample_ratio=0.5) to reshape (B, 1024, 1024) into (B, 256, 4096), then LayerNorm -> Linear(4096->896) -> GELU -> Linear(896->896), yielding 256 image tokens per tile in the Qwen2 hidden size. - The dynamic-tiling processor picks the closest aspect ratio (min 1, max 12 tiles, plus a thumbnail when split), applies ImageNet normalization, and emits channels-first tensors. - Image-token merge replaces `<IMG_CONTEXT>` (151667, resolved from the tokenizer) positions with the connector output through the shared `merge_llava` path. Mixed precision: the top-level `quantization` block makes the common VLM weight loader treat the checkpoint as quantized and skip its bf16->f16 conversion, which correctly keeps the 4-bit LLM and connector scales/biases as bf16 for `quantized_matmul`. The InternViT tower and the connector LayerNorm are plain bf16, which JIT-crash on M5, so the loader explicitly converts every remaining bf16 tensor to f16 except `.scales`/`.biases`, mirroring the standard Apple Silicon non-quantized-weight policy. RoPE: standard 1D RoPE is used (not Qwen2-VL MRoPE). The config's `rope_scaling` dynamic-NTK factor is parsed but not applied in the rope path; for the 1B at short context this is a no-op and is noted as a known limitation. Integration: detection maps `model_type == "internvl_chat"` to the new `ModelType::InternVLChatVLM`, which carries a Vlm metadata descriptor so both the CLI and the server route it as a VLM. A `LoadedModel::InternVLChatVLM` variant delegates the language model, a `VlmRuntimeRef::InternVL` runtime arm drives tile-count-based `<img> + <IMG_CONTEXT>*N + </img>` token expansion, and `load_internvl_vlm()` parses the config, handles the mixed-precision weights, and constructs the Qwen2 backbone, InternViT tower, connector, and processor. New modules live under `vision/encoders/internvl.rs`, `vision/internvl.rs`, `vision/processors/internvl.rs`, and `multimodal/internvl_prompt.rs`. Tensor-parallel inference maps InternVL onto the llama-family planner placeholder (TP is already refused for VLM-kind models upstream of that point). This change also fixes embedding-based VLM prefill on the M5 Neural Accelerator, which InternVL is the first VLM short enough to exercise on the single-pass prefill path: - Skip NA-tile prefill padding for embedding requests. The NA prefill alignment padded the token-id vector and built a matching padded causal mask, but VLM image requests run through `forward_with_embeddings` with pre-merged embeddings that are not padded, so a 293-token image prompt met a 320-wide mask and aborted with a broadcast-shape error. NA-tile alignment is now skipped whenever a sequence carries pre-merged embeddings; the backbone then builds a causal mask sized to the embeddings, matching the CLI generate path. Token-id (text-only) prefill is unchanged. - Route VLM embedding prefill to full prefill instead of chunked. Prompts longer than `prefill_chunk_size` (default 512) were sent to the chunked path, which re-triggered the same broadcast abort and is fundamentally incoherent for embedding-based prefill (the whole embedding sequence is consumed while the prefill offset advances by only one chunk, corrupting cache bookkeeping). A multi-tile image (e.g. a 2:1 photo at 768+ image tokens) trivially exceeds 512 and hit this latent path; the chunked route is now gated on the sequence having no pre-merged embeddings, mirroring the batched-prefill dispatch which already forces VLMs onto full prefill. Tests: unit tests cover the pixel_shuffle shape collapse (1024x1024 -> 256x4096), the dynamic-tiling processor (single-tile square, multi-tile plus thumbnail, ImageNet normalization, per-image tile counts), prompt-token insertion and expansion, and the two VLM prefill routing guards (both conditioned on the absence of pre-merged embeddings, exercised without requiring M5 hardware). A model-gated `tests/internvl_parity.rs` covers detection, processor shapes, and finite text-only and image-conditioned logits of the correct vocab dimension.
inureyes
added a commit
that referenced
this pull request
May 23, 2026
Bump version to 0.0.30. Highlights: unified pre-load memory estimator with the new mlxcel inspect subcommand and --estimate-memory preflight (#64, #65, #66, #67), Molmo v1 and InternVL VLM architectures (#41, #37), Gemma 3n / Phi-3.5 / Gemma / Jamba decode performance work (#42, #43, #44, #60, #62), and server --ctx-size as a shared parallel context budget (#57).
sjswerdloff
added a commit
to sjswerdloff/mlxcel
that referenced
this pull request
Jul 11, 2026
…blup#37 (not fixed here) From Xander's blind-spot pass, triaged three-way with Violet (independent verdicts converged at source): - REFUTED at source: 'fetch_kvarn8_blocks missing v4 guard' — the guard is at cache.rs:1507-1508 since 34df406 and its should_panic passed in witnessed runs on both seats. No change. - FIXED (hygiene, PM-blessed ride-along): downgrade_kvarn8_to_fp16_if_empty now resets kvarn_v_bits=8 with the mode — stale width on an Fp16 cache was inert but a trap for any future re-mode. Test with named mutation. - MARKED, NOT FIXED (task lablup#37, Violet PM call): KVCache::nbytes counts no kvarn/m3_idx state (~0 for kvarn caches). Store admission unaffected (DetachedKVCache::nbytes counts kvarn); the live pool's memory_usage_bytes under-reports active kvarn sequences. The fix changes what scheduler accounting sees on CURRENT k8v8 production and the m3_idx term touches every M3 boot — admission-policy impact wants its own sizing. Implementation staged: clement-7074f29f/staged_patches/task37_kvcache_nbytes_kvarn_m3idx_20260711.patch. KNOWN GAP doc on the method so the next reader finds lablup#37, not a mystery. cache:: 467/467. Co-Authored-By: clement-7074f29f <clement-7074f29f@sjstargetedsolutions.co.nz>
sjswerdloff
added a commit
to sjswerdloff/mlxcel
that referenced
this pull request
Jul 11, 2026
THE FIX (DESIGN_finalize_cap_at_true_length, Violet's design, built at the junction): pending_finalize_cap: Option<i32> on KVCache — absolute exclusive position, set by the scheduler pre-forward at padded sites, CONSUMED (take at entry, one-shot) by update_kvarn8. The cap lands at the finalize-boundary variable and nowhere else (the K8V4 birth constraint paying out): a tile finalizes only if it lies entirely below the cap, so prefill padding stays in the fp16 end-state. Both padding wrongs die at the root — no tile/offset desync (wrong 1), no Sinkhorn pollution (wrong 2, asserted bitwise). DESIGN AMENDMENT (found at source, Violet-confirmed + blessed): the doc's premise 'the existing dense trim already handles the tail' was wrong for kvarn — trim() touched zero kvarn fields, so capped padding would have desynced IN THE TAIL (wrong-1 relocated). trim() now dispatches KVarN8 to trim_kvarn_fp16_end: tail first, sink only while no tiles exist (short padded prefills park padding in the SINK — the cap deliberately does not stop the fp16 sink fill), REFUSE loudly with zero mutation if quantized tiles would be reached. is_trimmable stays false (general rewind still unsupported; spec-decode keeps gating). m3_idx_offset rolls back in lockstep — the idx accumulation received the padded rows too, and selection reading idx past offset is the cycle-79 desync class. [FILED, not fixed here: the DENSE trim has the same m3_idx gap on fp16 M3 caches under padded concurrency — behavior change on fp16 production, wants its own sizing like lablup#37.] PREDICATE SHARPENED: padding_trim_would_corrupt now asks per-cache can_trim_padding(excess) — true exactly when the arm can act (fp16 end-state suffices). The tripwire passes exactly when trimming is genuinely safe and stays armed for uncapped leaks (defense-in-depth). Existing empty-cache contract holds (no fp16 end-state → corrupt). TESTS (cache:: 475/475): six design edges — mid-tile cap, identity, beyond-end clamp, consume-and-clear, THE padded-roundtrip golden (stored fields AND fetched window AND offset-reconciliation bitwise vs the unpadded reference, THROUGH the arm), Sinkhorn purity field-level — plus refuse-zero-mutation, tail-bounded success with m3 lockstep, sink-case at the hist-empty boundary, and predicate-mirrors-arm. Named mutations (consume-clear drop; refuse-check drop) run against THIS commit next — mutations only ever on committed bases. Scheduler site placements (4) follow as the wiring commit. Co-Authored-By: clement-7074f29f <clement-7074f29f@sjstargetedsolutions.co.nz>
sjswerdloff
added a commit
to sjswerdloff/mlxcel
that referenced
this pull request
Jul 11, 2026
…jections, v_bits echo (design §3.4)
The resolver now returns ResolvedKvCacheConfig { mode, kvarn_v_bits }:
the k8v8/k8v4 split is a width on KVCache::kvarn_v_bits, deliberately
NOT a KVCacheMode variant (the field decision, audited at the surgery
commit). kvarn strings route through a string-level matrix BEFORE
KVCacheMode::from_str — kvarn4 has no mode spelling at all.
The matrix (exactly two pairs construct):
- kvarn8/kvarn8 -> KVarN8 + v_bits 8 (k8v8, production today)
- kvarn8/kvarn4 -> KVarN8 + v_bits 4 (k8v4, THE candidate; gs fixed 32)
- kvarn4 on K -> REJECTED citing RESULTS_kvarn4_realtile_2026-07-11
BY NAME (17-21% top-32 flips on real tiles — the operator deserves
the record, not a shrug)
- kvarn x turbo/int8/fp16 mixes -> REJECTED (unvalidated)
- legacy --kv-cache-mode k8v4 (alias kvarn-k8v4) -> same resolution
through the same path
Plumb: ResolvedKvCacheConfig -> ServerConfig/StartupConfig ->
WorkerSchedulerConfig -> BatchScheduler::with_kvarn_v_bits ->
apply_kvarn_v_bits (free fn, tested against real caches — the
set_prefill_finalize_caps pattern) applied in BOTH arms of
apply_kv_cache_mode_to. KVCache::set_kvarn_v_bits is the guarded
construction surface: width ∈ {4,8}, non-empty relabel REFUSED
(should_panic pinned) — stored V codes can never be silently relabeled.
generate/chat/bench paths REFUSE k8v4 loudly (no width plumbing there;
running k8v8 under a k8v4 label is the silent-wrong class).
Echo (Violet forward pin): the fail-loud startup artifact now carries
kvarn_v_bits + kvarn_format (k8v8/k8v4/n-a) — boots are
log-distinguishable. Deviation from §3.4's illustrative boot line noted:
bytes_per_token is NOT emitted (needs model geometry the artifact
doesn't hold; the format label carries the distinguishability pin).
MODE-GENERIC-METHOD AUDIT (Violet's systemic observation — kvarn's
parallel fields make every mode-generic KVCache method a blind-spot
candidate; trim() and nbytes() failed identically in one afternoon):
- update(): dispatches update_kvarn8; v_bits consumed (34df406)
- trim(): kvarn tail-bounded arm, refuse-loud (99d7ff6)
- trim_front(): ALLOWLIST Fp16|Int8 — returns 0 for KVarN8, both
widths fail SAFE (verified at source THIS commit;
the allowlist polarity is the correct pattern)
- nbytes(): kvarn-blind — KNOWN GAP, task lablup#37, patch staged
- is_trimmable/can_trim_prompt_cache/padding_trim_would_corrupt/
can_trim_padding: kvarn-aware, predicate mirrors the arm (99d7ff6)
- detach (clone_handle/install_detached/pool_backed_handle_clone):
v_bits round-trips the donation (34df406)
- DetachedKVCache::nbytes: counts kvarn — store admission correct
- downgrade_kvarn8_to_fp16_if_empty: resets v_bits=8 (4ad2ef3)
- synth_kvarn_state: width-parameterized, layout pinned to update
- fetch (fetch_kvarn8/fetch_kvarn8_blocks/kvarn_qmm_state): 3 loud v4
refusals until the read paths land (34df406)
- trim_to (detached): width-agnostic slice, V4 trailing dims pinned
Tests: 9 matrix tests (citation asserted BY NAME; both mix
orientations; requires-kvarn8-K named), 2 into_startup_config
integration pins (the real construction path), 2 apply_kvarn_v_bits
mixed-slice tests, 2 setter-guard tests (empty/same-width accepted,
non-empty relabel should_panic). 12 pre-existing resolver tests
mechanically widened (.mode). Named mutations to run on this committed
base: (1) drop the kvarn4-K citation arm -> citation test red;
(2) drop the mode condition in apply_kvarn_v_bits -> mixed-slice test
red. The config plumb itself is compiler-pinned (missing-field errors
at every initializer — proven during the cut, three initializers named).
cache:: 479/479 (477 + 2 setter guards); CLI selection 39/39;
cargo check clean including --tests.
Co-Authored-By: clement-7074f29f <clement-7074f29f@sjstargetedsolutions.co.nz>
sjswerdloff
added a commit
to sjswerdloff/mlxcel
that referenced
this pull request
Jul 12, 2026
…(nbytes kvarn-blind, confirmed), finding-3 refuted at source, lablup#36 to Clement Co-Authored-By: violet-14057653 <violet@sjstargetedsolutions.co.nz> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sjswerdloff
added a commit
to sjswerdloff/mlxcel
that referenced
this pull request
Jul 12, 2026
… wider / store-admission narrower), Violet server-premise refuted, lablup#29 mechanism found Co-Authored-By: violet-14057653 <violet@sjstargetedsolutions.co.nz> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
be-free-snowboy
pushed a commit
to freestylejs/snowbrain-mlxcel
that referenced
this pull request
Oct 27, 2026
Add end-to-end support for the InternVL VLM family so `internvl3-1b` loads and runs in both text-only and image modes, wired into the real CLI and server VLM dispatch. Architecture (verified against the real config.json and safetensors tensor names, which override the "reuse the InternLM backbone" assumption): - The language model is Qwen2, not InternLM. `text_config.model_type == "qwen2"` (hidden 896, 24 layers, 14 heads, 2 KV heads, QKV bias, untied embeddings, rope_theta 1e6), so we reuse mlxcel's existing Qwen2 backbone (`models::Qwen2Model`) rather than reimplementing a transformer. The bogus top-level `llm_config` (vocab 151936, 32B-template dims) is ignored in favor of the real `text_config` (vocab 151674). - The InternViT vision tower (`vision_model.*`) is a standard SDPA encoder with fused QKV (+bias), per-layer layer-scale (ls1/ls2), LayerNorm, a Conv2d patch embed, a CLS token, and a learned position embedding. `qk_normalization` is false for InternVL3 so the q/k norms are skipped. `select_layer=-1` returns the last hidden state and the CLS token is stripped before the connector. Every tile is exactly 448x448, so the patch grid matches the trained position-embed grid and interpolation is the identity. - The connector `mlp1.*` applies pixel_shuffle(downsample_ratio=0.5) to reshape (B, 1024, 1024) into (B, 256, 4096), then LayerNorm -> Linear(4096->896) -> GELU -> Linear(896->896), yielding 256 image tokens per tile in the Qwen2 hidden size. - The dynamic-tiling processor picks the closest aspect ratio (min 1, max 12 tiles, plus a thumbnail when split), applies ImageNet normalization, and emits channels-first tensors. - Image-token merge replaces `<IMG_CONTEXT>` (151667, resolved from the tokenizer) positions with the connector output through the shared `merge_llava` path. Mixed precision: the top-level `quantization` block makes the common VLM weight loader treat the checkpoint as quantized and skip its bf16->f16 conversion, which correctly keeps the 4-bit LLM and connector scales/biases as bf16 for `quantized_matmul`. The InternViT tower and the connector LayerNorm are plain bf16, which JIT-crash on M5, so the loader explicitly converts every remaining bf16 tensor to f16 except `.scales`/`.biases`, mirroring the standard Apple Silicon non-quantized-weight policy. RoPE: standard 1D RoPE is used (not Qwen2-VL MRoPE). The config's `rope_scaling` dynamic-NTK factor is parsed but not applied in the rope path; for the 1B at short context this is a no-op and is noted as a known limitation. Integration: detection maps `model_type == "internvl_chat"` to the new `ModelType::InternVLChatVLM`, which carries a Vlm metadata descriptor so both the CLI and the server route it as a VLM. A `LoadedModel::InternVLChatVLM` variant delegates the language model, a `VlmRuntimeRef::InternVL` runtime arm drives tile-count-based `<img> + <IMG_CONTEXT>*N + </img>` token expansion, and `load_internvl_vlm()` parses the config, handles the mixed-precision weights, and constructs the Qwen2 backbone, InternViT tower, connector, and processor. New modules live under `vision/encoders/internvl.rs`, `vision/internvl.rs`, `vision/processors/internvl.rs`, and `multimodal/internvl_prompt.rs`. Tensor-parallel inference maps InternVL onto the llama-family planner placeholder (TP is already refused for VLM-kind models upstream of that point). This change also fixes embedding-based VLM prefill on the M5 Neural Accelerator, which InternVL is the first VLM short enough to exercise on the single-pass prefill path: - Skip NA-tile prefill padding for embedding requests. The NA prefill alignment padded the token-id vector and built a matching padded causal mask, but VLM image requests run through `forward_with_embeddings` with pre-merged embeddings that are not padded, so a 293-token image prompt met a 320-wide mask and aborted with a broadcast-shape error. NA-tile alignment is now skipped whenever a sequence carries pre-merged embeddings; the backbone then builds a causal mask sized to the embeddings, matching the CLI generate path. Token-id (text-only) prefill is unchanged. - Route VLM embedding prefill to full prefill instead of chunked. Prompts longer than `prefill_chunk_size` (default 512) were sent to the chunked path, which re-triggered the same broadcast abort and is fundamentally incoherent for embedding-based prefill (the whole embedding sequence is consumed while the prefill offset advances by only one chunk, corrupting cache bookkeeping). A multi-tile image (e.g. a 2:1 photo at 768+ image tokens) trivially exceeds 512 and hit this latent path; the chunked route is now gated on the sequence having no pre-merged embeddings, mirroring the batched-prefill dispatch which already forces VLMs onto full prefill. Tests: unit tests cover the pixel_shuffle shape collapse (1024x1024 -> 256x4096), the dynamic-tiling processor (single-tile square, multi-tile plus thumbnail, ImageNet normalization, per-image tile counts), prompt-token insertion and expansion, and the two VLM prefill routing guards (both conditioned on the absence of pre-merged embeddings, exercised without requiring M5 hardware). A model-gated `tests/internvl_parity.rs` covers detection, processor shapes, and finite text-only and image-conditioned logits of the correct vocab dimension.
be-free-snowboy
pushed a commit
to freestylejs/snowbrain-mlxcel
that referenced
this pull request
Oct 27, 2026
Bump version to 0.0.30. Highlights: unified pre-load memory estimator with the new mlxcel inspect subcommand and --estimate-memory preflight (lablup#64, lablup#65, lablup#66, lablup#67), Molmo v1 and InternVL VLM architectures (lablup#41, lablup#37), Gemma 3n / Phi-3.5 / Gemma / Jamba decode performance work (lablup#42, lablup#43, lablup#44, lablup#60, lablup#62), and server --ctx-size as a shared parallel context budget (lablup#57).
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
Adds end-to-end support for the InternVL VLM family (
internvl_chat, e.g.internvl3-1b) so it loads and runs in both text-only and image modes, wired into the real CLI and server VLM dispatch rather than left as standalone modules.Architecture
Verified against the real
config.jsonand safetensors tensor names, which override the "reuse the InternLM backbone" assumption:text_config.model_type == "qwen2"(hidden 896, 24 layers, 14 heads, 2 KV heads, QKV bias, untied embeddings, rope_theta 1e6), so the existing Qwen2 backbone (models::Qwen2Model) is reused rather than reimplementing a transformer. The bogus top-levelllm_config(vocab 151936, 32B-template dims) is ignored in favor of the realtext_config(vocab 151674).vision_model.*): a standard SDPA encoder with fused QKV (+bias), per-layer layer-scale (ls1/ls2), LayerNorm, a Conv2d patch embed, a CLS token, and a learned position embedding.qk_normalizationis false for InternVL3 so q/k norms are skipped.select_layer=-1returns the last hidden state and the CLS token is stripped before the connector. Every tile is exactly 448x448, so the patch grid matches the trained position-embed grid and interpolation is the identity.mlp1.*: pixel_shuffle(downsample_ratio=0.5) reshapes (B, 1024, 1024) into (B, 256, 4096), then LayerNorm -> Linear(4096->896) -> GELU -> Linear(896->896), yielding 256 image tokens per tile in the Qwen2 hidden size.<IMG_CONTEXT>(151667, resolved from the tokenizer) positions with the connector output through the sharedmerge_llavapath.Mixed precision: the top-level
quantizationblock makes the common VLM weight loader treat the checkpoint as quantized and skip its bf16->f16 conversion, keeping the 4-bit LLM and connector scales/biases as bf16 forquantized_matmul. The InternViT tower and connector LayerNorm are plain bf16 (which JIT-crash on M5), so the loader explicitly converts every remaining bf16 tensor to f16 except.scales/.biases, mirroring the standard Apple Silicon non-quantized-weight policy.RoPE: standard 1D RoPE (not Qwen2-VL MRoPE). The config's
rope_scalingdynamic-NTK factor is parsed but not applied in the rope path; for the 1B at short context this is a no-op and is noted as a known limitation.Integration
models/detection.rs:model_type == "internvl_chat"->ModelType::InternVLChatVLM.models/mod.rs,model_metadata.rs: newInternVLChatVLMModelType plus its Vlm metadata descriptor so the CLI and server both route it as a VLM.loaded_model.rs:LoadedModel::InternVLChatVLMvariant with LanguageModel delegation.loaded_model_capabilities.rs,multimodal/vlm_runtime.rs:VlmRuntimeRef::InternVLruntime arm with tile-count-driven<img> + <IMG_CONTEXT>*N + </img>token expansion.loading/vlm_internvl.rs:load_internvl_vlm()(config parse, mixed-precision weight handling, Qwen2 + InternViT + connector + processor construction, token-id/EOS resolution).vision/encoders/internvl.rs,vision/internvl.rs,vision/processors/internvl.rs,multimodal/internvl_prompt.rs.distributed/tensor_parallel/inference.rs: maps InternVL onto the llama-family planner placeholder (TP is already refused for VLM-kind models upstream).M5 embedding-prefill fixes
InternVL is the first VLM short enough to exercise the single-pass prefill path on the M5 Neural Accelerator, which surfaced two embedding-prefill bugs fixed here:
forward_with_embeddingswith pre-merged embeddings that are not padded, so a 293-token image prompt met a 320-wide mask and aborted with a broadcast-shape error. Alignment is now skipped whenever a sequence carries pre-merged embeddings; text-only prefill is unchanged.prefill_chunk_size(default 512) were sent to the chunked path, which re-triggered the same broadcast abort and is incoherent for embedding-based prefill (the whole embedding sequence is consumed while the prefill offset advances one chunk, corrupting cache bookkeeping). A multi-tile image (e.g. a 2:1 photo at 768+ image tokens) trivially exceeds 512 and hit this path; the chunked route is now gated on the absence of pre-merged embeddings, mirroring the batched-prefill dispatch that already forces VLMs onto full prefill.Testing
cargo fmt --all -- --check— clean.cargo clippy --features metal,accelerate --all-targets -- -D warnings— clean.tests/internvl_parity.rscovers detection, processor shapes, and finite text-only and image-conditioned logits of the correct vocab dimension (runs when the model is present).