Prhub

#49206 fix: resolve silent request skipping in PRIORITY scheduling

原始 PR 作者 Tejas-Raj01 合并时间 2026-08-07 06:21 文件变更 2 提交数 7 评论 11 代码增减 +159 / -2

执行摘要

修复 PRIORITY 调度抢占后请求被静默跳过

issue #49097 指出:在 SchedulingPolicy.PRIORITY 下,preempted_req 通过 max(self.running, key=lambda r: (r.priority, r.arrival_time)) 选出,与列表位置无关;而 req_index 只在 victim 已在本轮被调度(在 scheduled_running_reqs 中)时才递减。当 victim 位于游标之前但本轮被 DP prefill 节流、pipeline 资格门控或 encoder 预算耗尽等 continue 路径跳过时,self.running.remove() 使后续元素左移而游标不动,下一次迭代就会越过本应处理的请求,造成整个 step 的静默跳过。PR body 也明确说明这是 req_index 记账未正确调整导致的逻辑 bug。

值得精读。虽然源码改动仅 12 行,但这是典型的“遍历中删除元素导致游标错位”的经典陷阱,且该 bug 为静默错误(不报错、不崩溃,只表现为请求被跳过),复现与根因分析过程很有教学价值;回归测试用三个不同优先级请求精确构造触发路径,适合作为调度器单元测试的模板。关注点:req_index 与 self.running 删除位置的关系,以及 FCFS/PRIORITY 两条抢占分支的对比。

讨论亮点

