Skip to content

feat(mtp): add multi-level LM head for Gemma4 MTP (2/3)#767

Open
shotsan wants to merge 11 commits into
vllm-project:mainfrom
shotsan:feat/gemma4-mtp-multi-level-head
Open

feat(mtp): add multi-level LM head for Gemma4 MTP (2/3)#767
shotsan wants to merge 11 commits into
vllm-project:mainfrom
shotsan:feat/gemma4-mtp-multi-level-head

Conversation

@shotsan

@shotsan shotsan commented Jul 10, 2026

Copy link
Copy Markdown

This is Part 2 of 3 in the rollout to introduce Gemma4 MTP support (Resolves #586).
Builds on top of the offline data pipeline from Part 1 (#758).

Overview

This PR implements the MultiLevelLMHead for Gemma4 MTP, which replicates the centroid-masked sparse argmax approach used by vLLM (Gemma4MTPMaskedEmbedder).

Instead of duplicating the standard flat cross-entropy logic, the MultiLevelLMHead acts as an auxiliary module that only computes the centroid classification loss using the token_ordering mapping buffer. The unreduced centroid loss is then added to the standard token loss inside core.py.

Changes

  • Added num_centroids and top_k_centroids to MTPSpeculatorConfig.
  • Created a compact MultiLevelLMHead (head.py) containing the centroid linear projection and token_ordering buffer. Note: Parameter names match vLLM's implementation exactly (masked_embedding.centroids.weight and masked_embedding.token_ordering) for seamless checkpoint loading.
  • Conditionally integrated MultiLevelLMHead into MTPDraftModel's forward pass.
  • Added comprehensive unit tests in tests/unit/models/test_mtp_head.py.

Context

By keeping the standard cross-entropy computation intact and making the centroid projection an additive loss term, we ensure that the logic is highly robust and compact. No existing code was deleted.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

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: 0623a08d-6c61-4a48-8cf0-9da878c76b23

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

Gemma4 MTP support adds centroid-based LM-head configuration and loss computation, and extends training data handling to preserve, cast, load, and collate optional verifier KV-cache tensors.

Changes

Gemma4 MTP support

Layer / File(s) Summary
Centroid head and loss contract
src/speculators/models/mtp/config.py, src/speculators/models/mtp/head.py, tests/unit/models/test_mtp_head.py
Adds centroid configuration, implements MultiLevelLMHead, and tests centroid mapping, ignored targets, and custom token ordering.
MTP model loss integration
src/speculators/models/mtp/core.py
Conditionally creates the centroid head, permits missing optional weights, and adds centroid loss to training loss computation.
Verifier KV-cache data pipeline
src/speculators/train/data.py, tests/unit/train/test_data.py
Propagates optional verifier KV caches through sample creation, standardization, dtype conversion, Arrow loading, collation, and padding tests.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes cover the requested multi-level LM head, config wiring, data-path KV support, and tests for Gemma4 MTP.
Out of Scope Changes check ✅ Passed No clearly unrelated code changes are evident beyond the Gemma4 MTP and data-path work described in the issue.
Title check ✅ Passed The title clearly summarizes the main change: adding a multi-level LM head for Gemma4 MTP.
Description check ✅ Passed The description matches the changeset and correctly describes the new head, config fields, integration, 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: 2

🧹 Nitpick comments (6)
tests/unit/models/test_mtp_head.py (1)

10-16: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add edge-case tests for non-divisible vocab_size and invalid num_centroids.

All tests use vocab_size=256, num_centroids=4 (evenly divisible). The missing cases are exactly the ones that would expose the out-of-bounds and division-by-zero bugs in head.py:

  • vocab_size not divisible by num_centroids (e.g., 255 and 4) → should raise ValueError after the proposed fix.
  • num_centroids=0 or negative → should raise ValueError.
  • num_centroids > vocab_sizetokens_per_centroid=0, division by zero in positions // 0.
  • Integration test calling MTPDraftModel.forward with num_centroids set, verifying the centroid loss is added to the training loss.
♻️ Proposed additional tests
def test_non_divisible_vocab_size_raises():
    """Non-divisible vocab_size should raise ValueError."""
    with pytest.raises(ValueError, match="divisible"):
        MultiLevelLMHead(hidden_size=16, vocab_size=255, num_centroids=4)


def test_zero_centroids_raises():
    """num_centroids=0 should raise ValueError."""
    with pytest.raises(ValueError, match="positive"):
        MultiLevelLMHead(hidden_size=16, vocab_size=256, num_centroids=0)
🤖 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, Add edge-case
coverage in the tests around MultiLevelLMHead: assert ValueError for
non-divisible vocab_size, zero or negative num_centroids, and num_centroids
greater than vocab_size, matching the expected error text. Also add an
integration test for MTPDraftModel.forward with centroids enabled that verifies
centroid loss is included in the training loss.
src/speculators/models/mtp/core.py (2)

232-237: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider replacing in-place += with out-of-place addition for torch.compile stability.

unreduced += self.masked_embedding.compute_centroid_loss(...) modifies unreduced in-place. Since this runs inside the @conditional_torch_compile-decorated forward pass, in-place operations on non-leaf tensors can trigger graph breaks or fallback to eager mode. An out-of-place addition is compile-friendly and semantically equivalent:

♻️ Proposed fix
-            unreduced += self.masked_embedding.compute_centroid_loss(
+            centroid_loss = self.masked_embedding.compute_centroid_loss(
                 hidden_states=mtp_output,
                 targets=step_targets,
                 ignore_index=_IGNORE_INDEX,
             )
+            unreduced = unreduced + centroid_loss

Additionally, the centroid loss (cross-entropy over num_centroids classes) is added directly to the token loss (cross-entropy over vocab_size classes) without a scaling factor. At initialization, the token loss is ~log(vocab_size) while the centroid loss is ~log(num_centroids), so the centroid contribution may be negligible for large vocabs. If this is intentional as a light regularizer, consider documenting it; if not, a centroid_loss_weight config field may be warranted.

🤖 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/core.py` around lines 232 - 237, Replace the
in-place `+=` in the `masked_embedding.compute_centroid_loss` branch of the
compiled forward path with out-of-place addition, assigning the summed result
back to `unreduced`. Also review the intended relative weighting of centroid and
token losses; either document the unscaled regularizer behavior or add and apply
a configurable `centroid_loss_weight`.

92-102: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a class-level type annotation for masked_embedding.

t2d and d2t are declared with class-level annotations (torch.Tensor | None), but masked_embedding is only set in __init__ without a corresponding declaration. This may cause mypy errors depending on strictness settings. Since MultiLevelLMHead is imported locally, use nn.Module | None at the class level:

♻️ Proposed annotation
     t2d: torch.Tensor | None
     d2t: torch.Tensor | None
+    masked_embedding: nn.Module | None

As per coding guidelines, verify type annotations are consistent with mypy requirements.

🤖 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/core.py` around lines 92 - 102, Add a class-level
annotation for masked_embedding using nn.Module | None, alongside the existing
t2d and d2t annotations in the relevant model class. Keep the local
MultiLevelLMHead import and __init__ assignment unchanged, and verify the
annotation satisfies mypy.

Source: Path instructions

src/speculators/models/mtp/head.py (1)

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

Precompute the inverse mapping and avoid in-place index assignment.

The inverse mapping (inverse tensor of size vocab_size) is rebuilt on every call to compute_centroid_loss, which runs once per MTP step per forward pass. For large vocabularies (e.g., 256k tokens), this is a significant per-step allocation. Additionally, the in-place index assignment loss[valid_mask] = ... may cause torch.compile graph breaks since this method is called inside the @conditional_torch_compile-decorated forward pass.

Consider precomputing the inverse as a registered buffer in __init__ (invalidated only if token_ordering changes) and using masked_scatter or torch.where instead of in-place assignment:

♻️ Proposed refactor
     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))
+        # Precomputed inverse: token_id -> centroid_id
+        self.register_buffer(
+            "_inverse_mapping",
+            torch.empty(vocab_size, dtype=torch.long),
+        )
+        self._rebuild_inverse()
+
+    def _rebuild_inverse(self) -> None:
+        """Rebuild the inverse mapping. Call if token_ordering changes."""
+        positions = torch.arange(self.token_ordering.shape[0], device=self.token_ordering.device)
+        self._inverse_mapping[self.token_ordering] = positions // self.tokens_per_centroid

Then in compute_centroid_loss, replace lines 30–37:

-        # Inverse mapping: token_id -> centroid_id
-        inverse = torch.empty_like(self.token_ordering)
-        positions = torch.arange(self.token_ordering.shape[0], device=self.token_ordering.device)
-        inverse[self.token_ordering] = positions // self.tokens_per_centroid
-
         centroid_logits = self.centroids(valid_hidden)
-        target_centroids = inverse[valid_targets]
-        loss[valid_mask] = F.cross_entropy(centroid_logits, target_centroids, reduction="none")
+        target_centroids = self._inverse_mapping[valid_targets]
+        centroid_loss = F.cross_entropy(centroid_logits, target_centroids, reduction="none")
+        loss = loss.masked_scatter(valid_mask, centroid_loss)

Note: the existing test test_compute_centroid_loss_with_custom_ordering mutates token_ordering at runtime, so it would need to call head._rebuild_inverse() after the mutation.

🤖 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, Precompute the
token-to-centroid inverse mapping in the model’s __init__ as a registered
buffer, and add an _rebuild_inverse() helper for cases where token_ordering
changes. Update compute_centroid_loss to use the cached inverse and replace the
in-place loss[valid_mask] assignment with torch.where or masked_scatter to
remain compatible with torch.compile. Update
test_compute_centroid_loss_with_custom_ordering to call _rebuild_inverse() after
mutating token_ordering.
src/speculators/train/data.py (1)

199-205: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Substring-based dtype selection is fragile.

"hidden_states" in k or "verifier_kv" in k casts by substring. This works today, but a future integer/long field whose name contains verifier_kv (e.g. a KV metadata index) would be incorrectly cast to hidden_states_dtype. Consider an explicit allowlist of float-tensor keys, or a prefix check on verifier_kv_last_.

🤖 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 199 - 205, Replace the
substring-based condition in the data dtype conversion comprehension with an
explicit allowlist of known float-tensor keys, or restrict verifier cache
matching to the `verifier_kv_last_` prefix while handling hidden-state keys
explicitly. Preserve integer and metadata fields without casting.
tests/unit/train/test_data.py (1)

474-514: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for heterogeneous batches and the dtype-cast path.

The test covers the single-sample happy path through standardize_data_v1 + collate_fn, but the new code paths with the highest risk are untested:

  1. Mixed batch — one sample with KV fields and one without — directly exercises the for key in batch[0] assumption in collate_fn.
  2. BaseDataset.__getitem__ dtype cast — the "verifier_kv" in k branch is never exercised; a sample with bfloat16-configured hidden_states_dtype would verify KV tensors are actually cast.
  3. ArrowDataset._get_raw_data — the conditional loaded_hsres copy is untested.

As per coding guidelines, tests should cover edge cases specific to the new code paths introduced in the PR.

♻️ Suggested additional test cases
+def test_verifier_kvs_mixed_batch():
+    """KV-present and KV-absent samples in one batch should not crash or drop data."""
+    with_kv = {**sample_with_kv_fields()}
+    without_kv = {k: v for k, v in with_kv.items() if not k.startswith("verifier_kv")}
+    collate_fn = create_collate_fn(max_len=5, hidden_size=2, num_target_layers=1)
+    # Expect either a documented KeyError (homogeneity invariant) or graceful handling.
+    try:
+        collated = collate_fn([with_kv, without_kv])
+        # If it succeeds, verify no partial/corrupt KV tensors.
+    except KeyError:
+        pass  # document this invariant
+
+def test_verifier_kvs_dtype_cast():
+    """BaseDataset.__getitem__ casts verifier_kv* to hidden_states_dtype."""
+    sample = standardize_data_v1(v1_data_with_kv())
+    # Drive through a minimal BaseDataset subclass with hidden_states_dtype=torch.bfloat16
+    # and assert sample["verifier_kv_last_local"].dtype == torch.bfloat16
🤖 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 - 514, Add tests covering the
new verifier-KV edge paths: collate a heterogeneous batch where one sample has
verifier KV fields and another does not, verify BaseDataset.__getitem__ casts
verifier KV tensors when hidden_states_dtype is bfloat16, and test
ArrowDataset._get_raw_data copies conditional loaded_hs data into res. Reuse the
existing assertions for key presence, dtype, values, and padding while targeting
these symbols directly.

Source: Path instructions

🤖 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-66: Validate centroid configuration in the config model
containing num_centroids and top_k_centroids: reject num_centroids values less
than 1, reject enabled configurations where vocab_size is not divisible by
num_centroids, and reject top_k_centroids greater than num_centroids. Implement
these checks in the model’s validation logic while allowing the standard flat LM
head when num_centroids is None.

In `@src/speculators/models/mtp/head.py`:
- Around line 11-16: Validate constructor arguments in __init__ before computing
tokens_per_centroid: reject num_centroids <= 0 and vocab_size values not evenly
divisible by num_centroids, raising a clear ValueError for each invalid
condition. Keep the existing centroid and token_ordering initialization
unchanged after validation.

---

Nitpick comments:
In `@src/speculators/models/mtp/core.py`:
- Around line 232-237: Replace the in-place `+=` in the
`masked_embedding.compute_centroid_loss` branch of the compiled forward path
with out-of-place addition, assigning the summed result back to `unreduced`.
Also review the intended relative weighting of centroid and token losses; either
document the unscaled regularizer behavior or add and apply a configurable
`centroid_loss_weight`.
- Around line 92-102: Add a class-level annotation for masked_embedding using
nn.Module | None, alongside the existing t2d and d2t annotations in the relevant
model class. Keep the local MultiLevelLMHead import and __init__ assignment
unchanged, and verify the annotation satisfies mypy.

In `@src/speculators/models/mtp/head.py`:
- Around line 30-37: Precompute the token-to-centroid inverse mapping in the
model’s __init__ as a registered buffer, and add an _rebuild_inverse() helper
for cases where token_ordering changes. Update compute_centroid_loss to use the
cached inverse and replace the in-place loss[valid_mask] assignment with
torch.where or masked_scatter to remain compatible with torch.compile. Update
test_compute_centroid_loss_with_custom_ordering to call _rebuild_inverse() after
mutating token_ordering.

In `@src/speculators/train/data.py`:
- Around line 199-205: Replace the substring-based condition in the data dtype
conversion comprehension with an explicit allowlist of known float-tensor keys,
or restrict verifier cache matching to the `verifier_kv_last_` prefix while
handling hidden-state keys explicitly. Preserve integer and metadata fields
without casting.

In `@tests/unit/models/test_mtp_head.py`:
- Around line 10-16: Add edge-case coverage in the tests around
MultiLevelLMHead: assert ValueError for non-divisible vocab_size, zero or
negative num_centroids, and num_centroids greater than vocab_size, matching the
expected error text. Also add an integration test for MTPDraftModel.forward with
centroids enabled that verifies centroid loss is included in the training loss.

In `@tests/unit/train/test_data.py`:
- Around line 474-514: Add tests covering the new verifier-KV edge paths:
collate a heterogeneous batch where one sample has verifier KV fields and
another does not, verify BaseDataset.__getitem__ casts verifier KV tensors when
hidden_states_dtype is bfloat16, and test ArrowDataset._get_raw_data copies
conditional loaded_hs data into res. Reuse the existing assertions for key
presence, dtype, values, and padding while targeting these symbols directly.
🪄 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: ba8b4a59-2748-434d-8824-c697a32271f2

📥 Commits

Reviewing files that changed from the base of the PR and between 6030a44 and 731f7ea.

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

Comment on lines +55 to +66
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== files ==\n'
git ls-files 'src/speculators/models/mtp/*' 'src/speculators/models/**/*.py' | sed -n '1,200p'

printf '\n== outline config ==\n'
ast-grep outline src/speculators/models/mtp/config.py --view expanded || true

printf '\n== outline mtp subtree ==\n'
ast-grep outline src/speculators/models/mtp --view expanded || true

printf '\n== search top_k_centroids ==\n'
rg -n "top_k_centroids|num_centroids|compute_centroid_loss|MultiLevelLMHead" src/speculators/models src/speculators -S || true

printf '\n== config file with line numbers ==\n'
wc -l src/speculators/models/mtp/config.py
sed -n '1,220p' src/speculators/models/mtp/config.py | cat -n

printf '\n== likely model files with line numbers ==\n'
for f in $(git ls-files 'src/speculators/models/mtp/*.py'); do
  echo "\n--- $f ---"
  wc -l "$f"
done

# Read only likely relevant files in chunks if small enough
for f in $(git ls-files 'src/speculators/models/mtp/*.py'); do
  lines=$(wc -l < "$f")
  if [ "$lines" -le 500 ]; then
    echo "\n== $f =="
    sed -n '1,260p' "$f" | cat -n
  fi
done

Repository: vllm-project/speculators

Length of output: 41846


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/speculators/models/mtp/config.py --view expanded || true
printf '\n--- search ---\n'
rg -n "top_k_centroids|num_centroids|compute_centroid_loss|MultiLevelLMHead" src/speculators/models -S || true
printf '\n--- config ---\n'
sed -n '1,220p' src/speculators/models/mtp/config.py | cat -n
printf '\n--- mtp files ---\n'
for f in $(git ls-files 'src/speculators/models/mtp/*.py'); do
  echo "### $f"
  sed -n '1,260p' "$f" | cat -n
done

Repository: vllm-project/speculators

Length of output: 35734


Add centroid config validation in src/speculators/models/mtp/config.py:55-66. num_centroids=0 will divide by zero in MultiLevelLMHead, and non-divisible vocab_size values can produce invalid centroid labels in compute_centroid_loss. Reject top_k_centroids > num_centroids when centroids are enabled.

🤖 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 - 66, Validate centroid
configuration in the config model containing num_centroids and top_k_centroids:
reject num_centroids values less than 1, reject enabled configurations where
vocab_size is not divisible by num_centroids, and reject top_k_centroids greater
than num_centroids. Implement these checks in the model’s validation logic while
allowing the standard flat LM head when num_centroids is None.

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 | 🟠 Major | ⚡ Quick win

Guard against non-divisible vocab_size and non-positive num_centroids.

tokens_per_centroid = vocab_size // num_centroids silently truncates when vocab_size is not evenly divisible. In compute_centroid_loss, positions // self.tokens_per_centroid then produces centroid IDs ≥ num_centroids for the remainder tokens, causing an out-of-bounds index error in F.cross_entropy. A num_centroids <= 0 also causes division by zero or negative centroid IDs.

Add a runtime guard in __init__ as a defense-in-depth check (the config validator in config.py is the primary gate, but this protects direct construction):

🛡️ Proposed fix
     def __init__(self, hidden_size: int, vocab_size: int, num_centroids: int):
         super().__init__()
+        if num_centroids <= 0:
+            raise ValueError(f"num_centroids must be positive, got {num_centroids}.")
+        if vocab_size % num_centroids != 0:
+            raise ValueError(
+                f"vocab_size ({vocab_size}) must be 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)
📝 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:
raise ValueError(f"num_centroids must be positive, got {num_centroids}.")
if vocab_size % num_centroids != 0:
raise ValueError(
f"vocab_size ({vocab_size}) must be 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, Validate
constructor arguments in __init__ before computing tokens_per_centroid: reject
num_centroids <= 0 and vocab_size values not evenly divisible by num_centroids,
raising a clear ValueError for each invalid condition. Keep the existing
centroid and token_ordering initialization unchanged after validation.

@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 is clean -- the centroid classification loss as an additive auxiliary term keeps the change compact and the existing flat CE path untouched. Centroid inverse-mapping and shape handling in compute_centroid_loss look correct.

Two notes:

  1. top_k_centroids naming / usage -- see inline comment.
  2. Undocumented data.py scope -- the verifier_kv_last_local / verifier_kv_last_global pipeline changes are part of RFC #586 point 3 ("Training data path") but are not mentioned in this PR's description. They are orthogonal to the multi-level head and could be split out or at least called out in the overview so reviewers know they are intentional.

Agree with @coderabbitai's suggestion to add input validation for num_centroids in both config and MultiLevelLMHead.__init__. Recommend approving once the naming concern below is resolved.

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

Comment thread src/speculators/models/mtp/config.py Outdated
Comment on lines +63 to +66
top_k_centroids: int = Field(
default=32,
description="Number of top centroids to select during multi-level LM head decoding.",
)

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.

top_k_centroids is defined here but never referenced anywhere in this PR's code -- neither MultiLevelLMHead nor MTPDraftModel reads it.

More importantly, the field name does not match what vLLM expects. vLLM's Gemma4MTPModel reads the top-K centroid count as:

# vllm/model_executor/models/gemma4_mtp.py:538
top_k = getattr(config, "centroid_intermediate_top_k", 32)

So a speculators-trained checkpoint that serializes top_k_centroids in its config.json will silently miss vLLM's lookup, and vLLM will fall back to its hardcoded default of 32. With the current default of 32 this is invisible, but it breaks interop if someone changes the value.

Suggestion: either rename to centroid_intermediate_top_k to match the vLLM contract, or defer adding the field until Part 3 when it is wired up (YAGNI).

@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 of the feedback:

  1. Config Naming & Validation: Renamed top_k_centroids to centroid_intermediate_top_k to match vLLM's contract. Added strict Pydantic validators enforcing num_centroids >= 1 and ensuring vocab_size is evenly divisible by num_centroids.
  2. Head Inverse Mapping: Precomputed the token-to-centroid inverse mapping in MultiLevelLMHead.__init__ as a registered buffer (token_ordering_inv) and exposed a _rebuild_inverse() helper so we aren't recomputing it on every forward pass.
  3. torch.compile Compatibility: Removed in-place additions during the loss computation, utilizing masked_scatter and out-of-place assignment (unreduced = unreduced + ...). Added the missing masked_embedding class-level annotation.
  4. Data Type Casting: Replaced the fuzzy substring float casting in data.py with explicit checks for verifier_last_hidden_states and .startswith("verifier_kv_last_").
  5. Testing: Added all the requested unit tests, including validation checks for non-divisible vocabulary sizes, integration checks verifying the centroid loss is included in the forward pass, and robust dataset collator tests handling missing verifier KV caches across mixed batches.

Let me know if there's anything else needed here!

@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
gh pr diff 767 --repo vllm-project/speculators > /tmp/pr767.diff
wc -l /tmp/pr767.diff
sed -n '1,400p' /tmp/pr767.diff

Length of output: 15693


🏁 Script executed:

#!/bin/bash
sed -n '400,547p' /tmp/pr767.diff

Length of output: 6564


🏁 Script executed:

#!/bin/bash
sed -n '1,260p' src/speculators/train/data.py | grep -n "create_collate_fn" -A 60

Length of output: 401


🏁 Script executed:

#!/bin/bash
grep -n "def create_collate_fn" -A 80 src/speculators/train/data.py

Length of output: 3111


@shotsan Thanks for the quick turnaround — I pulled the latest diff and verified each point:

  1. Config naming & validation ✅ — centroid_intermediate_top_k is in place, and the model_validator correctly enforces num_centroids >= 1, vocab_size % num_centroids == 0, and now also centroid_intermediate_top_k <= num_centroids.
  2. Inverse mapping ✅ — token_ordering_inv is precomputed as a buffer in __init__, with _rebuild_inverse() exposed for cases where token_ordering is mutated later (as exercised in test_compute_centroid_loss_with_custom_ordering).
  3. torch.compile compatibility ✅ — compute_centroid_loss now builds the loss via masked_scatter and core.py uses the out-of-place unreduced = unreduced + .... The masked_embedding: nn.Module | None class-level annotation is also present.
  4. Data type casting ✅ — data.py now uses explicit key checks (verifier_last_hidden_states / startswith("verifier_kv_last_")) instead of the fuzzy substring match.
  5. Testing — mostly solid, but one gap remains: test_verifier_kvs_mixed_batch wraps the collate call in a bare try/except KeyError: pass with no other assertions, so it passes trivially regardless of outcome. I traced through create_collate_fn (Lines 541-543 in src/speculators/train/data.py) — it only iterates batch[0]'s keys, so a mixed batch where the first sample lacks verifier_kv_last_local but a later one has it will silently drop that field for the whole batch, and the reverse ordering raises KeyError. Neither behavior is actually verified by the current test.

Would you like to harden create_collate_fn itself (e.g., union keys across the batch and default-fill missing optional KV entries with zeros) so mixed batches degrade gracefully instead of crashing/silently dropping data? Happy to help draft that if useful — otherwise let me know if this is intentionally deferred.

Everything else looks good to merge from my side. 🐇✨

@shotsan

shotsan commented Jul 13, 2026

Copy link
Copy Markdown
Author

@coderabbitai Thanks for catching that gap in the collator tests! You were spot on.

I just pushed a new commit to harden create_collate_fn directly. Instead of strictly relying on batch[0]'s keys, the collator now unions all keys across the entire batch. If an optional field like verifier_kv_last_local is missing for a particular sample, we now dynamically infer its required shape using the matching sequence length from its input_ids, and insert a zeroed-out tensor placeholder.

This ensures that mixed batches degrade gracefully and collate perfectly without crashing or silently dropping data for the rest of the batch! I also updated test_verifier_kvs_mixed_batch to explicitly assert the exact zero-padded shapes for both forward and reverse batch orderings.

Let me know if you need anything else to sign off!

@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
gh pr diff 767 --repo vllm-project/speculators > /tmp/pr767_v2.diff
wc -l /tmp/pr767_v2.diff
grep -n "def create_collate_fn" -A 100 src/speculators/train/data.py

Length of output: 3896


🏁 Script executed:

#!/bin/bash
sed -n '1,200p' /tmp/pr767_v2.diff | grep -n "test_verifier_kvs_mixed_batch" -A 60
grep -n "def test_verifier_kvs_mixed_batch" -A 60 tests/unit/train/test_data.py

Length of output: 2948


🏁 Script executed:

#!/bin/bash
sed -n '540,562p' src/speculators/train/data.py | cat -A | head -30

Length of output: 1127


Confirmed — I walked through the hardened create_collate_fn (Lines 518-599 in src/speculators/train/data.py) and the logic checks out:

  • all_keys is now unioned across the whole batch (Line 540-542), so no field silently disappears when batch[0] happens to lack it.
  • For each key, the template is sourced from the first sample that actually has it (Line 546), so dtype/device/feature-dims are preserved correctly even if that sample isn't first in the batch.
  • Missing entries get a per-sample zero placeholder sized off that sample's own input_ids seq_len (Lines 552-557), which correctly handles variable-length sequences in a mixed batch rather than assuming uniform length.
  • test_verifier_kvs_mixed_batch (Lines 517-546 in tests/unit/train/test_data.py) now asserts exact zero-padded values for both [with_kv, without_kv] and [without_kv, with_kv] orderings, which correctly exercises the fix — I traced the expected tensors and they match what the collator produces.

Two trivial nits (non-blocking, purely cosmetic): there's trailing whitespace on Line 547 and Line 559 in src/speculators/train/data.py (template = next(...) and the closing paren of torch.zeros(...)) — worth a quick pass through your linter/formatter (ruff/black) before merge, but not something I'd hold this up for.

This resolves the gap I flagged earlier. Nicely done — thanks for the thorough fix and tests. LGTM to merge from my side. 🐇✅

[approve]

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

Previous feedback has been thoroughly addressed -- naming, validation, inverse precomputation, torch.compile safety, explicit dtype casting, and mixed-batch collation all look correct. One minor gap below.

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

"t2d",
"d2t",
"masked_embedding.centroids.weight",
"masked_embedding.token_ordering",

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.

masked_embedding.token_ordering_inv (the precomputed inverse buffer added in head.py) is missing from this list. vLLM's Gemma4MTPMaskedEmbedder only stores token_ordering, not the inverse, so loading from a vLLM-style checkpoint will emit a spurious missing-key warning for this buffer.

Since _rebuild_inverse() always recomputes it in __init__, there is no need to load it from a checkpoint -- it just needs to be silenced here:

Suggested change
"masked_embedding.token_ordering",
"masked_embedding.token_ordering",
"masked_embedding.token_ordering_inv",

@shotsan

shotsan commented Jul 14, 2026

Copy link
Copy Markdown
Author

Good catch! Added masked_embedding.token_ordering_inv to _keys_to_ignore_on_load_missing in core.py to silence the warning when loading older vLLM checkpoints.

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

Previous finding (token_ordering_inv missing from _keys_to_ignore_on_load_missing) is 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.

LGTM. Weight names and config fields align with vLLM's Gemma4MTPMaskedEmbedder, centroid loss math and shape handling are correct, inverse mapping is properly precomputed with masked_scatter for torch.compile safety, and the hardened collator gracefully handles mixed KV/non-KV batches. Agree with the previous review -- recommend approving.

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

@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 10 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>

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

Unresolved merge conflict markers in tests/unit/train/test_data.py need to be fixed before merge. The source files (head.py, core.py, config.py, data.py) and test coverage in test_mtp_head.py all look correct -- previous feedback has been thoroughly addressed. One blocking item below.

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

Comment on lines +495 to +603
<<<<<<< HEAD
=======
def test_verifier_kvs_survive_pipeline():
"""Test that optional verifier KV caches survive standardize_data_v1 and collate_fn."""
# 1. Create a dummy v1 offline data dictionary with KV caches
v1_data = {
"input_ids": torch.tensor([0, 1, 2], dtype=torch.long),
"loss_mask": torch.tensor([0, 1, 1], dtype=torch.long),
"hidden_states": [
torch.tensor([[0.0, 0.1], [1.0, 1.1], [2.0, 2.1]]), # Layer 0
torch.tensor([[10.0, 10.1], [11.0, 11.1], [12.0, 12.1]]), # Verifier last hs
],
"verifier_kv_last_local": torch.tensor([[100.0], [101.0], [102.0]]),
"verifier_kv_last_global": torch.tensor([[200.0], [201.0], [202.0]]),
}

# 2. Pass through standardize_data_v1
standardized = standardize_data_v1(v1_data)

assert "verifier_kv_last_local" in standardized
assert "verifier_kv_last_global" in standardized
assert torch.equal(standardized["verifier_kv_last_local"], v1_data["verifier_kv_last_local"])

# 3. Add lengths and position_ids (simulate BaseDataset.__getitem__)
standardized["lengths"] = torch.tensor([3], dtype=torch.long)
standardized["position_ids"] = torch.tensor([0, 1, 2], dtype=torch.long)

# 4. Pass through collate_fn
collate_fn = create_collate_fn(max_len=5, hidden_size=2, num_target_layers=1)
batch = [standardized]

collated = collate_fn(batch)

# 5. Verify the KV caches survived collation and were properly padded
assert "verifier_kv_last_local" in collated
assert "verifier_kv_last_global" in collated

local_kv = collated["verifier_kv_last_local"]
assert local_kv.shape == (1, 5, 1) # [batch=1, max_len=5, ...]

# First 3 positions should match original, last 2 should be padded with 0
expected_local = torch.tensor([[[100.0], [101.0], [102.0], [0.0], [0.0]]])
assert torch.equal(local_kv, expected_local)


def test_verifier_kvs_mixed_batch():
"""KV-present and KV-absent samples in one batch should not crash or drop data."""
from speculators.train.data import create_collate_fn

with_kv = {
"input_ids": torch.tensor([0]),
"loss_mask": torch.tensor([1]),
"hidden_states": torch.tensor([[0.0]]),
"verifier_last_hidden_states": torch.tensor([[0.0]]),
"verifier_kv_last_local": torch.tensor([[100.0]]),
"lengths": torch.tensor([1]),
"position_ids": torch.tensor([0]),
}
without_kv = {k: v for k, v in with_kv.items() if not k.startswith("verifier_kv")}
collate_fn = create_collate_fn(max_len=5, hidden_size=1, num_target_layers=1)

# Test both orderings
for batch in [[with_kv, without_kv], [without_kv, with_kv]]:
collated = collate_fn(batch)

assert "verifier_kv_last_local" in collated
local_kv = collated["verifier_kv_last_local"]
assert local_kv.shape == (1, 5, 1)

if batch[0] == with_kv:
expected = torch.tensor([[[100.0], [0.0], [0.0], [0.0], [0.0]]])
else:
expected = torch.tensor([[[0.0], [100.0], [0.0], [0.0], [0.0]]])

assert torch.equal(local_kv, expected)


def test_verifier_kvs_dtype_cast():
"""Verify BaseDataset.__getitem__ casts verifier KV tensors when hidden_states_dtype is bfloat16."""
from speculators.train.data import BaseDataset

class DummyDataset(BaseDataset):
def __len__(self):
return 1

def _compute_approx_lengths(self):
return [1]

def _get_raw_data(self, idx):
return {
"input_ids": torch.tensor([0]),
"loss_mask": torch.tensor([1]),
"hidden_states": torch.tensor([[0.0]], dtype=torch.float32),
"verifier_last_hidden_states": torch.tensor([[0.0]], dtype=torch.float32),
"verifier_kv_last_local": torch.tensor([[100.0]], dtype=torch.float32),
}

ds = DummyDataset(
max_len=128,
hidden_states_dtype=torch.bfloat16,
)

item = ds[0]
assert item["verifier_kv_last_local"].dtype == torch.bfloat16
assert item["hidden_states"].dtype == torch.bfloat16
assert item["verifier_last_hidden_states"].dtype == torch.bfloat16


>>>>>>> 40e5d06 (fix(mtp): address PR 767 CodeRabbit and collaborator comments)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This file contains unresolved merge conflict markers (<<<<<<< HEAD, =======, >>>>>>> 40e5d06), which will cause a SyntaxError and prevent all tests in this module from running.

Additionally, this creates two issues:

  1. test_verifier_kvs_survive_pipeline is defined twice -- once inside the conflict block (line 497, older version using pre-stacked KV tensors) and once below (line 640, newer version using separate kv_last_local_k/kv_last_local_v keys). The newer version is the correct one matching the standardize_data_v1 changes.
  2. test_verifier_kvs_mixed_batch and test_verifier_kvs_dtype_cast only exist inside the conflict block. They need to be preserved after resolving the conflict.

To fix: remove lines 495-496 (<<<<<<< HEAD and =======) and line 603 (>>>>>>> 40e5d06 ...), then remove the older test_verifier_kvs_survive_pipeline (lines 497-537) since the updated version at line 640 supersedes it. Keep test_verifier_kvs_mixed_batch and test_verifier_kvs_dtype_cast.

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.

[RFC]: Gemma4 MTP support

3 participants