fix(models): dense-cache sliding-window prefill uses full mask over all retained keys (#413)#418
Conversation
Dense-`KVCache` sliding-window models (cohere2, gemma3n, olmo3) retain every key and slice K/V to the mask's key axis, but they routed mask construction through `create_sliding_window_prefill_mask`, whose full-mask gate is `size > window && sliding_offset == 0`. A rolled-over or chunked over-window prefill (`sliding_offset > 0 && size > window`) therefore fell to the clamped `[size, window]` mask. The clamped `tril` diagonal `window - size < 0` strands the earliest query rows (logical position `< size - window`) with an all-`-inf` row, which softmaxes to NaN and decodes to `<pad>`. This is a latent degeneration, not a regression from #412/#408: the behavior is byte-identical to the pre-#408 path. `RotatingKVCache` models (gpt_oss, mellum, exaone4, exaone_moe, ministral3, step3p5, gemma3, gemma4) and the gemma3 tensor-parallel path legitimately NEED the clamped path once the cache has rolled over, because the rotating cache then returns at most `window` keys. So the shared helper must not change behavior for them. Add a sibling helper `create_sliding_window_prefill_mask_dense` that builds the full `[size, size + sliding_offset]` windowed-causal mask for any `size > window` (at any offset), keeping the clamped path only for within-window prefills. For `size <= window` it is byte-identical to the rotating helper (same clamped builder and offset clamp), so within-window greedy output is unchanged. Reroute the five dense call sites (cohere2 x2, gemma3n x2, olmo3 x1) to the dense helper and leave the rotating-cache call sites untouched. Add three mask-layer regression tests: the in-scope offset>0 over-window fix (full mask, no all-`-inf` row), parity with the rotating helper for a fresh over-window prefill, and byte-identical output for within-window prefills including the within-window rolled-over clamped path.
Generalize the dense-cache fix to cover the adjacent latent case in the same change. A dense `KVCache` retains every key and the consumer slices K/V to the mask's key axis, so the correct prefill mask is ALWAYS the full `[size, size + sliding_offset]` windowed-causal mask over all retained keys, for every size/offset combination, not only `size > window`. This drops the `if size > window` branch from `create_sliding_window_prefill_mask_dense`; it now unconditionally calls `create_causal_mask_with_window_full`. For `size + sliding_offset <= window` (the common within-window prefill) this is byte-identical to the rotating helper: `create_causal_mask_with_window` takes its non-capped path there and produces the same `tril(offset)` intersect `triu(offset - window + 1)` band over `[size, size + sliding_offset]`, so greedy output is unchanged. The unconditional full mask fixes both dense-cache degeneration cases the clamped path caused once the total exceeded the window: (1) `size > window` (any offset) where the clamped `[size, window]` mask stranded the earliest query rows with an all-`-inf` row (NaN then `<pad>`), and (2) `size <= window && size + sliding_offset > window` where the clamped path's trailing-window K/V slice dropped the OLDEST in-window keys for the earliest query rows (coherent-but-wrong output, not NaN). RotatingKVCache models (gpt_oss, mellum, exaone4, exaone_moe, ministral3, step3p5, gemma3, gemma4) and the gemma3 tensor-parallel path keep `create_sliding_window_prefill_mask` and its clamped path, because a rotating cache physically returns at most `window` keys once rolled over. Tests: change `sliding_window_prefill_mask_dense_within_window_byte_identical` to within-total inputs only (`(3, 0, 8)` and `(3, 2, 8)`), since `(2, 9, 4)` is now the latent-fix case. Add `sliding_window_prefill_mask_dense_full_when_within_window_over_total` asserting the full `[2, 11]` mask (not clamped `[2, 4]`) and that row 0 (logical position 9) attends to its oldest in-window key (column 6) that the clamped path dropped. Update the five dense call-site comments to describe the unconditional full mask.
Real-model validation (gemma3n-e2b-4bit, before/after)Validated on a real checkpoint per the inference-change rule, using the single-node server's chunked prefill to drive Identical request (
The deterministic mask-layer unit tests cover both fixed cases at the exact layer of the bug: the over-window NaN case ( |
Implementation Review SummaryIntent
Findings AddressedNone. The implementation is correct and complete as submitted; no fixes were required. Verification
Correctness notes
|
Security and performance review (#413 dense sliding-window prefill mask)Reviewed the mask change and all five rerouted call sites (cohere2 x2, gemma3n x2, olmo3 x1) plus the shared Performance: acceptable / inherent (no decode regression)
Integer arithmetic: safe for realistic contexts
Within-window parity: confirmed
Note (preexisting, out of scope):
|
PR Finalization CompleteTests4 new unit tests for
All 34 DocumentationNo existing doc is inaccurate due to this fix. The sliding-window mask selection behavior (dense vs rotating) is not documented at the doc level, and the change is purely internal. The Lint/Format
All checks passing. Ready for merge. |
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
Dense-
KVCachesliding-window models (cohere2, gemma3n, olmo3) degenerated on prefills where the total key span exceeded the window. This makes their prefill mask construction unconditionally use the full windowed-causal mask over all retained keys, fixing both degeneration cases while leaving the rotating-cache models untouched.Root cause
A dense
KVCacheretains every key and the attention layer slices K/V to the mask's key axis, but these three models routed mask construction throughcreate_sliding_window_prefill_mask, whose full-mask gate issize > window && sliding_offset == 0(a FRESH prefill only). Any other over-window or over-total case fell to the clamped[size, window]mask, which assumes the cache physically trimmed towindowkeys. For a dense cache that assumption is false, so the clamped mask is the wrong shape for the keys actually present. This is a latent degeneration, not a regression introduced by #412 / #408: the behavior is byte-identical to the pre-#408 path.The fix
A dense
KVCachekeeps every key, so the correct prefill mask is ALWAYS the full[size, size + sliding_offset]windowed-causal mask over all retained keys, for every size/offset combination. The newcreate_sliding_window_prefill_mask_densehelper is therefore an unconditional call tocreate_causal_mask_with_window_full(no clamped branch). The window is enforced by the mask, not by physically dropping keys.For the common within-window prefill (
size + sliding_offset <= window) this is byte-identical tocreate_sliding_window_prefill_mask: the clamped builder takes its non-capped path there and produces the sametril(offset)intersecttriu(offset - window + 1)band over[size, size + sliding_offset], so greedy output is unchanged.Both degeneration cases fixed (dense caches: cohere2 / gemma3n / olmo3)
size > window, at any offset): the clamped[size, window]mask'strildiagonalwindow - size < 0stranded the earliest query rows (logical position< size - window) with an all--infrow, which softmaxed to NaN and decoded to<pad>.size <= window && size + sliding_offset > window): the clamped path's trailing-windowK/V slice dropped the OLDEST in-window keys for the earliest query rows, silently narrowing their attention window. This produced coherent-but-wrong output rather than NaN/<pad>, so it was harder to spot, but it is the same root cause.Prefills with
size + sliding_offset <= windowremain byte-identical (greedy output unchanged).RotatingKVCachemodels (gpt_oss, mellum, exaone4, exaone_moe, ministral3, step3p5, gemma3, gemma4) and the gemma3 tensor-parallel path are untouched: they keepcreate_sliding_window_prefill_maskand its clamped path, because a rotating cache physically returns at mostwindowkeys once it has rolled over, so the clamped mask is the matching shape.What changed
create_sliding_window_prefill_mask_denseinsrc/lib/mlxcel-core/src/utils.rssimplified to an unconditional full mask; doc comment rewritten to explain the dense-vs-rotating divergence and both fixed cases.src/models/cohere2.rs(two forward variants),src/models/gemma3n.rs(two forward variants),src/models/olmo3.rs(one); call-site comments updated to describe the unconditional full mask.Used by:lines updated:create_sliding_window_prefill_maskno longer lists the dense models;create_causal_mask_with_window_fulllists the dense prefill consumers.src/distributed/tensor_parallel/llama_runtime.rs(gemma3 TP), gemma3.rs, gemma4.rs, gpt_oss.rs, mellum.rs, exaone4.rs, exaone_moe.rs, ministral3.rs, step3p5.rs.utilstest module:sliding_window_prefill_mask_dense_full_when_rolled_over_window(over-window NaN case) andsliding_window_prefill_mask_dense_matches_fresh_over_window(parity with the rotating helper for a fresh over-window prefill);sliding_window_prefill_mask_dense_within_window_byte_identicalnow covers within-total inputs(3, 0, 8)and(3, 2, 8); newsliding_window_prefill_mask_dense_full_when_within_window_over_totalpins the within-window over-total fix (full[2, 11]mask, not clamped[2, 4]; row 0 at logical position 9 attends to its oldest in-window key at column 6 that the clamped path dropped).Test plan
cargo test --release -p mlxcel-core utils::(34 passed, 0 failed; includes the foursliding_window_prefill_mask_dense_*tests)cargo check --lib(root crate compiles with the rerouted call sites)cargo clippy --lib --tests -- -D warnings(clean)cargo fmt --check(clean)Closes #413