| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Multi-token prediction (MTP) head for HY V4 (NVIDIA).""" |
| 4 | |
| 5 | import copy |
| 6 | import typing |
| 7 | from collections.abc import Callable, Iterable |
| 8 | |
| 9 | import torch |
| 10 | from torch import nn |
| 11 | from transformers import PreTrainedConfig |
| 12 | |
| 13 | from vllm.config import CacheConfig, ModelConfig, VllmConfig |
| 14 | from vllm.distributed import ( |
| 15 | get_tensor_model_parallel_rank, |
| 16 | get_tensor_model_parallel_world_size, |
| 17 | ) |
| 18 | from vllm.logger import init_logger |
| 19 | from vllm.model_executor.layers.fused_moe import fused_moe_make_expert_params_mapping |
| 20 | from vllm.model_executor.layers.layernorm import RMSNorm |
| 21 | from vllm.model_executor.layers.logits_processor import LogitsProcessor |
| 22 | from vllm.model_executor.layers.quantization import QuantizationConfig |
| 23 | from vllm.model_executor.layers.vocab_parallel_embedding import ( |
| 24 | ParallelLMHead, |
| 25 | VocabParallelEmbedding, |
| 26 | ) |
| 27 | from vllm.model_executor.model_loader.weight_utils import ( |
| 28 | default_weight_loader, |
| 29 | maybe_remap_kv_scale_name, |
| 30 | ) |
| 31 | from vllm.model_executor.models.deepseek_v2 import _try_load_fp8_indexer_wk |
| 32 | from vllm.model_executor.models.utils import ( |
| 33 | get_pp_missing_layer_names, |
| 34 | is_pp_missing_parameter, |
| 35 | maybe_prefix, |
| 36 | ) |
| 37 | from vllm.sequence import IntermediateTensors |
| 38 | from vllm.v1.outputs import SamplerOutput |
| 39 | from vllm.v1.sample.metadata import SamplingMetadata |
| 40 | from vllm.v1.sample.sampler import Sampler |
| 41 | |
| 42 | from .model import HYV4DecoderLayer, _normalize_hyv4_config |
| 43 | |
| 44 | logger = init_logger(__name__) |
| 45 | |
| 46 | _MTP_SCALE_ONLY_PARAM_SUBSTRINGS = ( |
| 47 | "_scale", |
| 48 | "_weight_scale", |
| 49 | "_weight_scale_inv", |
| 50 | "_input_scale", |
| 51 | ) |
| 52 | |
| 53 | # Names of the quant-config attribute that stores the unquantized-module |
| 54 | # prefix list, in the order we look them up. Different QuantizationConfig |
| 55 | # subclasses expose the list under different names: |
| 56 | # * ``ignored_layers`` -- Fp8Config, GPTQ / AWQ configs. |
| 57 | # * ``exclude_modules`` -- ModelOpt* configs (Fp8, NvFp4, MxFp8, Mixed). |
| 58 | _MTP_QUANT_EXCLUSION_ATTRS = ("ignored_layers", "exclude_modules") |
| 59 | |
| 60 | |
| 61 | def _get_spec_layer_idx_from_weight_name( |
| 62 | config: PreTrainedConfig, weight_name: str |
| 63 | ) -> int | None: |
| 64 | """Return the MTP layer index a checkpoint weight belongs to, or None. |
| 65 | |
| 66 | Compatible with``num_nextn_predict_layers`` of 1 and 2 for a single MTP |
| 67 | head, matching how the HY V4 checkpoints are exported. |
| 68 | """ |
| 69 | if getattr(config, "num_nextn_predict_layers", 0) < 1: |
| 70 | return None |
| 71 | layer_idx = config.num_hidden_layers |
| 72 | num_mtp_layers = max(config.num_nextn_predict_layers - 1, 1) |
| 73 | for i in range(num_mtp_layers): |
| 74 | if weight_name.startswith(f"model.layers.{layer_idx + i}."): |
| 75 | return layer_idx + i |
| 76 | return None |
| 77 | |
| 78 | |
| 79 | def _should_skip_missing_mtp_scale_param( |
| 80 | quant_config: QuantizationConfig | None, |
| 81 | name: str, |
| 82 | ) -> bool: |
| 83 | """Whether an unmatched scale parameter can be silently ignored.""" |
| 84 | return quant_config is None and any( |
| 85 | scale_name in name for scale_name in _MTP_SCALE_ONLY_PARAM_SUBSTRINGS |
| 86 | ) |
| 87 | |
| 88 | |
| 89 | def _resolve_fused_expert_param( |
| 90 | param_base: str, |
| 91 | ckpt_suffix: str, |
| 92 | params_dict: dict, |
| 93 | ) -> str | None: |
| 94 | """Map a fused expert checkpoint suffix onto a draft parameter name. |
| 95 | |
| 96 | Fused checkpoints pack every expert into one tensor and name the companion |
| 97 | scales by suffixing the projection (``experts.gate_up_proj_scale_inv``), |
| 98 | while the draft model owns ``experts.routed_experts.w13_weight`` plus a |
| 99 | separate ``..._scale_inv`` parameter. Dropping the suffix would push the |
| 100 | scale into the weight parameter and leave the scale at its sentinel init. |
| 101 | |
| 102 | Args: |
| 103 | param_base: Draft parameter holding the packed weight. |
| 104 | ckpt_suffix: Checkpoint text following the projection name. |
| 105 | params_dict: The draft model's named parameters. |
| 106 | |
| 107 | Returns: |
| 108 | The matching draft parameter name, or None when there is none. |
| 109 | |
| 110 | """ |
| 111 | if not ckpt_suffix: |
| 112 | return param_base if param_base in params_dict else None |
| 113 | tail = ckpt_suffix.lstrip(".").removeprefix("weight_") |
| 114 | for candidate in ( |
| 115 | f"{param_base}_{tail}", |
| 116 | f"{param_base}_scale_inv", |
| 117 | f"{param_base}_scale", |
| 118 | ): |
| 119 | if candidate in params_dict: |
| 120 | return candidate |
| 121 | return None |
| 122 | |
| 123 | |
| 124 | def _prepare_mtp_fp8_expert_scale( |
| 125 | quant_config: QuantizationConfig | None, |
| 126 | name: str, |
| 127 | loaded_weight: torch.Tensor, |
| 128 | ) -> tuple[str, torch.Tensor]: |
| 129 | """Normalize block-wise FP8 expert scale names and dtypes.""" |
| 130 | from vllm.model_executor.layers.quantization.fp8 import Fp8Config |
| 131 | |
| 132 | if ( |
| 133 | isinstance(quant_config, Fp8Config) |
| 134 | and quant_config.weight_block_size is not None |
| 135 | and ".mlp.experts." in name |
| 136 | and name.endswith(".scale") |
| 137 | ): |
| 138 | name = name[: -len(".scale")] + ".weight_scale_inv" |
| 139 | if ( |
| 140 | getattr(quant_config, "is_scale_e8m0", False) |
| 141 | and loaded_weight.dtype == torch.uint8 |
| 142 | ): |
| 143 | # UE8M0 scale exponents are stored as raw uint8 bytes: reinterpret |
| 144 | # the bits instead of numerically converting them. |
| 145 | loaded_weight = loaded_weight.view(torch.float8_e8m0fnu) |
| 146 | return name, loaded_weight |
| 147 | |
| 148 | |
| 149 | def _create_mtp_quant_config( |
| 150 | hf_config: PreTrainedConfig, |
| 151 | backbone_quant_config: QuantizationConfig | None = None, |
| 152 | ) -> QuantizationConfig | None: |
| 153 | """Create the quantization config for the MTP layers. |
| 154 | |
| 155 | The MTP quantization algorithm is given by the ``mtp_quant_algo`` field in |
| 156 | ``config.json``, independently of the backbone's quantization. Supported |
| 157 | values are ``"FP8"``, ``"NONE"`` (inherit the backbone) and ``"BF16"`` / |
| 158 | ``"FP16"`` (unquantized). A missing or ``"NONE"`` value falls back to the |
| 159 | backbone config. |
| 160 | |
| 161 | Args: |
| 162 | hf_config: The draft model's HF config. |
| 163 | backbone_quant_config: The target model's quantization config. |
| 164 | |
| 165 | Returns: |
| 166 | The quantization config to use for the MTP layers, or None when the MTP |
| 167 | layers are unquantized. |
| 168 | |
| 169 | """ |
| 170 | mtp_quant_algo = getattr(hf_config, "mtp_quant_algo", None) |
| 171 | |
| 172 | if mtp_quant_algo is None or mtp_quant_algo.upper() == "NONE": |
| 173 | logger.info_once( |
| 174 | "No explicit MTP quant algo (mtp_quant_algo=%s). " |
| 175 | "Using the backbone's quant config.", |
| 176 | mtp_quant_algo, |
| 177 | ) |
| 178 | return backbone_quant_config |
| 179 | |
| 180 | mtp_quant_algo = mtp_quant_algo.upper() |
| 181 | |
| 182 | if mtp_quant_algo in ("BF16", "FP16"): |
| 183 | logger.info_once( |
| 184 | "MTP layers are unquantized (mtp_quant_algo=%s).", mtp_quant_algo |
| 185 | ) |
| 186 | return None |
| 187 | |
| 188 | if mtp_quant_algo == "FP8": |
| 189 | from vllm.model_executor.layers.quantization.fp8 import Fp8Config |
| 190 | |
| 191 | logger.info_once("MTP uses FP8 quantization (mtp_quant_algo=FP8).") |
| 192 | hf_quant_config = getattr(hf_config, "quantization_config", None) or {} |
| 193 | weight_block_size = hf_quant_config.get("weight_block_size") |
| 194 | activation_scheme = hf_quant_config.get("activation_scheme", "dynamic") |
| 195 | if weight_block_size is not None: |
| 196 | activation_scheme = "dynamic" |
| 197 | ignored_layers = hf_quant_config.get("ignored_layers") or hf_quant_config.get( |
| 198 | "modules_to_not_convert" |
| 199 | ) |
| 200 | |
| 201 | fp8_config = Fp8Config( |
| 202 | is_checkpoint_fp8_serialized=True, |
| 203 | activation_scheme=activation_scheme, |
| 204 | ignored_layers=ignored_layers or [], |
| 205 | weight_block_size=weight_block_size, |
| 206 | ) |
| 207 | if hf_quant_config.get("scale_fmt") == "ue8m0": |
| 208 | # Fp8LinearMethod reads this through getattr, so it is not a |
| 209 | # declared Fp8Config field. |
| 210 | setattr(fp8_config, "is_scale_e8m0", True) # noqa: B010 |
| 211 | return fp8_config |
| 212 | |
| 213 | logger.warning( |
| 214 | "Unknown mtp_quant_algo=%s, falling back to the backbone config.", |
| 215 | mtp_quant_algo, |
| 216 | ) |
| 217 | return backbone_quant_config |
| 218 | |
| 219 | |
| 220 | def _remap_mtp_quant_exclusions( |
| 221 | quant_config: QuantizationConfig | None, |
| 222 | mtp_start_layer_idx: int, |
| 223 | num_mtp_layers: int, |
| 224 | ) -> QuantizationConfig | None: |
| 225 | """Translate checkpoint-named MTP quant exclusions to draft prefixes. |
| 226 | |
| 227 | ``modules_to_not_convert`` / ``exclude_modules`` name MTP modules the way |
| 228 | the checkpoint does (``model.mtp_layers.0.self_attn.linear_gate``), while |
| 229 | the draft model builds them under ``model.layers.<num_hidden_layers + i>``. |
| 230 | ``is_layer_skipped`` compares prefixes for exact equality, so without this |
| 231 | translation an excluded MTP module gets a quant method even though its |
| 232 | checkpoint weight is BF16 with no ``weight_scale`` / ``weight_scale_inv`` |
| 233 | companion: the scale then keeps its ``finfo(float32).min`` sentinel and the |
| 234 | layer silently produces garbage. |
| 235 | |
| 236 | The list lives under a different attribute per config class, hence the |
| 237 | lookup over ``_MTP_QUANT_EXCLUSION_ATTRS``. |
| 238 | |
| 239 | Args: |
| 240 | quant_config: The MTP quantization config to widen. |
| 241 | mtp_start_layer_idx: Index of the first MTP layer in the draft model. |
| 242 | num_mtp_layers: Number of MTP layers. |
| 243 | |
| 244 | Returns: |
| 245 | A shallow copy with the translated exclusions, or the input unchanged. |
| 246 | |
| 247 | """ |
| 248 | if quant_config is None: |
| 249 | return None |
| 250 | |
| 251 | # Different QuantizationConfig subclasses expose the exclusion list under |
| 252 | # different attribute names; only the one that is actually populated on |
| 253 | # the backbone config governs which draft modules stay unquantized. For |
| 254 | # ModelOpt checkpoints (``quant_method: modelopt`` and friends) this is |
| 255 | # ``exclude_modules``; for the legacy fp8 path it is ``ignored_layers``. |
| 256 | exclusion_attr: str | None = None |
| 257 | patterns: list[str] | None = None |
| 258 | for attr in _MTP_QUANT_EXCLUSION_ATTRS: |
| 259 | value = getattr(quant_config, attr, None) |
| 260 | if value: |
| 261 | exclusion_attr = attr |
| 262 | patterns = list(value) |
| 263 | break |
| 264 | if not patterns or exclusion_attr is None: |
| 265 | return quant_config |
| 266 | |
| 267 | extra: list[str] = [] |
| 268 | for offset in range(num_mtp_layers): |
| 269 | source_prefix = f"model.mtp_layers.{offset}." |
| 270 | draft_prefix = f"model.layers.{mtp_start_layer_idx + offset}." |
| 271 | for pattern in patterns: |
| 272 | if pattern.startswith(source_prefix): |
| 273 | extra.append(draft_prefix + pattern[len(source_prefix) :]) |
| 274 | # ModelOpt exclusion patterns may end with a ``*`` wildcard; the |
| 275 | # remap must preserve that suffix so ``is_layer_skipped`` still |
| 276 | # matches the draft module prefixes. |
| 277 | elif pattern.rstrip("*").startswith(source_prefix): |
| 278 | stripped = pattern.rstrip("*") |
| 279 | trailing_stars = pattern[len(stripped) :] |
| 280 | extra.append( |
| 281 | draft_prefix + stripped[len(source_prefix) :] + trailing_stars |
| 282 | ) |
| 283 | if not extra: |
| 284 | return quant_config |
| 285 | |
| 286 | # The backbone shares this object with the target model; copy before |
| 287 | # widening the exclusion list. The attribute is only present on the |
| 288 | # concrete configs that support exclusions, hence the getattr/setattr pair. |
| 289 | quant_config = copy.copy(quant_config) |
| 290 | setattr(quant_config, exclusion_attr, patterns + extra) # noqa: B010 |
| 291 | logger.info_once( |
| 292 | "HYV4 MTP: mapped %d checkpoint-named quant exclusions (%s) onto the " |
| 293 | "draft module prefixes: %s", |
| 294 | len(extra), |
| 295 | exclusion_attr, |
| 296 | ", ".join(extra), |
| 297 | ) |
| 298 | return quant_config |
| 299 | |
| 300 | |
| 301 | class HYV4SharedHead(nn.Module): |
| 302 | """Holds the draft LM head shared with the target model.""" |
| 303 | |
| 304 | def __init__( |
| 305 | self, |
| 306 | config: PreTrainedConfig, |
| 307 | quant_config: QuantizationConfig | None = None, |
| 308 | ) -> None: |
| 309 | super().__init__() |
| 310 | # Use the same ``lm_head`` prefix as the backbone so that quant-config |
| 311 | # exclusion lists (e.g. ModelOpt's ``exclude_modules: ["lm_head", ...]``) |
| 312 | # also apply here: an unquantized checkpoint lm_head must not gain a |
| 313 | # spurious ``weight_scale`` parameter on the draft side, which would |
| 314 | # otherwise stay at its FP-min sentinel until the proposer swaps the |
| 315 | # whole ParallelLMHead for the target model's ``lm_head``. |
| 316 | self.head = ParallelLMHead( |
| 317 | config.vocab_size, |
| 318 | config.hidden_size, |
| 319 | quant_config=quant_config, |
| 320 | prefix="lm_head", |
| 321 | ) |
| 322 | |
| 323 | def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: |
| 324 | return hidden_states |
| 325 | |
| 326 | |
| 327 | class HYV4MultiTokenPredictorLayer(nn.Module): |
| 328 | """A single MTP draft block.""" |
| 329 | |
| 330 | def __init__( |
| 331 | self, |
| 332 | config: PreTrainedConfig, |
| 333 | prefix: str, |
| 334 | vllm_config: VllmConfig, |
| 335 | model_config: ModelConfig, |
| 336 | cache_config: CacheConfig | None = None, |
| 337 | quant_config: QuantizationConfig | None = None, |
| 338 | topk_indices_buffer: torch.Tensor | None = None, |
| 339 | ) -> None: |
| 340 | super().__init__() |
| 341 | del model_config # kept for signature parity with the other MTP heads |
| 342 | self.enorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) |
| 343 | self.hnorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) |
| 344 | self.eh_proj = nn.Linear(config.hidden_size * 2, config.hidden_size, bias=False) |
| 345 | self.shared_head = HYV4SharedHead(config=config, quant_config=quant_config) |
| 346 | |
| 347 | # HYV4DecoderLayer indexes layer_types/mlp_layer_types by the numeric |
| 348 | # layer id parsed from the prefix. MTP layers live after the backbone |
| 349 | # layers, so extend the per-layer config lists to cover those ids. |
| 350 | layer_idx = int(prefix.rsplit(".", 1)[-1]) |
| 351 | mtp_config = copy.deepcopy(config) |
| 352 | # MTP checkpoints do not carry iHC pre/post weights for mtp_layers, so |
| 353 | # the draft blocks are built without iHC to match their structure. |
| 354 | mtp_config.enable_ihc = False |
| 355 | mtp_config.layer_types = _extend_layer_types( |
| 356 | getattr(mtp_config, "layer_types", None), layer_idx, "full_attention" |
| 357 | ) |
| 358 | mtp_config.mlp_layer_types = _extend_layer_types( |
| 359 | getattr(mtp_config, "mlp_layer_types", None), layer_idx, "sparse" |
| 360 | ) |
| 361 | |
| 362 | self.mtp_block = HYV4DecoderLayer( |
| 363 | config=mtp_config, |
| 364 | vllm_config=vllm_config, |
| 365 | cache_config=cache_config, |
| 366 | quant_config=quant_config, |
| 367 | prefix=prefix, |
| 368 | topk_indices_buffer=topk_indices_buffer, |
| 369 | ) |
| 370 | self.final_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) |
| 371 | |
| 372 | def forward( |
| 373 | self, |
| 374 | input_ids: torch.Tensor, |
| 375 | positions: torch.Tensor, |
| 376 | previous_hidden_states: torch.Tensor, |
| 377 | inputs_embeds: torch.Tensor | None = None, |
| 378 | ) -> torch.Tensor: |
| 379 | del input_ids # embeddings are resolved by the caller |
| 380 | assert inputs_embeds is not None |
| 381 | inputs_embeds = self.enorm(inputs_embeds) |
| 382 | previous_hidden_states = self.hnorm(previous_hidden_states) |
| 383 | hidden_states = self.eh_proj( |
| 384 | torch.cat([inputs_embeds, previous_hidden_states], dim=-1) |
| 385 | ) |
| 386 | hidden_states, residual = self.mtp_block( |
| 387 | positions=positions, |
| 388 | hidden_states=hidden_states, |
| 389 | residual=None, |
| 390 | ) |
| 391 | hidden_states, _ = self.final_layernorm(hidden_states, residual) |
| 392 | return hidden_states |
| 393 | |
| 394 | |
| 395 | def _extend_layer_types( |
| 396 | layer_types: list[str] | None, layer_idx: int, fallback: str |
| 397 | ) -> list[str] | None: |
| 398 | """Pad a per-layer config list so``layer_idx`` is addressable.""" |
| 399 | if layer_types is None or len(layer_types) > layer_idx: |
| 400 | return layer_types |
| 401 | fill = layer_types[-1] if layer_types else fallback |
| 402 | return list(layer_types) + [fill] * (layer_idx + 1 - len(layer_types)) |
| 403 | |
| 404 | |
| 405 | class HYV4MultiTokenPredictor(nn.Module): |
| 406 | """Owns the MTP draft blocks and their shared embedding / logits path.""" |
| 407 | |
| 408 | def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): |
| 409 | super().__init__() |
| 410 | target_config = vllm_config.model_config.hf_config |
| 411 | speculative_config = vllm_config.speculative_config |
| 412 | assert speculative_config is not None, ( |
| 413 | "HYV4MTP can only be built as a speculative draft model" |
| 414 | ) |
| 415 | draft_model_config = speculative_config.draft_model_config |
| 416 | assert draft_model_config is not None |
| 417 | config = _normalize_hyv4_config(draft_model_config.hf_config) |
| 418 | self.mtp_start_layer_idx = target_config.num_hidden_layers |
| 419 | self.num_mtp_layers = max( |
| 420 | getattr(target_config, "num_nextn_predict_layers", 1), 1 |
| 421 | ) |
| 422 | self.quant_config = _remap_mtp_quant_exclusions( |
| 423 | _create_mtp_quant_config(config, vllm_config.quant_config), |
| 424 | self.mtp_start_layer_idx, |
| 425 | self.num_mtp_layers, |
| 426 | ) |
| 427 | if self.quant_config is not None: |
| 428 | self.quant_config.packed_modules_mapping = HYV4MTP.packed_modules_mapping |
| 429 | |
| 430 | # Sparse DSA draft blocks must share the target model's top-k buffer. |
| 431 | # The proposer injects it through HYV4MTP.set_topk_indices_buffer() |
| 432 | # before any draft forward. |
| 433 | self.topk_indices_buffer: torch.Tensor | None = None |
| 434 | if hasattr(config, "index_topk"): |
| 435 | from vllm.platforms import current_platform |
| 436 | |
| 437 | self.topk_indices_buffer = torch.empty( |
| 438 | vllm_config.scheduler_config.max_num_batched_tokens, |
| 439 | config.index_topk, |
| 440 | dtype=torch.int32, |
| 441 | device=current_platform.device_type, |
| 442 | ) |
| 443 | |
| 444 | self.layers = nn.ModuleDict( |
| 445 | { |
| 446 | str(idx): HYV4MultiTokenPredictorLayer( |
| 447 | config, |
| 448 | f"{prefix}.layers.{idx}", |
| 449 | vllm_config=vllm_config, |
| 450 | model_config=draft_model_config, |
| 451 | cache_config=vllm_config.cache_config, |
| 452 | quant_config=self.quant_config, |
| 453 | topk_indices_buffer=self.topk_indices_buffer, |
| 454 | ) |
| 455 | for idx in range( |
| 456 | self.mtp_start_layer_idx, |
| 457 | self.mtp_start_layer_idx + self.num_mtp_layers, |
| 458 | ) |
| 459 | } |
| 460 | ) |
| 461 | self.requires_topk_indices_buffer = any( |
| 462 | layer.mtp_block.self_attn.is_sparse for layer in self.layers.values() |
| 463 | ) |
| 464 | |
| 465 | self.embed_tokens = VocabParallelEmbedding( |
| 466 | config.vocab_size, |
| 467 | config.hidden_size, |
| 468 | ) |
| 469 | self.logits_processor = LogitsProcessor(config.vocab_size) |
| 470 | self.spec_step_idx: int = 0 |
| 471 | |
| 472 | def set_skip_topk(self, skip: bool) -> None: |
| 473 | """Toggle the draft indexer for ``index_share_for_mtp_iteration``. |
| 474 | |
| 475 | The proposer clears the flag for draft step 0 so the MTP layer builds |
| 476 | its own top-k indices, then sets it for steps 1+ so they reuse what |
| 477 | step 0 wrote into the shared buffer instead of re-running the indexer. |
| 478 | """ |
| 479 | for layer in self.layers.values(): |
| 480 | layer.mtp_block.self_attn.skip_topk = skip |
| 481 | |
| 482 | def compact_topk_indices(self, slot_ids: torch.Tensor) -> None: |
| 483 | """Move the top-k rows at ``slot_ids`` to the front of the buffer. |
| 484 | |
| 485 | Step 0 writes one row per query token of the multi-token batch, while |
| 486 | steps 1+ decode a single token per request and index the buffer from 0. |
| 487 | Without this gather they would read another token's rows. |
| 488 | """ |
| 489 | num_slots = slot_ids.numel() |
| 490 | if self.topk_indices_buffer is None or num_slots == 0: |
| 491 | return |
| 492 | self.topk_indices_buffer[:num_slots] = self.topk_indices_buffer[slot_ids] |
| 493 | |
| 494 | def forward( |
| 495 | self, |
| 496 | input_ids: torch.Tensor, |
| 497 | positions: torch.Tensor, |
| 498 | previous_hidden_states: torch.Tensor, |
| 499 | inputs_embeds: torch.Tensor | None = None, |
| 500 | ) -> torch.Tensor: |
| 501 | if inputs_embeds is None: |
| 502 | inputs_embeds = self.embed_tokens(input_ids) |
| 503 | inputs_embeds = torch.where((positions == 0).unsqueeze(-1), 0, inputs_embeds) |
| 504 | |
| 505 | current_step_idx = self.spec_step_idx % self.num_mtp_layers |
| 506 | return self.layers[str(self.mtp_start_layer_idx + current_step_idx)]( |
| 507 | input_ids, |
| 508 | positions, |
| 509 | previous_hidden_states, |
| 510 | inputs_embeds, |
| 511 | ) |
| 512 | |
| 513 | def compute_logits( |
| 514 | self, |
| 515 | hidden_states: torch.Tensor, |
| 516 | ) -> torch.Tensor: |
| 517 | current_step_idx = self.spec_step_idx % self.num_mtp_layers |
| 518 | mtp_layer = self.layers[str(self.mtp_start_layer_idx + current_step_idx)] |
| 519 | lm_head = mtp_layer.shared_head.head |
| 520 | proj_input = mtp_layer.shared_head(hidden_states) |
| 521 | return self.logits_processor(lm_head, proj_input) |
| 522 | |
| 523 | |
| 524 | class HYV4MTP(nn.Module): |
| 525 | """HY V4 MTP draft head. |
| 526 | |
| 527 | Not a pipeline-parallel stage: the draft head always runs on a single rank, |
| 528 | matching `DeepseekV32MTP` / `KimiK3MTP` / `HYV3MTP`. |
| 529 | """ |
| 530 | |
| 531 | packed_modules_mapping = { |
| 532 | "gate_up_proj": ["gate_proj", "up_proj"], |
| 533 | # MLA runs both latent down-projections as one GEMM. |
| 534 | "fused_qkv_a_proj": ["q_a_proj", "kv_a_proj_with_mqa"], |
| 535 | # The indexer fuses wk and weights_proj into one GEMM. |
| 536 | "wk_weights_proj": ["wk", "weights_proj"], |
| 537 | } |
| 538 | |
| 539 | def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): |
| 540 | super().__init__() |
| 541 | self.config = vllm_config.model_config.hf_config |
| 542 | self.model = HYV4MultiTokenPredictor( |
| 543 | vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model") |
| 544 | ) |
| 545 | self.quant_config = self.model.quant_config |
| 546 | self.sampler = Sampler() |
| 547 | |
| 548 | def set_topk_indices_buffer(self, topk_indices_buffer: torch.Tensor) -> None: |
| 549 | """Share the target sparse-index buffer with every draft consumer. |
| 550 | |
| 551 | Proposers that walk ``named_modules()`` instead of calling this reach |
| 552 | the same consumers via ``HYV4MLAAttention.topk_indices_buffer``. |
| 553 | """ |
| 554 | self.model.topk_indices_buffer = topk_indices_buffer |
| 555 | for layer in self.model.layers.values(): |
| 556 | self_attn = layer.mtp_block.self_attn |
| 557 | if not self_attn.is_sparse: |
| 558 | continue |
| 559 | |
| 560 | indexer = self_attn.indexer |
| 561 | assert indexer is not None, "Sparse HYV4 MTP attention requires an indexer" |
| 562 | indexer.topk_indices_buffer = topk_indices_buffer |
| 563 | indexer.indexer_op.topk_indices_buffer = topk_indices_buffer |
| 564 | |
| 565 | attn_impl = self_attn.mla_attn.impl |
| 566 | assert hasattr(attn_impl, "topk_indices_buffer"), ( |
| 567 | "Sparse HYV4 MTP attention backend requires a top-k indices buffer" |
| 568 | ) |
| 569 | attn_impl.topk_indices_buffer = topk_indices_buffer |
| 570 | |
| 571 | def forward( |
| 572 | self, |
| 573 | input_ids: torch.Tensor, |
| 574 | positions: torch.Tensor, |
| 575 | hidden_states: torch.Tensor, |
| 576 | intermediate_tensors: IntermediateTensors | None = None, |
| 577 | inputs_embeds: torch.Tensor | None = None, |
| 578 | spec_step_idx: int = 0, |
| 579 | ) -> torch.Tensor: |
| 580 | del intermediate_tensors # the MTP head is single-stage |
| 581 | if ( |
| 582 | self.model.requires_topk_indices_buffer |
| 583 | and self.model.topk_indices_buffer is None |
| 584 | ): |
| 585 | raise RuntimeError( |
| 586 | "HYV4 sparse MTP requires the target model's top-k indices buffer. " |
| 587 | "The proposer must call HYV4MTP.set_topk_indices_buffer() before " |
| 588 | "the first draft forward." |
| 589 | ) |
| 590 | self.model.spec_step_idx = spec_step_idx |
| 591 | return self.model(input_ids, positions, hidden_states, inputs_embeds) |
| 592 | |
| 593 | def compute_logits( |
| 594 | self, |
| 595 | hidden_states: torch.Tensor, |
| 596 | spec_step_idx: int = 0, |
| 597 | ) -> torch.Tensor | None: |
| 598 | self.model.spec_step_idx = spec_step_idx |
| 599 | return self.model.compute_logits(hidden_states) |
| 600 | |
| 601 | def sample( |
| 602 | self, |
| 603 | logits: torch.Tensor, |
| 604 | sampling_metadata: SamplingMetadata, |
| 605 | ) -> SamplerOutput | None: |
| 606 | return self.sampler(logits, sampling_metadata) |
| 607 | |
| 608 | def _rewrite_spec_layer_name(self, spec_layer: int, name: str) -> str: |
| 609 | if f"model.layers.{spec_layer}.embed_tokens" in name: |
| 610 | return "__skip__" |
| 611 | if f"model.layers.{spec_layer}.shared_head" in name: |
| 612 | return "__skip__" |
| 613 | |
| 614 | spec_layer_weight_names = ["enorm", "hnorm", "eh_proj", "final_layernorm"] |
| 615 | spec_layer_weight = any( |
| 616 | weight_name in name for weight_name in spec_layer_weight_names |
| 617 | ) |
| 618 | if not spec_layer_weight: |
| 619 | name = name.replace( |
| 620 | f"model.layers.{spec_layer}.", |
| 621 | f"model.layers.{spec_layer}.mtp_block.", |
| 622 | ) |
| 623 | return name |
| 624 | |
| 625 | def _load_fused_expert_weights( |
| 626 | self, |
| 627 | name: str, |
| 628 | params_dict: dict, |
| 629 | loaded_weight: torch.Tensor, |
| 630 | shard_id: str, |
| 631 | num_experts: int, |
| 632 | ) -> bool: |
| 633 | if name not in params_dict: |
| 634 | return False |
| 635 | param = params_dict[name] |
| 636 | weight_loader = typing.cast(Callable[..., bool], param.weight_loader) |
| 637 | loaded_local_expert = False |
| 638 | for expert_id in range(num_experts): |
| 639 | curr_expert_weight = loaded_weight[expert_id] |
| 640 | success = weight_loader( |
| 641 | param, |
| 642 | curr_expert_weight, |
| 643 | name, |
| 644 | shard_id, |
| 645 | expert_id, |
| 646 | return_success=True, |
| 647 | ) |
| 648 | if success: |
| 649 | loaded_local_expert = True |
| 650 | return loaded_local_expert |
| 651 | |
| 652 | def _load_expert_weight( |
| 653 | self, |
| 654 | name: str, |
| 655 | loaded_weight: torch.Tensor, |
| 656 | params_dict: dict, |
| 657 | loaded_params: set[str], |
| 658 | split_expert_params_mapping: list[tuple[str, str, int, str]], |
| 659 | fused_expert_param_names: dict[tuple[str, str], str], |
| 660 | num_experts: int, |
| 661 | ) -> bool: |
| 662 | """Load one routed-expert weight in either checkpoint layout. |
| 663 | |
| 664 | Args: |
| 665 | name: Weight name already rewritten to draft-module naming. |
| 666 | loaded_weight: The checkpoint tensor. |
| 667 | params_dict: The draft model's named parameters. |
| 668 | loaded_params: Set updated with the parameters that received a value. |
| 669 | split_expert_params_mapping: Mapping for the per-expert layout. |
| 670 | fused_expert_param_names: ``(mlp_prefix, tag) -> param name`` for the |
| 671 | all-experts-packed layout. |
| 672 | num_experts: Total number of routed experts. |
| 673 | |
| 674 | Returns: |
| 675 | True when the weight was consumed (even if this rank holds none of |
| 676 | the addressed experts). |
| 677 | |
| 678 | """ |
| 679 | base = name.split(".experts.")[0] |
| 680 | for ckpt_proj, tag in ( |
| 681 | (".experts.gate_up_proj", "w13_weight"), |
| 682 | (".experts.down_proj", "w2_weight"), |
| 683 | ): |
| 684 | if ckpt_proj not in name: |
| 685 | continue |
| 686 | param_base = fused_expert_param_names.get((base, tag)) |
| 687 | if param_base is None: |
| 688 | return False |
| 689 | # Keep the checkpoint suffix (e.g. `_scale_inv`) so block-scale |
| 690 | # tensors land in the scale parameter, not in the weight. |
| 691 | target = _resolve_fused_expert_param( |
| 692 | param_base, name.split(ckpt_proj, 1)[1], params_dict |
| 693 | ) |
| 694 | if target is None: |
| 695 | return False |
| 696 | if tag == "w13_weight": |
| 697 | chunks = loaded_weight.chunk(2, dim=-2) |
| 698 | loaded_w1 = self._load_fused_expert_weights( |
| 699 | target, params_dict, chunks[0], "w1", num_experts |
| 700 | ) |
| 701 | loaded_w3 = self._load_fused_expert_weights( |
| 702 | target, params_dict, chunks[1], "w3", num_experts |
| 703 | ) |
| 704 | loaded = loaded_w1 and loaded_w3 |
| 705 | else: |
| 706 | loaded = self._load_fused_expert_weights( |
| 707 | target, params_dict, loaded_weight, "w2", num_experts |
| 708 | ) |
| 709 | if loaded: |
| 710 | loaded_params.add(target) |
| 711 | # The weight belongs to the experts either way; never fall through. |
| 712 | return True |
| 713 | |
| 714 | consumed = False |
| 715 | for param_name, weight_name, expert_id, shard_id in split_expert_params_mapping: |
| 716 | if weight_name not in name: |
| 717 | continue |
| 718 | consumed = True |
| 719 | name_mapped = name.replace(weight_name, param_name) |
| 720 | if name_mapped not in params_dict: |
| 721 | continue |
| 722 | param = params_dict[name_mapped] |
| 723 | weight_loader = typing.cast(Callable[..., bool], param.weight_loader) |
| 724 | if weight_loader( |
| 725 | param, |
| 726 | loaded_weight, |
| 727 | name_mapped, |
| 728 | shard_id=shard_id, |
| 729 | expert_id=expert_id, |
| 730 | return_success=True, |
| 731 | ): |
| 732 | loaded_params.add(name_mapped) |
| 733 | return consumed |
| 734 | |
| 735 | def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: |
| 736 | params_dict = dict(self.named_parameters()) |
| 737 | pp_missing_layer_names = get_pp_missing_layer_names(self) |
| 738 | loaded_params: set[str] = set() |
| 739 | |
| 740 | mtp_start = self.config.num_hidden_layers |
| 741 | shared_weights = { |
| 742 | "model.embed_tokens.weight": "model.embed_tokens.weight", |
| 743 | "lm_head.weight": f"model.layers.{mtp_start}.shared_head.head.weight", |
| 744 | } |
| 745 | |
| 746 | num_experts = getattr(self.config, "n_routed_experts", 0) |
| 747 | sink_tp_size = get_tensor_model_parallel_world_size() |
| 748 | sink_tp_rank = get_tensor_model_parallel_rank() |
| 749 | n_local_head = self.config.num_attention_heads // sink_tp_size |
| 750 | head_rank_start = n_local_head * sink_tp_rank |
| 751 | head_rank_end = n_local_head * (sink_tp_rank + 1) |
| 752 | |
| 753 | # Routed-expert weights come in two checkpoint layouts: |
| 754 | # split: mlp.experts.<id>.gate_proj.weight |
| 755 | # fused: mlp.experts.gate_up_proj (all experts in one tensor) |
| 756 | # The split layout is resolved through the shared EPLB helper so the |
| 757 | # target param names stay in sync with the RoutedExperts module layout; |
| 758 | # the fused layout is resolved from the live parameter names. |
| 759 | split_expert_params_mapping = fused_moe_make_expert_params_mapping( |
| 760 | self, |
| 761 | ckpt_gate_proj_name="gate_proj", |
| 762 | ckpt_down_proj_name="down_proj", |
| 763 | ckpt_up_proj_name="up_proj", |
| 764 | num_experts=num_experts, |
| 765 | ) |
| 766 | fused_expert_param_names: dict[tuple[str, str], str] = {} |
| 767 | for param_name in params_dict: |
| 768 | for tag in ("w13_weight", "w2_weight"): |
| 769 | if param_name.endswith(tag) and ".experts." in param_name: |
| 770 | base = param_name.split(".experts.")[0] |
| 771 | fused_expert_param_names[base, tag] = param_name |
| 772 | stacked_mapping = [ |
| 773 | (".gate_up_proj", ".gate_proj", 0), |
| 774 | (".gate_up_proj", ".up_proj", 1), |
| 775 | # MLA runs both latent down-projections as one GEMM. |
| 776 | (".fused_qkv_a_proj", ".q_a_proj", 0), |
| 777 | (".fused_qkv_a_proj", ".kv_a_proj_with_mqa", 1), |
| 778 | ] |
| 779 | # Sparse DSA draft blocks build an Indexer whose wk / weights_proj are |
| 780 | # fused into a single MergedColumnParallelLinear (wk_weights_proj). |
| 781 | indexer_stacked_mapping = [ |
| 782 | (".wk_weights_proj", ".wk", 0), |
| 783 | (".wk_weights_proj", ".weights_proj", 1), |
| 784 | ] |
| 785 | # FP8 indexer wk dequant buffer (weight and scale arrive separately). |
| 786 | pending_wk_fp8: dict[str, dict[str, torch.Tensor]] = {} |
| 787 | |
| 788 | for name, loaded_weight in weights: |
| 789 | if name in shared_weights: |
| 790 | target_name = shared_weights[name] |
| 791 | if target_name in params_dict: |
| 792 | param = params_dict[target_name] |
| 793 | weight_loader = getattr( |
| 794 | param, "weight_loader", default_weight_loader |
| 795 | ) |
| 796 | weight_loader(param, loaded_weight) |
| 797 | loaded_params.add(target_name) |
| 798 | continue |
| 799 | |
| 800 | spec_layer = None |
| 801 | if name.startswith("model.mtp_layers."): |
| 802 | parts = name.split(".") |
| 803 | if len(parts) > 3 and parts[2].isdigit(): |
| 804 | spec_layer = mtp_start + int(parts[2]) |
| 805 | name = name.replace( |
| 806 | f"model.mtp_layers.{parts[2]}.", |
| 807 | f"model.layers.{spec_layer}.", |
| 808 | ) |
| 809 | else: |
| 810 | spec_layer = _get_spec_layer_idx_from_weight_name(self.config, name) |
| 811 | |
| 812 | if spec_layer is None: |
| 813 | continue |
| 814 | |
| 815 | name = self._rewrite_spec_layer_name(spec_layer, name) |
| 816 | if name == "__skip__": |
| 817 | continue |
| 818 | name, loaded_weight = _prepare_mtp_fp8_expert_scale( |
| 819 | self.quant_config, name, loaded_weight |
| 820 | ) |
| 821 | |
| 822 | if "mlp.gate.e_score_correction_bias" in name: |
| 823 | name = name.replace("gate.e_score_correction_bias", "expert_bias") |
| 824 | |
| 825 | is_loaded = False |
| 826 | for param_name, weight_name, shard_id in stacked_mapping: |
| 827 | if weight_name not in name or ".experts." in name: |
| 828 | continue |
| 829 | name_mapped = name.replace(weight_name, param_name) |
| 830 | if name_mapped not in params_dict: |
| 831 | if is_pp_missing_parameter(name_mapped, self): |
| 832 | is_loaded = True |
| 833 | break |
| 834 | param = params_dict[name_mapped] |
| 835 | param.weight_loader(param, loaded_weight, shard_id) |
| 836 | loaded_params.add(name_mapped) |
| 837 | is_loaded = True |
| 838 | break |
| 839 | if is_loaded: |
| 840 | continue |
| 841 | |
| 842 | # FP8 indexer wk: dequantize to BF16 and load into the fused |
| 843 | # wk_weights_proj. PP-aware (skips layers not held by this rank). |
| 844 | if _try_load_fp8_indexer_wk( |
| 845 | name, |
| 846 | loaded_weight, |
| 847 | pending_wk_fp8, |
| 848 | params_dict, |
| 849 | loaded_params, |
| 850 | pp_missing_layer_names, |
| 851 | ): |
| 852 | continue |
| 853 | |
| 854 | # BF16 indexer wk / weights_proj: merge into the fused param. |
| 855 | is_loaded = False |
| 856 | for param_name, weight_name, shard_id in indexer_stacked_mapping: |
| 857 | if weight_name not in name or "wk_weights" in name: |
| 858 | continue |
| 859 | name_mapped = name.replace(weight_name, param_name) |
| 860 | if name_mapped not in params_dict: |
| 861 | if is_pp_missing_parameter(name_mapped, self): |
| 862 | is_loaded = True |
| 863 | break |
| 864 | param = params_dict[name_mapped] |
| 865 | param.weight_loader(param, loaded_weight, shard_id) |
| 866 | loaded_params.add(name_mapped) |
| 867 | is_loaded = True |
| 868 | break |
| 869 | if is_loaded: |
| 870 | continue |
| 871 | |
| 872 | is_loaded = False |
| 873 | if ".experts." in name: |
| 874 | is_loaded = self._load_expert_weight( |
| 875 | name, |
| 876 | loaded_weight, |
| 877 | params_dict, |
| 878 | loaded_params, |
| 879 | split_expert_params_mapping, |
| 880 | fused_expert_param_names, |
| 881 | num_experts, |
| 882 | ) |
| 883 | if is_loaded: |
| 884 | continue |
| 885 | |
| 886 | if "learnable_sink_param" in name: |
| 887 | if name in params_dict: |
| 888 | narrow_weight = loaded_weight[head_rank_start:head_rank_end] |
| 889 | n = narrow_weight.shape[0] |
| 890 | with torch.no_grad(): |
| 891 | params_dict[name][:n].copy_(narrow_weight) |
| 892 | loaded_params.add(name) |
| 893 | continue |
| 894 | |
| 895 | remapped_name = maybe_remap_kv_scale_name(name, params_dict) |
| 896 | if remapped_name is None: |
| 897 | continue |
| 898 | name = remapped_name |
| 899 | if name not in params_dict: |
| 900 | if is_pp_missing_parameter(name, self): |
| 901 | continue |
| 902 | if _should_skip_missing_mtp_scale_param(self.quant_config, name): |
| 903 | continue |
| 904 | logger.warning_once("Skipping unknown MTP weight: %s", name) |
| 905 | continue |
| 906 | |
| 907 | param = params_dict[name] |
| 908 | weight_loader = getattr(param, "weight_loader", default_weight_loader) |
| 909 | weight_loader(param, loaded_weight) |
| 910 | loaded_params.add(name) |
| 911 | |
| 912 | logger.info_once("HYV4 MTP draft model loaded: %d params", len(loaded_params)) |
| 913 | unassigned_all = sorted(set(params_dict) - loaded_params) |
| 914 | # KVCacheScaleParameter (``k_scale`` / ``v_scale`` / ``q_scale`` / |
| 915 | # ``prob_scale``) is created by ``BaseKVCacheMethod.create_weights`` for |
| 916 | # every Attention layer that runs on an fp8-family quant method, and |
| 917 | # its -1.0 sentinel is replaced by the runtime default (1.0) in |
| 918 | # ``process_weights_after_loading`` -- which runs *after* this method. |
| 919 | # HY V4 checkpoints intentionally omit these (``kv_cache_quant_algo: |
| 920 | # null``); silence them only when the parameter is actually the |
| 921 | # sentinel type so a genuine mismatch on a same-named tensor still |
| 922 | # reaches the warning. |
| 923 | from vllm.model_executor.layers.quantization.kv_cache import ( |
| 924 | KVCacheScaleParameter, |
| 925 | ) |
| 926 | |
| 927 | unassigned = [ |
| 928 | name |
| 929 | for name in unassigned_all |
| 930 | if not isinstance(params_dict.get(name), KVCacheScaleParameter) |
| 931 | ] |
| 932 | if unassigned: |
| 933 | # A draft parameter with no checkpoint source keeps its sentinel |
| 934 | # init value (FP8 scales start at finfo(float32).min), which |
| 935 | # silently destroys draft quality instead of failing the load. |
| 936 | logger.warning( |
| 937 | "HYV4 MTP draft model: %d parameters received no checkpoint value: %s", |
| 938 | len(unassigned), |
| 939 | ", ".join(unassigned), |
| 940 | ) |
| 941 | return loaded_params |