Prhub

#42117 [bug] AsyncScheduler drops first post-resume token after pause_generation + clear_cache

原始 PR 作者 hao-aaron 合并时间 2026-05-19 16:06 文件变更 4 提交数 4 评论 3 代码增减 +13 / -15

执行摘要

修复异步调度器在恢复后丢弃首个 token 的 bug

修复由 issue #42043 报告的 CI 失败:pause_generation(mode="keep") 配合默认 clear_cache=True 导致 AsyncScheduler 静默丢弃恢复后的第一个有效 token,后续 token 整体偏移一位。该 bug 由 #41421 切换默认 executor 后端触发,但根本原因在于 reset_prefix_cache 中无条件设置 discard_latest_async_tokens=True,即使引擎已排空(例如 pause_generation 等待空闲后)也会错误丢弃 token。

该 PR 值得精读,尤其对理解 vLLM 异步调度器如何处理抢占和恢复的工程师。关键设计决策:用精确计数器替代布尔标志,从而支持流水线深度 >1 的场景(如投机解码)。建议后续补充单元测试覆盖连续抢占场景。

讨论亮点

gemini-code-assist[bot] 在 review 中提出一个高优先级问题:如果请求在短时间内被多次抢占,async_tokens_to_discard 会被覆盖而非累加,导致部分过期输出可能未被正确丢弃。例如请求有 2 个在途 token 被抢占设置为 2,重新调度后又产生 2 个在途 token,再次抢占时计数器被重置为 2 而非累加成 4。不过,根据因果分析,reset_prefix_cache 会抢占所有 running 请求并立即重置所有状态,之后请求会重新进入 waiting 队列从头调度,因此在再次抢占之前不会产生新的在途帧——但若存在嵌套或并发抢占路径,仍可能存在风险。该评论未被作者或合并者回复或处理。

实现拆解

  1. Request 数据结构变更 (vllm/v1/request.py):移除布尔字段 discard_latest_async_tokens,新增整数字段 async_tokens_to_discard(默认 0)。
  2. Scheduler.reset_prefix_cache 调整 (vllm/v1/core/sched/scheduler.py):在抢占循环中,将原来的 request.discard_latest_async_tokens = True; request.num_output_placeholders = 0 改为 request.async_tokens_to_discard = request.num_output_placeholders; request.num_output_placeholders = 0。通过从 num_output_placeholders 读取实际在途帧数,精确控制需要丢弃的数量,而非粗暴丢弃一个。
  3. AsyncScheduler._update_request_with_output 调整 (vllm/v1/core/sched/async_scheduler.py):将 if request.discard_latest_async_tokens: 改为 if request.async_tokens_to_discard > 0:,每次丢弃后递减计数器,直到归零。
  4. RLHF 示例移除 workaround (examples/rl/rlhf_async_new_apis.py):删除两处 async_scheduling=False 及其 TODO 注释,重新启用异步调度。
文件 模块 状态 重要度
vllm/v1/core/sched/scheduler.py 调度器 modified 6.26
vllm/v1/core/sched/async_scheduler.py 调度器 modified 6.09
examples/rl/rlhf_async_new_apis.py 示例脚本 modified 5.6
vllm/v1/request.py 请求模型 modified 4.99

关键符号

reset_prefix_cache _update_request_with_output __init__

关键源码片段

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

核心修复位置:在 reset_prefix_cache 中从 num_output_placeholders 精确初始化 async_tokens_to_discard,替代硬编码的 True。

# vllm/v1/core/sched/scheduler.py (reset_prefix_cache 局部 )
while self.running:
    request = self.running.pop()
    self._preempt_request(request, timestamp)
    # 异步调度中,抢占时已发出的输出帧返回后即为 " 过期 " 数据,
    # 必须丢弃。num_output_placeholders 恰好记录了这些在途帧数量:
    # - 若引擎已排空(如 pause_generation 等待空闲后),值为 0;
    # - 普通异步调度中间步骤抢占,值为 1;
    # - 投机解码或流水线并行场景,值为 1 + spec_tokens 数。
    # 精确记录此值而非设 True,可避免在排空场景下错误丢弃首个有效 token。
    request.async_tokens_to_discard = request.num_output_placeholders
    request.num_output_placeholders = 0
