Skip to content

[quantization] Add static decode step for StaticGemma4Runtime - #843

Merged
dvsav merged 1 commit into
Samsung:mainfrom
dvsav:decode
Jul 28, 2026
Merged

[quantization] Add static decode step for StaticGemma4Runtime#843
dvsav merged 1 commit into
Samsung:mainfrom
dvsav:decode

Conversation

@dvsav

@dvsav dvsav commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Related issue: #768
Related PR: #822

What

This PR implements the decode step for the StaticGemma4Runtime in the TICO project, enabling side-by-side validation of single-token autoregressive decoding against Hugging Face's reference implementation for Gemma4 E2B models.

Why

The static-shape NPU inference flow requires separate prefill and decode paths. While prefill was previously implemented, decode was a skeleton placeholder. This PR adds the full decode implementation to:

  • Validate KV cache management across decode steps
  • Verify per-layer-type masks and RoPE at decode positions
  • Ensure shared-KV bookkeeping works correctly for consumer layers
  • Enable end-to-end smoke testing of the static runtime

Key Design Decisions

Decision Rationale
Slice KV cache to past_len before passing to decode layers The full max_seq-sized cache buffer contains stale padding data. Passing only valid tokens (0..past_len) ensures the attention wrapper's capacity check passes and avoids carrying garbage through attention computation.
Shared-KV store layers write past_len + 1 entries After writing the new token's K/V delta at position past_len, the valid range is 0..past_len inclusive. Consumer layers need exactly this range to compute correct attention scores.
Reuse verify_step_prefill return value The verify_step_decode function accepts prefill_logits as a parameter to avoid redundant prefill computation, reducing runtime while maintaining test isolation.
HF reference uses DynamicCache Mirrors real-world HF usage patterns and ensures the runtime's cache management produces equivalent results to HF's standard cache implementation.
Per-layer-type decode masks and RoPE Gemma4 has alternating "full_attention" and "sliding_attention" layers with different head dimensions and attention patterns. The decode path mirrors the prefill design for consistency.
Add same_token_mode for multi-step decode When quantization causes different argmax tokens between runtime and HF, multi-step comparisons become invalid. The same_token_mode=True parameter forces both paths to use HF reference tokens, enabling isolated quantization error measurement.
Prefill divergence auto-detection If runtime and HF argmax differ at step 0, multi-step verification is automatically limited to 1 step with a warning, preventing meaningless comparisons.

Changes

tico/quantization/recipes/debug/static_gemma4_runtime.py

  • build_decode_masks_and_rope: Replaced placeholder with full implementation generating per-layer-type full/sliding decode masks and RoPE tensors at the current decode position
  • decode_one: Added PLE (Per-Layer Embeddings) support, shared-KV bookkeeping, and proper KV cache slicing (past_len instead of full max_seq)
  • verify_step_decode:
    • New validation function comparing runtime decode logits against HF's DynamicCache reference with PEIR metrics
    • Added same_token_mode: bool = False parameter for controlled multi-step comparison
    • Added prefill divergence detection with auto-limit to 1 step
    • Added per-step divergence warnings
  • run_static_gemma4_runtime: Integrated step 7 (decode verification) into the smoke flow, reusing prefill logits

test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py

  • TestBuildDecodeMasksAndRope: New test class with 6 tests for decode mask generation:
    • test_full_attention_mask_shape: Verifies mask shape is (batch_size, 1, max_seq)
    • test_full_attention_mask_values: Verifies positions 0..past_len are 0.0 (allowed)
    • test_sliding_window_mask_boundary_early_decode: Verifies window covers [0, past_len] when past_len < sliding_window
    • test_sliding_window_mask_boundary_late_decode: Verifies window covers [past_len-sw+1, past_len] when past_len >= sliding_window
    • test_sliding_window_mask_first_step: Verifies at past_len=0, only position 0 is allowed
    • test_rope_computed_at_past_len: Verifies RoPE is computed at the correct decode position

Tests

$ python -m pytest test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py -v
=============================================================== test session starts ================================================================
platform linux -- Python 3.10.12, pytest-9.0.3, pluggy-1.6.0 -- /home/d.savchenkov/myenv/bin/python
cachedir: .pytest_cache
rootdir: /home/d.savchenkov/TICO
configfile: pyproject.toml
plugins: anyio-4.13.0
collected 21 items                                                                                                                                 

