Prhub

#48245 [BugFix] Fix `num_output_placeholders` preemption underflow

原始 PR 作者 njhill 合并时间 2026-07-29 21:41 文件变更 5 提交数 12 评论 4 代码增减 +385 / -77

执行摘要

修复异步调度下 num_output_placeholders 下溢崩溃

Fix an EngineCore crash under async scheduling + KV-pressure preemption (most visible with spec decode / PP at high concurrency). Replaces PR #47900 whose discard-based approach caused CI failures. Also potentially fixes issue #41190.

该 PR 值得精读,尤其是 PipelinedEngine 测试模拟器的设计,展示了如何在不依赖 GPU 的情况下验证 PP 下的抢占竞争条件。修复方案中“保留 stale output 但跳过计数器更新”的权衡体现了对 spec decode 正确性的深入理解。

讨论亮点

ivanium 提出 Codex 提示的边缘情况:若 stale output 使可恢复流在 PREEMPTED 状态下停止,_handle_stopped_request() 可能将 session 重新入队到 skipped_waiting,但后续清理会将其从两个队列中移除,导致 session 悬空。ivanium 随后表示已通过 Codex 修复该问题,PR 最终获批。

实现拆解

  1. 新增状态字段vllm/v1/request.py):用 num_stale_output_tokensdrop_stale_output 替换旧的 async_tokens_to_discard
  2. 修改 _preempt_requestvllm/v1/core/sched/scheduler.py):新增 drop_stale_output 参数,将 num_in_flight_tokens 转移为 num_stale_output_tokens,清零 num_output_placeholders
  3. 调度时跳过未消耗完 stale 的请求scheduler.pyschedule 方法):若 num_stale_output_tokens > 0 且不丢弃,则延缓重新调度。
  4. 修改 _update_request_with_outputvllm/v1/core/sched/async_scheduler.py):新增 is_stale 参数,stale 输出跳过 placeholder 更新,移除旧的 async_tokens_to_discard
  5. 配套测试:引入 PipelinedEngine 模拟 PP 调度窗口,新增两个回归测试验证无下溢且 acceptance 不变。
文件 模块 状态 重要度
vllm/v1/core/sched/scheduler.py 调度器 modified 7.81
vllm/v1/core/sched/async_scheduler.py 异步调度器 modified 6.41
vllm/v1/request.py 请求模型 modified 5.63
tests/v1/core/test_async_scheduler.py 异步调度测试 modified 7.45
tests/v1/core/test_scheduler.py 调度测试 modified 4.65

关键符号

_preempt_request update_from_output _update_request_with_output Request.__init__

关键源码片段

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

核心抢占逻辑修改:`_preempt_request` 标记 stale output,`update_from_output` 同步减少 stale 计数,`schedule` 跳过未消耗完 stale 的请求。

def _preempt_request(
    self, request: Request, timestamp: float, drop_stale_output: bool = False
) -> None:
    '''Preempt a request, marking in-flight outputs as stale.'''
    assert request.status == RequestStatus.RUNNING
    self._free_request_blocks(request)
    self.encoder_cache_manager.free(request)
    self._inflight_prefills.discard(request)
    request.status = RequestStatus.PREEMPTED
    request.num_computed_tokens = 0
    if request.spec_token_ids:
        request.spec_token_ids = []
​
    # Transfer all in-flight tokens to stale counter.
    # Use assignment (not accumulate) because `num_in_flight_tokens`
    # already includes any previous stale share.
    request.drop_stale_output = drop_stale_output or (
        request.drop_stale_output and request.num_stale_output_tokens > 0
    )
    request.num_stale_output_tokens = request.num_in_flight_tokens
    request.num_output_placeholders = 0
    request.num_preemptions += 1
    if self.log_stats:
        request.record_event(EngineCoreEventType.PREEMPTED, timestamp)
vllm/v1/core/sched/async_scheduler.py core-logic

修改 `_update_request_with_output` 以处理 stale output,避免下溢断言。移除旧的 `async_tokens_to_discard` 逻辑。

def _update_request_with_output(
    self, request: Request, new_token_ids: list[int], is_stale: bool = False
) -> tuple[list[int], bool]:
    '''
    Updates request state with new token ids.
    If `is_stale`, skip placeholder decrement to avoid underflow.
    '''
    status_before_update = request.status
    new_token_ids, stopped = super()._update_request_with_output(
        request, new_token_ids
    )
​
    # Placeholders were zeroed by `_preempt_request`;
    # stale delivery must not decrement them.
    if not is_stale:
        request.num_output_placeholders -= len(new_token_ids)
        assert request.num_output_placeholders >= 0
​
    # Cache tokens only if the request was still RUNNING at output start.
    if status_before_update == RequestStatus.RUNNING:
        self.kv_cache_manager.cache_blocks(
            request,
            request.num_computed_tokens - request.num_output_placeholders,
        )
    return new_token_ids, stopped

评论区精华

Stale output 导致 session 悬空的风险 设计

ivanium 指出 Codex 提示的边界情况:当 stale output 使一个可恢复的流请求在 PREEMPTED 状态下停止时,`_handle_stopped_request()` 将 session 重新入队到 `skipped_waiting`,但后续清理可能将该请求从两个队列中移除,导致 session 悬空。另外,`num_computed_tokens` 在 preemption 后仍为 0,`_update_request_as_session()` 修剪 `_all_token_ids` 时可能出错。

结论:ivanium 随后表示已让 Codex 修复该边缘情况,最终 PR 获得批准。 · 已解决

风险与影响

改动集中在抢占路径,不影响正常调度。新状态 num_stale_output_tokens/drop_stale_output 需与 num_in_flight_tokens 协同维护,若不一致可能导致某些输出被错误处理。update_from_outputoutput_is_stale 判断逻辑保持对称是维护关键。经过新增的 PP 模拟测试和 e2e 测试,回归风险低。

对用户透明,仅修复崩溃。影响场景为 v1 async scheduling 下触发 KV 抢占的请求,尤其是使用 speculative decoding 或 pipeline parallelism 时。测试覆盖了这些场景,无行为变更。

抢占路径逻辑复杂 新增状态变量需谨慎维护

关联 Issue

#41190 [Bug]: TP=2 spec-decode (qwen3_next_mtp / DFlash) on Qwen3.6 hybrid GDN crashes at gpu_model_runner.py:1927 num_accepted_tokens_event.synchronize() (cudaErrorIllegalAddress)

完整报告

参与讨论