Skip to content

feat(mtp): implement QueryOnlyGemma2Attention for Gemma4 MTP draft models#768

Open
shotsan wants to merge 14 commits into
vllm-project:mainfrom
shotsan:feat/gemma4-mtp-query-attention
Open

feat(mtp): implement QueryOnlyGemma2Attention for Gemma4 MTP draft models#768
shotsan wants to merge 14 commits into
vllm-project:mainfrom
shotsan:feat/gemma4-mtp-query-attention

Conversation

@shotsan

@shotsan shotsan commented Jul 10, 2026

Copy link
Copy Markdown

Context

This PR introduces the final architecture modifications required for Gemma4 MTP draft models, completing the 3-part rollout strategy outlined in Issue #586.

Unlike Qwen-style MTP which maintains its own full self-attention layers and distinct KV caches, Gemma4 MTP borrows the verifier's local and global KV caches directly, stripping K/V projections out of its own layers.

Dependencies:

Implementation Details

  1. Registered Gemma2 Components: Added Gemma2DecoderLayer, Gemma2RMSNorm, and Gemma2RotaryEmbedding to the base_components.py registry.
  2. QueryOnlyGemma2Attention: Built a specialized customized self-attention module that overrides the base Gemma2Attention. It zeroes out K/V linear projections and instead cross-attends into verifier_kv_last_local and verifier_kv_last_global.
  3. Sliding Window Support: Dynamic routing of local versus global caches depending on the target layer's sliding_window size.
  4. RoPE Handling: Isolated Rotary Positional Embeddings to only apply to query states since the vLLM extracted verifier KV caches have already been rotated.

Testing

  • Unit tests added in tests/unit/models/test_mtp_gemma2.py explicitly mock out the KV cache tensor routing, verifying that local vs. global tensor routing succeeds based on window configuration and shape propagation works as expected.

@shotsan shotsan changed the title feat/gemma4 mtp query attention feat(mtp): implement QueryOnlyGemma2Attention for Gemma4 MTP draft models Jul 10, 2026
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Ignore keyword(s) in the title.

⛔ Ignored keywords (3)
  • WIP
  • DO NOT MERGE
  • DRAFT

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 531dfa46-659f-4b94-92da-a781abbbd98e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds optional Gemma2 support, verifier KV-cache propagation through training data and MTP attention, and centroid-masked loss computation with configuration and unit tests.

Changes

Gemma2 MTP and centroid-loss support

Layer / File(s) Summary
Component registration
src/speculators/models/base_components.py
Conditionally registers Gemma2 components and removes Qwen3-related component registrations.
Centroid head contract
src/speculators/models/mtp/config.py, src/speculators/models/mtp/head.py, tests/unit/models/test_mtp_head.py
Adds centroid configuration, the MultiLevelLMHead, and tests for mapping, masking, and loss computation.
Verifier KV data pipeline
src/speculators/train/data.py, tests/unit/train/test_data.py
Carries verifier KV tails through standardization, dtype conversion, Arrow loading, and collation with padding.
Gemma2 query-only attention
src/speculators/models/mtp/model_definitions.py, tests/unit/models/test_mtp_gemma2.py
Adds Gemma2 attention that consumes local or global verifier KV tensors and registers the Gemma2 MTP layer override.
Draft model integration
src/speculators/models/mtp/core.py
Adds the optional centroid head, forwards verifier KV inputs to the first MTP layer, and includes centroid loss in per-step training loss.

Possibly related issues

  • vllm-project/speculators#586 — Covers the Gemma query-only attention, verifier KV data flow, and centroid-masked LM head implemented here.

Possibly related PRs

  • vllm-project/speculators#366 — Earlier MTP draft-model training infrastructure overlaps with the modified MTPDraftModel forward and loss wiring.
  • vllm-project/speculators#452 — Introduced the MTP architecture extended here through configuration, centroid loss, and verifier KV passthrough.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: adding QueryOnlyGemma2Attention for Gemma4 MTP draft models.
Description check ✅ Passed The description matches the changeset and summarizes the Gemma4 MTP attention, KV-cache routing, LM head, and tests.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/speculators/train/data.py (1)

70-97: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Empty verifier KV placeholders need the real feature rank. create_empty_sample() builds verifier_kv_last_local and verifier_kv_last_global as 1D empty tensors, but the collate fallback treats them like the per-sample KV tensors and can pass a rank-mismatched batch into QueryOnlyGemma2Attention.forward, which will fail in the all-samples-failed fallback path. Thread the KV feature dimension through this fallback so the empty sample matches the real [seq_len, feature_dim] shape.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/speculators/train/data.py` around lines 70 - 97, create_empty_sample
currently creates 1D empty verifier KV tensors, but downstream collation expects
[seq_len, feature_dim]. Add a KV feature-dimension parameter to
create_empty_sample, pass the real dimension from the collate fallback, and
construct verifier_kv_last_local and verifier_kv_last_global as empty tensors
with shape [0, feature_dim]. Update all callers accordingly.
src/speculators/models/base_components.py (1)

72-120: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep the qwen3_next/qwen3_5_text/qwen3_5_moe_text registrations at src/speculators/models/base_components.py:72-120src/speculators/models/mtp/model_definitions.py only adds MTP variants when these base entries exist, and MTPDraftModel resolves through resolve_model_type(...); removing them makes those model types unsupported.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/speculators/models/base_components.py` around lines 72 - 120, Keep the
qwen3_next, qwen3_5_text, and qwen3_5_moe_text entries in the model_classes
registry, including their existing ModelComponents mappings and ImportError
handling. Verify that resolve_model_type(...) and MTPDraftModel continue to
resolve MTP variants through these base registrations.
🧹 Nitpick comments (3)
src/speculators/models/mtp/head.py (1)

30-37: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Recompute the inverse mapping only when token_ordering changes.

inverse is rebuilt with a full O(vocab_size) scatter on every compute_centroid_loss call (once per MTP step, per training step). Since token_ordering is a buffer that changes rarely (if ever, during training), caching the inverse mapping (recomputed only when token_ordering is updated) would avoid this repeated allocation/scatter on the hot training path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/speculators/models/mtp/head.py` around lines 30 - 37, Cache the
token-to-centroid inverse mapping instead of rebuilding it in every
compute_centroid_loss call. Add a cached buffer or equivalent tied to
token_ordering, initialize it from token_ordering, and refresh it whenever
token_ordering is updated; then have compute_centroid_loss reuse the cache for
valid_targets.
tests/unit/train/test_data.py (1)

474-516: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Good coverage for the normal path; consider also covering the empty-sample fallback.

This test verifies KV caches survive collation for real samples, but doesn't cover the create_empty_sample/all-batch-failed fallback path, where the KV placeholder shape diverges from real samples (see data.py comment). Adding a test using create_empty_sample + create_collate_fn would catch that regression. As per path instructions, tests should cover edge cases specific to speculative decoding and new code paths introduced in the PR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/train/test_data.py` around lines 474 - 516, Add a test covering
the all-batch-failed empty-sample fallback for verifier KV caches. Use
create_empty_sample to construct an empty item, pass it through
create_collate_fn, and assert verifier_kv_last_local/global exist with shapes
and padding compatible with real samples, preventing placeholder shape
divergence.

