diff --git a/vllm/config/vllm.py b/vllm/config/vllm.py index ba7d26c93..762e3052f 100644 --- a/vllm/config/vllm.py +++ b/vllm/config/vllm.py @@ -1690,6 +1690,20 @@ class VllmConfig: max_cudagraph_capture_size = min( self.scheduler_config.max_num_seqs * decode_query_len * 2, 512 ) + # The bound above sizes a purely-decode step: one query position + # per running sequence times the speculative width. Under chunked + # prefill a step also carries leftover prompt tokens, so its real + # size is bounded by the token budget instead, and MTP widens it + # further. Raise the default toward that budget, keeping a ceiling + # on capture time and device memory. An explicit + # max_cudagraph_capture_size still wins outright. + max_cudagraph_capture_size = max( + max_cudagraph_capture_size, + min( + self.scheduler_config.max_num_batched_tokens, + _MIXED_STEP_CAPTURE_CEILING, + ), + ) max_num_tokens = self.scheduler_config.max_num_batched_tokens max_cudagraph_capture_size = min(max_num_tokens, max_cudagraph_capture_size) @@ -2292,3 +2306,12 @@ def get_layers_from_vllm_config( for layer_name in layer_names if isinstance(layer := forward_context.get(layer_name), layer_type) } + + +# Ceiling on the token-keyed CUDA graph capture range chosen in +# _set_cudagraph_sizes when the config pins no explicit size. Capture cost and +# graph memory grow with the number and size of captured graphs, and every +# graph competes with the KV pool, so the token budget is only honoured this +# far. 1536 covers the common mixed prefill+decode widths under MTP without +# claiming as much memory as a full 8192-token pool would. +_MIXED_STEP_CAPTURE_CEILING = 1536 diff --git a/vllm/engine/arg_utils.py b/vllm/engine/arg_utils.py index 921f31466..7fe103ad9 100644 --- a/vllm/engine/arg_utils.py +++ b/vllm/engine/arg_utils.py @@ -1718,6 +1718,18 @@ class EngineArgs: ) self.speculative_config[key] = value + if self.speculative_config is None: + self.speculative_config = _checkpoint_mtp_speculative_config( + target_model_config + ) + if self.speculative_config is not None: + # Hybrid prefix caching stores GDN/mamba recurrent states at + # block boundaries; under MTP those cached entries are unsafe + # to share across requests, and the spec path gets ~0% hits + # here anyway (the EAGLE last-block drop pops them), so the + # cache costs accuracy for no speed. Disable it whenever this + # checkpoint default engages. + self.enable_prefix_caching = False if self.speculative_config is None: return None @@ -1834,6 +1846,37 @@ class EngineArgs: self.kv_cache_dtype, model_config ) + # Qwen3.5 hybrid on Hopper: the GDN linear-attention layers make prefix + # caching store per-block recurrent state, which costs more than it + # returns when prompts share no prefix, and FlashInfer's GDN prefill + # beats the Triton one on SM90. Gated on the architecture and GPU that + # make both claims true, and only when the caller left the backend at + # its default so an explicit --gdn-prefill-backend still wins. + if ( + self.gdn_prefill_backend in (None, "triton") + and current_platform.is_cuda() + and current_platform.is_device_capability(90) + and getattr(model_config, "is_hybrid", False) + and not { + getattr(getattr(model_config, "hf_text_config", None), "model_type", None), + getattr(getattr(model_config, "hf_config", None), "model_type", None), + }.isdisjoint(_CHECKPOINT_MTP_MODEL_TYPES) + ): + self.enable_prefix_caching = False + self.gdn_prefill_backend = "flashinfer" + + if ( + self.speculative_config is None + and _checkpoint_mtp_speculative_config(model_config) is not None + ): + # Hybrid prefix caching stores GDN/mamba recurrent states at block + # boundaries; with the checkpoint's MTP drafter active those cached + # entries are unsafe to share across requests, and the spec path + # gets ~0% hits anyway (the EAGLE last-block drop pops them). The + # cache costs accuracy for no speed, so the MTP default turns it + # off. + self.enable_prefix_caching = False + assert self.enable_prefix_caching is not None, ( "enable_prefix_caching must be set by this point" ) @@ -2692,3 +2735,44 @@ def _raise_unsupported_error(feature_name: str): f"remove {feature_name} from your config." ) raise NotImplementedError(msg) + + +# Checkpoints whose text config carries a bundled MTP drafter that vLLM can load +# from the same weights (method "mtp", draft = target model). +_CHECKPOINT_MTP_MODEL_TYPES: frozenset[str] = frozenset( + {"qwen3_5", "qwen3_5_moe", "qwen3_5_text", "qwen3_5_moe_text"} +) +# A one-layer MTP head against a 27B target makes each extra draft nearly +# free, so a wider gamma pays whenever acceptance holds. Keep it STATIC: +# CUDA graphs key on (batch, query_len), and a dynamic width would split +# the graph pool and force piecewise replay. +_CHECKPOINT_MTP_NUM_SPECULATIVE_TOKENS: int = 8 + + +def _checkpoint_mtp_speculative_config(model_config: ModelConfig) -> dict | None: + """Default speculative config from the checkpoint's own MTP head, or None. + + Only applies when the user gave no speculative config at all. The drafter + weights live in the target checkpoint, so nothing extra is downloaded. + """ + hf_config = getattr(model_config, "hf_config", None) + text_config = getattr(model_config, "hf_text_config", None) + # The MTP head is declared on the *text* block, but which object carries + # model_type differs by checkpoint: this one reports "qwen3_5" at the top + # level and "qwen3_5_text" underneath. Accept a match from either, and + # require the head itself to exist rather than trusting the name alone. + types = { + getattr(text_config, "model_type", None), + getattr(hf_config, "model_type", None), + } + if types.isdisjoint(_CHECKPOINT_MTP_MODEL_TYPES): + return None + n_mtp_layers = getattr(text_config, "mtp_num_hidden_layers", None) + if not isinstance(n_mtp_layers, int) or n_mtp_layers < 1: + n_mtp_layers = getattr(hf_config, "mtp_num_hidden_layers", None) + if not isinstance(n_mtp_layers, int) or n_mtp_layers < 1: + return None + return { + "method": "mtp", + "num_speculative_tokens": _CHECKPOINT_MTP_NUM_SPECULATIVE_TOKENS, + } diff --git a/vllm/model_executor/kernels/linear/__init__.py b/vllm/model_executor/kernels/linear/__init__.py index 4ac8d49cd..bca89ea4d 100644 --- a/vllm/model_executor/kernels/linear/__init__.py +++ b/vllm/model_executor/kernels/linear/__init__.py @@ -321,8 +321,10 @@ _POSSIBLE_FP8_BLOCK_KERNELS: dict[ PlatformEnum, list[type[Fp8BlockScaledMMLinearKernel | FP8ScaledMMLinearKernel]] ] = { PlatformEnum.CUDA: [ - FlashInferFp8DeepGEMMDynamicBlockScaledKernel, + # One kernel for every batch size: no M-dependent dispatch, and no + # TensorRT-LLM nvcc JIT on the cold-start path. DeepGemmFp8BlockScaledMMKernel, + FlashInferFp8DeepGEMMDynamicBlockScaledKernel, CutlassFp8BlockScaledMMKernel, MarlinFP8ScaledMMLinearKernel, TritonFp8BlockScaledMMKernel, diff --git a/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py b/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py index 06bfe5c5d..d7da9012a 100644 --- a/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py +++ b/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py @@ -1329,9 +1329,13 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention): if spec_sequence_masks is not None: if attn_metadata.num_prefills == 0 and attn_metadata.num_decodes == 0: mixed_qkv_spec = mixed_qkv + a_spec = a + b_spec = b mixed_qkv_non_spec = None else: mixed_qkv_spec = mixed_qkv.index_select(0, spec_token_indx) + a_spec = a.index_select(0, spec_token_indx) + b_spec = b.index_select(0, spec_token_indx) mixed_qkv_non_spec = mixed_qkv.index_select(0, non_spec_token_indx) else: mixed_qkv_spec = None @@ -1456,8 +1460,8 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention): core_attn_out_spec, last_recurrent_state = ( fused_sigmoid_gating_delta_rule_update( A_log=self.A_log, - a=a, - b=b, + a=a_spec, + b=b_spec, dt_bias=self.dt_bias, q=query_spec, k=key_spec, diff --git a/vllm/v1/attention/backends/gdn_attn.py b/vllm/v1/attention/backends/gdn_attn.py index 340a30403..48a30a0d3 100644 --- a/vllm/v1/attention/backends/gdn_attn.py +++ b/vllm/v1/attention/backends/gdn_attn.py @@ -177,7 +177,6 @@ class GDNAttentionMetadataBuilder(AttentionMetadataBuilder[GDNAttentionMetadata] query_start_loc = m.query_start_loc query_start_loc_cpu = m.query_start_loc_cpu - context_lens_tensor = m.compute_num_computed_tokens() nums_dict, batch_ptr, token_chunk_offset_ptr = None, None, None block_table_tensor = mamba_get_block_table_tensor( m.block_table_tensor, @@ -387,6 +386,7 @@ class GDNAttentionMetadataBuilder(AttentionMetadataBuilder[GDNAttentionMetadata] ) if num_prefills > 0: + context_lens_tensor = m.compute_num_computed_tokens() has_initial_state = context_lens_tensor > 0 if spec_sequence_masks_cpu is not None: has_initial_state = has_initial_state[~spec_sequence_masks_cpu] diff --git a/vllm/v1/spec_decode/llm_base_proposer.py b/vllm/v1/spec_decode/llm_base_proposer.py index 9f46cbd24..749feac90 100644 --- a/vllm/v1/spec_decode/llm_base_proposer.py +++ b/vllm/v1/spec_decode/llm_base_proposer.py @@ -7,6 +7,7 @@ import numpy as np import torch import torch.nn as nn +from vllm import _custom_ops as ops from vllm.compilation.breakable_cudagraph import BreakableCUDAGraphWrapper from vllm.config import ( CUDAGraphMode, @@ -17,6 +18,7 @@ from vllm.config import ( from vllm.distributed.parallel_state import get_pp_group from vllm.forward_context import set_forward_context from vllm.logger import init_logger +from vllm.distributed.parallel_state import get_tensor_model_parallel_world_size from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase from vllm.model_executor.model_loader import get_model from vllm.model_executor.models import supports_multimodal @@ -117,6 +119,10 @@ class SpecDecodeBaseProposer: self.parallel_drafting_hidden_state_tensor: torch.Tensor | None = None if self.parallel_drafting: self._init_parallel_drafting_params() + # Private fp8 draft head; None means "use the bf16 path" (see + # _maybe_build_fp8_draft_head). + self._draft_head8: torch.Tensor | None = None + self._draft_head8_scale: torch.Tensor | None = None self.use_local_argmax_reduction: bool = ( self.speculative_config.use_local_argmax_reduction ) @@ -408,8 +414,97 @@ class SpecDecodeBaseProposer: self.cudagraph_dispatcher.initialize_cudagraph_keys(eagle_cudagraph_mode) + def _maybe_build_fp8_draft_head(self) -> None: + """Build a private fp8-e4m3 copy of the draft LM head, or leave it unset. + + Only the drafter's argmax reads this weight. Greedy verification emits the + target's argmax at every accepted position, so draft-side quantisation + cannot change the generated text - it can only change how often a draft is + accepted. The shared module is deliberately left untouched: load_model + aliases the target's lm_head into the drafter, so quantising in place would + move the target's own greedy path. + + Any failure leaves the fields None and the bf16 path in use. + """ + self._draft_head8 = None + self._draft_head8_scale = None + try: + if self.method != "mtp" or get_tensor_model_parallel_world_size() != 1: + return + head = getattr(self.model, "lm_head", None) + weight = getattr(head, "weight", None) + if not isinstance(weight, torch.Tensor) or weight.dim() != 2: + return + if not weight.is_cuda or weight.dtype not in ( + torch.bfloat16, + torch.float16, + ): + return + # The logits slice must be the whole tensor; a padded vocab would make + # argmax able to return a padding id. + org_vocab_size = getattr(head, "org_vocab_size", None) + if org_vocab_size is not None and org_vocab_size != weight.shape[0]: + return + + fp8_max = torch.finfo(torch.float8_e4m3fn).max + num_rows, hidden_size = weight.shape + quantized = torch.empty( + (num_rows, hidden_size), + dtype=torch.float8_e4m3fn, + device=weight.device, + ) + scales = torch.empty( + (num_rows, 1), dtype=torch.float32, device=weight.device + ) + # Chunked so the fp32 staging copy stays a few hundred MB. + for start in range(0, num_rows, 8192): + block = weight[start : start + 8192].float() + scale = block.abs().amax(dim=1, keepdim=True).clamp_(min=1e-12) + scale = scale / fp8_max + quantized[start : start + 8192] = ( + (block / scale).clamp_(-fp8_max, fp8_max).to(torch.float8_e4m3fn) + ) + scales[start : start + 8192] = scale + del block + # cutlass wants B column-major: .t() of a row-major [N, K] gives + # stride(0) == 1. + self._draft_head8 = quantized.t() + self._draft_head8_scale = scales.reshape(1, num_rows) + + # Resolve GEMM heuristics for every batch width the drafter can see, + # here in load_model, so no first-token work is deferred into serving. + probe = torch.randn( + (32, hidden_size), dtype=weight.dtype, device=weight.device + ) + for width in (1, 2, 4, 6, 8, 12, 16, 24, 32): + self._greedy_sample(probe[:width]) + torch.cuda.synchronize() + logger.info( + "MTP: built private fp8 draft lm_head (%d x %d).", + num_rows, + hidden_size, + ) + except Exception: # noqa: BLE001 - degrade to bf16, never fail startup + self._draft_head8 = None + self._draft_head8_scale = None + logger.warning( + "fp8 draft lm_head unavailable; using the bf16 head.", exc_info=True + ) + def _greedy_sample(self, hidden_states: torch.Tensor) -> torch.Tensor: """Greedy-sample draft tokens from hidden states.""" + if self._draft_head8 is not None: + flat = hidden_states.reshape(-1, hidden_states.shape[-1]).contiguous() + quantized, scale = ops.scaled_fp8_quant( + flat, scale=None, use_per_token_if_dynamic=True + ) + return ops.cutlass_scaled_mm( + quantized, + self._draft_head8, + scale, + self._draft_head8_scale, + out_dtype=hidden_states.dtype, + ).argmax(dim=-1) if self.use_local_argmax_reduction: return self.model.get_top_tokens(hidden_states) return self.model.compute_logits(hidden_states).argmax(dim=-1) @@ -1306,6 +1401,7 @@ class SpecDecodeBaseProposer: self._maybe_share_embeddings(target_language_model) self._maybe_share_lm_head(target_language_model) + self._maybe_build_fp8_draft_head() if ( self.parallel_drafting diff --git a/vllm/v1/worker/gpu_model_runner.py b/vllm/v1/worker/gpu_model_runner.py index 74938a823..2b0c0fdf3 100644 --- a/vllm/v1/worker/gpu_model_runner.py +++ b/vllm/v1/worker/gpu_model_runner.py @@ -2159,6 +2159,9 @@ class GPUModelRunner( target.gpu[:, :total_num_scheduled_tokens] += drift use_spec_decode = len(scheduler_output.scheduled_spec_decode_tokens) > 0 + # See the GDN deferred-rewind notes below; consumed when building + # attention metadata, which happens in a different method. + self._gdn_spec_rewind_marked = False if not use_spec_decode: # NOTE(woosuk): Due to chunked prefills, the batch may contain # partial requests. While we should not sample any token @@ -2168,6 +2171,28 @@ class GPUModelRunner( logits_indices = query_start_loc[1:] - 1 spec_decode_metadata = None num_sampled_tokens = np.ones(num_reqs, dtype=np.int32) + # GDN keeps the state of a request that accepted a > 1 tokens in + # block column a-1 until a spec-path read performs the deferred + # rewind. With no drafts anywhere this step, the GDN builder + # would route every row down the non-spec path, which reads + # column 0 - a state stale by a-1 tokens. Mark decode rows as + # spec rows so the spec kernels read each row's true column from + # the GPU-side accepted counts (bit-identical when a == 1). + if ( + self.speculative_config is not None + and self.model_config.is_hybrid + ): + is_decode = ( + self.input_batch.num_computed_tokens_cpu[:num_reqs] + >= self.input_batch.num_prompt_tokens[:num_reqs] + ) + if is_decode.any(): + self.num_decode_draft_tokens.np[:num_reqs] = np.where( + is_decode, 1, -1 + ) + self.num_decode_draft_tokens.np[num_reqs:].fill(-1) + self.num_decode_draft_tokens.copy_to_gpu() + self._gdn_spec_rewind_marked = True else: # Get the number of draft tokens for each request. # Iterate over the dictionary rather than all requests since not all @@ -2188,6 +2213,22 @@ class GPUModelRunner( >= self.input_batch.num_prompt_tokens[req_idx] ): num_decode_draft_tokens[req_idx] = draft_len + # Draft-starved decode rows must also take the GDN spec path + # (see the draft-less branch above); rows with drafts keep their + # real counts. + if ( + self.speculative_config is not None + and self.model_config.is_hybrid + ): + is_decode = ( + self.input_batch.num_computed_tokens_cpu[:num_reqs] + >= self.input_batch.num_prompt_tokens[:num_reqs] + ) + num_decode_draft_tokens = np.where( + is_decode & (num_decode_draft_tokens < 0), + 1, + num_decode_draft_tokens, + ) spec_decode_metadata = self._calc_spec_decode_metadata( num_draft_tokens, cu_num_tokens ) @@ -2404,7 +2445,9 @@ class GPUModelRunner( ) extra_attn_metadata_args = {} - if use_spec_decode and isinstance( + if ( + use_spec_decode or getattr(self, "_gdn_spec_rewind_marked", False) + ) and isinstance( builder, (Mamba2AttentionMetadataBuilder, GDNAttentionMetadataBuilder) ): assert ubid is None, "UBatching not supported with GDN yet"