njhill:should be slightly more efficient —— 既然已经用 index() 拿到了下标,直接 del self.running[victim_index] 即可,避免 remove() 再做一次线性查找。作者回复 @njhill Done,最终版本采纳。
njhill(nit):两处多余的空行与注释行建议删除,保持补丁最小化。作者均已落实。
njhill 最终给出 APPROVED,CI 由维护者触发(Buildkite CI #82707)。

实现拆解

  1. 根因定位:issue #49097 结合 base 版本 scheduler.py 第 591-614 行确认,PRIORITY 抢占分支仅在 victim 属于 scheduled_running_reqs 时执行 req_index -= 1;对“位于游标之前但本轮被跳过”的 victim 缺少处理,remove() 造成的左移使外层循环访问错位。
  2. 核心修复:在 vllm/v1/core/sched/scheduler.py 的 PRIORITY 分支中,先用 self.running.index(preempted_req) 记录 victim_index,再用 del self.running[victim_index] 删除;当 victim_index < req_index 时递减 req_index。同时把原来 scheduled_running_reqs 分支内的 req_index -= 1 移除——因为已调度 victim 必在游标之前,统一由新条件覆盖,逻辑等价且更完整。
  3. 回归测试:新增 tests/v1/core/test_priority_preemption_bug.py,构造 A(priority=9、200 token、最早到达)、B(priority=0、15 token)、C(priority=1、1 token)三个请求,在 throttle_prefills=True 触发抢占时断言 C 仍出现在 num_scheduled_tokens 中且保持 RequestStatus.RUNNING。
  4. Review 调整:按 njhill 建议用 del 替代 remove 避免二次线性查找,并清理多余空行与注释;随后通过多次 merge main 同步后合入。
文件 模块 状态 重要度
vllm/v1/core/sched/scheduler.py 调度器 modified 6.18
tests/v1/core/test_priority_preemption_bug.py 调度器 added 7.25

关键符号

Scheduler.schedule test_priority_scheduler_preempt_skipped_request build_scheduler make_request mock_output

关键源码片段

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

修复点所在:PRIORITY 抢占分支中记录 victim 下标并按位置条件递减 req_index,消除请求静默跳过;同时把 req_index 调整从 scheduled_running_reqs 分支统一外提,逻辑更完整。

# 请求无法获得 KV 块时,进入抢占循环
while True:
    new_blocks = self.kv_cache_manager.allocate_slots(
        request,
        num_new_tokens,
        num_lookahead_tokens=self.num_lookahead_tokens,
    )
    if new_blocks is not None:
        break # 分配成功,正常调度
​
    # PRIORITY 策略:抢占全局最低优先级请求作为 victim
    if self.policy == SchedulingPolicy.PRIORITY:
        preempted_req = max(
            self.running,
            key=lambda r: (r.priority, r.arrival_time),
        )
        # 先记录 victim 在 running 列表中的下标,再删除
        victim_index = self.running.index(preempted_req)
        del self.running[victim_index]
        # 关键修复:若 victim 位于当前迭代游标之前(即便本轮
        # 只是被 continue 跳过、尚未调度),删除后列表左移,
        # 必须同步递减 req_index,否则下一请求会被静默跳过
        if victim_index < req_index:
            req_index -= 1
​
        if preempted_req in scheduled_running_reqs:
            # victim 本轮已调度过,回收其 token 预算与 KV 分配
            preempted_req_id = preempted_req.request_id
            scheduled_running_reqs.remove(preempted_req)
            token_budget += num_scheduled_tokens.pop(preempted_req_id)
            req_to_new_blocks.pop(preempted_req_id)
            scheduled_spec_decode_tokens.pop(preempted_req_id, None)
            preempted_encoder_inputs = scheduled_encoder_inputs.pop(
                preempted_req_id, None
            )
            if preempted_encoder_inputs:
                # 恢复被抢占请求占用的 encoder 计算预算
                num_embeds_to_restore = sum(
                    preempted_req.get_num_encoder_embeds(i)
                    for i in preempted_encoder_inputs
                )
                encoder_compute_budget += num_embeds_to_restore
    else:
        # FCFS 策略:直接弹出队尾请求
        preempted_req = self.running.pop()
​
    self._preempt_request(
        preempted_req,
        scheduled_timestamp,
        drop_stale_output=self.requires_kv_delivery,
    )
    preempted_reqs.append(preempted_req)
    if preempted_req == request:
        # 已无其他请求可抢占,当前请求本轮无法调度
        break
tests/v1/core/test_priority_preemption_bug.py test-coverage

新增回归测试,用三个不同优先级请求复现抢占路径下的游标错位,断言中等优先级请求 C 在节流抢占后仍被调度并保持 RUNNING。

# 前置:build_scheduler() 内部以 SchedulerConfig(policy="priority", ...) 构建,
# 且 KV 池仅 6 块(NUM_BLOCKS = 6),保证后续抢占真实发生
def test_priority_scheduler_preempt_skipped_request():
    scheduler = build_scheduler()
​
    # A:最差优先级且最早到达,会被选为抢占 victim;
    # B:最好优先级;C:中等优先级,位于 A 与 B 之间
    A = make_request("A", num_tokens=200, priority=9, arrival_time=1.0)
    scheduler.add_request(A)
    out = scheduler.schedule()
    scheduler.update_from_output(out, mock_output(out))
​
    B = make_request("B", num_tokens=15, priority=0, arrival_time=2.0)
    scheduler.add_request(B)
    out = scheduler.schedule()
    scheduler.update_from_output(out, mock_output(out))
​
    C = make_request("C", num_tokens=1, priority=1, arrival_time=3.0)
    scheduler.add_request(C)
    out = scheduler.schedule()
    scheduler.update_from_output(out, mock_output(out))
​
    # 触发 prefill 节流,迫使 C 抢占 A;旧代码因游标错位
    # 会让 C 本轮静默跳过,修复后 C 应正常出现在调度输出中
    out = scheduler.schedule(throttle_prefills=True)
    is_c_scheduled = "C" in out.num_scheduled_tokens
    assert is_c_scheduled, (
        "Bug present: Request C was silently skipped during scheduling "
        "because the req_index was not decremented after preempting A."
    )
    assert C.status == RequestStatus.RUNNING

评论区精华

用 del 替代 remove 提升删除效率 性能

njhill 建议既然已用 self.running.index() 取得 victim 下标,应直接 del self.running[victim_index],避免 remove() 再做一次 O(n) 线性查找(should be slightly more efficient)。

结论:作者回复 @njhill Done,最终提交采用 del 写法。 · 已解决

删除多余空行与注释的 nit 建议 style

njhill 针对新增代码提出两处 nit:删除 victim_index 前的空行、删除多余的注释行,保持补丁最小。

结论:作者已按要求清理,最终版本保留 4 行必要注释。 · 已解决

风险与影响

  • 核心路径变更:Scheduler.schedule() 是 vLLM v1 每步执行的热点路径,PRIORITY 分支的游标逻辑影响所有启用 PRIORITY 策略的请求的调度正确性;虽然修复仅 10 行,但属于核心调度语义变更。
  • 逻辑等价性验证:req_index -= 1 从 scheduled_running_reqs 分支外提后,需确认已调度 victim 必满足 victim_index < req_index(按循环顺序遍历成立),新条件覆盖原逻辑且补上了缺失分支。
  • 复杂度:index() 与 del 均为 O(n),与原 remove() 同级,无性能退化;但每次抢占多一次线性扫描,在超大规模 running 列表下可忽略。
  • 测试覆盖局限:回归测试仅覆盖 PRIORITY + 单抢占场景,DP 多请求、encoder 预算回收等分支未有专门用例。
  • 用户影响:PRIORITY 策略下,KV 压力触发抢占时不再出现请求整步静默饥饿,延迟与吞吐更可预期;FCFS 等其他策略走 pop() 分支,行为完全不变。
  • 系统影响:调度器输出更稳定,preempted_reqs、scheduled_running_reqs 与 token 预算回收逻辑保持一致。
  • 团队影响:新增回归测试为后续调度器改动提供保护网,特别是对索引记账类改动。
核心路径变更 索引游标易回归 仅 PRIORITY 策略受影响

关联 Issue

#49097 [Bug]: PRIORITY scheduling can silently skip a running request for a full step when the preemption victim was already deferred earlier in the same schedule() call

完整报告

参与讨论