Prhub

#54436 [Bugfix][PP] Never drop a decoding request from the sampled-token broadcast

原始 PR 作者 ArcheyChen 合并时间 2026-09-01 07:49 文件变更 6 提交数 3 评论 10 代码增减 +87 / -22

执行摘要

修复 PP 投机解码下请求被误排除出采样广播的问题

PR body 明确指出了根因:在投机解码下 num_computed_tokens 每步可推进多个 token,会越过 prompt_len + max_tokens 而调度器仍在运行请求,此时 not_finishing 谓词对仍在解码的行返回 False 并跳过广播,结果是“永久的失步而非掉一个 token”:前段 stage 的 last_sampled_tokens 与 draft_tokens 冻结在最后一次收到的值上,随后在持续推进的位置上重复嵌入同一 token 块,产生退化的重复输出,并把 KV 写到末段 stage 从未使用的位置。作者在 2 节点 × 2 GPU(pipeline_parallel_size=2, tensor_parallel_size=2,DeepSeek-V4-Flash + dspark 投机解码 K=7)生产部署上运行数周,症状与根因分析完全吻合。

值得精读。这是一个“删掉看似合理的守卫”的正确性修复范本:关键在于理解 compute_need_sampled_mask 的语义边界、投机解码 token 推进与 max_seq_len 预算之间的错位,以及下游槽位生命周期校验(req_idx_gen)如何承担“已结束请求”的过滤职责。对后续维护 v1 执行引擎 PP 与投机解码路径的工程师有直接参考价值。关注点:函数语义收缩后,任何新增排除条件都必须证明“该行本步确定不产生样本”,否则会重蹈本 PR 的覆辙。

讨论亮点

评审由维护者 njhill 主导,整体认可修复方向并直接批准("Thanks again @ArcheyChen")。

  • 确认问题边界:njhill 在首条评论中指出 "Thanks @ArcheyChen good catch, this is a spec-decoding specific issue."——普通 decode 下 num_computed_tokens 严格每步 +1,不会越过长度上限,因此这是投机解码专属缺陷。
  • docstring 精简:njhill 对 pp_utils.py 的 diff 评论 "lets remove all of these words",建议把长段 rationale 从函数 docstring 中删掉。作者接受("Done — trimmed to your suggestion"),把“为什么不排除即将结束的行”的论证移到 PR 描述与回归测试 test_keeps_decoding_request_past_its_length_cap 的 docstring 中,既保持可发现性又不挤占函数注释。
  • 删除 max_seq_len_np 字段:njhill 在 model_runner.py 评论 "Can delete this now and also remove max_seq_len_np from InputBatch",作者确认 pcp_manager 是唯一其他消费点后,在第二个 commit 中连同 pcp_manager.partition_batch 构造点一起删除,形成完整的数据契约清理。

实现拆解

