Skip to content

Add static KV cache (TensorScatter) support for Gemma4 - #246

Merged
justinchuby merged 17 commits into
mainfrom
gemma4-static-cache
Jul 25, 2026
Merged

Add static KV cache (TensorScatter) support for Gemma4#246
justinchuby merged 17 commits into
mainfrom
gemma4-static-cache

Conversation

@justinchuby

Copy link
Copy Markdown
Member

Summary

Adds --static-cache support for Gemma4 models, enabling pre-allocated KV cache buffers with TensorScatter ops (opset 24).

Changes

src/mobius/models/gemma4.py

  • Added StaticCacheState dispatch to Gemma4DecoderLayer.forward() — extracts static cache from past_key_value and passes to attention
  • Added static_cache parameter to Gemma4TextAttention.forward() — threads through to _apply_attention()
  • Added static_cache_mode flag in Gemma4TextModel.forward() — skips GQA, fallback mask construction, and uses None attention bias when attention_mask is None

src/mobius/tasks/_gemma4.py

  • New _make_gemma4_static_cache_inputs() — creates per-layer static cache with correct dual head_dim (sliding=256, full=512) and skips KV-shared layers
  • Updated Gemma4TextCausalLMTask with static_cache/max_seq_len params and static vs dynamic cache branching

src/mobius/tasks/_causal_lm.py

  • Added Gemma4DecoderLayer to _validate_static_cache_support()
  • Scoped validation to skip encoder sub-modules (vision_encoder, audio_encoder, etc.)

src/mobius/__main__.py

  • Updated --static-cache CLI to resolve the correct task class for Gemma4 (uses text model type for multimodal models)

Testing

  • ✅ All 1186 existing tests pass
  • mobius build --model google/gemma-4-e2b-it --dtype f16 --static-cache --max-seq-len 4096 produces correct model with 30 TensorScatter + 35 Attention + 0 GQA ops
  • ✅ Tiny config unit test verified programmatically

Phase 1 Scope / Limitations

This is Phase 1 — full-attention and sliding-attention layers both work, but:

  • Sliding window masking: Static cache path uses is_causal=1 without sliding window constraint (standard causal attention). This matches the generic DecoderLayer static cache behavior.
  • KV-shared layers: Correctly excluded from cache allocation. They continue to borrow K,V from source layers via the existing shared_kv_states mechanism.
  • Requires ORT ≥ 1.25.0 for TensorScatter opset 24 kernel support.

@github-actions

github-actions Bot commented May 4, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing 68fdc6712781d9

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 60 60 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 68 68 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 107 107 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 54 54 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 62 62 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 58 58 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 98 98 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 60 60 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 56 56 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 62 62 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 58 58 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 275 275 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 129 129 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 413 413 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 166 166 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 128 128 +0.0%

No performance regressions.

@codecov

codecov Bot commented May 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.00000% with 36 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/mobius/tasks/_gemma4.py 78.12% 13 Missing and 8 partials ⚠️
src/mobius/__main__.py 50.00% 7 Missing and 2 partials ⚠️
src/mobius/models/gemma4.py 81.48% 3 Missing and 2 partials ⚠️
src/mobius/tasks/_causal_lm.py 66.66% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented May 4, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing 68fdc6712781d9

Model Sub-model Changes Status
bert (feature-extraction) model 0
falcon model 0
gemma2 model 0
gemma4 (gemma4) decoder 0
gemma4 (gemma4) embedding 0
gemma4 (gemma4) vision_encoder 0
gemma4_text model 0
gpt2 model 0
llama model 0
llama (static-cache) model 0
mamba (ssm-text-generation) model 0
phi3 model 0
phi3 (static-cache) model 0
qwen model 0
qwen (static-cache) model 0
qwen2 model 0
qwen2 (static-cache) model 0
qwen2_moe model 0
qwen2_moe (static-cache) model 0
qwen3 model 0
qwen3 (static-cache) model 0
qwen3_5_moe (hybrid-text-generation) model 0
qwen3_5_text (hybrid-text-generation) model 0
qwen3_5_vl (hybrid-qwen-vl) decoder 0
qwen3_5_vl (hybrid-qwen-vl) embedding 0
qwen3_5_vl (hybrid-qwen-vl) vision_encoder 0
qwen3_moe model 0
qwen3_moe (static-cache) model 0
qwen3_next (hybrid-text-generation) model 0
t5 (seq2seq) decoder 0
t5 (seq2seq) encoder 0
whisper (speech-to-text) decoder 0
whisper (speech-to-text) encoder 0

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

