Repository navigation
fix(gemma4): size chunked-prefill masks to returned keys per family - #891
Conversation
Gemma 4 sliding-window models served with chunked prefill under concurrent load could collapse into a repeating cycle of reserved `<unused...>` tokens. During a chunked-prefill CONTINUATION the scheduler passes the server's chunk-local `create_padded_prefill_mask` into the model forward. Gemma 4 is a model-owned-cache batching model (`supports_batching`, `make_caches` returns an empty Vec), so the scheduler builds that mask against its dummy cache view whose offset reads 0. The mask's key axis is therefore far shorter than the per-family caches actually return once `forward_with_sequence_id` resolves the real caches: the full-attention `KVCache` returns `global_live_len + l` keys and the sliding `RotatingKVCache` returns `min(sliding_live_len, window - 1) + l` keys. The `else if let Some(mask) = mask` branch of `forward_with_speculative_sinks` reused that single short mask for BOTH attention families, so `trim_mask_to_keys` discarded it (mask shorter than keys) and every layer fell through to the maskless `causal_attention` path, dropping causal / sliding-window alignment and corrupting attention. The fix rebuilds each family's mask from its cache's live window when the caller mask is too short for the returned keys, mirroring the proven non-caller `l > 1` branch: full attention uses `create_causal_mask(l, global_live_len)` and the sliding family uses `create_sliding_window_prefill_mask(l, sliding_live_len, window)`. Each family's mask key axis then matches its returned keys, so `trim_mask_to_keys` never discards, and chunked prefill is byte-identical to unchunked prefill. A correctly-sized caller mask (cold batched prefill, or a full prefill that adopted a prompt-cache prefix) is preserved unchanged. `trim_mask_to_keys` is additionally hardened: a too-short mask on a multi-token (`query_len > 1`) chunk now fails loudly instead of silently discarding, since that is the corruption; single-token decode keeps the safe discard-and-derive-own-mask behaviour. Adds deterministic regression coverage that runs without a GPU or a large model: mask-level unit tests asserting the rebuilt sliding/global masks match the rotating/standard caches' returned keys while the offset-0 caller mask is shorter, a `trim_mask_to_keys` panic test for the multi-token too-short case, and an end-to-end tiny-model parity test that feeds the server's offset-0 caller mask on every continuation chunk and asserts logit parity with single-pass prefill for sliding (window 4 and 8, chunk 8) and full attention. Closes #885
Project style prohibits em dashes in all output including code comments. The `trim_mask_to_keys_crops_when_mask_longer_than_keys` doc comment added in this PR used one; replace it with a colon. Comment-only change, no behavior impact.
Security & performance review: one CRITICAL finding (fixed on-branch)CRITICAL: the
|
…erify crash) The panic added to trim_mask_to_keys for multi-token chunks was reachable via the Gemma 4 MTP speculative verify path, a different caller than chunked prefill. In an MTP verify round the buffered RotatingKVCache returns sliding_live_len + l uncompacted keys, so once sliding_live_len reaches the window the internally built sliding mask is shorter than the returned keys, and the panic crashed the server worker (a normal request decoding past the sliding window would trigger it). Revert to discarding a too-short mask with a warning for any query_len (the pre-existing behavior), which is correct for the MTP buffered chronological layout where the kernel falls back to windowed causal_attention. The chunked-prefill caller-mask rebuild (the actual issue #885 fix) already guarantees that path never delivers a too-short multi-token mask, so nothing depends on the panic. Adds mtp_multi_token_verify_at_window_does_not_panic as a permanent regression test and converts the former should_panic test to assert the discard.
PR Finalization CompleteVerified the branch at HEAD e230d8b ( Formatting / Lint
Tests
Documentation
No commits were added; the branch was already clean and ready. All checks passing. |
Summary
Gemma 4 sliding-window models served with chunked prefill under concurrent load could collapse into a repeating cycle of reserved
<unused...>tokens instead of a real answer. The root cause is a chunk-local prefill mask that is sized to the wrong key axis and then silently discarded, dropping causal / sliding-window alignment during a chunked-prefill continuation. This makes each family's prefill mask match the keys its cache actually returns, so the discard never happens and chunked prefill matches unchunked prefill.Root cause (confirmed against code)
During a chunked-prefill CONTINUATION the scheduler passes the server's chunk-local
create_padded_prefill_maskinto the model forward. Gemma 4 is a model-owned-cache batching model (supports_batching() == true,make_caches()returns an emptyVec), so the cache pool hands the scheduler dummy placeholder caches andcontinue_chunked_prefillcomputeskv_offset = caches.first().map_or(0, |c| c.offset) == 0. The caller mask is therefore built with offset 0 (key axis 160 in the reporter'smask_len=160log). Inside the model,forward_with_sequence_idresolves the REAL per-sequence caches (offset 2048), so the full-attentionKVCachereturnsglobal_live_len + lkeys (2208) and the slidingRotatingKVCachereturnsmin(sliding_live_len, window - 1) + lkeys (671, the reporter'skey_len=671). Theelse if let Some(mask) = maskbranch offorward_with_speculative_sinks(src/models/gemma4.rs:3058) reused that single short mask for BOTH families, sotrim_mask_to_keys(src/models/gemma4.rs:2364) took its too-short branch and discarded the mask. Every layer then fell through to the masklesscausal_attentionpath, which cannot reconstruct the correct causal + windowed alignment for queries at[offset, total)against a rotated key buffer, corrupting attention and driving the LM head into the reserved<unused...>region.The full-attention (global) layers were affected by the same discard: their offset-0 caller mask (key axis 160) is also shorter than the full cache's returned 2208 keys, so their mask was discarded too and they relied on the maskless causal fallback. The fix now gives them an explicit mask sized to the live window as well.
What changed
src/models/gemma4.rs(forward_with_speculative_sinks,else if let Some(mask) = maskbranch): whenl > 1and the caller mask's key axis is shorter than the keys either family returns, rebuild BOTH masks from the caches' live windows exactly as the proven non-callerl > 1branch does:create_causal_mask(l, global_live_len)for full attention andcreate_sliding_window_prefill_mask(l, sliding_live_len, window)for the sliding family. A correctly-sized caller mask (cold batched prefill, or a full prefill that adopted a prompt-cache prefix) is preserved unchanged and cropped per family bytrim_mask_to_keys.src/models/gemma4.rs(trim_mask_to_keys/Attention::attend): the function now takesquery_len. A too-short mask on a MULTI-token chunk (query_len > 1) fails loudly (panic referencing issue bug: Gemma 4 sliding-window server output collapses to reserved <unused> tokens under chunked prefill #885) instead of silently discarding, since silent discard is the corruption. Single-token decode (query_len == 1) keeps the safe discard-and-derive-own-mask behaviour. The mask-longer-than-keys crop path is unchanged.src/models/gemma4_tests.rsand thegemma4_unified_mask_testsmodule: deterministic regression coverage (below).Test plan
cargo test --lib gemma4_unified_mask_tests(9 passed) including new:chunked_continuation_sliding_mask_matches_returned_keysandchunked_continuation_global_mask_matches_returned_keys(rebuilt mask key axis equals the rotating/standard cache's returned keys, while the offset-0 caller mask is shorter),trim_mask_to_keys_crops_when_mask_longer_than_keys,trim_mask_to_keys_panics_on_too_short_multi_token(#[should_panic]),trim_mask_to_keys_discards_too_short_single_token_decode.cargo test --lib models::gemma4_tests::cache_isolation(4 passed, 3 ignored) including new PRIMARY parity testgemma4_chunked_prefill_with_server_caller_mask_matches_single_pass: on a tiny synthetic sliding-window Gemma 4 model, chunked prefill that feeds the server's offset-0create_padded_prefill_maskon every continuation chunk produces logits within 1e-3 of single-pass prefill for sliding (window 4 and 8, chunk 8, over-window) and full attention. The rebuild is load-bearing: without it the sliding window-4 continuation chunk hits the newtrim_mask_to_keyspanic.cargo test --lib models::gemma4(88 passed, 8 ignored) confirms the MTP / ragged / divergent-verify and existing chunked-parity paths still pass.cargo check --libandcargo clippy --libclean (only pre-existing mlxcel-core C++ build notes).Full CUDA build plus server E2E on the GB10 host (
gemma-4-e4b-it-qat-4bit, concurrent chunked prefill) is left to the orchestrator, since the MLX C++ link and a 26B/GPU run exceed the sandbox watchdog.Closes #885