Prhub

#47029 [Bugfix] Prevent padding placeholders from reaching embeddings

原始 PR 作者 qianlihuang 合并时间 2026-07-02 00:26 文件变更 3 提交数 9 评论 5 代码增减 +60 / -0

执行摘要

修复投机解码中填充占位符 -1 到达 embedding 层导致 CUDA 断言失败

在 DeepSeek-V4 使用 MTP 投机解码时,scheduler 用 -1 draft token ID 保持统一形状,但 -1 进入模型 embedding 查找(F.embedding)后触发 CUDA 断言:indexSelectSmallIndex: block: [9,0,0], thread: [126,0,0] Assertion srcIndex < srcSelectDimSize failed。必须阻止 -1 到达 embedding,同时让拒绝采样继续拒绝占位符 slot。

值得精读:修复简洁但典型,展示了在托管张量边界层做防御性处理的模式。设计权衡(在 scheduler 清 0 还是 embedding 前 clamp)最终选择 clamp 以最小化改动,是低侵入修复的范例。

讨论亮点

Copilot 在 review 中指出,原有辅助函数 _mask_placeholder_draft_token_ids 中使用 torch.tensor(..., device=self.device) 会同步拷贝到 GPU,建议改用 async_tensor_h2d 避免 per-step 开销。最终维护者 njhill 重新设计并简化了修复方案,去除了该辅助函数,改为在 _preprocess 中直接 clamp_,消除了同步拷贝问题。njhill 还修复了因 clamp 位置不当导致的异步接受率退化(revert/fix async acceptance rate breakage 提交)。

实现拆解

  1. 核心修复:在 GPUModelRunner._preprocess 方法开始处(vllm/v1/worker/gpu_model_runner.py),增加条件 clamp:当 self.speculative_config 非空时,对 self.input_ids.gpu[:num_input_tokens] 执行 clamp_(min=0),确保 embedding 查找前所有 token ID 非负。
  2. 数据路径不变:InputBatch 中的 spec_token_ids 和 token_ids_cpu 仍保留原始 -1,拒绝采样通过 SpecDecodeMetadata.draft_token_ids 获取未修改的 -1,从而正确拒绝填充 slot。
  3. 测试配套:添加两个单元测试:
    • test_invalid_draft_suffixes_remain_rejected_in_metadata:验证 _calc_spec_decode_metadata 输出的 draft_token_ids 保留 -1。
    • test_placeholder_spec_token_ids_written_verbatim:验证 InputBatch.update_req_spec_token_ids 将占位符原样写入 spec_token_ids 和 token_ids_cpu。
文件 模块 状态 重要度
vllm/v1/worker/gpu_model_runner.py 模型执行 modified 5.68
tests/v1/worker/test_gpu_model_runner.py 模型执行 modified 5.53
tests/v1/worker/test_gpu_input_batch.py 输入批处理 modified 5.07

关键符号

_preprocess

关键源码片段

vllm/v1/worker/gpu_model_runner.py data-contract

核心修复文件:在 _preprocess 方法中增加 clamp_ 操作,防止占位符 -1 到达 embedding 层。

# vllm/v1/worker/gpu_model_runner.py
# 在 _preprocess 方法开头,embedding 查找之前
num_scheduled_tokens = scheduler_output.total_num_scheduled_tokens
is_first_rank = get_pp_group().is_first_rank
is_encoder_decoder = self.model_config.is_encoder_decoder
​
# Clamp speculative scheduler placeholders (-1) before embedding lookup.
# 只有启用投机配置时才需要 clamp,普通解码路径不受影响
if self.speculative_config is not None:
    self.input_ids.gpu[:num_input_tokens].clamp_(min=0)
​
# 后续多模态编码、prompt_embeds 等逻辑使用 clamp 后的 safe 输入
# 但 spec_token_ids 仍保留原始 -1 给拒绝采样
tests/v1/worker/test_gpu_model_runner.py test-coverage

