[Bugfix][Spec Decode] Implement get_top_tokens() on the ROCm DeepSeek V4 MTP drafter (#57568)

Signed-off-by: BaoYunkai <ybao@amd.com>
This commit is contained in:
BaoYunkai
2026-09-28 20:16:38 +08:00
committed by GitHub
parent 8cc9aa5ad3
commit f6877f563c
2 changed files with 200 additions and 6 deletions
@@ -0,0 +1,163 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""``get_top_tokens`` on the DeepSeek V4 MTP drafter.
The speculator opts into ``use_local_argmax_reduction`` by probing the draft
model for ``get_top_tokens``, and expects it to select the same greedy draft
token the full-vocab argmax would. V4 reaches its LM head through hc_head and
the shared head's RMSNorm, so both methods have to share that transform.
"""
from types import SimpleNamespace
from unittest import mock
import pytest
import torch
from transformers import PreTrainedConfig
from vllm.model_executor.layers.logits_processor import LogitsProcessor
from vllm.model_executor.layers.mhc import HCHeadOp
from vllm.model_executor.models.deepseek_mtp import SharedHead
from vllm.platforms import current_platform
if not current_platform.is_rocm():
pytest.skip(
"get_top_tokens is implemented on the ROCm DeepSeek V4 MTP drafter only.",
allow_module_level=True,
)
from vllm.models.deepseek_v4.amd.mtp import ( # noqa: E402
DeepSeekV4MTP,
DeepSeekV4MultiTokenPredictor,
)
DEVICE_TYPE = current_platform.device_type
START_LAYER_IDX = 61
HIDDEN_SIZE = 4096
HC_MULT = 4
RMS_NORM_EPS = 1e-6
HC_EPS = 1e-6
_EMBEDDING = "vllm.model_executor.layers.vocab_parallel_embedding"
def _make_predictor(vocab_size: int) -> DeepSeekV4MultiTokenPredictor:
config = PreTrainedConfig()
config.hidden_size = HIDDEN_SIZE
config.vocab_size = vocab_size
config.rms_norm_eps = RMS_NORM_EPS
# A single vocab shard, so the reduction returns the local argmax without
# touching the process group.
with (
mock.patch(f"{_EMBEDDING}.get_tensor_model_parallel_rank", return_value=0),
mock.patch(
f"{_EMBEDDING}.get_tensor_model_parallel_world_size", return_value=1
),
):
shared_head = SharedHead(config=config, prefix="shared_head")
generator = torch.Generator().manual_seed(7)
with torch.no_grad():
shared_head.head.weight.normal_(0.0, 0.02, generator=generator)
# The default norm weight is all ones, and the argmax is invariant to
# the norm's per-row rescale, so a uniform gain would hide a skipped
# shared_head. Vary it so the comparison below can see one.
shared_head.norm.weight.normal_(1.0, 0.5, generator=generator)
shared_head = shared_head.to(device=DEVICE_TYPE, dtype=torch.bfloat16)
hc_head_fn = torch.randn(
HC_MULT, HC_MULT * HIDDEN_SIZE, dtype=torch.float32, generator=generator
)
# Non-trivial gate scale and bias: hc_head mixes the hc_mult streams with
# these, and defaults of 0 would make a skipped hc_head harder to see.
hc_head_scale = torch.randn(1, dtype=torch.float32, generator=generator) * 0.1
hc_head_base = torch.randn(HC_MULT, dtype=torch.float32, generator=generator) * 0.1
mtp_layer = SimpleNamespace(
config=config,
hc_mult=HC_MULT,
hc_eps=HC_EPS,
rms_norm_eps=RMS_NORM_EPS,
hc_head_op=HCHeadOp(),
hc_head_fn=(hc_head_fn * 1e-4).to(DEVICE_TYPE),
hc_head_scale=hc_head_scale.to(DEVICE_TYPE),
hc_head_base=hc_head_base.to(DEVICE_TYPE),
shared_head=shared_head,
)
# Only the attributes the two methods read; a real predictor would also
# build the MLA/MoE draft block, which is irrelevant here.
predictor = object.__new__(DeepSeekV4MultiTokenPredictor)
torch.nn.Module.__init__(predictor)
predictor.layers = {str(START_LAYER_IDX): mtp_layer}
predictor.mtp_start_layer_idx = START_LAYER_IDX
predictor.num_mtp_layers = 1
predictor.logits_processor = LogitsProcessor(vocab_size)
return predictor
def _hidden_states(num_tokens: int, seed: int) -> torch.Tensor:
"""The flat pre-hc_head residual the MTP layer returns."""
generator = torch.Generator().manual_seed(seed)
hidden_states = torch.randn(
num_tokens, HC_MULT * HIDDEN_SIZE, dtype=torch.float32, generator=generator
)
return hidden_states.to(device=DEVICE_TYPE, dtype=torch.bfloat16)
def _assert_selects_max_logit(top: torch.Tensor, logits: torch.Tensor) -> None:
"""The selected token must carry the row's maximum logit.
hc_head emits bf16, so rows can tie for the maximum and then no single
index is the right answer; compare the values the two paths select.
"""
assert top.shape == (logits.shape[0],)
selected = logits.gather(1, top.unsqueeze(1)).squeeze(1)
assert torch.equal(selected, logits.max(dim=-1).values)
def test_get_top_tokens_matches_full_argmax(default_vllm_config):
"""Same draft token as the path taken when the reduction is off.
``get_top_tokens`` has to run the same pre-head transform as
``compute_logits``: hc_head over the (T, hc_mult, D) residual, then the
shared head's RMSNorm. Dropping either one returns a different draft token
instead of failing, which is what this comparison catches.
"""
predictor = _make_predictor(vocab_size=512)
for num_tokens in (1, 16, 128):
hidden_states = _hidden_states(num_tokens, seed=11)
logits = predictor.compute_logits(hidden_states)
top = predictor.get_top_tokens(hidden_states)
assert top.dtype == torch.int64
_assert_selects_max_logit(top, logits)
def test_get_top_tokens_honors_padded_vocab(default_vllm_config):
"""Padding entries above ``org_vocab_size`` must never win the argmax."""
vocab_size = 500
predictor = _make_predictor(vocab_size)
head = predictor.layers[str(START_LAYER_IDX)].shared_head.head
assert head.shard_indices.num_org_vocab_padding > 0
hidden_states = _hidden_states(32, seed=13)
logits = predictor.compute_logits(hidden_states)
top = predictor.get_top_tokens(hidden_states)
_assert_selects_max_logit(top, logits)
assert (top < vocab_size).all()
def test_wrapper_forwards_spec_step_idx():
"""``DeepSeekV4MTP`` is what the speculator probes, so it needs the method."""
assert hasattr(DeepSeekV4MTP, "get_top_tokens")
hidden_states = torch.empty(0)
wrapper = object.__new__(DeepSeekV4MTP)
wrapper.model = SimpleNamespace(get_top_tokens=lambda *args: args)
assert DeepSeekV4MTP.get_top_tokens(wrapper, hidden_states, 2) == (hidden_states, 2)
assert DeepSeekV4MTP.get_top_tokens(wrapper, hidden_states) == (hidden_states, 0)
+37 -6
View File
@@ -240,13 +240,11 @@ class DeepSeekV4MultiTokenPredictor(nn.Module):
current_step_idx,
)
def compute_logits(
def _pre_head(
self,
mtp_layer: DeepSeekV4MultiTokenPredictorLayer,
hidden_states: torch.Tensor,
spec_step_idx: int = 0,
) -> torch.Tensor:
current_step_idx = spec_step_idx % self.num_mtp_layers
mtp_layer = self.layers[str(self.mtp_start_layer_idx + current_step_idx)]
# MTP forward returns the pre-hc_head residual (T, hc_mult * D); apply
# hc_head here so logits are computed from the dense hidden state.
hidden_states = hidden_states.view(
@@ -260,14 +258,38 @@ class DeepSeekV4MultiTokenPredictor(nn.Module):
mtp_layer.rms_norm_eps,
mtp_layer.hc_eps,
)
hidden_states = _MTP_SHARED_HEAD_RMSNORM_KERNEL(
return _MTP_SHARED_HEAD_RMSNORM_KERNEL(
hidden_states,
mtp_layer.shared_head.norm.weight.data,
mtp_layer.shared_head.norm.variance_epsilon,
)
logits = self.logits_processor(mtp_layer.shared_head.head, hidden_states)
def compute_logits(
self,
hidden_states: torch.Tensor,
spec_step_idx: int = 0,
) -> torch.Tensor:
current_step_idx = spec_step_idx % self.num_mtp_layers
mtp_layer = self.layers[str(self.mtp_start_layer_idx + current_step_idx)]
logits = self.logits_processor(
mtp_layer.shared_head.head, self._pre_head(mtp_layer, hidden_states)
)
return logits
def get_top_tokens(
self,
hidden_states: torch.Tensor,
spec_step_idx: int = 0,
) -> torch.Tensor:
current_step_idx = spec_step_idx % self.num_mtp_layers
mtp_layer = self.layers[str(self.mtp_start_layer_idx + current_step_idx)]
# Vocab-parallel argmax for the greedy draft: per-rank head projection
# + local argmax + a [batch, 2*tp] (value, index) reduce, instead of
# all-gathering the full vocab logits.
return self.logits_processor.get_top_tokens(
mtp_layer.shared_head.head, self._pre_head(mtp_layer, hidden_states)
)
class DeepSeekV4MTP(nn.Module):
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
@@ -302,6 +324,15 @@ class DeepSeekV4MTP(nn.Module):
) -> torch.Tensor | None:
return self.model.compute_logits(hidden_states, spec_step_idx)
def get_top_tokens(
self,
hidden_states: torch.Tensor,
spec_step_idx: int = 0,
) -> torch.Tensor:
# Greedy-draft path used when use_local_argmax_reduction is enabled:
# vocab-parallel argmax, no full-vocab logits.
return self.model.get_top_tokens(hidden_states, spec_step_idx)
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
# Weight name remapping for checkpoint compatibility.
# Maps checkpoint weight paths to model parameter paths.