Prhub

#48892 [Model Runner V2][Spec Decode] Add multi-layer MTP speculator

原始 PR 作者 TheEpicDolphin 合并时间 2026-07-31 06:54 文件变更 9 提交数 1 评论 8 代码增减 +1227 / -32

执行摘要

新增多模块 MTP 推测器,支持逐层独立 draft

PR 正文指出,Multi-Module MTP 是 MTP 的变体,每个 speculative step 对应独立的模型层(MTP module #i drafts the ith token),Inkling 模型的 MTP 最多支持 8 个 speculative token;而 Model Runner V2 目前只支持单模块 MTP,即用同一个 MTP 层推测所有 N 个 token。实现的最大难点是被拒绝的 draft token 会让后序 MTP 层的 KV cache 过期,因此需要从上一 decode 步重新 prefill 这些 token,并配合延迟释放/缓存 KV block 来避免写脏已被其他请求引用的 KV。由于完整功能依赖 KV cache 支持(#50062)与多模态 embeddings lookahead(#50306),MultiModuleMTPSpeculator 在当前 PR 中保持 DISABLED 状态。

值得精读。核心看点包括:MultiModuleMTPSpeculator 如何通过缓存上一 decode 步输入与 hidden state 实现 re-prefill、_build_draft_attn_metadata 如何支持非均匀 query 布局,以及 issue 中作者对 KV 读侧/写侧/释放侧三层机制的阐述。对 spec decode 开发者而言,这是一个多模块状态修复的典型设计案例。建议合入后跟进 #50062、#50306,并着手补充单元测试与复用性重构。

讨论亮点

核心讨论集中在代码复用、KV 管理机制与两个具体实现细节上:

  • 代码规模与复用(benchislett):新 speculator 约 1300 行,对仅一两个相关模型来说不是好的 tradeoff;建议子类化 AutoRegressiveSpeculator,共享初始化与 kernel。TheEpicDolphin 在 issue 回复中论证无法简单统一:eagle_cache_drop 是读侧、num_reprefillable_tokens 是写侧、extra_retained_tokens 是释放侧,三者针对不同问题且多模块 MTP 都需要。
  • Piecewise cudagraph 禁用(benchislett):询问是否为真实 blocker。TheEpicDolphin 确认是真实 blocker:run_pw_graph 只在第一次调用时捕获 spec_step_idx = 0,回放时只有第 0 层生效,需等待后续修复。
  • Dummy run 设备不一致(benchislett):device="cpu" 的改动因为 warmup 用 GPU dummy、运行时用 CPU 张量,MultiModuleMTPSpeculator 的 numpy 操作会触发该差异。
  • Pos 0 接受率调查(WoosukKwon):“It looks very mysterious/suspicious to me 🤔”,TheEpicDolphin 按 previous accepted 分桶后确认差异来自样本组成而非 bug。

实现拆解

实现拆解按以下 5 步展开,涉及文件与关键符号说明如下:

  1. 配置开关与模型扩展
    - vllm/config/speculative.py 新增 SpeculativeConfig.use_multi_module_mtp():当 method == "mtp"min(num_nextn_predict_layers, num_speculative_tokens) > 1 时返回 True,作为全局启用条件。
    - vllm/models/inkling/nvidia/mtp.py 移除“仅支持 1 个 speculative token”的硬限制:新增 _select_mtp_depth_count() 决定实际构建的深度层数 num_mtp_layers = min(num_nextn_predict_layers, num_speculative_tokens)layers 从单层 ModuleDict 扩展为多层;forward 通过 spec_step_idx % num_mtp_layers 选择当前深度层;权重加载按 depth >= num_mtp_layers 跳过多余层。

  2. 新增多模块 MTP 推测器
    - 新增 vllm/v1/worker/gpu/spec_decode/multi_module_mtp/speculator.py,定义 MultiModuleMTPSpeculator(继承 DraftModelSpeculator)。
    - 预分配 hidden_statescached_draft_input_ids/embedscached_target_hidden_statesdraft_input_id_overrides 等持久 buffer,用于跨步缓存 draft 输入与目标 hidden state,支撑 re-prefill。
    - propose() 先调用 _prepare_inputs() 准备当前步输入,再经 dispatch_cg_and_sync_dp 选择 eager 或 cudagraph 模式,最后在 _generate_drafts 循环中逐 MTP 深度执行,每次 _run_model() 传入 spec_step_idx
    - _run_model() 通过 set_forward_context 构造 BatchDescriptor 并把 spec_step_idx 传给模型,模型据此选择对应深度层。
    - init_cudagraph_manager() 检测到 piecewise cudagraph 时降级为 FULL_DECODE_ONLYNONE,因为目前 piecewise 会把 spec_step_idx = 0 烘焙进图,回放时只重放第 0 层。

  3. 运行时数据契约改造
    - vllm/v1/worker/gpu/input_batch.py_prepare_prefill_inputs_kernel 从只写 1 个 next prefill token 扩展为写 num_lookahead 个,next_prefill_tokens 变为 [num_lookahead, max_num_reqs] 二维布局。
    - vllm/v1/worker/gpu/states.pyRequestState 新增 num_prefill_lookahead 参数,next_prefill_tokens 张量同步改为二维。
    - vllm/v1/worker/gpu/model_runner.py 根据 use_multi_module_mtp()num_prefill_lookahead 设为 num_speculative_steps(其他情况保持 1);dummy run 中的 is_mm_embed 设备从 GPU 改为 CPU,与运行时的 CPU 张量对齐。

  4. 通用 attention 元数据扩展
    - vllm/v1/worker/gpu/spec_decode/speculator.py_build_draft_attn_metadata 新增 query_start_loc_np 参数,支持 re-prefill 产生的非均匀 prefill/decode 混合 query 布局,并动态计算 max_query_lenvllm/v1/worker/gpu/spec_decode/dflash/speculator.py 同步透传该参数。
    - MultiModuleMTPSpeculator.propose 中重建 slot mappings,并用 pad_trailing_draft_slots 把非真实 draft token 的槽位填充为 PAD_SLOT_ID,防止写脏 KV。

  5. 测试、基准与部署状态
    - 本 PR 未新增测试文件;PR body 用 GSM8K、MMAU、Speed-Bench 等基准验证正确性与性能,显示输出吞吐提升约 30%,但 P99 ITL 明显变差。
    - 新功能当前默认 DISABLED,需与 KV cache 支持(#50062)和多模态 lookahead(#50306)一起合入后才能启用。

文件 模块 状态 重要度
vllm/v1/worker/gpu/spec_decode/multi_module_mtp/speculator.py 推测器 added 9.08
vllm/models/inkling/nvidia/mtp.py 模型层 modified 7.37
vllm/config/speculative.py 配置 modified 6.23
vllm/v1/worker/gpu/spec_decode/speculator.py 推测器 modified 6.53
vllm/v1/worker/gpu/model_runner.py 运行器 modified 6.23
vllm/v1/worker/gpu/input_batch.py 输入批处理 modified 6.21
vllm/v1/worker/gpu/states.py 请求状态 modified 5.19
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py 推测器 modified 4.76
vllm/v1/worker/gpu/spec_decode/multi_module_mtp/__init__.py 推测器 added 4.16

关键符号

MultiModuleMTPSpeculator.__init__ MultiModuleMTPSpeculator.load_draft_model MultiModuleMTPSpeculator.init_cudagraph_manager MultiModuleMTPSpeculator.capture MultiModuleMTPSpeculator.propose MultiModuleMTPSpeculator._run_model MultiModuleMTPSpeculator._prepare_inputs InklingMultiTokenPredictor.forward _select_mtp_depth_count DraftModelSpeculator._build_draft_attn_metadata SpeculativeConfig.use_multi_module_mtp prepare_prefill_inputs DflashSpeculator._build_draft_attn_metadata

关键源码片段

vllm/v1/worker/gpu/spec_decode/multi_module_mtp/speculator.py core-logic

新增的核心推测器:实现 `MultiModuleMTPSpeculator` 的完整生命周期(加载、cudagraph 捕获、propose 循环),并通过缓存上一 decode 步输入与 hidden state 支持 re-prefill 修复过期 KV。

@torch.inference_mode()
def _run_model(
    self,
    num_tokens: int,
    attn_metadata: dict[str, Any] | None,
    slot_mappings: dict[str, torch.Tensor] | None,
    num_tokens_across_dp: torch.Tensor | None,
    spec_module_idx: int,
    cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE,
) -> tuple[torch.Tensor, torch.Tensor]:
    # 每个 MTP 深度层都通过 forward context 暴露独立的 spec_step_idx,
    # 模型(如 InklingMultiTokenPredictor)据此选择对应的 depth layer。
    batch_descriptor = BatchDescriptor(num_tokens=num_tokens)
    with set_forward_context(
        attn_metadata,
        self.vllm_config,
        num_tokens=num_tokens,
        cudagraph_runtime_mode=cudagraph_runtime_mode,
        num_tokens_across_dp=num_tokens_across_dp,
        slot_mapping=slot_mappings,
        batch_descriptor=batch_descriptor,
    ):
        model_inputs = dict(
            input_ids=self.input_buffers.input_ids[:num_tokens],
            positions=self.input_buffers.positions[:num_tokens],
            hidden_states=self.hidden_states[:num_tokens],
            inputs_embeds=(
                self.inputs_embeds[:num_tokens]
                if self.inputs_embeds is not None
                else None
            ),
            spec_step_idx=spec_module_idx,
        )
        if cudagraph_runtime_mode == CUDAGraphMode.PIECEWISE:
            # 目前 piecewise 图只在首个 `spec_step_idx = 0` 时捕获,
            # 回放时会固定为第 0 层,因此多模块 MTP 暂回退到 `FULL graph`。
            assert self.cudagraph_manager is not None
            ret_hidden_states = self.cudagraph_manager.run_pw_graph(
                self.model, model_inputs
            )
        else:
            # Eager 模式(即 `CUDAGraphMode.NONE`)直接调用原始模型。
            ret_hidden_states = self.model(**model_inputs)
​
    # 兼容单 tensor 与 `(logits_hidden, feedback_hidden)` 两种契约:
    # 部分 MTP 模型需要反馈 `hidden state` 作为下一个深度层的输入。
    if isinstance(ret_hidden_states, tuple):
        last_hidden_states, hidden_states = ret_hidden_states
    else:
        last_hidden_states = ret_hidden_states
        hidden_states = ret_hidden_states
    return last_hidden_states, hidden_states
vllm/models/inkling/nvidia/mtp.py data-contract

Inkling MTP 模型从单层扩展为多层:新增 `_select_mtp_depth_count`,`forward` 按 `spec_step_idx` 选层,权重加载跳过超出构建范围的深度,是多模块 MTP 的模型侧支撑。

def forward(
    self,
    input_ids: torch.Tensor,
    positions: torch.Tensor,
    previous_hidden_states: torch.Tensor,
    inputs_embeds: torch.Tensor | None = None,
    spec_step_idx: int = 0,
) -> torch.Tensor:
    # spec_step_idx 即当前第几步 draft,取模得到深度层索引。
    # 每个深度层都是完整的 `Inkling transformer block`(含自己的 sconv 与 KV cache),
    # 所以这里必须按 `depth` 选层,而不是复用同一个层。
    depth = spec_step_idx % self.num_mtp_layers
    layer = self.layers[str(depth)]
    # 融合上一层的 `hidden state` 与当前 token 的 embedding(含 `backbone embed_norm`)。
    combined = self.fused_input_cat(
        layer, previous_hidden_states, input_ids, inputs_embeds
    )
    hidden = layer(combined, positions)
    if self.chain_norm is not None:
        hidden = self.chain_norm(hidden)
    return hidden
vllm/config/speculative.py core-logic

新增 `use_multi_module_mtp()`,是全局启用多模块 MTP 的配置开关,被 model_runner 和 speculator 初始化逻辑依赖。

def use_multi_module_mtp(self) -> bool:
    # 只有 `method == "mtp"` 且存在 `draft model` 时才可能启用多模块 MTP。
    if self.method != "mtp" or self.draft_model_config is None:
        return False
    # 从 draft checkpoint 读取实际包含的 MTP 深度层数。
    num_mtp_layers = getattr(
        self.draft_model_config.hf_config, "num_nextn_predict_layers", 1
    )
    # 生效条件是:实际层数与 `speculative token` 数的较小者大于 1。
    return min(num_mtp_layers, self.num_speculative_tokens) > 1

评论区精华

Piecewise cudagraph 禁用原因 设计

benchislett 询问 `init_cudagraph_manager` 中禁用 piecewise cudagraph 是真实 blocker 还是未检查的占位。TheEpicDolphin 确认是真实 blocker:`_run_model` 会为每个 MTP 层调用 `run_pw_graph`,但只有第一个 descriptor 会被捕获且 `spec_step_idx = 0` 被烘焙,回放时只有第 0 层生效,导致接受率下降;修复简单但因为 PR 已很大而推迟。

结论:确认为真实 blocker,通过降级到 FULL_DECODE_ONLY/NONE 规避,后续单独修复。 · 已解决

dummy run 中 is_mm_embed 设备不一致 正确性

benchislett 问 `device="cpu"` 的改动目的。TheEpicDolphin 解释 warmup 时 dummy `is_mm_embed` 在 GPU,而运行时常量为 CPU(来自 `EncoderRunner.gather_mm_embeddings`);`AutoRegressiveSpeculator` 未暴露该问题,但 `MultiModuleMTPSpeculator` 在 chunked prefill 分支会对该张量做 numpy 操作,因此需要统一到 CPU。

结论:正确的设备一致性修复,保持与运行时行为一致。 · 已解决

1300 行新代码与复用性 设计

benchislett 认为新 speculator 对仅一两个模型来说过大,建议子类化 AutoRegressiveSpeculator 共享初始化、kernel 与多模态支持。TheEpicDolphin 在 issue 回复中说明无法简单统一:`eagle_cache_drop` 是读侧(prefix-cache 命中时丢弃未匹配块)、`num_reprefillable_tokens` 是写侧(阻止未最终确定的 KV 被缓存)、`extra_retained_tokens` 是释放侧(保证 Inkling 短卷积层窗口内的块不被提前释放),三者针对不同环节。

结论:评审接受当前方案;代码复用与精简作为后续优化方向保留。 · 已解决

Pos 0 接受率异常调查 question

WoosukKwon 对 MTP 8 的 Pos 0 接受率(76.17%)明显低于 MTP 1(83.39%)表示可疑。TheEpicDolphin 按上一 step 接受数分桶:previous accepted == 0 时 pos0 接受率约 70%,previous accepted == 1 时约 85.66%;MTP 1 的 draft 绝大多数属于后者,因此整体 pos0 高;MTP 8 混合了大量 rejected 后的重新 draft,造成平均值偏低,并非实现 bug。

结论:调查确认差异源于样本组成而非正确性问题。 · 已解决

风险与影响

  • MultiModuleMTPSpeculator 新增约 1106 行且无测试覆盖,回归风险较高。
  • re-prefill 机制与现有 prefix caching / KV 释放策略(eagle_cache_dropnum_reprefillable_tokensextra_retained_tokens)交互复杂,若其他路径未同步延迟释放逻辑,可能出现 KV 块被提前复用导致脏数据。
  • piecewise cudagraph 被降级:启用时只有 MTP 第 0 层被正确回放,接受率会受影响,init_cudagraph_manager 中通过回退规避。
  • 多模态支持不完整:sample_tokens 中只 gather 了 draft_lookahead=1 的 MM embeddings,多模块 MTP 的多模态输入仍依赖 #50306。
  • next_prefill_tokens 从一维变为二维、_build_draft_attn_metadata 增加非均匀 query 支持,属于数据契约变更,会影响 EAGLE、dflash 等共基类路径,需验证兼容性。
  • 功能默认 DISABLED,依赖未合入的 #50062、#50306,若提前打开可能触发未完成路径。

影响范围集中在 Model Runner V2 的推测解码路径:改动覆盖 speculative.py 配置、model_runner.pyinput_batch.pystates.py 以及 MTP/EAGLE/dflash 共用的 DraftModelSpeculator 基类,并扩展了 Inkling 模型。对用户:默认无行为变化;启用多模块 MTP 后 GSM8K 输出吞吐提升约 30%、Speed-Bench 提升约 22%,但 P99 ITL 在 GSM8K 场景下恶化约 612%,长尾时延敏感的用户需谨慎。对团队:本 PR 是多模块 MTP 能力的第一块基石,后续 KV cache 与多模态 lookahead PR 都依赖此结构;同时 1300 行新代码会让维护成本上升,评审中已明确未来需要做代码复用收敛。

核心路径变更 缺少测试覆盖 默认禁用 依赖未合并 PR 多模态支持不完整 piecewise cudagraph 降级

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论