vllm/v1/core/sched/async_scheduler.py core-logic

消费端逻辑:将布尔标志的贪婪丢弃改为计数器逐次递减,支持多个在途帧的精确丢弃。

# vllm/v1/core/sched/async_scheduler.py
class AsyncScheduler(Scheduler):
    # ... 其他方法 ...
    def _update_request_with_output(
        self, request: Request, new_token_ids: list[int]
    ) -> tuple[list[int], bool]:
        # 如果还有需要丢弃的过期输出帧,消耗一个并返回空(不更新 token)
        if request.async_tokens_to_discard > 0:
            # 请求在 reset_prefix_cache 中被强制抢占;每次调用丢弃一个
            # 过期的在途异步输出帧,直到计数器归零。
            request.async_tokens_to_discard -= 1
            return [], False
​
        # 正常输出处理 ...
        status_before_update = request.status
        new_token_ids, stopped = super()._update_request_with_output(
            request, new_token_ids
        )
        request.num_output_placeholders -= len(new_token_ids)
        # ... 剩余处理
examples/rl/rlhf_async_new_apis.py entrypoint

移除 workaround:删除两处 async_scheduling=False 及其 TODO 注释,验证修复生效。

# examples/rl/rlhf_async_new_apis.py(局部)
llm_kwargs = dict(
    model=MODEL_NAME_V1,
    enforce_eager=True,
    max_model_len=8192,
    distributed_executor_backend="ray",
    attention_backend=ATTN_BACKEND,
    gpu_memory_utilization=0.75,
    weight_transfer_config=WeightTransferConfig(backend="nccl"),
    # 已移除 async_scheduling=False 及其 TODO 注释(issue#42043 已修复)
)

评论区精华

async_tokens_to_discard 在多次抢占时被覆盖而非累加 正确性

gemini-code-assist[bot] 指出,如果请求在短时间被连续抢占多次,`async_tokens_to_discard` 会被覆盖重置,而非累加,可能导致过期 token 未被完全丢弃。

结论:未在 PR 中得到作者或合并者回应;但鉴于当前代码路径中 `reset_prefix_cache` 会清空所有 running 请求,重新调度后才可能再次抢占,实际触发累加场景可能性较低。 · unresolved

风险与影响

  1. 多次抢占计数器覆盖风险:如 review 评论所述,async_tokens_to_discard 在连续抢占时被覆盖而非累加。虽然当前代码逻辑中 reset_prefix_cache 会清空所有 running 请求,重新调度后才可能再次抢占,但若存在例外路径,可能导致部分过期 token 未被丢弃。
  2. 回归风险低:变更逻辑清晰,仅替换布尔标志为精确计数器,且 RLHF 示例重新启用异步调度后可正常通过测试(如 issue 所述)。
  3. 缺少单元测试:PR 未新增针对 reset_prefix_cache + pause_generation 组合场景的单元测试,CI 覆盖依赖端到端 RLHF 示例。

影响范围:V1 调度器核心逻辑(AsyncScheduler),主要影响使用 pause_generationclear_cache 的 RLHF/DPO 等训练场景。生产环境受影响较小(RLHF 损失被噪声吸收),但修复后确保正确性。影响程度:中等。修复了确定性的 token 丢失 bug,对依赖精确 token 对齐的验证流程至关重要。

可能的多重抢占计数器覆盖

关联 Issue

#42043 [CI Failure][Bug] AsyncScheduler drops first post-resume token after pause_generation(mode="keep") + clear_cache

完整报告

参与讨论