验证投机解码元数据中占位符 -1 被保留,拒绝采样能正确识别无效 draft slot。

# tests/v1/worker/test_gpu_model_runner.py
# 新测试:验证 _calc_spec_decode_metadata 输出的 draft_token_ids 保留 -1
def test_invalid_draft_suffixes_remain_rejected_in_metadata():
    runner = object.__new__(GPUModelRunner)
    runner.device = torch.device("cpu")
    runner.arange_np = np.arange(64, dtype=np.int64)
    runner._arange_scratch = np.empty(64, dtype=np.int64)
​
    # 输入张量包含两个 -1 占位符(位置 2 和 7)
    runner.input_ids = SimpleNamespace(
        gpu=torch.tensor([99, 10, -1, 99, 12, 99, 13, -1], dtype=torch.int32),
    )
​
    metadata = GPUModelRunner._calc_spec_decode_metadata(
        runner,
        np.array([2, 1, 2], dtype=np.int32), # 每个请求的 draft 数量
        np.array([3, 5, 8], dtype=np.int32), # 累积边界
    )
​
    # 占位符 -1 必须在 metadata 中保留,拒绝采样据此拒绝这些 slot
    assert metadata.draft_token_ids.tolist() == [10, -1, 12, 13, -1]
tests/v1/worker/test_gpu_input_batch.py test-coverage

验证 InputBatch 的 update_req_spec_token_ids 将占位符 -1 原样写入 spec_token_ids 和 token_ids_cpu。

# tests/v1/worker/test_gpu_input_batch.py
# 新测试:验证占位符 -1 在 InputBatch 内部 buffer 中原样存储
def test_placeholder_spec_token_ids_written_verbatim():
    input_batch = InputBatch(
        max_num_reqs=1,
        max_model_len=8,
        max_num_batched_tokens=8,
        device=torch.device("cpu"),
        vocab_size=VOCAB_SIZE,
        block_sizes=[16],
        kernel_block_sizes=[16],
    )
    req = CachedRequestState(
        req_id="req",
        prompt_token_ids=[10, 11],
        mm_features=[],
        sampling_params=SamplingParams(),
        block_ids=([],),
        generator=None,
        num_computed_tokens=3,
        output_token_ids=[12],
    )
    input_batch.add_request(req)
​
    # 更新投机 token:包含两个 -1 占位符
    input_batch.update_req_spec_token_ids(
        req,
        {"req": [13, -1, -1]},
    )
​
    # spec_token_ids 保留原始值
    assert input_batch.spec_token_ids[0] == [13, -1, -1]
    # token_ids_cpu buffer 也原样保留,供 _preprocess 之后 clamp
    assert input_batch.token_ids_cpu[0, 3:6].tolist() == [13, -1, -1]

评论区精华

mask 辅助函数同步拷贝 GPU 带来 per-step 开销 性能

Copilot 指出原有 `_mask_placeholder_draft_token_ids` 使用 `torch.tensor(..., device=self.device)` 同步拷贝,建议改用 `async_tensor_h2d`。但 njhill 后续完全移除了该辅助函数,改为 `clamp_` in-place 操作,避免了该问题。

结论:njhill 重新设计并简化,去除了辅助函数,直接 clamp 输入张量,消除了同步开销和额外内存分配。 · 已解决

风险与影响

风险极低:clamp 仅作用于 speculative_config 非空时的 token ID,且范围限定在 num_input_tokens 内。不会影响常规解码路径。若未来 scheduler 的占位符不再使用 -1,clamp 仍安全(无副作用)。但需注意 clamp 在 _prepare_inputs 之前执行,可能影响依赖原始 ID 的其他逻辑(当前无此类依赖)。

直接影响:修复了 DeepSeek-V4 等模型在 MTP 投机解码时的崩溃,使该配置可用。影响范围限于使用 v1 引擎且开启投机解码的用户。测试覆盖增加,提升了代码健壮性。

speculative-only path modification clamp may mask scheduler bugs

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论