nano-vllm-omni · 提示 ← P11P06 →
#16 · Track B · Stage 进程与调度 · 书 p.72

P07 · Scheduler:continuous batching + chunked prefill + 抢占

nano-vllm 的核心:token budget、并发上限、KV block 上限三重约束下,每步决定谁跑、跑几个 token——还要支持 chunked prefill 和 recompute 抢占。

⏱ 75 min 难度 难 实现 mine/p07_scheduler.py 验收 ./check.sh p07 提示 A-16 · 参考解 C-16 solutions/p07_scheduler.py

题面速览

budget = max_num_batched_tokens
1) running(按顺序):n = min(剩余未算 token, budget)     # decode 时为 1;chunked prefill 未完时更多
   block 不够 → 抢占 running 中最后一个(最晚加入):释放 block、num_computed=0、放回 waiting 队首
            → 重试;如果被抢占的就是它自己,则跳过它
2) waiting(严格 FCFS):len(running) < max_num_seqs 且 budget > 0 时,n = min(剩余, budget)
   第一个 block 放不下的请求就停止(后面的不许插队)
例子(budget=16,三个 prompt 长 10)
step1: s0:10  s1:6            # s1 只做了一部分 prefill
step2: s0:1   s1:4   s2:10    # s0 decode;s1 prefill 剩 4;s2 整段
step3: s0:1   s1:1   s2:1

验收要点:token budget 与 chunked prefill 的精确调度结果 · max_num_seqs 限制 · FCFS:队首放不下时后面的小请求也不能插队 · KV 极小时发生抢占,所有请求最终正确完成且输出不变 · EOS 提前结束并释放 block。

直觉与类比

餐厅的“每轮上菜”调度

waiting = 门口排队的客人(严格按到店顺序);running = 已坐下的桌子;max_num_seqs = 一共几张桌子;KV block = 桌面面积(越吃越久餐具越多,只增不减,直到他走);token budget = 厨房每一轮能出多少个菜——一桌 10 人的大团一轮出不完就先出一部分(chunked prefill)。抢占 = 桌面实在不够时,请最后坐下的那桌先回到队伍最前面:他们吃掉的(已生成的 token)不用吐出来,但餐具被收走了(KV 释放),回来得重新摆一遍(recompute)。

抢占 = 请回队首,不是赶出剧院

抢占不是取消请求:丢的只是缓存(KV),不是进度(已生成的 token)。用户完全看不出发生过抢占:最终输出与从不抢占时逐 token 相同,只是慢了一点——这是本题最容易写错、也最值得自己验证的性质。

框架视角 · 两个阶段 · 三本预算 · 六条不变量

P07 是单线程纯状态机,难点是预算记账和队列搬动必须同时正确。

预算 谁持有 / 怎么计量 扣 退
token budget 局部变量,每次 schedule() 重置 每成功调度一个序列 budget -= n,n = min(num_tokens − num_computed, budget) 本步不退;下一步重新满额
running 槽位 len(self.running),上限 max_num_seqs 阶段 2 接纳一个就 append 到末尾(末尾 = 最低优先级 = 抢占首选) 抢占或结束时 running.remove
KV block blocks_used 与每个 seq.num_blocks 两处同时记 _try_grow(seq, num_computed + n):算出增量 need,放得下才同时改两处,否则一点不改返回 False 抢占 / 结束:blocks_used -= seq.num_blocks; seq.num_blocks = 0
阶段 遍历什么 放不下怎么办 终止条件
阶段 1 running self.running,按下标 while(抢占会改列表) 抢占 running[-1] 然后重试同一个序列;受害者是自己 → 跳过、下标不前进 下标到末尾,或 budget == 0
阶段 2 waiting 只看 self.waiting[0](FCFS) break,不许跳过它 waiting 空 / budget == 0 / running 满
不变量 违反时的症状
I1 blocks_used == sum(s.num_blocks for s in running) ≤ num_blocks;全部结束后为 0 block 泄漏:后面谁都调度不进来,或超发导致真实系统 OOM
I2 sum(schedule().values()) ≤ max_num_batched_tokens,且每个 n ≥ 1 batch 超预算 → 激活显存爆;n == 0 的序列白占一行
I3 0 ≤ num_computed ≤ num_tokens;只有相等时才允许采样 chunked prefill 中途就采样 → 多出 token、提前达到 max_tokens
I4 抢占只重置 num_computed、num_blocks、status;output 必须保留 最终输出与无抢占时不同(最难查)
I5 被抢占者进 waiting 队首;waiting 内部始终是到达顺序 被抢占的请求饿死;压力下顺序乱掉,延迟长尾爆炸
I6 每个序列同时只在 waiting、running、finished 之一 同一请求被调度两次,或结束后仍被调度