@titaiwangms

Copy link
Copy Markdown
Contributor

Code Review

🔴 Critical — Sliding-window masking silently disabled in static cache mode

src/mobius/components/_attention.py:156-158 (and the static-cache branch of _apply_attention)

The static-cache path calls Attention with is_causal=1 and no local_window_size / attn_mask. For Gemma4 sliding layers (e.g. sliding_window=512), once the sequence exceeds the window the model will attend to all past tokens instead of just the last 512 — diverging from HuggingFace and from the dynamic-cache path in this same model.

The PR description calls this a "Phase 1 limitation matching the generic DecoderLayer," but the dynamic-cache GQA path here does enforce sliding via GQAContext.local_window_size (gemma4.py:1488-1495), so the two cache modes will produce different outputs for long sequences. This reads as a latent correctness bug, not just a limitation.

Suggested fix: either (a) reject --static-cache in _validate_static_cache_support when any layer has sliding_window set, or (b) re-document as a known correctness bug with a runtime warning so users aren't silently surprised.

🟡 Major — Encoder-skip heuristic in _validate_static_cache_support is fragile

src/mobius/tasks/_causal_lm.py:377-385

Validation skips ModuleLists by string-prefix matching the module path against vision_encoder / audio_encoder / etc. A future module nested under a differently-named path would silently bypass validation. Prefer iterating module.named_modules() and explicitly whitelisting instances of DecoderLayer | MoEDecoderLayer | Gemma4DecoderLayer rather than blacklisting by path prefix.

🟢 Minor — attention_mask: ir.Value | None overloads None

src/mobius/models/gemma4.py:1429,1451-1455

static_cache_mode = attention_mask is None conflates "no mask provided" with "static-cache mode is on" and weakens the type contract of Gemma4TextModel.forward. Prefer an explicit static_cache_mode: bool parameter (or detect from the past_key_values type via isinstance(..., StaticCacheState)), keeping attention_mask: ir.Value non-optional.

Notes

  • Out-of-scope diff in integrations/gguf/_config_mapping.py and rewrite_rules/_group_query_attention.py aren't mentioned in the PR body — worth a one-line note for reviewers.
  • Dual head_dim handling (256 sliding / 512 full), KV-shared layer exclusion, and per-layer cache index alignment look correct.
  • Test coverage covers the happy path well; consider adding a test that exercises a sliding layer with seq_len > sliding_window to make the Critical issue above visible.

@justinchuby

justinchuby commented May 4, 2026

Copy link
Copy Markdown
Member Author

Thanks for the thorough review @titaiwangms! All three findings addressed in commit f50b2b8:

🟡 Major — Fragile encoder-skip heuristic

Replaced the path-prefix blacklist (vision_encoder, audio_encoder, ...) with a whitelist isinstance check against _supported = (DecoderLayer, MoEDecoderLayer, Gemma4DecoderLayer). Vision/audio encoder layers naturally skip validation since they use different classes (e.g. Gemma4VisionEncoderLayer). No more fragile string matching.

🟢 Minor — attention_mask is None overload

Now detects static_cache_mode by checking isinstance(kv, StaticCacheState) in past_key_values, keeping attention_mask: ir.Value | None with its natural semantics. The GQAContext import was updated to also import StaticCacheState for this check.

@justinchuby

This comment was marked as resolved.

@justinchuby
justinchuby marked this pull request as draft May 5, 2026 14:49
@justinchuby

Copy link
Copy Markdown
Member Author

