fix: size gemma3/gemma4/exaone_moe prefill mask from live_len under --max-kv-size#431
Merged
Merged
Conversation
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).
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.
Member
Author
Implementation Review SummaryIntent
Findings Addressed
Remaining Items
Regression verdict
Tests
Verification
Narrow gate results (this dev host, MLX CPU build cached)
The fix commit ( |
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
gemma3, gemma4, and exaone_moe sized their multi-token (l > 1) prefill attention masks from the KV cache's monotonic
offsetinstead of the live window, for both the global (full-attention) causal mask and the sliding-window prefill mask. Under the server--max-kv-sizepath,enforce_max_kv_size_fortrims the global Standard cache viaKVCache::trim_front, which advanceslive_startwhileoffsetkeeps growing for RoPE, soupdate_and_fetchreturns onlylive_len = offset - live_startkeys. A mask sized fromoffsetis then wider than the live K/V, MLXbroadcast_shapesthrows, and because the op is a non-Result cxx function the throw becomesstd::terminateand aborts the whole server. This completes theoffset->live_lenprefill-mask migration started in #419/#420 and #421/#422 for the three Gemma-family / ExaOne dense-cache models they missed.The fix is mask-only and byte-identical when untrimmed (
live_start == 0, solive_len == offset). RoPE, position_ids, and the batched-MTP coordinate math keep reading the monotonicoffset.What changed
src/models/gemma3.rs: theseq_len > 1prefill branch sizes the global mask fromcaches[global_idx].live_len()and the sliding mask fromcaches[0].live_len(). Added alive_len()accessor to the model'sCacheInterfacetrait (KVCache->live_len(),RotatingKVCache->seq_len()).src/models/gemma4.rs: added afirst_cache_live_lenhelper (free function plus method form) that mirrorsfirst_cache_offsetand returns the live window (live_len()for the full-attention Standard cache,seq_len()for the sliding Rotating cache). It feeds the two genuine prefill-mask sites: the non-raggedl > 1branch inforward_with_speculative_sinksand the stage-forwardexecute_hiddenpath. The batched-MTP sites that use the offset as aper_row_valid_endcolumn coordinate (divergent verify,has_padding,mask_stale_key_gap) intentionally keep the monotonicfirst_cache_offset; that regime enforceslive_len == offset(slot_base == 0) anyway, and the value there is a coordinate, not a pure mask width. The now-unusedfirst_cache_offsetmethod form was removed (the free function remains in use).src/models/exaone_moe.rs: the global and sliding prefill masks size from a newAnyKVCache::live_len()(Standard->live_len(),Rotating->seq_len()) instead ofoffset().Mask sites switched to
live_lenvs offset uses keptSwitched to
live_len/seq_len(prefill mask width):gemma3.rs, seq_len > 1 branch) and sliding mask.l > 1prefill (create_causal_mask+create_sliding_window_prefill_mask) and theexecute_hiddenstage forward (seq_len > 1).Intentionally kept on the monotonic
offset:fast_ropeoffsets (attention reads each layer's owncache.offset()).first_present_cache_offsetdivergence detection,build_divergent_verify_mask, thehas_paddingleft-padding masks, andmask_stale_key_gap(these compare againstper_row_valid_end, which lives in monotonic-offset space, and the MTP regime requiresslot_base == 0).speculative_cache_offsetdrafter RoPE rebind.gemma4_mtp_target verdict
Verified safe, no change needed.
gemma4_mtp_targetdoes not implementLanguageModeland is not registered inmodel_metadata.rs; its adapters drive forward throughGemma4Wrapper::forward_with_speculative_sinks_explicit_cacheon caller-ownedVec<Cache>that never enters the scheduler pool, soenforce_max_kv_size_forcannot reach it. Its only mask builder (create_causal_mask_with_left_padding) sizes from a fixedoffset 0prefill. The Gemma 4 prefill-mask sites it routes into are already covered by thegemma4.rsedits.Test plan
cargo fmtcargo check --lib --tests(clean, no warnings)cargo test --lib gemma3(22 passed; newglobal_prefill_mask_sizes_from_live_len_not_offset_after_trimandsliding_cache_live_len_matches_returned_keyspass)cargo test --lib gemma4(newfirst_cache_live_len_sizes_global_mask_from_live_window_after_trimandfirst_cache_live_len_sliding_matches_returned_keyspass; one pre-existing unrelated failureload_and_sanitize_weights_dequantizes_nvfp4_gemma4_checkpointreproduces identically on the clean base commit and is NVFP4 weight-dequant, not mask logic)cargo test --lib exaone_moe(2 passed: newglobal_mask_sizes_from_live_len_not_offset_after_trimandsliding_cache_live_len_matches_returned_keys)cargo clippy --lib --tests -- -D warnings(clean)The new per-model tests trim a Standard cache so
live_start > 0, then assert thelive_len-sized prefill mask key axis matches the continuation chunk's returned K/V (live_len + m) while theoffset-sized mask is strictly wider, plus a Rotating-cache case wherelive_lenequals the returned key axis. The shared cache invariant is already pinned bykv_cache_continuation_mask_sizes_from_live_len_not_offset_after_trimincache.rs(#419).Closes #430