Source: Path instructions

src/speculators/models/base_components.py (1)

15-22: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fragile except NameError guard for optional Gemma2 support.

Catching NameError to detect a prior failed import is a fragile pattern: it will silently swallow any unrelated NameError raised inside the try block (e.g., a future typo when extending this block), masking real bugs instead of surfacing them at import time.

♻️ Suggested clearer pattern
+_HAS_GEMMA2 = False
 try:
     from transformers.models.gemma2.modeling_gemma2 import (
         Gemma2DecoderLayer,
         Gemma2RMSNorm,
         Gemma2RotaryEmbedding,
     )
+    _HAS_GEMMA2 = True
 except ImportError:
     pass
...
-try:
+if _HAS_GEMMA2:
     model_classes["gemma2"] = ModelComponents(
         Gemma2DecoderLayer,
         Gemma2DecoderLayer,
         Gemma2RMSNorm,
         Gemma2RotaryEmbedding,
     )
-except NameError:
-    pass

Also applies to: 62-71

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/speculators/models/base_components.py` around lines 15 - 22, Replace the
fragile `NameError`-based optional Gemma2 detection in `base_components.py` with
an explicit availability flag initialized before the import and set only after
`Gemma2DecoderLayer`, `Gemma2RMSNorm`, and `Gemma2RotaryEmbedding` import
successfully; catch only `ImportError`, and use that flag wherever these classes
are referenced, including the corresponding block around lines 62–71.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/speculators/models/mtp/config.py`:
- Around line 55-67: In the configuration model containing num_centroids and
top_k_centroids, add an after-model validator that requires num_centroids to be
positive and, when set, evenly divide vocab_size; raise a clear validation error
for invalid values before constructing MultiLevelLMHead. Preserve the existing
None behavior for the standard flat LM head.

In `@src/speculators/models/mtp/head.py`:
- Around line 11-16: Fix centroid assignment for vocabularies not evenly
divisible by num_centroids: update the initialization in __init__ and the
token-to-centroid mapping used by compute_centroid_loss so every token ID maps
within 0..num_centroids-1, including remainder tokens. Ensure the mapping
distributes or explicitly assigns all vocab_size tokens without producing
centroid index num_centroids, and keep token_ordering consistent with that
mapping.

In `@src/speculators/models/mtp/model_definitions.py`:
- Around line 263-278: Update the MTP attention mask construction to call
create_sliding_window_causal_mask when self.sliding_window is set, while
retaining create_causal_mask otherwise; do not rely on forwarding sliding_window
through QueryOnlyGemma2Attention. Add a regression test verifying that tokens
outside the configured window are masked for local Gemma2 attention.

In `@tests/unit/models/test_mtp_gemma2.py`:
- Around line 37-60: Strengthen test_query_only_gemma2_attention_local_kv and
the related tests so they verify cache routing rather than only output shape:
run paired forwards while varying only the selected verifier cache, assert the
output changes, then vary the inactive cache and assert the output remains
unchanged. Cover both local and global cache paths introduced by the PR, using
distinct cache values and the appropriate configuration or inputs to select each
path.
- Around line 18-34: Update the test fixture to instantiate Gemma2Config instead
of PretrainedConfig, supplying the required Gemma2-specific fields such as
layer_types and attn_logit_softcapping. Configure the global-attention case with
layer_types set to full_attention rather than relying only on sliding_window =
None, while preserving the settings needed by QueryOnlyGemma2Attention.
- Around line 48-50: The RoPE test fixtures use an incorrect extra singleton
dimension. In both tests around the rotary embedding setup, update the `cos` and
`sin` tensors to shape `[batch_sz, seq_len, 16]` instead of `[1, 1, seq_len,
16]`, matching the input expected by `apply_rotary_pos_emb`.

In `@tests/unit/models/test_mtp_head.py`:
- Around line 10-16: The test fixture only covers evenly divisible vocabulary
and centroid sizes. Add a PyTest case alongside `head_setup` using values where
`vocab_size % num_centroids != 0`, then exercise the relevant `MultiLevelLMHead`
forward or vocab-mapping behavior and assert outputs remain valid without
out-of-range centroid indices, covering the mapping boundary for speculative
decoding.

---

Outside diff comments:
In `@src/speculators/models/base_components.py`:
- Around line 72-120: Keep the qwen3_next, qwen3_5_text, and qwen3_5_moe_text
entries in the model_classes registry, including their existing ModelComponents
mappings and ImportError handling. Verify that resolve_model_type(...) and
MTPDraftModel continue to resolve MTP variants through these base registrations.

In `@src/speculators/train/data.py`:
- Around line 70-97: create_empty_sample currently creates 1D empty verifier KV
tensors, but downstream collation expects [seq_len, feature_dim]. Add a KV
feature-dimension parameter to create_empty_sample, pass the real dimension from
the collate fallback, and construct verifier_kv_last_local and
verifier_kv_last_global as empty tensors with shape [0, feature_dim]. Update all
callers accordingly.

---

Nitpick comments:
In `@src/speculators/models/base_components.py`:
- Around line 15-22: Replace the fragile `NameError`-based optional Gemma2
detection in `base_components.py` with an explicit availability flag initialized
before the import and set only after `Gemma2DecoderLayer`, `Gemma2RMSNorm`, and
`Gemma2RotaryEmbedding` import successfully; catch only `ImportError`, and use
that flag wherever these classes are referenced, including the corresponding
block around lines 62–71.

In `@src/speculators/models/mtp/head.py`:
- Around line 30-37: Cache the token-to-centroid inverse mapping instead of
rebuilding it in every compute_centroid_loss call. Add a cached buffer or
equivalent tied to token_ordering, initialize it from token_ordering, and
refresh it whenever token_ordering is updated; then have compute_centroid_loss
reuse the cache for valid_targets.

In `@tests/unit/train/test_data.py`:
- Around line 474-516: Add a test covering the all-batch-failed empty-sample
fallback for verifier KV caches. Use create_empty_sample to construct an empty
item, pass it through create_collate_fn, and assert
verifier_kv_last_local/global exist with shapes and padding compatible with real
samples, preventing placeholder shape divergence.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9b5a90a1-e15e-4ef1-9fa5-b442f53e3ff8

📥 Commits

Reviewing files that changed from the base of the PR and between 6030a44 and 20c69b3.

📒 Files selected for processing (9)
  • src/speculators/models/base_components.py
  • src/speculators/models/mtp/config.py
  • src/speculators/models/mtp/core.py
  • src/speculators/models/mtp/head.py
  • src/speculators/models/mtp/model_definitions.py
  • src/speculators/train/data.py
  • tests/unit/models/test_mtp_gemma2.py
  • tests/unit/models/test_mtp_head.py
  • tests/unit/train/test_data.py

Comment on lines +55 to +67
num_centroids: int | None = Field(
default=None,
description=(
"Number of centroids for a centroid-masked multi-level LM head. "
"If None, standard flat LM head is used."
),
)