易错点

bug 症状
按 num_tokens 而不是 num_computed+n 算 block chunked prefill 时提前占满 block,无谓抢占
被抢占后没把 num_computed 归零 重新进入时只调度 1 个 token:KV 已随 block 释放却没有重算(模型读到别人的 KV,不报错)
抢占时把 output 也清空了 最终 output 与无抢占时不同
放回 waiting 尾部 被抢占的序列饿死,或在压力下顺序乱掉
prefill 未完成也调用 sample_fn output 多出 token,max_tokens 提前达到
阶段 2 用 continue 跳过放不下的 违反 FCFS:例 3 中 a3 会被调度
遍历 running 用 for 抢占时 pop 让迭代器跳元素或访问到已被抢占的序列
update 不知道本步调度了谁 update 没有参数,必须把 schedule() 结果存进实例字段(返回副本)
先判 eos 再 append eos 本身也是输出的一部分:先 append 再判 tok == eos or len(output) >= max_tokens

逐步图:抢占别人(例 2)

▶ num_blocks=3, block_size=2, budget=64;a1(prompt 2, max 3)、a2(prompt 3, max 2);aN 的第 k 个输出 = 100·N + k
1. step 1

{a1: 2, a2: 3}:a1 1 块、a2 2 块 → blocks_used = 3(满了)

blocks:a1a2a2;waiting []

2. step 2:a1 要第 2 块,放不下

a1 要持有 3 个 token → 需要 2 块,+1 → 4 > 3 ✗ → 抢占 running[-1] = a2:释放 2 块、num_computed=0、放回 waiting 队首,num_preemptions = 1 → 重试 a1 成功。

阶段 2:a2 需要 ceil(4/2)=2 块,2+2 > 3 → break。返回 {a1: 1}。

blocks:a1a1·;waiting [a2]

3. step 3

{a1: 1}:a1 第 3 个输出,结束并释放 → blocks_used = 0。a2 仍放不下(同一步 a1 还占着)。

4. step 4:recompute

{a2: 4}:prompt 3 + 已生成 1 = 4 个 token 一次算完,采样出第 2 个输出,结束。

最终 a1.output = [100, 101, 102],a2.output = [200, 201]——与没有抢占时完全一样。

三级提示

L1I/O 示例与关键问题I/O 示例 + 关键问题 + 细节(轻度剧透)

例 1:token budget + chunked prefill(num_blocks=100, block_size=4, max_num_seqs=8, budget=8;a1(prompt 6, max 3)、a2(prompt 5, max 2)、a3(prompt 3, max 1);每步 schedule 后立刻 update):

step schedule() update 后 (num_computed / num_tokens / num_blocks) blocks_used 本步结束
1 {a1: 6, a2: 2} a1 6/7/2;a2 2/5/1(prefill 没完,不采样);a3 仍 waiting 3 -
2 {a1: 1, a2: 3, a3: 3} a1 7/8/2;a2 5/6/2(这一步才采样第一个 token);a3 采样 1 个即达 max 4 a3
3 {a1: 1, a2: 1} a1 输出 3 个、a2 输出 2 个,均达 max 0 a1, a2

step 1:a1 先拿 6,剩 budget 2 给 a2。step 2:running 先排:a1 decode 1,a2 剩余 3;budget 还剩 4,a3 整段进入。block 数按“已算 + 本步要算”算:step 1 后 a2 只持有 2 个 token → 1 块。

例 2:见逐步图。例 3:FCFS 队首阻塞(num_blocks=5, block_size=4, budget=100;a1(9)、a2(9)、a3(1)):schedule() → {"a1": 9},blocks_used 3,waiting ["a2", "a3"]——a2 需要 3 块但只剩 2 块 → 停;a3 只要 1 块、放得下,但也不许插队。

