fix(models): size dense prefill masks from live_len under trim (#419)#420
Conversation
The dense-KVCache sliding-window models (cohere2, gemma3n, olmo3) built their multi-token (l > 1) prefill attention masks from the cache's monotonic offset, for both the global mask (create_causal_mask) and the sliding mask (create_sliding_window_prefill_mask_dense). That is correct only while no trim has happened. Under the server --max-kv-size path, enforce_max_kv_size_for calls KVCache::trim_front, which physically slices the buffer to the live window and advances live_start while offset keeps growing monotonically (to preserve RoPE relative positions). update_and_fetch (Fp16 and Int8) then returns only the live window of live_len = offset - live_start keys, not offset keys. On a multi-token continuation chunk after at least one trim (live_start > 0), a mask sized from offset is wider than the returned K/V, so the mask key axis and the K/V key axis disagree, MLX broadcast_shapes throws, the cxx bridge turns the C++ exception into std::terminate, and the server process aborts. Reproduced on gemma3n-e2b-4bit with --max-kv-size 1024 --prefill-chunk-size 512 on a 2631-token prompt: crash on chunk 4 with [broadcast_shapes] Shapes (512,2048) and (1,8,512,1536) cannot be broadcast. Fix: build the model-level prefill masks from cache.live_len() instead of cache.offset, for both the global and sliding masks. For a query row q at monotonic abs position offset + q, the live key column k is at abs position live_start + k, so the causal bound live_start + k <= offset + q reduces to k <= q + (offset - live_start) = q + live_len, exactly create_causal_mask(l, live_len); the window bound reduces the same way. The mask key dim l + live_len then matches update_and_fetch's returned l + live_len. When untrimmed, live_start == 0 so live_len == offset and the masks are byte-identical, keeping greedy output unchanged on the common path. These offset reads are mask-only: RoPE keeps using each layer's own monotonic cache.offset, which the attention layer reads internally. gemma3n's KV-shared attention layer already slices its K/V to cache.live_len(); aligning the model-level mask to the same bound makes the two consistent. Adds a cache-level regression test (kv_cache_continuation_mask_sizes_from_live_len_not_offset_after_trim) that trims an Fp16 KVCache so live_start > 0, then asserts a continuation update returns live_len + m keys and that create_causal_mask(m, live_len) matches that key axis while create_causal_mask(m, offset) is strictly wider (the bug).
Real-model validation across all three dense sliding-window models (before/after)The bug is a full server-process abort, reproduced on all three affected models with Before (main
The mask shape Semantic-correctness control: cohere2 with the fix binary and NO trim ( |
Security / performance reviewReviewed the In scope (the 3 models): correct and complete, no findings
Out of scope, pre-existing (HIGH, recommend follow-up)The same crash signature (a model-level prefill mask sized from a dense
For contrast, Not merging. |
PR Finalization CompleteSummary
All checks passing. Ready for merge. |
…tron_nas/qwen-vl under --max-kv-size (#421) Completes the offset->live_len prefill-mask migration started in #419/#420 for the remaining dense Vec<KVCache> scheduler-pool models that build their multi-token prefill mask from the cache's monotonic offset. Under the server --max-kv-size path, enforce_max_kv_size_for calls KVCache::trim_front, which advances live_start while offset keeps growing for RoPE. update_and_fetch then returns only live_len = offset - live_start keys, so a mask sized from offset is wider than the live K/V, MLX broadcast_shapes throws, and the non-Result C++ exception crosses the cxx boundary to std::terminate and aborts the whole server. A remote client can crash all sessions with one long prompt plus a multi-token continuation chunk. Fix is mask-only: build the prefill causal mask from cache.live_len() (alias of seq_len(), both = offset - live_start) instead of offset. RoPE, position_ids, and the Llama-4 attention scale keep reading the monotonic offset. Byte-identical when untrimmed (live_start == 0, so live_len == offset). Models changed: - mistral4: transformer_body mask uses caches[0].live_len(); the Llama-4 attention scale below keeps caches[0].offset. - nemotron_nas: forward no-mask branch uses caches.first().map(|c| c.live_len()); per-layer RoPE offset untouched. - qwen2_vl / qwen3_vl / qwen3_vl_moe: only the auto_mask create_causal_mask argument switches to caches[0].live_len(); cache_offset stays monotonic for position_ids/RoPE grid slicing. nemotron_h and jamba were investigated and excluded: they are ModelOwnedSequenceState caches, so get_caches_mut returns an empty slice and enforce_max_kv_size_for never trims them (latent inconsistency only, not remotely triggerable today).
…tron_nas/qwen-vl under --max-kv-size (#421) (#422) Completes the offset->live_len prefill-mask migration started in #419/#420 for the remaining dense Vec<KVCache> scheduler-pool models that build their multi-token prefill mask from the cache's monotonic offset. Under the server --max-kv-size path, enforce_max_kv_size_for calls KVCache::trim_front, which advances live_start while offset keeps growing for RoPE. update_and_fetch then returns only live_len = offset - live_start keys, so a mask sized from offset is wider than the live K/V, MLX broadcast_shapes throws, and the non-Result C++ exception crosses the cxx boundary to std::terminate and aborts the whole server. A remote client can crash all sessions with one long prompt plus a multi-token continuation chunk. Fix is mask-only: build the prefill causal mask from cache.live_len() (alias of seq_len(), both = offset - live_start) instead of offset. RoPE, position_ids, and the Llama-4 attention scale keep reading the monotonic offset. Byte-identical when untrimmed (live_start == 0, so live_len == offset). Models changed: - mistral4: transformer_body mask uses caches[0].live_len(); the Llama-4 attention scale below keeps caches[0].offset. - nemotron_nas: forward no-mask branch uses caches.first().map(|c| c.live_len()); per-layer RoPE offset untouched. - qwen2_vl / qwen3_vl / qwen3_vl_moe: only the auto_mask create_causal_mask argument switches to caches[0].live_len(); cache_offset stays monotonic for position_ids/RoPE grid slicing. nemotron_h and jamba were investigated and excluded: they are ModelOwnedSequenceState caches, so get_caches_mut returns an empty slice and enforce_max_kv_size_for never trims them (latent inconsistency only, not remotely triggerable today).
…-max-kv-size (#431) * fix(models): size gemma3/gemma4/exaone_moe prefill mask from live_len gemma3, gemma4, and exaone_moe built their multi-token (l > 1) prefill attention masks from the cache's monotonic offset instead of the live window, for both the global (full-attention) causal mask and the sliding-window prefill mask. That is correct only while no trim has happened. Under the server --max-kv-size path, enforce_max_kv_size_for calls KVCache::trim_front, which advances live_start while offset keeps growing for RoPE; update_and_fetch then returns only live_len = offset - live_start keys, so a mask sized from offset is wider than the live K/V, MLX broadcast_shapes throws, and the non-Result cxx boundary turns the throw into std::terminate and aborts the whole server. This completes the offset->live_len prefill-mask migration started in #419/#420 and #421/#422 for the three Gemma-family / ExaOne dense-cache models they missed. Fix is mask-only: size the prefill masks from the cache's live window (live_len() for a Standard KVCache, seq_len() for a Rotating cache, both equal to the keys update_and_fetch returns) instead of offset. RoPE and position bookkeeping keep reading the monotonic offset. Byte-identical when untrimmed (live_start == 0, so live_len == offset). Models changed: - gemma3: the seq_len > 1 prefill branch sizes the global mask from caches[global_idx].live_len() and the sliding mask from caches[0].live_len(); a live_len() accessor was added to the model's CacheInterface trait (KVCache -> live_len(), RotatingKVCache -> seq_len()). - gemma4: a first_cache_live_len helper (free function plus method form) mirrors first_cache_offset and feeds the two genuine prefill-mask sites (the non-ragged l > 1 branch and the stage-forward execute_hidden path). The batched-MTP sites that use the offset as a per_row_valid_end column coordinate (divergent verify, has_padding, mask_stale_key_gap) intentionally keep the monotonic first_cache_offset; that regime enforces live_len == offset (slot_base == 0) anyway. - exaone_moe: the global and sliding prefill masks size from a new AnyKVCache::live_len() (Standard -> live_len(), Rotating -> seq_len()) instead of offset(). gemma4_mtp_target was verified and needs no change: it is not a registered LanguageModel, drives forward through caller-owned caches that never enter the scheduler pool, and its only mask builder sizes from a fixed offset 0 prefill. Adds per-model unit tests (gemma3_mask_tests, gemma4_unified_mask_tests, exaone_moe_mask_tests) that trim a Standard cache so live_start > 0, then assert the live_len-sized prefill mask key axis matches the continuation chunk's returned K/V (live_len + m) while the offset-sized mask is strictly wider, plus a Rotating-cache case where live_len equals the returned key axis. The shared cache invariant is already pinned by kv_cache_continuation_mask_sizes_from_live_len_not_offset_after_trim in cache.rs (#419). * fix(models): size gemma3 stage prefill mask from live_len The Gemma3StageModel::execute_hidden pipeline-stage path still sized both the global causal mask and the sliding-window prefill mask from the cache's monotonic offset, while the gemma4 stage analog (Gemma4StageModel::execute_hidden) and the gemma3 single-process path (Gemma3Model::forward_with_caches) were already switched to live_len in this PR. This completes the offset to live_len prefill-mask migration for gemma3 so both stage paths match. Like the rest of #430, this is byte-identical on the untrimmed path: the stage executor's PointerOwnedCacheStore syncs only offset (never live_start) into the model-owned internal caches, so live_start stays 0 and live_len == offset there today. The change uses the existing as_interface().live_len() accessor (KVCache to live_len, RotatingKVCache to seq_len), and keeps RoPE on the monotonic offset. * docs(changelog): record gemma3/gemma4/exaone_moe prefill-mask consistency fix (#430)
The initial v0.3.3 cut (4d5f4fb) was never tagged, and 16 more commits landed on main afterward. Fold those fixes into the v0.3.3 CHANGELOG and debian/changelog entries and set the release date to 2026-06-25: - N-gram loop detection (#433) and Nemotron-H Nano Omni audio input (#443) - Prefill masks sized from the live window under --max-kv-size trim (#418, #420, #422, #431) - Double-transpose crash on mlx-community conv checkpoints (#429) - conv1d/conv2d and nemotron audio-encoder convs fallible at the FFI boundary (#434, #439) - Gemma 4 audio placed in the user turn (#438, #440) - mistral4 MLA backbone routing and 2D MoE token flattening (#423, #425) - Bump actions/checkout from 6 to 7 (#395)
Summary
The dense-
KVCachesliding-window models (cohere2, gemma3n, olmo3) built their multi-token (l > 1) prefill attention masks from the cache's monotonicoffset, for both the global mask (create_causal_mask) and the sliding mask (create_sliding_window_prefill_mask_dense). Under the server--max-kv-sizepath that assumption is wrong after a trim, and the mismatch aborts the whole server process. This sizes the masks fromcache.live_len()instead, which is byte-identical on the common untrimmed path.The bug
enforce_max_kv_size_for(called incontinue_chunked_prefill, among others) callsKVCache::trim_front, which physically slices the buffer to the live window and advanceslive_startwhileoffsetkeeps growing monotonically (to preserve RoPE relative positions).update_and_fetch(Fp16 and Int8) then returns only the live window oflive_len = offset - live_startkeys, notoffsetkeys. On a multi-token continuation chunk after at least one trim (live_start > 0), a mask sized from the monotonicoffsetis WIDER than the returned K/V, so the mask key axis and the K/V key axis disagree. MLXbroadcast_shapesthrows, the cxx bridge turns the C++ exception intostd::terminate, and the server process ABORTS (not a recoverable per-request error).Reproduced on
gemma3n-e2b-4bitwith--max-kv-size 1024 --prefill-chunk-size 512on a 2631-token prompt: crash on chunk 4 with[broadcast_shapes] Shapes (512,2048) and (1,8,512,1536) cannot be broadcast.What changed
src/models/cohere2.rs(forward_implandforward_with_embeddings_impl): build the global and sliding prefill masks fromcaches[..].live_len()instead ofcaches[..].offset.src/models/gemma3n.rs(bothforwardvariants): same change forglobal_mask/sliding_mask; the comment notes consistency with the KV-shared attention layer, which already slices its K/V tocache.live_len().src/models/olmo3.rs(forward_impl): same change for the global and sliding masks.src/lib/mlxcel-core/src/cache.rs: new cache-level regression testkv_cache_continuation_mask_sizes_from_live_len_not_offset_after_trim.These
offsetreads were mask-only. RoPE keeps using each layer's own monotoniccache.offset(read internally by the attention layer); no positional / layer-forward path changed.create_causal_mask,create_causal_mask_with_window_full,create_sliding_window_prefill_mask_dense,trim_front,update_and_fetch, and the RotatingKVCache consumers are untouched.Why it is correct (and byte-identical when untrimmed)
For a query row
qat monotonic abs positionoffset + q, the live key columnkis at abs positionlive_start + k. The causal boundlive_start + k <= offset + qreduces tok <= q + (offset - live_start) = q + live_len, which is exactlycreate_causal_mask(l, live_len); the window bound reduces the same way. The mask key diml + live_lenthen matchesupdate_and_fetch's returnedl + live_len. When no trim has happened,live_start == 0solive_len == offsetand the masks are bit-for-bit the same, so greedy output on the common path is unchanged.Test plan
cargo test --release -p mlxcel-core --features metal,accelerate cache::(402 passed, incl. the newkv_cache_continuation_mask_sizes_from_live_len_not_offset_after_trim)cargo check --lib --features metal,acceleratecargo clippy --lib --tests --features metal,accelerate -- -D warnings(clean)cargo fmt --checkCloses #419