fix(models): size dense prefill masks from live_len for mistral4/nemotron_nas/qwen-vl under --max-kv-size (#421)#422
Merged
Conversation
4 tasks
…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).
inureyes
force-pushed
the
fix/issue-421-dense-mask-live-len-migration
branch
from
June 24, 2026 15:35
f0d5f90 to
af05dc7
Compare
Member
Author
PR Finalization CompleteSummary
No commits were added; the branch is already production-ready. Not merged. |
Member
Author
Real-model validation (all 5 models, before/after under
|
inureyes
added a commit
that referenced
this pull request
Jun 24, 2026
…-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)
inureyes
added a commit
that referenced
this pull request
Jun 25, 2026
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)
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
Completes the
offset->live_lenprefill-mask migration started in #419 / PR #420, applying it to the five remaining denseVec<KVCache>scheduler-pool models that still built their multi-token (l > 1) prefill causal mask from the cache's monotonicoffset.The bug
Under the server
--max-kv-sizepath,enforce_max_kv_size_for(src/server/batch/scheduler.rs) callsKVCache::trim_front, which advanceslive_startwhileoffsetkeeps growing monotonically (the RoPE invariant).KVCache::update_and_fetchthen returns only the live window oflive_len = offset - live_startkeys, notoffsetkeys. A model-level mask sized fromoffsetis therefore WIDER than the live K/V after a trim. MLXbroadcast_shapesthrows, and because the throw crosses the cxx FFI boundary as a non-ResultC++ exception it reachesstd::terminateand ABORTS the whole server process. This is a remote-triggerable full-server abort: one long prompt plus a multi-token continuation chunk crashes every session.The fix (mask-only)
For mask construction only, size the prefill causal mask from
cache.live_len()(alias ofseq_len(), both= offset - live_start) instead ofcache.offset. RoPE,position_ids, and the Llama-4 attention scale keep reading the monotonicoffset. With no trim (live_start == 0),live_len == offset, so greedy output on the normal untrimmed path is byte-identical.What changed
src/models/mistral4.rs:Mistral4Model::transformer_bodybuilds the prefill mask fromcaches[0].live_len(). The separatelet offset = caches[0].offsetthat feeds the Llama-4 attention scale (get_llama4_attn_scale) is left monotonic and unchanged.src/models/nemotron_nas.rs:LanguageModel::forwardno-mask branch (the server path) builds the mask fromcaches.first().map(|c| c.live_len()). The per-layer RoPEoffsetin the attention block is untouched.src/models/qwen2_vl.rs,src/models/qwen3_vl.rs,src/models/qwen3_vl_moe.rs: only theauto_mask = create_causal_mask(seq_len, ...)argument switches tocaches[0].live_len(). Thecache_offsetvariable stayscaches[0].offsetand continues to driveposition_ids/ MRoPE grid slicing and the deepstackcache_offset == 0checks, because position_ids and RoPE need absolute (monotonic) positions.Scope: confirmed affected vs excluded
The five models above each store attention K/V as a plain
Vec<KVCache>in the scheduler pool, soenforce_max_kv_size_fortrims them and the offset-sized mask is a live (remotely triggerable) bug.nemotron_handjambawere investigated and EXCLUDED: they useModelOwnedSequenceState, soCachePool::get_caches_mutreturns an empty slice and the trim loop never runs against their attention caches. For them the offset-based mask is a latent inconsistency, not reachable via--max-kv-size, and is left unchanged.glm4_moe_liteandminicpm3already size fromseq_len()and are the reference pattern.Test plan
cargo fmt --checkcargo check --lib --features metal,accelerate(compiles all five changed models)cargo clippy --lib --tests --features metal,accelerate -- -D warnings(clean)cargo test --release -p mlxcel-core --features metal,accelerate cache::(402 passed) including the shared invariant guardkv_cache_continuation_mask_sizes_from_live_len_not_offset_after_trim(added in fix(models): size dense prefill masks from live_len under trim (#419) #420), which pins the trimmed-cache (live_start > 0) invariant these five models now rely on.No per-model unit test is added: exercising each model's mask construction requires a fully loaded real checkpoint (heavy, not in CI), and the underlying primitive is already pinned by the shared cache-level invariant test above.
Real-model E2E validation status will be added by the orchestrator: nemotron_nas / qwen2_vl / qwen3_vl / qwen3_vl_moe validated locally under a small
--max-kv-sizewith a multi-token continuation chunk; mistral4 (Mistral-Small-4-119B) validated when its checkpoint is available.Closes #421