Prhub

#46974 [BugFix][MRV2] Ensure all req slots are accounted for when scheduling

原始 PR 作者 njhill 合并时间 2026-07-03 01:19 文件变更 2 提交数 3 评论 0 代码增减 +223 / -5

执行摘要

修复 V2 ModelRunner 调度时请求槽计数遗漏

V2 Model Runner 使用固定大小的槽位数组(大小 = max_num_seqs),从请求出现在 scheduled_new_reqs 到出现在 finished_req_ids/preempted_req_ids 期间占用一个槽。调度器必须保证:任何仍持有一个工作槽的请求都计入准入限制。PR 指出两种边界情况违反了该不变式:等待流式输入的请求(WAITING_FOR_STREAMING_REQ)不在运行队列但占用槽;被外部抢占的请求(如 reset_prefix_cache)可能被新请求覆盖。修复并添加回归测试。

建议所有使用 V2 Model Runner 的团队同步此修复。测试文件的 WorkerSlots 模拟类设计值得学习,可复用至其他需要验证调度器余额的场景。PR 提交清晰,改动集中,可快速 Code Review。

讨论亮点

该 PR 无 review 评论线程。审定者 WoosukKwon 直接批准合并。PR body 由作者阐述清楚动机和方案。

实现拆解

实现步骤

  1. 新增状态跟踪:在 Scheduler.__init__(vllm/v1/core/sched/scheduler.py)中初始化 self.reset_preempted_req_ids: set[str],用于累积两次 schedule() 调用之间发生的抢占请求 ID。在 _preempt_request 方法中,将请求 ID 加入该集合。

  2. 修正准入计数:在 schedule() 主循环中,将运行队列长度判断改为 len(self.running) + self.num_waiting_for_streaming_input >= self.max_num_running_reqs,将等待流式输入的请求也视为占用槽位,从而防止在它们仍在占用时额外准入新请求。

  3. 传递抢占 ID:在构建 SchedulerOutput 时,将 preempted_req_ids 参数改为 self.reset_preempted_req_ids,确保之前步骤中发生的抢占也被包含,而非仅当前步骤抢占了哪些。

  4. 正确清空:在 _update_after_schedule 中,除了清空 finished_req_ids,也清空 self.reset_preempted_req_ids,保证状态在步骤间重置(通过创建新集合避免引用问题)。

  5. 添加回归测试:新增 tests/v1/core/test_worker_slot_overflow.py,包含 WorkerSlots 类,忠实模拟工作槽行为(apply 方法在溢出时抛出相同断言错误)。三个测试用例分别覆盖流式暂停不超限、reset_prefix_cache 优先级模式不超限、同一步骤抢占后恢复并重新添加的场景。

文件 模块 状态 重要度
tests/v1/core/test_worker_slot_overflow.py 槽位测试 added 7.91
vllm/v1/core/sched/scheduler.py 调度器 modified 6.31

关键符号

WorkerSlots.__init__ WorkerSlots.apply _model_runner_output test_streaming_pause_does_not_over_admit_worker_slot test_reset_prefix_cache_priority_does_not_over_admit_worker_slot test_reset_prefix_cache_same_step_resume_purges_then_re_adds Scheduler.__init__ Scheduler.schedule Scheduler._preempt_request Scheduler._update_after_schedule

关键源码片段

tests/v1/core/test_worker_slot_overflow.py test-coverage

新增的回归测试文件,覆盖了导致“No free indices”断言失败的两个边界场景,并通过 WorkerSlots 模拟类验证调度器槽位计数的正确性。

class WorkerSlots:
    """Faithful model of the V2 model runner's request-slot accounting.
​
    Mirrors ``GPUModelRunner.finish_requests`` + ``add_requests`` and
    ``RequestState.add_request``/``remove_request`` (the ``free_indices`` pool).
    ``apply`` raises ``AssertionError("No free indices")`` exactly where the real
    worker would, so a scheduler over-admission surfaces as the same failure.
    """
​
    def __init__(self, max_num_reqs: int):
        self.max_num_reqs = max_num_reqs
        self.occupied: set[str] = set()
​
    def apply(self, scheduler_output) -> None:
        # finish_requests: free finished and preempted slots first.
        freed = set(scheduler_output.finished_req_ids)
        freed |= set(scheduler_output.preempted_req_ids)
        self.occupied -= freed
​
        # update_requests: cached reqs must already own a slot, else the
        # worker's req_id_to_index lookup would KeyError.
        for req_id in scheduler_output.scheduled_cached_reqs.req_ids:
            assert req_id in self.occupied, f"cached req {req_id!r} has no worker slot"
​
        # add_requests: new (and, under V2, resumed) reqs claim a slot. The
        # same-id _remove_request guard frees a stale slot for the same id.
        for new_req in scheduler_output.scheduled_new_reqs:
            self.occupied.discard(new_req.req_id)
            assert len(self.occupied) < self.max_num_reqs, "No free indices"
            self.occupied.add(new_req.req_id)
​
​
def _model_runner_output(req_ids: list[str], sampled: list[list[int]]):
    return ModelRunnerOutput(
        req_ids=req_ids,
        req_id_to_index={req_id: i for i, req_id in enumerate(req_ids)},
        sampled_token_ids=sampled,
        logprobs=None,
        prompt_logprobs_dict={},
        pooler_output=[],
    )
vllm/v1/core/sched/scheduler.py core-logic

包含核心调度器变更:新增 reset_preempted_req_ids 跟踪外部抢占;修正准入计数包含等待流式输入请求;以及在 SchedulerOutput 中正确传递抢占 ID。

# In __init__:
self.reset_preempted_req_ids: set[str] = set()
​
# In schedule():
# Paused streaming sessions (WAITING_FOR_STREAMING_REQ) are not
# in `running` but still hold a model-runner request slot.
num_running = len(self.running) + self.num_waiting_for_streaming_input
if num_running >= self.max_num_running_reqs:
    break
​
# In SchedulerOutput construction:
preempted_req_ids=self.reset_preempted_req_ids,
​
# In _preempt_request:
self.reset_preempted_req_ids.add(request.request_id)
​
# In _update_after_schedule:
self.finished_req_ids = set()
self.reset_preempted_req_ids = set()

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

风险较低。主要变更是对调度器计数逻辑的修正,并且新增了专门测试覆盖。可能的风险在于:若 num_waiting_for_streaming_input 计数不准确(例如在其他地方未同步更新),仍可能导致误判;reset_preempted_req_ids 可能与其他抢占逻辑(如正常抢占)重叠,导致重复报告,但测试表明其行为正确。另外,变更仅影响 V2 Model Runner 路径,默认未对所有模型启用。

影响使用 V2 Model Runner(默认未对所有模型启用)的用户,修复了在流式暂停和强制清空缓存时可能发生的断言失败,提升了系统稳定性。对系统性能无负面影响。对团队而言,新增测试可作为未来调度器变更的回归保护。

核心调度逻辑修改 边界场景修复 新增测试覆盖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论