关键问题

  1. 一个正在 decode 的序列(刚采样了一个 token)下一步 num_tokens − num_computed 等于多少?chunked prefill 做了一半的呢?
  2. 判断 block 放不放得下时按多少个 token 算:num_tokens 还是 num_computed + n?例 1 step 1 的 a2 分别几块?
  3. running 中的 X 放不下时被抢占的是谁?抢占后继续处理 X 还是跳过?被抢占的恰好是 X 自己呢——下标要不要前进?
  4. 被抢占的序列哪些字段重置、哪些保留?重新进入时要算多少个 token?放到 waiting 哪一端?
  5. 阶段 2 遇到第一个放不下的:continue 还是 break?max_num_seqs 限制哪个阶段?
  6. update 里什么情况下被调度的序列不调用 sample_fn?结束时除了改 status 还要做哪两件事?
  7. update 没有参数,“上一步调度了谁、各几个 token”存哪?

细节

  • num_tokens = prompt_len + len(output);decode 时剩余就是 1(刚采样的那个还没算 KV)——decode 和 prefill 用同一个公式,这就是 vLLM v1 调度器统一的地方。
  • block 增量 need = ceil((num_computed + n) / bs) − seq.num_blocks(≤0 不用动)。
  • 两个阶段共用一个 budget;budget 用完后后面的 running 序列本步不被调度(不是抢占)。
  • 被抢占的是最晚进来的,但仍比 waiting 里所有序列都早,所以放队首;它在同一步的阶段 2 也会被尝试。
  • 结束:status=FINISHED、移出 running、释放 block、放进 finished。
L2思路与常见坑思路拆分与常见坑(中度剧透)
  • 先写三个 helper:_blocks_for(n)(向上取整)、_try_grow(seq, n_tokens) -> bool(放得下就同时更新 blocks_used 和 seq.num_blocks,否则什么都不改)、_preempt(seq)(从 running 移除、释放 block、num_computed=0、status=WAITING、waiting.appendleft、计数+1)。
  • 阶段 1 用下标 while;内层 while not _try_grow(seq, ...):抢占 running[-1];若它就是 seq 则跳出并标记“本序列被跳过”。只有成功才写 scheduled、扣 budget、下标+1。(Python 的 while … else 很适合)
  • 阶段 2:while waiting and budget > 0 and len(running) < max_num_seqs:看队首,_try_grow 失败就 break;成功则 popleft、RUNNING、append、记录 n、扣 budget。
  • 把 schedule 结果存进 self._scheduled(返回副本);update 遍历它,结束后清空。update 中先建 {seq_id: seq},因为遍历中会把结束的序列从 running 删除。
L3详解:答案与追踪详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
  1. decode:1;chunked prefill:prompt 剩下没算的部分。
  2. num_computed + n。例 1 step 1 的 a2:按 num_tokens=5 是 2 块,按 0+2=2 是 1 块;正确是 1 块。
  3. running 的最后一个。抢占别人后重试 X;X 自己被抢占则跳过,下标不前进(running 已变短)。
  4. 重置:num_computed=0、num_blocks=0(并从 blocks_used 减掉)、status=WAITING;保留:output。重新算 prompt_len + len(output) 个 token。放队首。
  5. break。max_num_seqs 只在阶段 2 作为准入条件。
  6. num_computed 加完后仍 < num_tokens(prefill 块没做完)时不采样。结束时:从 running 移除并释放 block、放进 finished(并返回)。
  7. 实例字段,例如 self._scheduled。

追踪:抢占自己(num_blocks=3, block_size=2, budget=64;a1、a2 都是 prompt 2、max 3):

step 阶段 1 阶段 2 返回 blocks_used(update 后) a2.output
1 running 为空 a1 n=2 → 1 块;a2 n=2 → 1 块 {a1:2, a2:2} 2 [200]
2 a1 n=1,需 2 块,+1 → 3 ✓;a2 n=1,需 2 块,+1 → 4 ✗ → 抢占 running[-1] = ?? ?? ?? 2 ??
3 a1 n=1,不需新块 a2 仍放不下 {a1:1} 0(a1 结束) [200]
4 running 为空 a2 n=?? ?? 2 ??
5 a2 decode - {a2:1} 0 [200, 201, 202]
填空答案

step 2 victim 是 a2 自己 → 跳过;阶段 2 中 a2 n=3(2+1),需 2 块,2+2 > 3 → break;返回 {a1: 1};a2.output 仍是 [200]。step 4:n=3,返回 {a2: 3},output 变为 [200, 201]。num_preemptions = 1。

自检:把例 1、例 2、例 3 和上面的追踪各写成几行脚本跑一遍;每步断言 blocks_used <= num_blocks 且 blocks_used == sum(s.num_blocks for s in running);所有序列结束后 blocks_used 回到 0。

面试官追问 & 对照源码

对照源码:vllm/v1/core/sched/scheduler.py