top_k_centroids: int = Field(
default=32,
description="Number of top centroids to select during multi-level LM head decoding.",
)

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Consider validating num_centroids against vocab_size here.

num_centroids has no validation (e.g., must be > 0 and ideally divide vocab_size evenly). As shown in head.py, a non-divisible combination causes out-of-range centroid targets. A model_validator(mode="after") here (with access to vocab_size) would be a natural place to enforce this contract before it reaches MultiLevelLMHead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/speculators/models/mtp/config.py` around lines 55 - 67, In the
configuration model containing num_centroids and top_k_centroids, add an
after-model validator that requires num_centroids to be positive and, when set,
evenly divide vocab_size; raise a clear validation error for invalid values
before constructing MultiLevelLMHead. Preserve the existing None behavior for
the standard flat LM head.

Comment on lines +11 to +16
def __init__(self, hidden_size: int, vocab_size: int, num_centroids: int):
super().__init__()
self.tokens_per_centroid = vocab_size // num_centroids
self.centroids = nn.Linear(hidden_size, num_centroids, bias=False)
# Token ordering mapping (centroid subsets -> token IDs)
self.register_buffer("token_ordering", torch.arange(vocab_size, dtype=torch.long))

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.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Out-of-range centroid index when vocab_size is not evenly divisible by num_centroids.

tokens_per_centroid = vocab_size // num_centroids truncates; remainder tokens then map to a centroid id equal to num_centroids (out of the valid 0..num_centroids-1 range used by self.centroids), causing F.cross_entropy in compute_centroid_loss to index out of bounds for any target token in that trailing remainder. E.g., vocab_size=10, num_centroids=3 maps token position 9 to centroid 3, which doesn't exist.

🐛 Suggested fix
     def __init__(self, hidden_size: int, vocab_size: int, num_centroids: int):
         super().__init__()
+        if num_centroids <= 0 or vocab_size % num_centroids != 0:
+            raise ValueError(
+                f"vocab_size ({vocab_size}) must be evenly divisible by "
+                f"num_centroids ({num_centroids})."
+            )
         self.tokens_per_centroid = vocab_size // num_centroids
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def __init__(self, hidden_size: int, vocab_size: int, num_centroids: int):
super().__init__()
self.tokens_per_centroid = vocab_size // num_centroids
self.centroids = nn.Linear(hidden_size, num_centroids, bias=False)
# Token ordering mapping (centroid subsets -> token IDs)
self.register_buffer("token_ordering", torch.arange(vocab_size, dtype=torch.long))
def __init__(self, hidden_size: int, vocab_size: int, num_centroids: int):
super().__init__()
if num_centroids <= 0 or vocab_size % num_centroids != 0:
raise ValueError(
f"vocab_size ({vocab_size}) must be evenly divisible by "
f"num_centroids ({num_centroids})."
)
self.tokens_per_centroid = vocab_size // num_centroids
self.centroids = nn.Linear(hidden_size, num_centroids, bias=False)
# Token ordering mapping (centroid subsets -> token IDs)
self.register_buffer("token_ordering", torch.arange(vocab_size, dtype=torch.long))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/speculators/models/mtp/head.py` around lines 11 - 16, Fix centroid
assignment for vocabularies not evenly divisible by num_centroids: update the
initialization in __init__ and the token-to-centroid mapping used by
compute_centroid_loss so every token ID maps within 0..num_centroids-1,
including remainder tokens. Ensure the mapping distributes or explicitly assigns
all vocab_size tokens without producing centroid index num_centroids, and keep
token_ordering consistent with that mapping.

Comment on lines +263 to +278
attention_interface = eager_attention_forward
if getattr(self.config, "_attn_implementation", "eager") != "eager":
attention_interface = ALL_ATTENTION_FUNCTIONS[self.config._attn_implementation]

attn_output, attn_weights = attention_interface(
self,
query_states,
key_states,
value_states,
attention_mask,
dropout=self.attention_dropout if self.training else 0.0,
scaling=self.scaling,
sliding_window=self.sliding_window,
softcap=self.attn_logit_softcapping,
**kwargs,
)

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files and inspect the surrounding implementation.
git ls-files src/speculators/models/mtp/model_definitions.py src/speculators/models/mtp/core.py
printf '\n--- model_definitions outline ---\n'
ast-grep outline src/speculators/models/mtp/model_definitions.py --view expanded || true
printf '\n--- core outline ---\n'
ast-grep outline src/speculators/models/mtp/core.py --view expanded || true

printf '\n--- model_definitions excerpt ---\n'
sed -n '220,320p' src/speculators/models/mtp/model_definitions.py

printf '\n--- core excerpt ---\n'
sed -n '1,260p' src/speculators/models/mtp/core.py

Repository: vllm-project/speculators

Length of output: 16216


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for mask creation helpers and Gemma2-specific handling.
rg -n "create_(causal|sliding_window_causal)_mask|sliding_window|Gemma2|attention_mask" src/speculators/models/mtp src/speculators/models -g '*.py'

Repository: vllm-project/speculators

Length of output: 10863


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the upstream Gemma2 attention implementation reference in installed docs/source if available in repo deps.
python3 - <<'PY'
import os, sys, subprocess, textwrap, json, pathlib

candidates = [
    "transformers/src/transformers/models/gemma2/modeling_gemma2.py",
    "site-packages/transformers/models/gemma2/modeling_gemma2.py",
]
for c in candidates:
    if os.path.exists(c):
        print("FOUND", c)
        with open(c, "r", encoding="utf-8") as f:
            lines = f.readlines()
        for i, line in enumerate(lines, 1):
            if "def eager_attention_forward" in line or "sliding_window" in line or "attention_mask" in line:
                start = max(1, i-20)
                end = min(len(lines), i+120)
                print(f"\n--- {c}:{start}-{end} ---")
                for j in range(start, end+1):
                    print(f"{j}:{lines[j-1].rstrip()}")
                break
PY

Repository: vllm-project/speculators

Length of output: 162


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Try to inspect the relevant raw upstream source via web if the repository doesn't vendor it.
# This is a read-only query to validate whether eager Gemma2 attention relies on the passed attention_mask.
python3 - <<'PY'
import urllib.request, ssl
url = "https://raw.githubusercontent.com/huggingface/transformers/v4.56.1/src/transformers/models/gemma2/modeling_gemma2.py"
ctx = ssl.create_default_context()
with urllib.request.urlopen(url, context=ctx, timeout=20) as resp:
    text = resp.read().decode("utf-8")
for needle in ["def eager_attention_forward", "sliding_window", "attention_mask"]:
    idx = text.find(needle)
    if idx != -1:
        start = max(0, idx - 600)
        end = min(len(text), idx + 2200)
        print(text[start:end])
        print("\n--- END SNIP ---\n")
PY

Repository: vllm-project/speculators

Length of output: 2756


🌐 Web query:

Transformers 4.56.1 Gemma2 eager_attention_forward sliding_window attention_mask source

💡 Result:

In the Hugging Face Transformers library (v4.56.1 and surrounding versions), the eager_attention_forward function in the Gemma2 model implementation serves as a fallback or default attention mechanism when the _attn_implementation is not set to optimized alternatives like SDPA or Flash Attention [1][2][3]. Key technical details regarding your query: 1. sliding_window: In Gemma2, the sliding_window parameter is defined in the configuration (defaulting to 4096) [4] and is passed to the attention forward pass based on whether the specific layer is configured as a sliding_attention layer [5]. However, in the standard eager_attention_forward implementation for Gemma2, the sliding_window argument is accepted but is typically not explicitly used to mask or limit attention within that specific function, which instead performs full attention (with potential softcapping and causal masking) [1][2][3]. The sliding window behavior is often handled by external mechanisms or specific model architecture configurations that define layer types [5]. 2. attention_mask: The attention_mask in eager_attention_forward is used to augment the attention scores (attn_weights) before the softmax operation [1][2][3]. In the Gemma2 implementation, this is typically done via simple addition (attn_weights = attn_weights + attention_mask) after applying the attention logit softcapping [1][2][3]. 3. Source Location: The implementation can be found in the transformers library under src/transformers/models/gemma2/modeling_gemma2.py [6][2][3]. Gemma2 is unique in that it interleaves local (sliding window) and global attention across its layers, and it utilizes Grouped Query Attention (GQA) and logit softcapping (via the softcap parameter) to maintain stability during training and inference [1][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import inspect
import transformers.masking_utils as mu
from transformers import PretrainedConfig

print("transformers", __import__("transformers").__version__)
print("\ncreate_causal_mask signature:")
print(inspect.signature(mu.create_causal_mask))
print("\ncreate_causal_mask source excerpt:")
src = inspect.getsource(mu.create_causal_mask)
print(src[:5000])
PY

Repository: vllm-project/speculators

Length of output: 5708


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import inspect
import transformers.masking_utils as mu

for name in ["create_sliding_window_causal_mask", "create_causal_mask"]:
    fn = getattr(mu, name, None)
    print(f"{name}:", fn)
    if fn is not None:
        print("signature:", inspect.signature(fn))
        try:
            src = inspect.getsource(fn)
            print(src[:2500])
        except Exception as e:
            print("source unavailable:", e)
    print("-" * 80)
PY

Repository: vllm-project/speculators

Length of output: 6430


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import inspect
import transformers.masking_utils as mu

src = inspect.getsource(mu.create_causal_mask)
for needle in ["sliding_window", "create_sliding_window_causal_mask", "config.sliding_window", "mask_kwargs"]:
    print(f"\n=== {needle} ===")
    idx = src.find(needle)
    print(idx)
    if idx != -1:
        start = max(0, idx - 800)
        end = min(len(src), idx + 1800)
        print(src[start:end])
PY

Repository: vllm-project/speculators

Length of output: 293


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import inspect
import transformers.masking_utils as mu

for name in ["create_sliding_window_causal_mask", "create_causal_mask"]:
    fn = getattr(mu, name, None)
    print(f"\n### {name}")
    if fn is None:
        print("MISSING")
        continue
    try:
        print(inspect.signature(fn))
    except Exception as e:
        print("signature error:", e)
    try:
        src = inspect.getsource(fn)
        print(src[:6000])
    except Exception as e:
        print("source error:", e)
PY

Repository: vllm-project/speculators

Length of output: 13143


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect how verifier KV tensors are produced/passed, and whether local/global tensors differ in sequence length.
rg -n "verifier_kv_last_local|verifier_kv_last_global|sliding_window" src/speculators -g '*.py'

Repository: vllm-project/speculators

Length of output: 5648


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

paths = [
    Path("src/speculators/models/mtp/core.py"),
    Path("src/speculators/models/mtp/model_definitions.py"),
]
for path in paths:
    txt = path.read_text(encoding="utf-8").splitlines()
    for needle in ["verifier_kv_last_local", "verifier_kv_last_global", "sliding_window"]:
        hits = [i+1 for i,l in enumerate(txt) if needle in l]
        if hits:
            print(f"\n{path} :: {needle} -> {hits}")
            for i in hits:
                start = max(1, i-8)
                end = min(len(txt), i+12)
                print(f"--- lines {start}-{end} ---")
                for j in range(start, end+1):
                    print(f"{j}: {txt[j-1]}")
PY

Repository: vllm-project/speculators

Length of output: 9459


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether there are already tests around MTP/Gemma2 masking.
rg -n "MTP|Gemma2|sliding_window|create_sliding_window_causal_mask|create_causal_mask" tests src -g '*.py'

Repository: vllm-project/speculators

Length of output: 29084


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import inspect
import transformers.models.gemma2.modeling_gemma2 as mg

print("transformers", __import__("transformers").__version__)
for name in ["eager_attention_forward", "Gemma2Attention"]:
    obj = getattr(mg, name, None)
    print(f"\n### {name}: {obj}")
    if obj is None:
        continue
    try:
        src = inspect.getsource(obj)
        for needle in ["sliding_window", "attention_mask", "attn_weights"]:
            print(f"\n-- {needle} --")
            idx = src.find(needle)
            print(idx)
            if idx != -1:
                start = max(0, idx - 800)
                end = min(len(src), idx + 2400)
                print(src[start:end])
    except Exception as e:
        print("source error:", e)
PY

Repository: vllm-project/speculators

Length of output: 9338


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,220p' tests/unit/models/test_mtp_gemma2.py

Repository: vllm-project/speculators

Length of output: 2843


🏁 Script executed:

#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
path = Path("tests/unit/models/test_mtp_gemma2.py")
print(path.read_text(encoding="utf-8"))
PY

Repository: vllm-project/speculators

Length of output: 2844


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cat -n tests/unit/models/test_mtp_gemma2.py

Repository: vllm-project/speculators

Length of output: 3438


Use a sliding-window causal mask for Gemma2 local attention. QueryOnlyGemma2Attention only consumes attention_mask; the forwarded sliding_window kwarg does not narrow keys in eager Gemma2, so create_causal_mask still lets local layers attend over the full causal prefix. Switch the MTP mask build to create_sliding_window_causal_mask when self.sliding_window is set, and add a regression test that tokens outside the window are masked.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/speculators/models/mtp/model_definitions.py` around lines 263 - 278,
Update the MTP attention mask construction to call
create_sliding_window_causal_mask when self.sliding_window is set, while
retaining create_causal_mask otherwise; do not rely on forwarding sliding_window
through QueryOnlyGemma2Attention. Add a regression test verifying that tokens
outside the configured window are masked for local Gemma2 attention.

Source: Path instructions

Comment thread tests/unit/models/test_mtp_gemma2.py Outdated
Comment on lines +18 to +34
config = PretrainedConfig(
hidden_size=64,
num_attention_heads=4,
num_key_value_heads=2,
head_dim=16,
attention_bias=False,
attention_dropout=0.0,
_attn_implementation="eager"
)
# Mock some Gemma2 specific attrs
config.sliding_window = 4096
config.query_pre_attn_scalar = 224
config.hidden_size = 64
config.num_attention_heads = 4
config.num_key_value_heads = 2
config.head_dim = 16
return config

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the test file around the referenced lines
wc -l tests/unit/models/test_mtp_gemma2.py
cat -n tests/unit/models/test_mtp_gemma2.py | sed -n '1,140p'

# Locate any Gemma2 config/test usage in the repo
rg -n "Gemma2Config|Gemma2Attention|attn_logit_softcapping|layer_types|sliding_window|query_pre_attn_scalar" tests src -g '!**/node_modules/**'

# If the transformers source is vendored or referenced in-repo, inspect the relevant model code
fd -a "modeling_gemma2.py|configuration_gemma2.py" .

Repository: vllm-project/speculators

Length of output: 11035


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the wrapper that instantiates Gemma2Attention
wc -l src/speculators/models/mtp/model_definitions.py
cat -n src/speculators/models/mtp/model_definitions.py | sed -n '180,320p'

# Check where Gemma2 config-specific fields are read in the repo
rg -n "attn_logit_softcapping|query_pre_attn_scalar|layer_types|sliding_window|Gemma2Attention" src/speculators/models -g '!**/node_modules/**'

# Inspect any tests or fixtures that already use Gemma2Config or similar patterns
rg -n "Gemma2Config|PretrainedConfig\(" tests src -g '!**/node_modules/**'

Repository: vllm-project/speculators

Length of output: 10522


🌐 Web query:

Transformers 4.56.1 Gemma2Attention __init__ attn_logit_softcapping layer_types Gemma2Config source

💡 Result:

In the Hugging Face Transformers library, the Gemma2Attention class implements specialized attention mechanisms for the Gemma 2 model, including the use of attn_logit_softcapping. The attn_logit_softcapping parameter serves as a scaling factor when applying tanh-based softcapping to attention scores [1][2]. Within the init method of the Gemma2Attention class, this value is initialized directly from the Gemma2Config object [3][4][5]: self.attn_logit_softcapping = self.config.attn_logit_softcapping The Gemma2Config class defines this as an optional float, defaulting to 50.0 [6][1]. Additionally, the Gemma2Attention class uses the layer_types configuration list to determine the attention behavior for each specific layer [7][3][4]. The layer type (e.g., "sliding_attention" or "full_attention") is retrieved during initialization [7][5]: self.layer_type = config.layer_types[layer_idx] if hasattr(config, "layer_types") else None This layer_type is then used to set the sliding_window attribute for that attention instance [7][8][4]. These mechanisms are central to the Gemma 2 architecture as implemented in the transformers library [3][9][2].

Citations:


🌐 Web query:

Transformers v4.56.1 src/transformers/models/gemma2/modeling_gemma2.py Gemma2Attention __init__ attn_logit_softcapping layer_types

💡 Result:

In the Hugging Face Transformers library, the Gemma2Attention class implements attention mechanisms designed for the Gemma 2 architecture, which includes specific handling for soft-capping and varying attention types [1][2]. attn_logit_softcapping This parameter represents a scaling factor used for logit soft-capping within the attention layers [3][4]. Soft-capping is a technique used to stabilize training by preventing attention scores (logits) from growing excessively large [5][6]. It is implemented using the formula: logits = soft_cap * tanh(logits / soft_cap) The default value for attn_logit_softcapping is 50.0 [3][7]. In the Gemma2Attention.init method, this value is assigned to self.attn_logit_softcapping from the configuration, where it is later passed to the attention forward pass to constrain the logits [1][2]. layer_types The layer_types configuration attribute is used to determine the specific attention implementation for a given layer index [1][2]. Gemma 2 models interleave different types of attention—specifically sliding window attention and standard (often full-quadratic or global) attention—to balance performance and long-context capabilities [5][6][4]. In the init method of Gemma2Attention, the code typically checks the layer_types configuration at the current layer_idx to set the sliding_window behavior [1][2]: self.sliding_window = config.sliding_window if config.layer_types[layer_idx] == "sliding_attention" else None This ensures that only layers designated as "sliding_attention" apply the restricted window size, while others operate without that constraint [1][2]. Older versions of the implementation sometimes used a modulo operation (e.g., if not bool(layer_idx % 2)) to achieve this interleaving, but modern implementations favor the explicit layer_types list for clarity and flexibility [8][9][2].

Citations:


Use Gemma2Config here
QueryOnlyGemma2Attention inherits Gemma2Attention, so this fixture needs Gemma2-specific config such as layer_types and attn_logit_softcapping; PretrainedConfig omits them and can fail during construction before either path runs. For the global-path case, switch the layer type to full_attention instead of only setting sliding_window = None.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/models/test_mtp_gemma2.py` around lines 18 - 34, Update the test
fixture to instantiate Gemma2Config instead of PretrainedConfig, supplying the
required Gemma2-specific fields such as layer_types and attn_logit_softcapping.
Configure the global-attention case with layer_types set to full_attention
rather than relying only on sliding_window = None, while preserving the settings
needed by QueryOnlyGemma2Attention.

Comment thread tests/unit/models/test_mtp_gemma2.py Outdated
Comment on lines +37 to +60
def test_query_only_gemma2_attention_local_kv(gemma2_config):
"""Test that local KV cache is used when sliding_window > 0."""
attn = QueryOnlyGemma2Attention(gemma2_config, layer_idx=0)

batch_sz, seq_len = 2, 5
hidden_states = torch.randn(batch_sz, seq_len, 64)

# [batch, seq_len, 2, num_kv_heads, head_dim]
local_kv = torch.randn(batch_sz, seq_len, 2, 2, 16)
global_kv = torch.randn(batch_sz, seq_len, 2, 2, 16)

# Mock position embeddings (cos, sin)
cos = torch.randn(1, 1, seq_len, 16)
sin = torch.randn(1, 1, seq_len, 16)

output, _ = attn(
hidden_states=hidden_states,
position_embeddings=(cos, sin),
attention_mask=None,
verifier_kv_last_local=local_kv,
verifier_kv_last_global=global_kv,
)

assert output.shape == (batch_sz, seq_len, 64)

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Assert which verifier cache is selected.

Both caches have identical shapes and only the output shape is checked, so these tests pass if routing is inverted, ignored, or replaced with zeros. Run paired forwards that vary only the active cache and confirm changes to the inactive cache leave output unchanged.

As per path instructions, ensure new code paths introduced in the PR are covered.

Also applies to: 63-85

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/models/test_mtp_gemma2.py` around lines 37 - 60, Strengthen
test_query_only_gemma2_attention_local_kv and the related tests so they verify
cache routing rather than only output shape: run paired forwards while varying
only the selected verifier cache, assert the output changes, then vary the
inactive cache and assert the output remains unchanged. Cover both local and
global cache paths introduced by the PR, using distinct cache values and the
appropriate configuration or inputs to select each path.

Source: Path instructions

Comment thread tests/unit/models/test_mtp_gemma2.py Outdated
Comment on lines +48 to +50
# Mock position embeddings (cos, sin)
cos = torch.randn(1, 1, seq_len, 16)
sin = torch.randn(1, 1, seq_len, 16)

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== test file =="
sed -n '1,140p' tests/unit/models/test_mtp_gemma2.py

echo
echo "== search for Gemma2 / rotary usage =="
rg -n "apply_rotary_pos_emb|cos = torch.randn|sin = torch.randn|rope|Gemma2" tests src . -g '!**/.git/**'

Repository: vllm-project/speculators

Length of output: 20400


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== mtp Gemma2 implementation =="
sed -n '200,280p' src/speculators/models/mtp/model_definitions.py

echo
echo "== dflash rotary helper (same pattern) =="
sed -n '1,220p' src/speculators/models/dflash/model_definitions.py

Repository: vllm-project/speculators

Length of output: 12186


🏁 Script executed:

python3 - <<'PY'
import inspect
from transformers.models.gemma2.modeling_gemma2 import apply_rotary_pos_emb

print("signature:", inspect.signature(apply_rotary_pos_emb))
print("\nsource:")
src = inspect.getsource(apply_rotary_pos_emb)
print(src)
PY

Repository: vllm-project/speculators

Length of output: 1692


🏁 Script executed:

python3 - <<'PY'
import torch
from transformers.models.gemma2.modeling_gemma2 import apply_rotary_pos_emb

B, Hq, Hk, S, D = 2, 4, 2, 5, 16
q = torch.randn(B, Hq, S, D)
k = torch.randn(B, Hk, S, D)

cases = {
    "expected_3d": (torch.randn(B, S, D), torch.randn(B, S, D)),
    "current_4d": (torch.randn(1, 1, S, D), torch.randn(1, 1, S, D)),
}

for name, (cos, sin) in cases.items():
    print(f"\n{name}: input cos shape={tuple(cos.shape)}")
    try:
        q_embed, k_embed = apply_rotary_pos_emb(q, k, cos, sin)
        print("  q_embed:", tuple(q_embed.shape))
        print("  k_embed:", tuple(k_embed.shape))
    except Exception as e:
        print("  error:", type(e).__name__, e)
PY

Repository: vllm-project/speculators

Length of output: 352


Use the Gemma2 RoPE input shape here. apply_rotary_pos_emb expects cos/sin as [batch, seq_len, head_dim]; the extra singleton axis makes the rotated tensors 5D and relies on unintended broadcasting. Update both tests to build cos and sin as [batch_sz, seq_len, 16].

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/models/test_mtp_gemma2.py` around lines 48 - 50, The RoPE test
fixtures use an incorrect extra singleton dimension. In both tests around the
rotary embedding setup, update the `cos` and `sin` tensors to shape `[batch_sz,
seq_len, 16]` instead of `[1, 1, seq_len, 16]`, matching the input expected by
`apply_rotary_pos_emb`.

Comment on lines +10 to +16
@pytest.fixture
def head_setup():
hidden_size = 16
vocab_size = 256
num_centroids = 4
head = MultiLevelLMHead(hidden_size, vocab_size, num_centroids)
return head

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add a test for non-divisible vocab_size/num_centroids.

All tests use vocab_size=256, num_centroids=4 (evenly divisible). Add a case where vocab_size % num_centroids != 0 to cover the vocab-mapping boundary — this would have caught the out-of-range centroid index issue flagged in head.py. As per path instructions, PyTest tests should cover edge cases specific to speculative decoding such as vocab mapping boundaries.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/models/test_mtp_head.py` around lines 10 - 16, The test fixture
only covers evenly divisible vocabulary and centroid sizes. Add a PyTest case
alongside `head_setup` using values where `vocab_size % num_centroids != 0`,
then exercise the relevant `MultiLevelLMHead` forward or vocab-mapping behavior
and assert outputs remain valid without out-of-range centroid indices, covering
the mapping boundary for speculative decoding.

Source: Path instructions

@orestis-z orestis-z left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Design looks solid. The query-only cross-attention into verifier KV caches is the right approach for Gemma4's shared-cache architecture, and the kwargs propagation path through Gemma2DecoderLayer.forwardself.self_attn is confirmed working (the installed transformers 5.12.1 passes **kwargs through). Agree with @coderabbitai on the sliding-window mask gap (the eager attention path silently drops the sliding_window kwarg, so local layers see the full causal prefix during training) and on strengthening the attention tests to assert cache routing, not just output shape. Recommend approving once those are addressed.

🤖 Generated with Claude Code using the /pr-review skill

@shotsan
shotsan force-pushed the feat/gemma4-mtp-query-attention branch from 20c69b3 to fb6b2ae Compare July 13, 2026 20:09
@shotsan

shotsan commented Jul 13, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed review @coderabbitai and @orestis-z! I've pushed a new commit addressing all the remaining edge cases and test gaps:

  1. Safer Gemma2 Imports: Replaced the fragile NameError block in base_components.py with an explicit HAS_GEMMA2 boolean flag to cleanly and safely guard the model registry.
  2. Sliding Window Mask: eager_attention_forward drops the sliding_window parameter, so I've manually constructed a boolean sliding window mask using torch.triu/tril and applied it directly to attention_mask inside QueryOnlyGemma2Attention.forward.
  3. Cache Routing Unit Tests: Rewrote the test cases in test_mtp_gemma2.py to mathematically prove the KV cache routing works. The tests now run three forward passes: a baseline pass, a pass where we scramble the inactive cache (asserting torch.equal true), and a pass where we scramble the active cache (asserting torch.equal false).
  4. Empty Sample Shape: Updated create_empty_sample in data.py to accept kv_feature_dim and construct dummy KV tensors with shape [0, feature_dim], passing the true feature dimension down directly from the collator fallback to prevent downstream shape crashes.

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
echo "=== base_components.py HAS_GEMMA2 ==="
rg -n "HAS_GEMMA2|gemma2" src/speculators/models/base_components.py

echo "=== QueryOnlyGemma2Attention sliding window ==="
rg -n "sliding_window|triu|tril|eager_attention_forward" src/speculators/models/mtp/model_definitions.py

echo "=== test_mtp_gemma2.py ==="
cat -n tests/unit/models/test_mtp_gemma2.py

echo "=== create_empty_sample kv_feature_dim ==="
rg -n "kv_feature_dim|create_empty_sample" src/speculators/train/data.py -A5

Length of output: 8088


🏁 Script executed:

#!/bin/bash
sed -n '190,300p' src/speculators/models/mtp/model_definitions.py

Length of output: 5229


@shotsan Thanks for pushing these updates — I went through the diff and can confirm all four items are implemented as described:

  1. HAS_GEMMA2 flag (Lines 15-22, 64-65 in base_components.py) — clean, explicit guard; model_classes["gemma2"] is only registered when the Gemma2 imports succeed. Much safer than relying on NameError.
  2. Sliding-window mask — the manual torch.tril/torch.triu construction with diagonal=-self.sliding_window + 1 correctly builds a boolean window mask and applies it to attention_mask before the mask is passed into eager_attention_forward. Logic checks out.
  3. Cache-routing tests — the baseline/scramble-inactive/scramble-active pattern in test_mtp_gemma2.py is a solid way to prove routing correctness without needing to inspect internals directly. Both local (sliding_window > 0) and global (full_attention) branches are covered.
  4. Empty sample shapecreate_empty_sample(..., kv_feature_dim=0) now produces properly shaped [0, feature_dim] placeholders, and the fallback in collate_fn (Line 536) forwards kv_feature_dim through, so this should prevent the shape mismatch on empty batches.

One small, non-blocking observation: the sliding-window masking fix in QueryOnlyGemma2Attention.forward is only applied when attention_mask is not None (Line 269 in model_definitions.py). The new unit tests pass attention_mask=None, so they validate KV-cache routing correctly but don't exercise the windowing behavior itself. If there's any real inference/training path where a local (sliding-window) layer could be called with attention_mask=None, it would silently fall back to full attention instead of windowed attention. Worth double-checking that callers of this attention module always supply a mask when running Gemma2 draft layers — if that's already guaranteed elsewhere, feel free to disregard this.

Nice work overall — this looks ready from my side pending any outstanding feedback from @orestis-z.

(=^・ω・^=) ヘ(=^・ω・^)ノ

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
gh pr diff 768 --repo vllm-project/speculators > /tmp/pr768.diff || true
wc -l /tmp/pr768.diff
sed -n '1,400p' /tmp/pr768.diff

Length of output: 17682


🏁 Script executed:

#!/bin/bash
sed -n '400,851p' /tmp/pr768.diff

Length of output: 16990


🏁 Script executed:

#!/bin/bash
python3 -c "
import transformers, inspect
print(transformers.__version__)
from transformers.models.gemma2 import modeling_gemma2 as m
src = inspect.getsource(m.Gemma2Attention.__init__)
print(src)
"
</dev/null

Length of output: 1134


🏁 Script executed:

#!/bin/bash
python3 -c "
import transformers
print(transformers.__version__)
"
pip show transformers 2>/dev/null | head -5
python3 - <<'EOF'
import inspect
from transformers.models.gemma2 import modeling_gemma2 as m
import re
src = inspect.getsource(m)
# find sliding_window occurrences
for i, line in enumerate(src.splitlines()):
    if 'sliding_window' in line:
        print(i, line)
EOF

Length of output: 1294


🏁 Script executed:

#!/bin/bash
python3 - <<'EOF'
import inspect
from transformers.models.gemma2 import modeling_gemma2 as m
print(inspect.getsource(m.eager_attention_forward))
EOF

Length of output: 1525


🏁 Script executed:

#!/bin/bash
rg -n 'top_k_centroids|centroid_intermediate_top_k' --type=py

Length of output: 381


🏁 Script executed:

#!/bin/bash
sed -n '1,120p' src/speculators/models/mtp/head.py 2>/dev/null
echo "---"
rg -n 'token_ordering_inv|_keys_to_ignore_on_load_missing' src/speculators/models/mtp/head.py src/speculators/models/mtp/core.py

Length of output: 2958


@shotsan Thanks for the thorough follow-up — I pulled the latest diff and independently verified the key claims against transformers==5.13.0:

  1. HAS_GEMMA2 flag — clean and correct; the try/except properly guards model_classes["gemma2"] registration.
  2. Sliding window mask — confirmed this was a real necessity: eager_attention_forward in transformers/models/gemma2/modeling_gemma2.py has no sliding_window parameter at all (it's absorbed into unused **kwargs), so the manual torch.triu/torch.tril band mask you build and merge into attention_mask is the right fix. I also confirmed self.sliding_window in Gemma2Attention.__init__ is set from config.sliding_window if self.layer_type == "sliding_attention" else None, matching the is_local logic in QueryOnlyGemma2Attention.
  3. Cache routing tests — the three-pass (baseline / scramble-inactive / scramble-active) approach is a solid way to mathematically prove routing correctness. One gap: both tests call the attention module with attention_mask=None, so the new torch.triu/torch.tril masking branch (guarded by attention_mask is not None) is never actually exercised. Worth a follow-up test with a real causal mask to confirm the window boundary itself (not just routing) is correct.
  4. Empty sample shapekv_feature_dim threading through create_empty_samplecreate_collate_fn looks correct and avoids the shape-mismatch crash.

Two smaller items I noticed while reviewing, non-blocking:

  • In src/speculators/models/mtp/head.py, token_ordering_inv is registered as a persistent buffer (default) but isn't added to MTPDraftModel._keys_to_ignore_on_load_missing in core.py (only masked_embedding.centroids.weight and masked_embedding.token_ordering are listed). Since token_ordering_inv is fully derivable from token_ordering, consider either marking it persistent=False or adding it to the ignore list to avoid missing-key issues when loading older/partial checkpoints.
  • tests/unit/train/test_data.py::test_verifier_kvs_mixed_batch wraps the collate call in try/except KeyError: pass, so it passes regardless of whether a KeyError is raised or the field is silently dropped — it doesn't actually assert an invariant. Might be worth tightening once the intended mixed-batch behavior is decided.

Overall this looks good to merge from my side; the sliding-window/KV-routing fixes are sound. Nice work.


✏️ Learnings added
Learnt from: shotsan
URL: https://github.com/vllm-project/speculators/pull/768

Timestamp: 2026-07-13T22:20:11.329Z
Learning: In speculators (vllm-project/speculators), `transformers.models.gemma2.modeling_gemma2.eager_attention_forward` (as of transformers 5.13.0) does not accept a `sliding_window` parameter — any `sliding_window` value passed via kwargs is silently unused. Custom attention forwards that need sliding-window behavior with the eager path (e.g. `QueryOnlyGemma2Attention` in `src/speculators/models/mtp/model_definitions.py`) must manually construct a boolean band mask (e.g. via `torch.triu`/`torch.tril`) and merge it into `attention_mask` before calling the attention interface. `self.sliding_window` on `Gemma2Attention` is set from `config.sliding_window if self.layer_type == "sliding_attention" else None`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@shotsan

shotsan commented Jul 14, 2026

Copy link
Copy Markdown
Author

Thanks for the incredibly thorough review @coderabbitai!

I have addressed all three of your non-blocking items:

  1. test_verifier_kvs_mixed_batch assertions: That test is actually introduced in the Multi-Level LM Head PR (PR feat(mtp): add multi-level LM head for Gemma4 MTP (2/3) #767), and we already pushed a fix over in that PR to explicitly assert the exact zero-padded tensor shapes rather than relying on the try/except KeyError! Once PR feat(mtp): add multi-level LM head for Gemma4 MTP (2/3) #767 merges, those fixes will automatically propagate here.
  2. token_ordering_inv persistence: Great call. I updated src/speculators/models/mtp/head.py to explicitly set persistent=False when registering the token_ordering_inv buffer, which should completely prevent any missing-key issues when loading older checkpoints.
  3. Sliding Window Mask Test: I added a brand new test (test_query_only_gemma2_attention_sliding_window_mask) in test_mtp_gemma2.py that explicitly passes a full causal attention_mask into the forward pass. The test mathematically proves that when sliding_window is set (e.g. 2), queries only attend to their window boundary and completely ignore perturbations in the KV cache outside of that boundary.

I just pushed these updates to this branch. We should be totally good to go!

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
gh pr diff 768 --repo vllm-project/speculators > /tmp/pr768_new.diff || true
echo "=== token_ordering_inv persistent=False ==="
rg -n "token_ordering_inv|persistent" -A2 -B2 /tmp/pr768_new.diff | head -60

echo "=== new sliding window mask test ==="
rg -n "test_query_only_gemma2_attention_sliding_window_mask" -A60 /tmp/pr768_new.diff | head -120

Length of output: 4112


@shotsan Thanks for pushing these updates — I verified all three against the latest diff:

  1. test_verifier_kvs_mixed_batch — makes sense that this lives in #767 and will propagate here once merged. Nothing further needed on this PR for that item.
  2. token_ordering_inv persistence — confirmed: it's now registered with persistent=False in src/speculators/models/mtp/head.py (Line 173), and _rebuild_inverse() recomputes it from token_ordering on init. This fully resolves the missing-key concern for older checkpoints.
  3. Sliding window mask test — confirmed: test_query_only_gemma2_attention_sliding_window_mask builds a real lower-triangular causal attention_mask and sets sliding_window=2. The assertions are exactly right — perturbing local_kv at index 0 leaves output[:, 4] unchanged (outside the window) but changes output[:, 0] (inside the window). This closes the gap I flagged earlier and actually exercises the torch.triu/torch.tril masking branch rather than just KV-cache routing.

All three items check out. This looks good to merge from my side.

(=^・ω・^=) ヘ(=^・ω・^)ノ

@orestis-z orestis-z left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Both outstanding items from my previous review are addressed:

  • Sliding-window mask: The eager attention path now explicitly enforces the window constraint on the causal mask, so local layers no longer silently see the full causal prefix.
  • Cache-routing tests: Tests now verify that changing the active cache changes output while changing the inactive cache does not, and a dedicated sliding-window mask test confirms tokens outside the window are correctly masked.

The coderabbitai feedback (NameError guard, centroid validation, inverse caching, empty-sample KV shape) has also been addressed. LGTM. Recommend approving.

🤖 Generated with Claude Code using the /pr-review skill

@speculatorsbot speculatorsbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The query-only cross-attention design for Gemma4 MTP is clean, and the kwargs propagation path through Gemma2DecoderLayer.forward(**kwargs) into QueryOnlyGemma2Attention is correct. One shape-mismatch bug in core.py will crash Gemma2 MTP training at runtime -- details in the inline comment.

🤖 Generated with Claude Code using the /pr-review skill

Comment on lines +214 to +215
verifier_kv_last_local=kwargs.get("verifier_kv_last_local"),
verifier_kv_last_global=kwargs.get("verifier_kv_last_global"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: verifier KV caches are not sliced to valid_len, causing a shape mismatch during attention.

All other sequence-dimension tensors in the loop are sliced to valid_len (e.g., step_hidden = current_hidden[:, :valid_len] at L202, and the causal mask is created for valid_len at L191–198). The KV caches are passed through at full seq_len.

Inside eager_attention_forward the attention scores have shape [batch, heads, valid_len, seq_len] but the causal mask has shape [batch, 1, valid_len, valid_len]. The element-wise add crashes:

RuntimeError: The size of tensor a (seq_len) must match the size of
tensor b (valid_len) at non-singleton dimension 3

(I reproduced this locally with seq_len=10, valid_len=6.)

Fix: slice once before the loop and pass the sliced tensors:

# before the for-loop
step_kv_local = kwargs.get("verifier_kv_last_local")
step_kv_global = kwargs.get("verifier_kv_last_global")
if step_kv_local is not None:
    step_kv_local = step_kv_local[:, :valid_len]
if step_kv_global is not None:
    step_kv_global = step_kv_global[:, :valid_len]

then pass step_kv_local / step_kv_global at L214–215 instead of re-reading from kwargs.

The unit tests in test_mtp_gemma2.py do not catch this because they feed KV tensors with the same seq_len as the query and never go through MTPDraftModel.forward() (which is where the slicing to valid_len happens).

@mergify

mergify Bot commented Jul 20, 2026

Copy link
Copy Markdown

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @shotsan.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Jul 20, 2026
shotsan and others added 13 commits July 20, 2026 10:52
Signed-off-by: santosh-agebold <12378904+shotsan@users.noreply.github.com>

Signed-off-by: shotsan <shotsan@users.noreply.github.com>
Signed-off-by: santosh-agebold <12378904+shotsan@users.noreply.github.com>

Signed-off-by: shotsan <shotsan@users.noreply.github.com>
Signed-off-by: shotsan <shotsan@users.noreply.github.com>
Signed-off-by: shotsan <shotsan@users.noreply.github.com>
Signed-off-by: shotsan <shotsan@users.noreply.github.com>
Signed-off-by: shotsan <shotsan@users.noreply.github.com>
Signed-off-by: santosh-agebold <12378904+shotsan@users.noreply.github.com>

Signed-off-by: shotsan <shotsan@users.noreply.github.com>
Signed-off-by: santosh-agebold <12378904+shotsan@users.noreply.github.com>

Signed-off-by: shotsan <shotsan@users.noreply.github.com>
Signed-off-by: shotsan <shotsan@users.noreply.github.com>
Signed-off-by: shotsan <shotsan@users.noreply.github.com>
…dels

This commit introduces the architecture modifications required for Gemma4 MTP support.
Unlike Qwen-style MTP which maintains its own full attention layers and KV cache,
Gemma4 MTP borrows the last local and global KV caches from the verifier base model.

- Registered Gemma2 components in base_components.py
- Implemented QueryOnlyGemma2Attention to bypass K/V projections and route external KV caches
- Updated MTPDraftModel.forward to pass verifier_kv_last_local/global to layers
- Added unit tests for KV cache routing in Gemma4 MTP

Relates to vllm-project#586, vllm-project#758

Signed-off-by: santosh-agebold <12378904+shotsan@users.noreply.github.com>

Signed-off-by: shotsan <shotsan@users.noreply.github.com>
Signed-off-by: shotsan <shotsan@users.noreply.github.com>
Signed-off-by: shotsan <shotsan@users.noreply.github.com>
Signed-off-by: shotsan <shotsan@users.noreply.github.com>
@mergify

mergify Bot commented Jul 24, 2026

Copy link
Copy Markdown

Merge Protections

🔴 1 of 1 protections blocking · waiting on 👀 reviews

Protection Waiting on
🔴 Require approval from approved reviewers list 👀 reviews

🔴 Require approval from approved reviewers list

Waiting for any of

  • approved-reviews-by = dsikka
  • approved-reviews-by = fynnsu
  • approved-reviews-by = orestis-z
  • approved-reviews-by = rahul-tuli
  • approved-reviews-by = shanjiaz
This rule is failing.

All pull requests must have at least one approving review from a member of the approved reviewers list before merging.

  • any of:
    • approved-reviews-by = dsikka
    • approved-reviews-by = fynnsu
    • approved-reviews-by = orestis-z
    • approved-reviews-by = rahul-tuli
    • approved-reviews-by = shanjiaz

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.

3 participants