Prhub

#46066 [Bugfix][Core] Fix num_output_placeholders underflow with async scheduling + spec decode

原始 PR 作者 Sunt-ing 合并时间 2026-07-06 21:50 文件变更 3 提交数 8 评论 6 代码增减 +58 / -3

执行摘要

修复 async scheduling + spec decode 时 num_output_placeholders 下溢

在异步调度与推测解码组合下,reset_prefix_cache(或 RLHF 权重更新等)强制抢占正在运行的请求,将 num_output_placeholders 清零并将未完成的帧计数记录到 async_tokens_to_discard。随后当这些过时帧返回时,update_from_output 中的推测拒绝逻辑错误地将前一次的 num_rejected 从重新添加的小占位符计数中减去,导致下溢。该负值最终会触发 assert num_output_placeholders >= 0 并崩溃 EngineCore(关联 Issue #30142)。

此 PR 值得精读,因为它展示了异步调度与推测解码交互中的微妙边界情况,修复手法干净(增加一个守卫条件),并且测试充分(e2e 和单元测试)。对于理解 vLLM 调度器内部状态机有帮助,也体现了代码 review 中保留边缘情况测试的重要性。

讨论亮点

审核者 njhill 注意到作者最初删除了单元测试,建议保留回归测试,因为边缘情况微妙且值得覆盖。作者起初因之前同事要求删除配置测试而移除,但最终按审核者建议恢复并推送。审核者多次批准(因 Wi-Fi 问题重复)。

实现拆解

  1. 在 vllm/v1/core/sched/scheduler.py 的 update_from_output 方法中,修改推测拒绝调整的进入条件:增加 request.async_tokens_to_discard == 0 要求。若 async_tokens_to_discard > 0,表示此帧等待丢弃,则跳过对整个占位符和 computed_tokens 的调整,避免下溢。

  2. 在 tests/v1/core/utils.py 的 create_scheduler 函数中,新增 speculative_method 参数,允许测试指定推测方法(如 ngram_gpu),并在构建 SpeculativeConfig 时传入 method 和 prompt_lookup 参数,为测试提供必要的配置能力。

  3. 在 tests/v1/core/test_async_scheduler.py 中,新增测试函数 test_no_placeholder_underflow_on_discarded_spec_frame,创建具有 async_tokens_to_discard > 0 的请求,调用 update_from_output 后断言 num_output_placeholders 不变、computed_tokens 不变、async_tokens_to_discard 递减、请求状态保持 RUNNING。该测试在未修补的调度器上会失败,在修补后通过。

文件 模块 状态 重要度
vllm/v1/core/sched/scheduler.py 调度器 modified 5.88
tests/v1/core/test_async_scheduler.py 测试 modified 5.77
tests/v1/core/utils.py 测试工具 modified 4.58

关键符号

update_from_output create_scheduler test_no_placeholder_underflow_on_discarded_spec_frame

关键源码片段

vllm/v1/core/sched/scheduler.py core-logic

核心修改文件:在 update_from_output 中修复推测拒绝调整的条件,防止 num_output_placeholders 下溢。

# vllm/v1/core/sched/scheduler.py: update_from_output, 推测拒绝调整部分
            scheduled_spec_token_ids = (
                scheduler_output.scheduled_spec_decode_tokens.get(req_id)
            )
            # 跳过等待丢弃的过时帧(async_tokens_to_discard > 0),
            # 其重置前的拒绝计数会导致占位符下溢。
            if (
                scheduled_spec_token_ids
                and (generated_token_ids or self.num_sampled_tokens_per_step == 0)
                and request.async_tokens_to_discard == 0
            ):
                num_draft_tokens = len(scheduled_spec_token_ids)
                num_sampled = self.num_sampled_tokens_per_step
                num_accepted = max(len(generated_token_ids) - num_sampled, 0)
                num_rejected = num_draft_tokens - num_accepted
                if request.num_computed_tokens > 0:
                    request.num_computed_tokens -= num_rejected
                if request.num_output_placeholders > 0:
                    request.num_output_placeholders -= num_rejected
                # 更新统计等 ...
tests/v1/core/test_async_scheduler.py test-coverage

新增回归测试,覆盖边缘情况,确保 num_output_placeholders 不因过时帧而递减。

# tests/v1/core/test_async_scheduler.py
def test_no_placeholder_underflow_on_discarded_spec_frame():
    num_spec = 5
    scheduler = create_scheduler(
        async_scheduling=True,
        num_speculative_tokens=num_spec,
        speculative_method="ngram_gpu",
    )
    req = create_requests(num_requests=1, max_tokens=20)[0]
    req.num_computed_tokens = req.num_tokens
    scheduler.requests[req.request_id] = req
    scheduler.running.append(req)
    req.status = RequestStatus.RUNNING
​
    req.num_output_placeholders = 1
    req.async_tokens_to_discard = num_spec
    computed_before = req.num_computed_tokens
​
    scheduler_output = SchedulerOutput(
        scheduled_new_reqs=[],
        scheduled_cached_reqs=CachedRequestData.make_empty(),
        num_scheduled_tokens={req.request_id: num_spec + 1},
        total_num_scheduled_tokens=num_spec + 1,
        scheduled_encoder_inputs={},
        scheduled_spec_decode_tokens={req.request_id: [10] * num_spec},
        num_common_prefix_blocks=[],
        finished_req_ids=set(),
        free_encoder_mm_hashes=[],
    )
    model_runner_output = ModelRunnerOutput(
        req_ids=[req.request_id],
        req_id_to_index={req.request_id: 0},
        sampled_token_ids=[[999]],
        logprobs=None,
        prompt_logprobs_dict={},
        pooler_output=[],
    )
​
    scheduler.update_from_output(scheduler_output, model_runner_output)
​
    # 断言:占位符数量不变(未被减去拒绝数),computed_tokens 不变,
    # async_tokens_to_discard 减少 1(正常消耗),状态仍为 RUNNING
    assert req.num_output_placeholders == 1
    assert req.num_computed_tokens == computed_before
    assert req.async_tokens_to_discard == num_spec - 1
    assert req.status == RequestStatus.RUNNING

评论区精华

是否保留回归测试 测试

审核者 njhill 建议保留测试,认为边缘情况微妙且值得覆盖。作者最初因之前同事要求删除配置测试而移除,但最终恢复。

结论:保留回归测试,测试代码已恢复并合并。 · 已解决

风险与影响

风险极低。改动仅在 update_from_output 的推测拒绝调整处新增一个条件检查,非异步路径(async_tokens_to_discard 始终为 0)行为完全不变。异步调度但非推测解码路径不受影响(scheduled_spec_token_ids 为空)。仅异步调度 + 推测解码组合场景受影响,且修复是保守的跳过调整,不会引入新问题。唯一可能的隐患是如果未来其他路径修改 async_tokens_to_discard 的语义,但当前含义清晰。

影响范围:使用 --async-scheduling 和 --speculative-config 的用户。修复防止了潜在的 EngineCore 崩溃,提高了系统稳定性。对不使用这些功能的用户无影响。团队现在拥有 e2e 复现方法和单元测试,可防止回归。

async-scheduling-only spec-decode-edge-case

关联 Issue

#30142 [Bug]: `assert request.num_output_placeholders >= 0` can fail in async scheduling

完整报告

参与讨论