Update: The hybrid per-layer approach (commit 0ebf291) fully addresses the Critical finding:

🔴 Sliding-window — RESOLVED

Instead of disabling sliding window or just warning, we now use per-layer hybrid cache:

  • Full-attention layers → TensorScatter + standard Attention (static cache)
  • Sliding-attention layers → GQA with local_window_size (dynamic cache)

This ensures sliding window constraints are correctly enforced via GQA's native local_window_size attribute, while full-attention layers benefit from static pre-allocated buffers. The two cache types coexist in the same model.

🟡 Fragile validation — RESOLVED

Whitelist isinstance check (from f50b2b8) remains in place.

🟢 None overload — RESOLVED

use_gqa now depends on attention_mask is not None (which is always True in hybrid mode since sliding layers need it). Per-layer dispatch uses isinstance(past_kv, StaticCacheState) to route each layer to the correct path.

@justinchuby
justinchuby marked this pull request as ready for review May 6, 2026 14:10
@justinchuby
justinchuby force-pushed the gemma4-static-cache branch from 0e0e65a to db1c23a Compare May 6, 2026 15:46
@justinchuby
justinchuby marked this pull request as draft May 6, 2026 23:35
@justinchuby

Copy link
Copy Markdown
Member Author

Will wait after the standard paths land

@justinchuby
justinchuby force-pushed the gemma4-static-cache branch from 24279cc to b95ef3f Compare May 7, 2026 01:11
Comment thread src/mobius/models/gemma4.py Fixed
justinchuby and others added 11 commits July 25, 2026 00:19
Enable --static-cache for Gemma4 models by:

- Adding StaticCacheState dispatch to Gemma4DecoderLayer.forward() and
  Gemma4TextAttention.forward(), matching the pattern from DecoderLayer
- Creating _make_gemma4_static_cache_inputs() that handles dual head_dim
  (sliding=256, full=512) and KV-shared layers (no cache entries)
- Updating Gemma4TextCausalLMTask to support static_cache=True with
  pre-allocated fixed-size cache buffers and TensorScatter ops
- Adding Gemma4DecoderLayer to _validate_static_cache_support() and
  scoping validation to skip encoder sub-modules
- Updating CLI to resolve the correct task class for Gemma4 when
  --static-cache is used (text model class for multimodal models)

In static cache mode, attention_mask is None and the Attention op uses
is_causal=1 + nonpad_kv_seqlen for masking. GQA is automatically
disabled since it requires attention_mask.

Tested with google/gemma-4-e2b-it: produces 30 TensorScatter ops
(15 non-shared layers x 2), 35 Attention ops, 0 GQA ops.
All 1186 existing tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
- Fix input ordering: write_indices/nonpad_kv_seqlen now come after
  all cache inputs, matching the standard _make_static_cache_inputs
  pattern
- Add 5 Gemma4 static cache tests: graph build, dual head_dim shapes,
  TensorScatter op counts, KV-shared layer exclusion, input ordering
- Refactor CLI: remove ~50 lines of duplicated build() logic, use
  build(module_class=...) to override model class for multimodal
  models instead of reimplementing the build pipeline
- No unrelated test deletions found (Finding 4 N/A)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Add static_cache/max_seq_len params to Gemma4Task so multimodal models
(gemma4 model_type) get TensorScatter on their decoder while vision,
audio, and embedding models stay unchanged.

This removes the need for the text-only model class override in the
CLI — build() now works directly with Gemma4Model + Gemma4Task for
both dynamic and static cache modes.

CLI _resolve_static_cache_task now maps:
- gemma4 → Gemma4Task(static_cache=True)
- gemma4_text → Gemma4TextCausalLMTask(static_cache=True)
- others → CausalLMTask(static_cache=True)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
…atic detection

Address review findings from @titaiwangms:

1. [Critical] Sliding window warning: _validate_static_cache_support()
   now warns when the model uses sliding_window, since the static
   cache path (is_causal=1) does not enforce window constraints.

2. [Major] Replace fragile encoder-prefix blacklist with whitelist:
   validation now checks isinstance against _supported tuple, which
   naturally skips vision/audio encoder layers (different classes).