test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestNormalizeValidTokenMask::test_batched_input PASSED          [  4%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestNormalizeValidTokenMask::test_shape_mismatch_raises PASSED  [  9%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestNormalizeValidTokenMask::test_with_attention_mask PASSED    [ 14%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestNormalizeValidTokenMask::test_without_attention_mask_no_pad_token_id PASSED [ 19%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestNormalizeValidTokenMask::test_without_attention_mask_uses_pad_token_id PASSED [ 23%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestValidatePaddingLayout::test_batched_right_padding_invalid PASSED [ 28%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestValidatePaddingLayout::test_batched_right_padding_valid PASSED [ 33%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestValidatePaddingLayout::test_right_padding_invalid PASSED    [ 38%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestValidatePaddingLayout::test_right_padding_no_padding PASSED [ 42%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestValidatePaddingLayout::test_right_padding_valid PASSED      [ 47%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestValidatePaddingLayout::test_unsupported_padding_side_raises PASSED [ 52%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestAllocateEmptyCache::test_all_full_attention_layers PASSED   [ 57%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestAllocateEmptyCache::test_all_sliding_layers PASSED          [ 61%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestAllocateEmptyCache::test_no_global_head_dim_falls_back PASSED [ 66%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestAllocateEmptyCache::test_per_layer_type_head_dim PASSED     [ 71%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestBuildDecodeMasksAndRope::test_full_attention_mask_shape PASSED [ 76%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestBuildDecodeMasksAndRope::test_full_attention_mask_values PASSED [ 80%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestBuildDecodeMasksAndRope::test_rope_computed_at_past_len PASSED [ 85%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestBuildDecodeMasksAndRope::test_sliding_window_mask_boundary_early_decode PASSED [ 90%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestBuildDecodeMasksAndRope::test_sliding_window_mask_boundary_late_decode PASSED [ 95%]
test/quantization/recipes/integration/test_static_gemma4_runtime_helpers.py::TestBuildDecodeMasksAndRope::test_sliding_window_mask_first_step PASSED [100%]

========================================================== 21 passed, 2 warnings in 7.43s ==========================================================

Example Script

$ python -m pdb ./tico/quantization/examples/inspector.py \
    --mode static-gemma4-runtime \
    --config ./tico/quantization/examples/configs/static_gemma4_runtime.yaml

[run_static_gemma4_runtime] Loading model: google/gemma-4-e2b-it
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
Loading weights: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1951/1951 [00:00<00:00, 5027.45it/s]
[run_static_gemma4_runtime] Creating StaticGemma4Runtime ...
[run_static_gemma4_runtime] Step 1: verify build_static_inputs
[verify_step_build_static_inputs] All checks passed.
[run_static_gemma4_runtime] Step 2: verify token_embedding
[verify_step_token_embedding] All checks passed.
[run_static_gemma4_runtime] Step 3: verify vision_prefill
[verify_step_vision_prefill] PEIR = 0.714286 %
[verify_step_vision_prefill] All checks passed.
[run_static_gemma4_runtime] Step 4: verify mm_fusion
[verify_step_mm_fusion] All checks passed.
[run_static_gemma4_runtime] Step 5: verify masks_and_rope
[verify_step_masks_and_rope] RoPE exact match for 2 layer types.
[verify_step_masks_and_rope] Mask patterns match for 2 layer types.
[verify_step_masks_and_rope] All checks passed.
[run_static_gemma4_runtime] Step 6: verify prefill
[verify_step_prefill] Runtime logits shape: (1, 262144)
[verify_step_prefill] Reference logits shape: (1, 262144)
[verify_step_prefill] mean|diff| = 0.05616927
[verify_step_prefill]  max|diff| = 0.50000000
[verify_step_prefill] PEIR       = 2.285714 %
[verify_step_prefill] Runtime argmax token: 106
[verify_step_prefill] Reference argmax token: 106
[verify_step_prefill] Argmax tokens MATCH.
[verify_step_prefill] All checks passed.
[run_static_gemma4_runtime] Step 7: verify decode
[verify_step_decode] Step 1:
[verify_step_decode]   Runtime logits shape: (1, 262144)
[verify_step_decode]   mean|diff| = 0.17796150
[verify_step_decode]    max|diff| = 0.75000000
[verify_step_decode]   PEIR       = 2.922374 %
[verify_step_decode]   Runtime argmax token: 106
[verify_step_decode]   Reference argmax token: 106
[verify_step_decode]   Argmax tokens MATCH.
[verify_step_decode] All checks passed.
[run_static_gemma4_runtime] SKIP: generation (not implemented in this PR)
[run_static_gemma4_runtime] Done.

Comment on lines +737 to +738
cache.past_k[:, :, : self.past_len, :],
cache.past_v[:, :, : self.past_len, :],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use a fixed past cache, for example (B, Hkv, max_seq - 1, D) as in StaticLlamaRuntime, compact valid prefill K/V into its prefix, and pass the full buffer on every decode invocation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use a fixed past cache, for example (B, Hkv, max_seq - 1, D) as in StaticLlamaRuntime, compact valid prefill K/V into its prefix, and pass the full buffer on every decode invocation.

Thank you for the suggestion, but... I've investigated adopting the StaticLlamaRuntime pattern for StaticGemma4Runtime.decode_one, and found that it requires changes beyond the scope of this PR.

The Issue:

The Gemma4 text attention wrapper (quant_text_attention.py) performs a capacity check in _build_attention_mask that raises when k_len exceeds the causal_mask_template size:

if k_len > self.causal_mask_template.size(-1):
    raise RuntimeError(
        f"Key length exceeds causal mask capacity: k_len={k_len}, capacity={self.causal_mask_template.size(-1)}."
    )

When passing the full cache buffer (2048) and concatenating with the new token's K/V, we get k_len = 2049, which triggers this error.

Why StaticLlamaRuntime Works:

StaticLlamaRuntime uses max_seq - 1 cache capacity AND processes prefill via chunked append_prefill, so the cache never contains more than max_seq - 1 valid tokens. The decode concatenation produces k_len <= max_seq - 1, which passes the capacity check.

StaticGemma4Runtime Difference:

Gemma4 uses single-shot prefill that returns full max_seq K/V tensors. Passing this full buffer to decode and concatenating with the new token produces k_len = max_seq + 1, exceeding capacity.

Required Changes for StaticLlamaRuntime Pattern:

To support passing the full buffer while only using valid positions, we could do the following:

  1. quant_text_attention.py: Add cache_valid_length parameter to _build_present_key_value to slice past K/V before concatenation
  2. quant_text_decoder_layer.py: Forward cache_valid_length through the decoder layer
  3. export_adapters.py: Update Gemma4TextDecoderLayerDecodeExportAdapter.forward to accept and forward cache_valid_length

These changes touch core wrappers and, I think, require a separate, focused PR.

Current Approach:

For this PR, decode_one slices the cache to past_len before passing:

past_key_value=(
    cache.past_k[:, :, :self.past_len, :],
    cache.past_v[:, :, :self.past_len, :],
)

This ensures k_len = past_len + 1 <= max_seq, passing the capacity check while the attention mask (which is sliced to match k_len by _normalize_attention_mask_shape) correctly indicates valid positions.

Future Work:

I can file a follow-up issue to add cache_valid_length support to the Gemma4 attention wrapper infrastructure, enabling the cleaner StaticLlamaRuntime pattern for StaticGemma4Runtime. What do you think?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is a misunderstanding about the proposed cache shape.

The suggestion was not to pass the current max_seq-sized cache buffer in full. The persistent past cache itself should be allocated with capacity max_seq - 1.

With max_seq = 2048, the decode shapes would therefore be:

past_key_value:  (B, Hkv, 2047, D)
new_key_value:   (B, Hkv,    1, D)
present KV:      (B, Hkv, 2048, D)

Therefore, the concatenation does not produce k_len = 2049; it produces exactly k_len = max_seq, which satisfies the existing capacity check. No cache_valid_length argument or wrapper change is needed.

The single-shot prefill output may still have sequence length max_seq. The runtime should copy only the valid prompt K/V range into the prefix of the max_seq - 1 persistent cache, as StaticLlamaRuntime.prefill() already
does. StaticLlamaRuntime does not require chunked append-prefill for its initial prefill; append_prefill is a separate multi-turn path.

For decode, the fixed physical layout is:

[past cache: max_seq - 1 slots][current token: 1 slot]

Unused past-cache slots are masked. The decode mask should therefore expose [:past_len] plus the final slot [-1], rather than placing the current token at physical index past_len.

For a shared-KV store layer, the fixed-size shared K/V passed to consumer layers can similarly be constructed from the max_seq - 1 past cache and the returned one-token K/V delta.

Slicing by a runtime cache_valid_length inside the exported wrapper would make the K dimension dynamic again, which is what the fixed-shape runtime should avoid.

If this is still difficult to follow, we can merge this PR as-is, and I can submit a follow-up PR implementing the fixed-shape cache design. Alternatively, you can implement it directly in this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, let me handle that in the next PR)

# --- Step 7: decode validation ---
print("[run_static_gemma4_runtime] Step 7: verify decode")
rt_decode_logits = verify_step_decode(
runtime, batch, cfg.prompt, image, prefill_logits=rt_logits, num_decode_steps=1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
runtime, batch, cfg.prompt, image, prefill_logits=rt_logits, num_decode_steps=1
runtime, batch, cfg.prompt, image, prefill_logits=rt_logits, num_decode_steps=cfg.verify_steps,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 done

Implements decode_one with PLE, shared-KV support, and verify_step_decode validation.

Co-authored-by: Cline

TICO-DCO-1.0-Signed-off-by: d.savchenkov <d.savchenkov@partner.samsung.com>

@mhs4670go mhs4670go left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@dvsav
dvsav merged commit 11c7041 into Samsung:main Jul 28, 2026
7 checks passed
@dvsav
dvsav deleted the decode branch July 28, 2026 13:20
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