变更入口是 vllm/v1/worker/gpu/pp_utils.py 的 compute_need_sampled_mask,随后是一串数据契约连锁清理与测试配套。

  1. 核心修复(vllm/v1/worker/gpu/pp_utils.py):删除 not_finishing = np.maximum(old_computed, prefill_len) + 1 < max_seq_len 及其与 produces_sample 的求交,同时删除对 input_batch.max_seq_len_np 非空的 assert。返回值改为 produces_sample if produces_sample.any() else None。理由:请求是否终止只有采样之后才知道,非末段 prefill chunk 是唯一能证明“本步不产生样本”的排除项;真正结束的请求由 get_prev_sampled_outputs 中的 req_idx_gen 校验在槽位释放后丢弃更新,因此该守卫不提供额外保护却破坏正确性。docstring 同步改为只描述返回语义,并说明唯一可靠排除规则,防止守卫被重新引入。

  2. 数据链路清理(vllm/v1/worker/gpu/model_runner.py、vllm/v1/worker/gpu/input_batch.py、vllm/v1/worker/gpu/pcp_manager.py):守卫删除后 nothing 再消费 max_seq_len_np:model_runner.prepare_inputs 删除 PP 分支下的 max_seq_len_np = self.req_states.max_seq_len[idx_mapping_np] gather 与 InputBatch 构造参数;input_batch.py 删除字段声明、注释及 make_dummy 中的 max_seq_len_np=None;pcp_manager.partition_batch 删除唯一的另一处透传参数。这是同一数据契约的连带清理,避免死代码误导后续维护。

  3. 测试与 CI 配套(tests/v1/worker/test_pp_utils.py、tests/v1/worker/test_gpu_batch_shard.py):新增 test_pp_utils.py 共 4 个用例——2 个 pin 既有行为(test_excludes_non_final_prefill_chunks 非末段 prefill 仍被排除;test_none_when_no_row_samples 全 prefill 批量仍返回 None),2 个为回归用例(test_keeps_decoding_request_past_its_length_cap 用 num_computed=14176、prefill_len=12175、max_seq_len=14175 复现旧谓词误判;test_decode_row_ahead_of_a_prefill_chunk 验证行序不影响判定)。test_gpu_batch_shard.py 同步删除构造参数。维护者 njhill 触发两次 /ci run(Buildkite CI #86399、#86411),单测在 main 上 4 通过,回退补丁后 2 个回归用例失败、2 个行为 pin 用例通过。

文件 模块 状态 重要度
vllm/v1/worker/gpu/pp_utils.py PP 广播 modified 6.26
tests/v1/worker/test_pp_utils.py 单元测试 added 7.1
vllm/v1/worker/gpu/model_runner.py 输入装配 modified 5.56
vllm/v1/worker/gpu/pcp_manager.py 批切分 modified 4.76
vllm/v1/worker/gpu/input_batch.py 数据结构 modified 4.67
tests/v1/worker/test_gpu_batch_shard.py 单元测试 modified 2.88

关键符号

compute_need_sampled_mask prepare_inputs partition_batch

关键源码片段

vllm/v1/worker/gpu/pp_utils.py core-logic

核心修复点:删除 not_finishing 排除与 max_seq_len 断言,compute_need_sampled_mask 只保留唯一可靠的 prefill chunk 排除,并更新 docstring 说明为何不能重新引入该守卫。

def compute_need_sampled_mask(input_batch: InputBatch) -> np.ndarray | None:
    """Return a bool array of shape `[input_batch.num_reqs]` marking requests
    that produce a sampled token this step, and therefore must have that token
    (and the draft block proposed from it) propagated to the earlier PP stages.
    Returns None if no request in the batch produces a sample."""
​
    old_computed = input_batch.num_computed_tokens_np
    prefill_len = input_batch.prefill_len_np
​
    # 唯一可靠的排除规则:非末段 prefill chunk 本步不会产生样本。
    # 末段 rank 在 get_num_sampled_and_rejected 中将 num_sampled/num_rejected 清零,
    # 前段 rank 在 post_update_num_computed_tokens 中按完整 query 长度推进
    # num_computed_tokens,各 stage 因此无需广播也能保持同步。
    produces_sample = old_computed + input_batch.num_scheduled_tokens >= prefill_len
​
    # 这里不能再按 max_seq_len 排除“看起来即将结束”的请求:请求是否终止只有
    # 采样之后才知道,而投机解码下 num_computed_tokens 每步可推进多个 token,
    # 可能越过 prompt_len + max_tokens 但调度器仍在运行该请求。若误排除,前段
    # stage 的 last_sampled_tokens 与 draft_tokens 将永久冻结,输出退化为重复
    # 文本且 KV 写入错位(详见 PR#54436)。真正结束的请求由
    # get_prev_sampled_outputs 中的 req_idx_gen 校验在槽位释放后统一丢弃,
    # 因此这里无需预测“即将结束”。
    return produces_sample if produces_sample.any() else None
tests/v1/worker/test_pp_utils.py test-coverage

新增 4 个用例:2 个 pin 既有行为(非末段 prefill 排除、全 prefill 返回 None),2 个为回归用例,直接复现旧谓词误判的数值,防止守卫被重新引入。

def test_keeps_decoding_request_past_its_length_cap():
    """A decoding request must never be dropped from the broadcast.
​
    Speculative decoding advances `num_computed_tokens` several tokens per step,
    so it can overrun `prompt_len + max_tokens` while the scheduler is still
    running the request. Predicting "this one is finishing" and skipping its
    broadcast freezes the earlier pipeline stages' `last_sampled_tokens` and
    `draft_tokens` while the last rank keeps advancing its own, and the stages
    then diverge permanently.
    """
    batch = _batch(
        # 14176 computed tokens is already past this request's own
        # prompt_len + max_tokens; the scheduler is still running it.
        num_computed=[14176],
        prefill_len=[12175],
        num_scheduled=[8],
    )
​
    mask = pp_utils.compute_need_sampled_mask(batch)
​
    # 旧谓词下 not_finishing = max(14176, 12175) + 1 < 14175 为 False,
    # 该行会被误排除出广播,导致前段 stage 与末段 rank 永久失步——
    # 这正是本次修复的回归点。
    assert mask is not None
    assert mask.tolist() == [True]

评论区精华

compute_need_sampled_mask 的 docstring 长度与 rationale 存放位置 documentation

njhill 在 diff 评论中建议删掉整段 design rationale("lets remove all of these words"),认为函数注释不必承载过长分析。

结论:作者接受裁剪,将 " 为何不能排除即将结束的行 " 的论证移入 PR 描述与回归测试 docstring,既保持可发现性又不挤占函数注释。 · 已解决

max_seq_len_np 是否整体删除 设计

njhill 指出 model_runner 中既然不再消费即可删除,并建议同时移除 InputBatch.max_seq_len_np 字段("Can delete this now and also remove `max_seq_len_np` from `InputBatch`")。

结论:作者确认 pcp_manager 是唯一其他消费点后,在第二个 commit 中连同 model_runner、input_batch、pcp_manager 及两处测试一并清理,形成完整数据契约变更。 · 已解决

问题边界确认:spec-decoding 专属缺陷 other

njhill 批准前指出 "good catch, this is a spec-decoding specific issue",确认普通 decode 下 num_computed_tokens 严格每步 +1、不会越过长度上限,此缺陷仅在投机解码下触发。

结论:评审整体认可根因分析与修复方向,njhill 批准合并。 · 已解决

风险与影响

正确性支点转移:修复后“某行是否需要广播”不再由长度预算预判,真正结束或被 abort 的请求完全依赖 get_prev_sampled_outputs 的 req_idx_gen 校验在槽位释放后丢弃更新。该机制已在既有逻辑中覆盖,但本次修改的整个正确性建立在这个下游校验之上,若存在 abort 与 finish 并发下的槽位复用竞态,前段 stage 可能收到已释放槽位的采样结果,建议在后续审查中重点确认这一 invariant。

数据契约变更无编译期保护:InputBatch.max_seq_len_np 字段被移除,仓库内已同步 model_runner、pcp_manager 与两处测试,但任何外部扩展或分支若自行构造 InputBatch 会在构造点直接报错,属于静默 API 变更。

广播流量微增:所有产生采样的行都会进入广播(含真正终止的前一步),此前这些行可能被跳过;因下游会过滤无效更新,开销仅为每请求最后一步的常量广播,可忽略;同时每步省去一次 PP 下的 max_seq_len gather,整体收益为正。

测试覆盖局限:新增用例均为 Mock 级单测,未覆盖真实多卡 PP broadcast/recv 与槽位释放的端到端交互;作者提供了生产部署验证(运行数周),但 CI 中没有对应的分布式回归测试。

对用户与输出质量:修复了一个静默数据损坏级别的缺陷——PP + 投机解码组合下长请求不再退化为重复文本、KV 不再错位,恢复生成正确性。对系统:PP 下每步减少一次 CPU 侧 max_seq_len gather;广播路径在终止步略有增加但由下游过滤,净收益为正。对团队与工程:InputBatch 数据契约得到简化;新增 4 个单测固化“唯一可靠排除规则”,防止 not_finishing 类守卫被重新引入;修复经验(投机解码下不能预判请求终止)已沉淀进测试 docstring,为后续维护 PP/投机解码代码提供直接参考。

核心 PP 广播路径变更 依赖下游 req_idx_gen 槽位校验 InputBatch 数据契约变更 缺少分布式端到端回归测试

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论