3. [Minor] Detect static_cache_mode from past_key_values content
   (isinstance StaticCacheState) instead of overloading
   attention_mask=None. Keeps the type contract of forward() clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Replace the sliding-window UserWarning with a hard ValueError.
The ONNX Attention op does not have a local_window_size attribute
(only com.microsoft.GroupQueryAttention supports it), so static
cache cannot enforce window constraints. This would silently
produce incorrect outputs for sequences longer than the window.

Models with sliding_window > 0 must use dynamic cache (without
--static-cache) for correct behavior.

Tests updated:
- Default Gemma4 static cache test config uses full_attention only
- New test: verify ValueError raised for sliding_window > 0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Replace the per-model static cache approach with per-layer dispatch:

- Full-attention layers → StaticCacheState (TensorScatter + Attention)
- Sliding-attention layers → dynamic (past_key, past_value) tuples
  with GQA and local_window_size for correct window enforcement
- KV-shared layers → None (borrow from source layers)

This is required because the ONNX Attention op does not support
local_window_size — only com.microsoft.GroupQueryAttention does.
Sliding layers must use GQA to enforce window constraints correctly.

Changes:
- _make_gemma4_static_cache_inputs: creates StaticCacheState for
  full-attention layers and dynamic tuples for sliding layers
- _register_hybrid_cache_outputs: uses updated_key_cache.N for
  static layers and present.N.key for dynamic layers
- Gemma4TextModel.forward: per-layer dispatch based on
  isinstance(past_kv, StaticCacheState)
- Removed sliding-window rejection from _validate_static_cache_support
- Both task classes (text-only + multimodal) provide attention_mask
  for sliding GQA layers in hybrid mode

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
justinchuby and others added 5 commits July 25, 2026 00:21
Three fixes for static cache export after main merge:

1. Define past_seq_len for static cache path (sliding layers need
   it for dynamic cache within hybrid static/dynamic scheme)

2. Set attention_mask=None for static cache (uses is_causal +
   nonpad_kv_seqlen instead of explicit mask)

3. Guard create_attention_bias: skip when attention_mask is None
   (prevents Shape node with None input that crashes constant
   folding)

4. Fix test: use _config instead of unbound config variable

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Handle 3D static cache tensors in KV-shared layer attention:
- Static cache sources produce 3D [B, max_seq, kv_hidden] (already
  in BSNH-like layout), skip Transpose
- Dynamic cache sources produce 4D [B, kv_heads, seq, head_dim],
  need BNSH→BSNH transpose + flatten
- Pass nonpad_kv_seqlen from static source to KV-shared Attention
  for correct Flash Attention dispatch
- Restore o_proj call before return
- Fix else: keyword placement after GQA shared_kv block

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
…KV-shared

- Static cache Attention: use is_causal=0 (nonpad_kv_seqlen enforces
  bounds; is_causal=1 causes wrong upper-left alignment for decode)
- KV-shared layers: always use dynamic _apply_attention path even
  when borrowing from static cache source (nonpad + is_causal=0
  triggers ORT CUDA kernel issue for decode)
- Non-shared static layers (4, 9, 14): prefill works, decode needs
  further investigation for KV-shared layers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <justinchu@microsoft.com>
Correct Gemma4 static-cache RoPE setup and stale causality coverage, then carry forward the ONNXScript 0.7.1 rewrite/test API migrations and current synthetic-parity compatibility fixes required by the unpinned CI environment.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The latest main supports maskless external-cache Attention with built-in causality while retaining non-causal mode for additive-bias callers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby force-pushed the gemma4-static-cache branch from d8fbf6d to 5f24f98 Compare July 25, 2026 00:33
@justinchuby
justinchuby marked this pull request as ready for review July 25, 2026 02:11
@justinchuby
justinchuby merged commit cd16c61 into main Jul 25, 2026
20 of 23 checks passed
@justinchuby
justinchuby deleted the gemma4-static-cache branch July 25, 2026 02:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants