home/tutorial/M3 调度器

Month 3 · 调度器与 Continuous Batching

这是把"显存优化"变成"吞吐优化"的关键一步。

Month 2 学完后你已经知道显存怎么省。但 GPU 算力本身怎么榨干? 传统做法 batching 是 "等到 32 个请求凑齐再一起跑"——延迟高、长短不一时浪费。 Orca (OSDI'22) 和 vLLM 的答案叫 continuous batching:每一步都重组 batch。 这一节学这套机制。读完你应该能回答: "为什么 LLM serving 不用 Linux CFS?""prefill 来了为什么 decode 会卡?""显存满了 vLLM 怎么决定抢谁?"

驱动问题
100 个请求同时进来,prompt 长度在 10-2000 之间随机,期望输出 50-500 token 随机。 你只有一张 A100。怎么 batch 它们能让总吞吐最高?同时保证第一个 token 在 200ms 内出来? 这是 SLO 化的版本——你写出来的 scheduler 要同时优化吞吐与尾延迟。
三种朴素策略 + 它们的问题

策略 A · 静态 batching:等 32 个请求齐了,一起 forward 直到全部结束。
问题:先做完的请求要等最慢的(trailing decode 浪费算力);新请求要等下一 batch(高 TTFT)。 举例:32 个请求里 31 个生成 50 token、1 个生成 500 token,所有 31 个都得等那 1 个跑完——99% slot 在最后 450 步都是空的。

策略 B · 一个一个跑:每个请求独占 GPU 跑完。
问题:GPU 利用率极低(A100 decode 阶段算力使用率不到 5%——memory-bound),算力浪费严重,吞吐相比 batch 掉一个数量级。

策略 C · 动态 batching(continuous batching):每个 token step 都重新决定 batch 成员。 新请求随时插入、完成的随时退出、显存不够时挤掉低优先级的。
代价:复杂、需要 scheduler 配合内存管理、需要 attention kernel 支持异构 batch。 收益:吞吐 vs 静态 batch 高 2-4x,TTFT P50 降 10x(按 vLLM/Orca 论文 benchmark)。

本月的所有内容,本质上就在详细回答"策略 C 怎么做对"

01背景 · 三种 batching 策略全景

在拆解 vLLM 之前,先把"batching"这个词在 LLM serving 里的三个不同含义分清。 这一节是 OS 调度章节通常没有的,因为对 OS 而言"批处理"只是历史名词。 但对 LLM 而言,它是设计空间的主轴。

① Static batching · 古早做法

给定 batch size B,等够 B 个请求就一起跑,跑完整批后切下一批。 T5/BERT 时代的 fastapi + ONNX serving 几乎都这么干。它的好处是 kernel 简单——每一步整个 batch 都做同样形状的矩阵乘。但对 LLM 致命:

② Padding-based batching · 折中

同一个 batch 里允许长度不同,padding 到最长那条。kernel 通过 attention mask 忽略 padding 位置。 这是 HuggingFace generate(batch=…) 的默认。问题:

③ Continuous batching · Orca 提出,vLLM 推广

核心动作:把"batch"从"一组请求"重定义为"一组 token 计算位"。 每个 step 只算"现在该算的那一个 token",不同请求处于不同阶段(有的 prefill、有的 decode),但凑成一个 batch 一起 forward。 完成的请求当场离开 batch、新请求当场加入。

time (ms) → 0 200 400 600 800 1000 ① Static batching · B=4 wait 凑齐 prefill A done · 50 tok slot 空转 B · 300 tok C · 120 D · 90 batch 切换 (新 4 个才能进) ② Padding · B=4,pad 到最长 wait A: 50 padding 仍占算力 B: 300 C: 120 D: 90 ③ Continuous · 每 step 重组 pf A · 50 E pf E · 600 (decode) B · 300 F pf F C · 120 G pf G · 500 D · 90 H H · 550 8 个请求在同一时间窗里完成,几乎零空转 等待 prefill decode 新请求 prefill slot 空转 padding 浪费
同一组请求在三种 batching 下的甘特图。Continuous batching 用"插入新请求复用 slot"换来了完整窗口的高利用率。
策略批形成时间批内空转新请求 TTFTkernel 复杂度适用场景
Static等齐 B 个等最慢请求高(数百 ms ~ 秒)简单批离线推理
Padding等齐 B 个padding tokens 仍算简单BERT 类、固定长度
Continuous0(动态)近乎 0低(一两个 step)高,attention 要支持变长LLM 在线 serving
💡 何时各自合理
Static 不是"过时"——它在批 offline scoring(比如评测 1 万个样本算 perplexity)下仍然最快,因为没有 TTFT 要求、长度方差也小。 Padding 在 encoder-only(BERT-style,没有 decode 阶段)上是合理的。 Continuous 是 autoregressive decoder + 在线 serving 的标配。 选错 batching 策略,就像用 FCFS 跑实时音频驱动——错配。

02Prefill vs Decode · 微观分析

LLM 推理分两阶段,节奏天差地别。读 scheduler 前必须先理解这个—— 所有 batching 策略的复杂性,根源就在 prefill 和 decode 的"性格"完全不一样。

PrefillDecode
触发请求刚到,处理 prompt已开始生成,每出一个 token 一步
输入长度整个 prompt(100-10000 tokens)1 个 token(上次的输出)
每步 FLOPsO(N · d²) ~ O(N² · d)O(d²)(每层一次 matmul)
每步搬运权重一次 + KV 一次 + activations权重一次 + 全部 KV 一次
瓶颈compute-bound(FLOPs 多)memory-bound(搬 KV 多于算)
耗时几十 ms ~ 几秒几 ms 一步,连续做几百步
显存增量分配 N 个 KV block每 16 tokens 加 1 个 block
能否 batch少量好处(compute 已饱和)大量好处(amortize 搬权重)

Roofline 视角 · 用 arithmetic intensity 量化

Arithmetic intensity(AI)= FLOPs / bytes,单位 FLOP/B。 当 AI 远高于硬件的"ridge point"(compute 峰值 / 带宽,A100 BF16 ≈ 312e12 / 1.5e12 ≈ 208 FLOP/B), 计算阶段是 compute-bound;远低于则是 memory-bound。 对 Transformer 的 attention + FFN 做近似计算:

# Llama 3.1 8B, hidden=4096, n_layer=32, n_head=32, head_dim=128
# 一个 token 走全部层的 FLOPs ≈ 2 · params ≈ 2 · 8e9 = 16 GFLOPs  (per token)
# Decode 一步要读:weights(16 GB BF16) + KV(K + V, 之前所有 tokens)
# Prefill N tokens 要读:weights 一次 (amortized) + 计算 ~N · 16 GFLOPs

# === Decode(生成第 (T+1) 个 token,已经累计 T 个 token)===
flops_decode  = 16e9                              # FLOPs / token
bytes_decode  = 16e9                              # weight bytes (BF16, 8B params · 2 B/param)
              + 2 · 32 · 32 · 128 · T · 2         # KV bytes: 2(K,V) · n_layer · n_head · head_dim · T · 2 B
              = 16e9 + 524288 · T                 # ≈ 16 GB + 0.5 MB per past token
AI_decode(T=1024)   = 16e9 / (16e9 + 5e8) ≈ 0.97 FLOP/B   # 强烈 memory-bound
AI_decode(T=4096)   = 16e9 / (16e9 + 2e9) ≈ 0.89 FLOP/B   # 略低

# === Prefill(处理 N=1024 个 prompt token,一次性算完)===
flops_prefill = 1024 · 16e9 ≈ 1.6e13            # 16 TFLOPs
bytes_prefill ≈ 16e9 + 524288 · 512             # weights + 中位 KV ≈ 16.3 GB
AI_prefill(N=1024) ≈ 1.6e13 / 1.6e10 ≈ 1000 FLOP/B  # compute-bound

两个数对比:

Arithmetic Intensity vs 序列长度 (Llama-8B, A100) AI (FLOP/B, log scale) sequence length (tokens, log) 0.1 1 10 100 1000 1 8 64 512 4096 A100 ridge ≈ 208 prefill N=64 → ~100 decode (T=past) T=64 → ~1.0 ↑ compute-bound ↓ memory-bound
同一个模型,prefill 与 decode 的算术强度差 100 倍以上。Ridge line 把图分成两个 regime——prefill 在 compute 域,decode 在 memory 域。

把两类混 batch 的核心后果:prefill 一来,整个 step 的时间被它"卡住"—— 即使 31 个 decode 请求也得等 prefill 完成。你为了照顾一个"重活",让 31 个"轻活"也卡住。 这就是 Sarathi-Serve (OSDI'24) 要解的问题(chunked prefill,本章第 11 节展开)。

⚠ 容易混淆的点
"memory-bound 所以慢" 不对。Decode 一步的绝对时间很短(几 ms),慢的是"要做 100 步"。 memory-bound 的真正含义是:增大 batch 几乎不增加 step 时间——这是 continuous batching 能 work 的物理基础。

03OS 调度器的复习

OS 的调度器有一段几十年的历史。把它们梳理一遍,再看 vLLM 的选择就清楚多了。 这一节不深入 OSTEP 的细节,只把每个算法的核心 idea + 为什么这么设计讲清。

算法年代/出处核心 idea解的痛点留下的问题
FCFS · First-Come First-Served 1950s 批处理 先到先跑,跑完再下一个。 简单、公平、零调度开销。 convoy effect:一个长任务卡住所有人。
RR · Round Robin 1960s 分时 OS(CTSS) 每个进程跑固定 quantum(~10ms),用完踢下一个。 响应快、不饿死。 I/O bound 不友好;quantum 选择两难。
MLFQ · Multi-Level Feedback Queue CTSS '62, Unix BSD 多个优先级队列;新进程进高优;用完 quantum 降级;I/O 等待时升级。 自动区分 I/O bound(响应敏感)和 CPU bound(吞吐型)。 参数多(队列数、quantum 长度、boost 周期);难调;可能饿死。
Lottery / Stride Waldspurger '94 给每个进程发"彩票",按比例抽签调度。 权重分配直观;数学性质好。 方差大;实际系统没流行。
CFS · Completely Fair Scheduler Linux 2.6.23 ('07) 每进程算 vruntime(已跑时间 × 权重),红黑树选最小者跑。 "理想多任务"的近似:每个进程感觉拿到 1/N CPU。 红黑树 O(log N) 开销;尾延迟优化需 BPF 调;多核 NUMA 平衡复杂。
EEVDF · Earliest Eligible Virtual Deadline First Linux 6.6 ('23) CFS + deadline 概念,更短延迟更准的 latency-nice 控制。 取代 CFS(同样 O(log N)),延迟更可控。 太新;论文 '95,工程化最近才完成。
同样 5 个任务(长度 5/2/8/1/3 ms),不同算法的 Gantt FCFS A · 5 B · 2 C · 8 D E · 3 avg wait ≈ 9.8 ms · D 等最久 SJF (short job first) D B · 2 E · 3 A · 5 C · 8 avg wait ≈ 4.6 ms (最优),但需预知 RR · q=2ms A B C D E A C E A C C 公平响应;上下文切换 N 次 MLFQ D Q0 B Q0 E Q0→Q1 A Q0→Q1 A Q1 C Q0→Q2 (CPU bound) 短任务先完,长任务降级 CFS A B C D E B A C E A C A E C C C C C C 每人按 vruntime 均匀拿 CPU time ms →
5 个任务在 5 种算法下的执行顺序对比。FCFS 简单但平均等待时间高;SJF 最优但需预知;RR/CFS 公平但切换多;MLFQ 平衡。

这套历史揭示的两个根本 trade-off

记住这两条,下一节看 vLLM 的选择就豁然开朗。

04vLLM 的调度选择 · 为什么不用 CFS

OS 进程调度跟 LLM 请求调度结构上同构——都是"有限资源 + 多并发用户 + 时间分片"——但具体策略 vLLM 选了不同的解。把差别一项一项拆开:

问题OS (Linux CFS)vLLM差别根源
谁先跑 vruntime 排(红黑树取最小) FCFS(waiting deque pop front)+ optional priority 请求长度方差大,公平 = 浪费
跑多久 time slice (~6ms-24ms 动态) 1 个 token step (~30-100 ms 整 batch) 切换代价不同
切换代价 context switch ~3 µs (TLB flush, register save) ~0(每 step 都要重排,没有"额外"开销) GPU 调度状态全是 metadata
满了怎么办 swap-out 整个进程到 swap partition preempt + recompute 或 swap-out KV blocks swap 单位不同(page vs KV block)
饥饿 vruntime 自然缓解 + 不需 aging FIFO 天然不饿;带 priority 时需 aging FCFS 简化了启动顺序
实时保证 RT scheduling class (SCHED_FIFO/RR) (暂无强保证;靠 priority hint + Sarathi 减抖) LLM 还没普及实时类应用
决策频率 每 µs 级(每秒几千次) 每 step(每秒几十次) 决策预算多 1000x

① 切换代价 · 关键差异

OS context switch 要做:保存寄存器、刷 TLB、换页表 root、可能 L1/L2 cache miss。 Linux 测得 ~3 µs(intel x86),是 quantum 的 0.05%(10ms quantum),可观但可承受。 这就是为什么 OS quantum 不能太短——切换占比会失控。

vLLM 的"context switch"是什么?答案:什么都不用做。 所有"上下文"(KV cache、block table、token 位置)都在 metadata 数据结构里,每 step 都重新打包给 kernel。 "换出一个请求 + 换入另一个" = 在 scheduler 的 Python list 里删一行加一行。开销 < 1 µs。

结论:vLLM 可以每 step 都重排,没有"切换 vs 不切换"的 trade-off。 这就是 continuous batching 能 work 的另一半基础(第一半是 decode memory-bound)。

② 请求长度方差 · 决定不能用 CFS

CFS 的核心假设:"每个进程都想要等量 CPU"。它把 100ms 平均分给 100 个进程,每个拿 1ms。 这在桌面 OS 是合理的(你不希望浏览器卡死)。 但 LLM 请求长度方差极大:

把这两个请求"公平"地交错跑(CFS 思路),结果是:

  1. 短请求多等 ~1000 步才能完成(被长请求公平地穿插)。
  2. 长请求也没多省时间(它本来就要 8000 步)。
  3. 每 step 切换 batch 成员 → KV 频繁分配释放 → 显存碎片化 → 命中率下降。

对比 FCFS:短请求几乎不等就出去了,长请求按到达顺序"占住"slot 但不影响后来的(continuous batching 允许新短请求复用其他 slot)。 请求长度方差大时,FCFS 几乎没有公平性损失——因为长请求本来就该长

③ 工作量"颗粒度"较均匀

这条容易被忽略:在 LLM serving 里,一个 token step 的每个请求的算力消耗几乎相等(decode 模式下)。 不像 OS 里"进程 A 在做密集 FFT、进程 B 在等 I/O"那种异质负载。所以 vLLM 不需要 CFS 那种"用 vruntime 衡量真实 CPU 使用量"的复杂记账—— "用了几个 step" 就等于 "用了几份算力",简单计数足够

④ Token budget = LLM 的 quantum

OS 的 quantum 是时间(ms);vLLM 的"quantum"是 token budget: "这一步最多算 max_num_batched_tokens 个 token(包括所有 prefill + 所有 decode)"。 这是个比时间更直接的资源单位——因为每个 token 的算力消耗近乎常数。 配上 KV 余量,就够支撑整个调度决策。下一节详细看。

面试常问
"vLLM 的 scheduler 为什么不用 CFS?" 答出 (1) 请求长度方差 + (2) 切换代价 ≈ 0 两点,再补一句 "FCFS 在这两个条件下没有可观察的不公平",就过关。

05Continuous Batching 实现的 4 个子问题

"每步重组 batch" 一句话听着简单,实现要同时回答 4 个互相耦合的问题。 这 4 个问题分别对应 vLLM 代码的 4 个子系统:

① 这一步谁参与?

选取 batch 成员:从 waiting 队列招新人,从 running 队列继续老人。 决策依据:FIFO 顺序 + token budget 余量 + KV 余量。
对应文件: vllm/v1/core/sched/scheduler.py · schedule()

② 每个人算什么?

每个参与者:是 prefill 还是 decode?要算多少 token?
Prefill 可能被 chunk(Sarathi 风格);decode 永远 1 token。 输出叫 num_scheduled_tokens[req_id] = N
对应: SchedulerOutput.num_scheduled_tokens

③ 显存够吗?

给每个 decode 请求看:它的下一个 token 是否会跨入新的 logical block?跨入了就要在 BlockPool 里申请一个 physical block。 申请不到 → 触发 preempt。
对应: KVCacheManager.allocate_slots()

④ 怎么打包给 kernel?

异构 batch(prefill+decode 混)要打包成单个 forward。 Worker 端用 slot_mapping(每个 query token 要写哪个 slot)+ block_table(每个 sequence 的 KV 在哪些 blocks)告诉 attention kernel。
对应: vllm/v1/worker/gpu_model_runner.py

这 4 个问题耦合在一起——比如 token budget 决定了 ② 能放多少新 prefill,prefill 多了 ③ 的显存压力就大。 这就是为什么 scheduler 不是"挑请求"那么简单。下面几节都是在拆这个耦合。

06vLLM Scheduler 状态机

每个请求在 vLLM 内部有 4 个状态。这是 Linux task_struct.state 的 LLM 版。

WAITING 尚未开始 prefill RUNNING 在当前 batch 里 FINISHED EOS / max_tokens / abort PREEMPTED KV 被释放/swap,等回归 admit budget OK · KV OK terminate EOS · len · abort preempt KV pool 空 · 抢自己 re-admit recompute 或 swap-in 每 step 生成 1 token
每次 schedule() 决定所有状态迁移。注意 RUNNING 的自循环——这就是 continuous batching 的"心跳"。

转移条件 · 一句话总结

转移触发条件scheduler 动作
(start) → WAITING客户端发 request,engine.add_request()append to self.waiting
WAITING → RUNNING有 token budget + 能分到 KV blocks从 waiting popleft,分配 KV,加入 running
RUNNING → RUNNING (self)每 step 跑一次 forward,token 数 +1(decode 时)更新 num_computed_tokens;可能申请新 KV block
RUNNING → FINISHED遇到 EOS token / 达到 max_tokens / 客户端 abort从 running 移除,KV 归还 BlockPool
RUNNING → PREEMPTED需要新 KV block 但 pool 没了释放/swap-out 它的 KV;放回 waiting 头或 swapped list
PREEMPTED → RUNNINGKV 池有空、它排到队首recompute(重 prefill)或 swap-in,再 admit

Worked example · 5 个请求 10 个 ticks

假设 token budget = 8、KV capacity = 6 blocks(每 block 16 tokens)。5 个请求:

R1: prompt=16, output=32     # 1 block prefill, +2 blocks decode
R2: prompt=8,  output=8      # 0.5 block prefill, +0.5 decode → 1 block total
R3: prompt=32, output=16     # 2 blocks prefill, +1 decode
R4: prompt=8,  output=64     # 0.5 prefill, +4 decode
R5: prompt=16, output=8      # 1 prefill, +0.5 decode
tickarrivalscheduledeventsKV used
1R1, R2R1 prefill (16)R1 → RUNNING;budget 用完,R2 等1 / 6
2R1 decode(1) + R2 prefill(8)R2 → RUNNING;R1 token=17 (跨入 block 2)2 / 6
3R3, R4R1 dec + R2 dec + R3 prefill(6 of 32)chunked prefill;R4 等3 / 6
4R1 dec + R2 dec + R3 prefill(6 of 32)R3 已计算 12/324 / 6
5R1 dec + R2 dec + R3 prefill(rest 20→ chunk 6)4 / 6
6R5R1 dec + R2 dec + R3 prefill(chunk 6)R3 = 24/324 / 6
7R1 dec + R2 dec + R3 prefill(last 8)R2 token=15→16 (满 1 block)5 / 6
8R1 dec + R2 dec + R3 dec + R4 prefill(8)R3 全部 prefill 完;R4 加入5 / 6
9所有 4 个 decode;R5 等不进 (budget 满)R1 token=24,逼近 block 2 满6 / 6
10R5 想进但 KV 满 → R4 被 preemptR4 → PREEMPTED;释放 1 block5 / 6

注意 tick 10 的 preempt 决策:选 R4 是因为它是 running 中最年轻的(最近加入),这是 vLLM 默认策略—— 理由是"已生成最少 token,重 prefill 代价最小"。

07Token Budget 与 KV Budget · 双约束

每个 step 上,scheduler 必须同时满足两个独立的资源约束。同时满足—— 单看其中一个会得出错误的调度决策。

Token budget

这一 step 全 batch 算的 token 总数不能超过 max_num_batched_tokens。 "算的 token" 指 forward 中的 query token 数:每个 prefill 序列贡献 num_scheduled_prompt_tokens,每个 decode 贡献 1。

典型值:A100 8B 模型 → 8192-16384。
决定因素:kernel buffer 上限、单 step 时间预算。
限制:step 时间上界

KV budget

BlockPool 的空闲 block 数 = block_pool.free_blocks。 每个新 prefill 序列要 ceil(N/block_size) 个 block;每个 decode 在跨 block 边界时要 +1 个。

典型值:A100 40GB 跑 8B BF16 → 大概 6000-12000 blocks(block_size=16)。
决定因素:模型大小、显存余量、Activation 预留。
限制:并发上界

两个 budget 怎么互动

看一个具体情景:

configuration: max_num_batched_tokens = 8192, kv_blocks_total = 1000, block_size = 16
state: running = [R1: 500 toks, R2: 1500 toks, R3: 300 toks]
       KV used = ceil(500/16) + ceil(1500/16) + ceil(300/16) = 32 + 94 + 19 = 145 blocks
       budget for new prefills this step = 8192 - 3 (decode tokens) ≈ 8189

waiting = [R4: 5000 prompt, R5: 1000 prompt, R6: 200 prompt]

scheduler 决定:
 - 看 R4: 5000 tokens 能塞吗?token budget OK (5000 < 8189)。
          KV: 需要 ceil(5000/16) = 313 blocks。当前 free = 1000 - 145 = 855。OK。
          → admit R4,budget 剩 3189,KV 剩 542
 - 看 R5: 1000 tokens。budget OK。KV 需要 63 blocks,剩 479。
          → admit R5
 - 看 R6: 200 tokens。budget = 1989 OK。KV 需要 13 blocks。
          → admit R6
 - waiting 空,本 step admit 3 个,全部进入 RUNNING。

但如果 KV 不够呢?比如 R4 进来发现需要 800 blocks 但只剩 600:

  1. 简单情况:跳过 R4,看 waiting 后面的小请求 R5 能否塞下。这是 head-of-line blocking 的代价——vLLM 默认 strict FCFS,不跳过;但有些版本允许 "skip large requests"。
  2. 更激进:preempt running 里的低优先级请求,腾出 KV。但只有在 R4 紧急(高 priority)时才做。
可行域 · 3 个独立约束的交集 batch_size 0 total tokens / step KV blocks used token budget = 8192 KV budget = 1000 blocks max_num_seqs = 256 FEASIBLE 操作点
三个独立约束在 (batch_size, tokens_per_step, KV_blocks) 空间里围出可行域。Scheduler 每 step 都要选一个内部点最大化吞吐。

实际 vLLM 默认值

调参的常识

08Preemption · 两种实现深读

Preemption 是 vLLM 区别于"普通 batching"的最关键动作。OS 的 swap 是页粒度,vLLM 的 preempt 是整请求粒度—— 要么把它的 KV 全释放(recompute),要么全 swap 到 host RAM。

① 触发条件:什么时候 preempt

step 中 running 里有人 需要新 KV block block_pool.free > 0 ? allocate → 继续 无 preempt 必须释放 KV 选 victim running.pop() · 最年轻的请求 "已生成最少 → 重 prefill 代价最低" recompute (默认) drop KV → 放回 waiting swap-out (可选) copy KV → host RAM
Preemption 决策树。先看 KV 池是否空,空了才选 victim;vLLM 默认选 running 最尾部(最年轻)的请求 + recompute 模式。

② Recompute · 默认实现

动作:

  1. 把 victim 请求从 running 移到 waiting 头(保留位置优势)。
  2. 释放它的所有 KV blocks(kv_cache_manager.free(req_id))。
  3. 重置 num_computed_tokens = 0——下次轮到时从头 prefill。

代价 = 已浪费的 prefill 工作。如果它已经生成了 G 个 token,则 prefill 长度为 prompt_len + G。 公式上:

recompute_cost(req) ≈ flops_per_token · (prompt_len + tokens_generated)
                    ≈ 16e9 · (P + G)   # Llama-8B BF16

具体数:

所以 recompute 适合短请求或刚开始 decode 的请求。这也是为什么 vLLM 选"最年轻的 victim"—— 它生成的少,重算便宜。

③ Swap-out · 长请求救星

动作:

  1. 把 victim 的所有 physical blocks 内容拷贝到 host RAM 的"swap pool"。
  2. 释放 GPU 上的 blocks。
  3. victim 状态 → "swapped",记录它在 swap pool 的位置。
  4. 下次调入时:拷回 GPU、重新挂回 block table、状态 → RUNNING。

代价 = PCIe 拷贝时间。每 token 的 KV 大约:

# KV 单 token 大小(BF16, Llama-8B)
kv_bytes_per_token = 2 (K and V) · n_layer · n_head · head_dim · 2 (BF16)
                   = 2 · 32 · 32 · 128 · 2
                   = 524288 bytes ≈ 0.5 MB / token

# A100 PCIe Gen4 x16: ~32 GB/s 实际单向
swap_time(N tokens) = N · 0.5e6 / 32e9   ≈ N · 16 µs / token

# 例子
swap(prompt=4000, generated=100) = 4100 · 16 µs = 65.6 ms
recompute(prompt=4000, generated=100) ≈ 210 ms   # 上面算过

对长请求,swap 比 recompute 快 ~3x。所以 vLLM 提供 preemption_mode='swap' 选项。 默认 recompute 是因为:

读 · _preempt 决策代码
关注 victim 是怎么选的(running.pop() 还是更复杂的策略?),以及 preempt 后这个 req 进入哪个数据结构。
如果你要加一个"基于剩余 token 数选 victim" 的策略,需要改的最小集合是?(提示:scheduler 是否记录 max_tokens?)
⚠ 反直觉
"抢剩余最多 token 的请求" 是个常见的反模式。理由:被抢的请求重新调入后还要从被抢点继续,总时间不变。 抢"已投入最少"的反而合理——已经做的工作浪费最少。这跟 "sunk cost fallacy" 的方向是反的, 因为这里 sunk cost 就是要被浪费,所以选最少 sunk 的 victim。

09代码深读 · scheduler.py

这是本月的"硬核教材"。不要试图一次看懂全部——按下面 6 个 walk 顺序进。 每个 walk 配合 scheduler.py 一起读, 代码版本以 main 分支为准(截至 2026 年)。

Walk 1 · SchedulerConfig 和构造器

先看 scheduler 的"知识范围":它需要哪些配置?维护哪些状态?

class SchedulerConfig:
    # 容量上限
    max_num_batched_tokens: int       # 每 step 全 batch token 数上限("token quantum")
    max_num_seqs: int                 # 同时 RUNNING 的请求数上限
    max_model_len: int                # 单请求最大长度(prompt + output)

    # 调度策略
    policy: str = "fcfs"              # "fcfs" | "priority"
    enable_chunked_prefill: bool      # 是否切分大 prefill
    long_prefill_token_threshold: int # 多长才算"长" prefill

    # Preempt 策略
    preemption_mode: str = "recompute"  # "recompute" | "swap"

class Scheduler:
    def __init__(self, vllm_config, kv_cache_config):
        self.scheduler_config = vllm_config.scheduler_config
        self.cache_config = vllm_config.cache_config
        self.kv_cache_manager = KVCacheManager(...)   # 内存子系统 (M2)

        # 三个状态队列
        self.waiting: deque[Request] = deque()
        self.running: list[Request] = []              # 顺序很重要:pop() 是最新加入
        self.finished_req_ids: set[str] = set()

        # 统计
        self.num_preempted: int = 0
        self.step_count: int = 0

关键观察:

Walk 2 · schedule() 顶层结构

scheduler 的入口。每次 engine step 调一次。它的契约:

def schedule(self) -> SchedulerOutput:
    """每个 engine step 调一次。决定这一 step 谁参与、各算多少 token。"""
    self.step_count += 1

    # === 阶段 1: 处理已 RUNNING 的请求 ===
    # 给每个继续生成一个 token (decode),或继续推进 chunked prefill
    scheduled_running_reqs = []
    token_budget = self.max_num_batched_tokens

    req_index = 0
    while req_index < len(self.running):
        request = self.running[req_index]
        num_new_tokens = self._get_num_new_tokens(request, token_budget)
        # decode 通常 num_new_tokens = 1
        # prefill 阶段(chunked) num_new_tokens = chunk_size

        # 分配新 KV blocks(如果跨入新 logical block)
        new_blocks = self.kv_cache_manager.allocate_slots(
            request, num_new_tokens
        )

        if new_blocks is None:        # KV 满了!
            preempted = self._preempt()         # 抢最年轻的
            if preempted is request:           # 抢的是自己 → 这个 req 也不能跑了
                break
            else:
                continue              # 抢了别人 → 再试一次

        scheduled_running_reqs.append((request, num_new_tokens))
        token_budget -= num_new_tokens
        req_index += 1

        if token_budget <= 0:
            break

    # === 阶段 2: 从 WAITING 招新 ===
    scheduled_new_reqs = []
    while self.waiting and token_budget > 0:
        request = self.waiting[0]    # peek
        num_new_tokens = min(
            request.num_prompt_tokens,
            token_budget,
            self.long_prefill_token_threshold if chunked else float('inf'),
        )

        new_blocks = self.kv_cache_manager.allocate_slots(
            request, num_new_tokens
        )
        if new_blocks is None:
            break                    # KV 不够,等下个 step

        self.waiting.popleft()
        self.running.append(request)
        scheduled_new_reqs.append((request, num_new_tokens))
        token_budget -= num_new_tokens

    # === 阶段 3: 打包 SchedulerOutput ===
    return SchedulerOutput(
        scheduled_new_reqs=scheduled_new_reqs,
        scheduled_running_reqs=scheduled_running_reqs,
        num_scheduled_tokens={
            r.req_id: n for r, n in (scheduled_new_reqs + scheduled_running_reqs)
        },
        preempted_req_ids={...},
        finished_req_ids=self.finished_req_ids,
        # 加上 block_table 等元数据给 worker
    )
💡 阅读顺序
实际代码比这个 sketch 多 5-10 倍——有 chunked prefill、prefix cache 命中、priority 排序、KV 跨节点等。 第一次读建议忽略这些,先抓住三个阶段(running 继续 / waiting 招新 / 打包 output)的骨架。 之后每个分支可单独 dive in。

Walk 3 · 招新循环细节(WAITING → RUNNING)

放大上面阶段 2。两个关键点:"先看 prefix cache" + "长 prefill 要 chunk"

while self.waiting:
    request = self.waiting[0]

    # 关键 1: 先查 prefix cache(来自 M2 章节的 BlockPool)
    # 如果 prompt 前缀已在缓存里,跳过这些 tokens 的 prefill
    cached_block_ids = self.kv_cache_manager.get_cached_blocks(request)
    num_cached_tokens = len(cached_block_ids) · block_size
    num_tokens_to_compute = request.num_prompt_tokens - num_cached_tokens

    # 关键 2: chunked prefill
    if self.enable_chunked_prefill:
        # 把单次能算的 token 数限制在 budget 内
        num_new_tokens = min(num_tokens_to_compute, token_budget,
                             self.scheduler_config.max_num_batched_tokens // 2)
    else:
        # 严格模式:要么一次全算完,要么不开始
        if num_tokens_to_compute > token_budget:
            break
        num_new_tokens = num_tokens_to_compute

    # 关键 3: 分配 KV
    new_blocks = self.kv_cache_manager.allocate_slots(
        request,
        num_tokens=num_new_tokens,
        new_computed_blocks=cached_block_ids,    # 复用 prefix cache
    )
    if new_blocks is None:
        break    # KV 不够,本 step 不再 admit

    # 招进来
    self.waiting.popleft()
    self.running.append(request)
    request.status = RequestStatus.RUNNING
    request.num_computed_tokens = num_cached_tokens + num_new_tokens

    scheduled_new_reqs.append((request, num_new_tokens))
    token_budget -= num_new_tokens

注意:

Walk 4 · 继续循环细节(RUNNING 自循环)

放大阶段 1。注意 chunked prefill 的请求和纯 decode 请求会走不同的分支

for request in list(self.running):
    if request.num_computed_tokens < request.num_prompt_tokens:
        # 这个请求还在 prefill 阶段(chunked prefill 没算完)
        remaining = request.num_prompt_tokens - request.num_computed_tokens
        num_new_tokens = min(remaining, token_budget, chunk_size)
    else:
        # 纯 decode 模式:每 step 算 1 token
        num_new_tokens = 1

    # 分配 KV(很可能复用现有 block;只在跨 block 边界时申请新)
    new_blocks = self.kv_cache_manager.allocate_slots(
        request, num_new_tokens
    )

    if new_blocks is None:
        # KV 不够:触发 preempt(在另一个函数)
        victim = self._preempt_one()
        if victim is request:
            # 已经把自己抢了,退出循环
            self.running.remove(request)
            break
        else:
            # 抢了别人,腾出了空间,retry
            new_blocks = self.kv_cache_manager.allocate_slots(
                request, num_new_tokens
            )

    request.num_computed_tokens += num_new_tokens
    token_budget -= num_new_tokens
    scheduled_running_reqs.append((request, num_new_tokens))

Walk 5 · _preempt() 实现

def _preempt(self) -> Request | None:
    """从 running 队尾选 victim,释放其 KV,放回 waiting 头。

    返回被抢的 Request;如果 running 空,返回 None。
    """
    if not self.running:
        return None

    # 选 victim: 最年轻 = list 末尾(vLLM 默认;priority 模式下可以排序)
    victim = self.running.pop()      # 注意是 pop(),从尾部

    if self.preemption_mode == "recompute":
        # 1. 释放 KV blocks
        self.kv_cache_manager.free(victim)
        # 2. 重置已算 token 计数
        victim.num_computed_tokens = 0
        victim.status = RequestStatus.WAITING
        # 3. 放回 waiting 队首(保留位置优势)
        self.waiting.appendleft(victim)

    elif self.preemption_mode == "swap":
        # 1. 拷 KV blocks 到 host swap pool
        block_ids = self.kv_cache_manager.get_blocks(victim)
        self.kv_cache_manager.swap_out(block_ids)
        # 2. 状态切到 SWAPPED
        victim.status = RequestStatus.SWAPPED
        self.swapped.append(victim)

    self.num_preempted += 1
    return victim

实际代码里还会处理"prefix cache 命中的共享 block 不能 free"等细节。读时关注

Walk 6 · SchedulerOutput dataclass

scheduler 跟 worker 的契约——这个数据结构决定了"调度器到底要告诉 worker 多少信息"。

@dataclass
class SchedulerOutput:
    # 谁是 prefill 阶段(首次或继续 chunked)
    scheduled_new_reqs: list[NewRequestData]
    # 谁是 decode 阶段(已 prefill 完成的)
    scheduled_cached_reqs: list[CachedRequestData]

    # 每个 req 这 step 算多少 token (1=decode, N=prefill chunk)
    num_scheduled_tokens: dict[str, int]
    total_num_scheduled_tokens: int

    # KV block 映射(worker 的 attention kernel 要)
    # 这是 M2 PagedAttention 的 block_table,传给 GPU
    # 略:实际通过 KVCacheManager 同步

    # 这 step 哪些请求被抢了(worker 端要清理)
    preempted_req_ids: set[str]

    # 哪些完成了(EOS / max_tokens)
    finished_req_ids: set[str]

    # 哪些被释放(aborted)
    free_encoder_input_ids: list[tuple[str, int]]

    # 可选:长 prefill 状态、speculative decode 状态等

这个结构反映 scheduler 的"输出"哲学:所有必要的工作打包成一个不可变的 step plan, worker 只看这个 plan 干活,不需要回头问 scheduler。这是 Orca-style "iteration-level batching" 的关键 API 设计—— scheduler 和 worker 解耦,可以并行(next step plan 计算与 current step forward 重叠)。

✓ 读完这 6 个 walk 后
你应该能:(1) 在 scheduler.py 里 5 分钟内定位"招新逻辑"的位置;(2) 解释为什么 self.running 是 list 而不是 deque;(3) 修改 victim 选择策略需要改的最小函数集;(4) 看出 SchedulerOutput 里哪些字段是给 worker 的、哪些是给 metric 的。

10Chunked Prefill (Sarathi-Serve)

Sarathi-Serve (Agrawal et al., OSDI'24) 是继 PagedAttention 之后 LLM serving 最重要的 paper 之一。 它的 contribution 看似简单:把长 prefill 切成小块,跟 decode 混在同一 step。但效果显著。

问题 · 没 chunked prefill 时的 stall

回顾第 02 节的 AI 图:prefill 是 compute-bound。一个 4000-token prefill 在 A100 上要 ~100 ms。 如果不 chunk,这 100 ms 里 batch 里所有 decode 请求都不能跑下一个 token——它们干等。

Prefill stall 问题与 chunked 解法 未切分 d 大 prefill · 4000 tok · 100 ms d d decode 卡住 100 ms → TTFT 跳变 / ITL 抖动 Sarathi · chunk=512 d pf₁ d pf₂ pf₃ pf₄ d d decode 也跑 → decode 节奏稳定 time →
未切分时,大 prefill 让所有 decode 卡住 100 ms。Sarathi 把 prefill 切成 512 token 一片,每片之间穿插 decode,让 decode 节奏接近常数。

vLLM 的实现

核心代码在 schedule() 阶段 1 和阶段 2 共用的逻辑:num_computed_tokens 这个字段。 关键不变量:

Scheduler 看到一个新 admission 时,决定它本 step 算多少(num_new_tokens):

num_new_tokens = min(
    request.num_prompt_tokens - request.num_computed_tokens,   # 还需要的
    token_budget,                                              # budget 余量
    self.scheduler_config.long_prefill_token_threshold,        # 单次 chunk 上限
)

这样一个 4000 token 的 prefill 会跨 ~8 个 step(chunk=512),每个 step 都为 decode 请求让出 budget。 **TTFT 不会增加**(甚至可能减少——因为 decode 请求不再被堵)。

什么时候 chunked prefill 反而吃亏

chunked 不是免费午餐:

所以 vLLM 默认有阈值 long_prefill_token_threshold(典型 2048),只对超过它的 prefill 才切

读 · chunked prefill 实现
scheduler.py 搜 chunked_prefill / num_computed_tokens
注意 chunked prefill 和 prefix cache 的交互——如果前缀已缓存,chunked 只切剩余部分。
attention kernel 怎么知道一个 chunk 算的是 prefill 还是 decode?(提示:看 query_start_loc 这个变量)

11Priority + Aging · 高级特性

实际 production 里有时需要"优先级":付费用户高优、内部测试低优、batch 任务最低。 vLLM 在 v1 提供了 priority hint。

实现机制

请求带个 priority 字段(数字,小的优先)。当 policy = "priority"

这立刻引入了 OS 调度里的经典问题:低优先级请求可能饿死。如果高优请求源源不断,低优永远 admit 不上。 解法是 aging:等久了优先级自动提升。

# 概念性代码
def effective_priority(request, now):
    wait_time = now - request.arrival_time
    age_bonus = math.floor(wait_time / aging_period_seconds)
    return request.priority - age_bonus    # 等久了数字变小(更优先)

vLLM 当前的实现细节在演进中——截至 2026 年 v1,aging 是个相对简单的版本, 也是社区频繁讨论的方向(搜 issue label priority 看活跃的 RFC)。

⚠ 注意
Priority + LLM 是个陷阱多的领域:因为请求长度方差大,"高优先级请求"如果是个 8K token 的 prefill,它会霸占 token budget,让本来可以并行跑的 decode 都被推后。 Production 里一般还要加上"preempt 配额"(同一个高优请求不能连续 preempt 多个),避免雪崩。

12动手 · 完整 mini-scheduler 代码

本月最重的作业。不要在真 GPU 上——用假数据跑通逻辑。 下面是完整可运行 Python 实现,~200 行,包含模拟器、调度器、指标。

把它存为 mini_sched.pypython mini_sched.py 即可跑。 依赖只有 stdlib + matplotlib(如果不画图,可省)。

"""
mini_sched.py — vLLM-style continuous batching scheduler simulator.
~200 lines. Run: python mini_sched.py
"""
import random
import math
from collections import deque
from dataclasses import dataclass, field
from typing import Literal, Optional

# ===== 1. Data classes =====

@dataclass
class Request:
    id: int
    prompt_len: int
    output_len: int
    arrival_time: float

    # state
    status: Literal["WAITING", "RUNNING", "FINISHED", "PREEMPTED"] = "WAITING"
    num_computed_tokens: int = 0      # how many we've processed (prefill + decode)

    # metrics
    first_token_time: Optional[float] = None
    finish_time: Optional[float] = None
    preempt_count: int = 0

    @property
    def total_tokens(self) -> int:
        return self.prompt_len + self.output_len

    @property
    def is_prefill(self) -> bool:
        return self.num_computed_tokens < self.prompt_len

    @property
    def is_done(self) -> bool:
        return self.num_computed_tokens >= self.total_tokens

    def kv_blocks_needed(self, block_size: int) -> int:
        return math.ceil(self.num_computed_tokens / block_size)


# ===== 2. Scheduler =====

class Scheduler:
    def __init__(
        self,
        kv_capacity_blocks: int = 200,
        block_size: int = 16,
        token_budget: int = 2048,
        long_prefill_chunk: int = 512,
        preempt_mode: str = "recompute",
    ):
        self.kv_capacity = kv_capacity_blocks
        self.block_size = block_size
        self.token_budget = token_budget
        self.chunk_size = long_prefill_chunk
        self.preempt_mode = preempt_mode

        self.waiting: deque[Request] = deque()
        self.running: list[Request] = []
        self.finished: list[Request] = []

        # metric counters
        self.num_preempted = 0
        self.step_count = 0
        self.total_tokens_processed = 0

    def kv_used(self) -> int:
        return sum(r.kv_blocks_needed(self.block_size) for r in self.running)

    def kv_free(self) -> int:
        return self.kv_capacity - self.kv_used()

    def step(self, now: float) -> list[tuple[Request, int]]:
        """Run one scheduling step. Returns [(request, num_tokens_this_step), ...].
        Simulates 'execute' = increment num_computed_tokens for each.
        """
        self.step_count += 1
        scheduled: list[tuple[Request, int]] = []
        budget = self.token_budget

        # === Phase 1: continue running ===
        for req in list(self.running):
            if budget <= 0:
                break
            if req.is_prefill:
                # chunked prefill
                remaining = req.prompt_len - req.num_computed_tokens
                n = min(remaining, budget, self.chunk_size)
            else:
                n = 1  # decode

            # Need new KV block?
            tokens_after = req.num_computed_tokens + n
            blocks_after = math.ceil(tokens_after / self.block_size)
            new_blocks_needed = blocks_after - req.kv_blocks_needed(self.block_size)

            if new_blocks_needed > self.kv_free():
                # preempt one and retry
                victim = self._preempt(exclude=req)
                if victim is None:
                    break
                # retry below
                if new_blocks_needed > self.kv_free():
                    # still not enough → preempt self
                    self._do_preempt(req)
                    continue

            scheduled.append((req, n))
            budget -= n

        # === Phase 2: admit from waiting ===
        while self.waiting and budget > 0:
            req = self.waiting[0]

            # try chunked admit
            n = min(req.prompt_len, budget, self.chunk_size)
            blocks_needed = math.ceil(n / self.block_size)

            if blocks_needed > self.kv_free():
                break  # not enough KV, give up this step

            self.waiting.popleft()
            self.running.append(req)
            req.status = "RUNNING"
            scheduled.append((req, n))
            budget -= n

        # === Phase 3: execute (simulated) ===
        for req, n in scheduled:
            was_prefill = req.is_prefill
            req.num_computed_tokens += n
            self.total_tokens_processed += n

            # record first token time when prefill done
            if was_prefill and not req.is_prefill and req.first_token_time is None:
                req.first_token_time = now

            # check finish
            if req.is_done:
                req.status = "FINISHED"
                req.finish_time = now
                self.running.remove(req)
                self.finished.append(req)

        return scheduled

    def _preempt(self, exclude: Optional[Request] = None) -> Optional[Request]:
        """Pick a victim, free its KV. Returns victim or None if no victim."""
        for victim in reversed(self.running):
            if victim is not exclude:
                self._do_preempt(victim)
                return victim
        return None

    def _do_preempt(self, victim: Request):
        self.running.remove(victim)
        victim.preempt_count += 1
        self.num_preempted += 1

        if self.preempt_mode == "recompute":
            victim.num_computed_tokens = 0
            victim.status = "WAITING"
            victim.first_token_time = None    # will be re-set
            self.waiting.appendleft(victim)
        else:  # swap
            victim.status = "PREEMPTED"
            # in real vLLM, KV would go to host; here we just track
            self.waiting.appendleft(victim)


# ===== 3. Simulator =====

def simulate(
    num_requests: int = 100,
    arrival_rate: float = 5.0,           # requests per "second"
    prompt_dist=(50, 2000),
    output_dist=(50, 500),
    sim_duration: float = 30.0,
    seed: int = 42,
    **sched_kwargs,
):
    random.seed(seed)
    sched = Scheduler(**sched_kwargs)

    # Pre-generate arrivals (Poisson process)
    arrivals = []
    t = 0.0
    for i in range(num_requests):
        t += random.expovariate(arrival_rate)
        arrivals.append(Request(
            id=i,
            prompt_len=random.randint(*prompt_dist),
            output_len=random.randint(*output_dist),
            arrival_time=t,
        ))

    # Time-stepped simulation: each step = 1 ms of wall time
    # In real vLLM, a step's wall time depends on batch; we approximate ~30ms / step
    STEP_MS = 30
    now = 0.0
    arrival_idx = 0
    log = []
    while now < sim_duration * 1000:
        # admit arrivals
        while arrival_idx < len(arrivals) and arrivals[arrival_idx].arrival_time * 1000 <= now:
            sched.waiting.append(arrivals[arrival_idx])
            arrival_idx += 1

        sched.step(now)

        log.append({
            "t": now,
            "running": len(sched.running),
            "waiting": len(sched.waiting),
            "kv_used": sched.kv_used(),
            "preempted": sched.num_preempted,
        })
        now += STEP_MS

        if arrival_idx == len(arrivals) and not sched.waiting and not sched.running:
            break

    return sched, log


def report(sched, log):
    finished = sched.finished
    if not finished:
        print("No finished requests.")
        return

    ttfts = [r.first_token_time - r.arrival_time * 1000 for r in finished
             if r.first_token_time is not None]
    latencies = [r.finish_time - r.arrival_time * 1000 for r in finished]

    print(f"=== mini-scheduler report ===")
    print(f"Finished:           {len(finished)} / {len(finished) + len(sched.running) + len(sched.waiting)}")
    print(f"Total steps:        {sched.step_count}")
    print(f"Total tokens:       {sched.total_tokens_processed}")
    print(f"Throughput:         {sched.total_tokens_processed / log[-1]['t'] * 1000:.0f} tok/s")
    print(f"TTFT  p50 / p99:    {sorted(ttfts)[len(ttfts)//2]:.0f} / {sorted(ttfts)[int(len(ttfts)*0.99)]:.0f} ms")
    print(f"Latency p50 / p99:  {sorted(latencies)[len(latencies)//2]:.0f} / {sorted(latencies)[int(len(latencies)*0.99)]:.0f} ms")
    print(f"Preemptions:        {sched.num_preempted}")


def plot(log):
    """Optional: visualize state over time."""
    try:
        import matplotlib.pyplot as plt
    except ImportError:
        return
    ts = [e["t"] / 1000 for e in log]
    fig, ax = plt.subplots(2, 1, figsize=(10, 5), sharex=True)
    ax[0].plot(ts, [e["running"] for e in log], label="running")
    ax[0].plot(ts, [e["waiting"] for e in log], label="waiting", alpha=0.6)
    ax[0].set_ylabel("# requests")
    ax[0].legend()
    ax[1].plot(ts, [e["kv_used"] for e in log], color="tab:orange")
    ax[1].set_ylabel("KV blocks used")
    ax[1].set_xlabel("time (s)")
    plt.tight_layout()
    plt.savefig("mini_sched.png", dpi=120)
    print("Saved mini_sched.png")


if __name__ == "__main__":
    sched, log = simulate(
        num_requests=100,
        arrival_rate=5.0,
        kv_capacity_blocks=200,
        token_budget=2048,
    )
    report(sched, log)
    plot(log)

预期输出

=== mini-scheduler report ===
Finished:           100 / 100
Total steps:        ~700
Total tokens:       ~45000
Throughput:         ~2100 tok/s
TTFT  p50 / p99:    180 / 2800 ms
Latency p50 / p99:  6200 / 14000 ms
Preemptions:        ~25

数字会因随机种子和参数浮动。关键是看趋势——下一节让你跑参数 sweep 验证 trade-off。

13基准测试 · 把你的 scheduler 调参

跑通基本版本只是第一步。真正学到东西是看参数 sweep 的曲线,验证你对系统的直觉。 下面 3 个实验,每个 ~10 行代码:

实验 1 · Token budget 扫描

for budget in [256, 512, 1024, 2048, 4096, 8192]:
    sched, log = simulate(token_budget=budget, kv_capacity_blocks=400)
    print(f"budget={budget:5d}  thpt={sched.total_tokens_processed/log[-1]['t']*1000:6.0f} tok/s  "
          f"TTFT p50={...}  preempt={sched.num_preempted}")

预期观察:

实验 2 · KV capacity 扫描

for kv in [50, 100, 200, 400, 800, 1600]:
    sched, log = simulate(kv_capacity_blocks=kv, token_budget=2048)
    print(f"kv={kv:5d}  thpt=...  preempt={sched.num_preempted}  ttft p99=...")

预期:

实验 3 · 长度方差敏感性

for prompt_max in [200, 500, 1000, 2000, 5000]:
    sched, log = simulate(
        prompt_dist=(50, prompt_max),
        token_budget=2048,
        kv_capacity_blocks=400,
    )
    # ...

预期:

✓ 评估你的 mini scheduler
跑完应该能回答:
  • kv_capacity 减一半,throughput 掉多少?preempt 次数升多少?
  • 把 token budget 加倍,TTFT 怎么变?(batch 变大、TTFT 升、throughput 也升直到饱和)
  • 给 prompt 长度方差加大,平均延迟怎么变?P99 怎么变?
能给出符合直觉的曲线,你就懂了 90%。

额外加分:把 chunked prefill 关掉(设 long_prefill_chunk = 99999),跑同样实验,比较 TTFT P99 的差异。

14常见误解

"更大的 batch 总是吞吐更高"
错。Batch 大到一定程度后:
  1. KV 压力指数增长——preempt 频繁,反而浪费算力。
  2. kernel launch + 同步开销不随 batch 线性减少。
  3. tail latency 上升(更多请求挤同一 step 的 forward 时间)。
实际曲线:throughput 随 batch 上升然后持平(甚至轻微下降)。 甜点要 benchmark 出来,不能假设"越大越好"。
"FCFS 不公平"
取决于情境。对长度相近的请求,FCFS 等价于最优——所有人都按到达顺序公平等待。 只有长度方差极大时,FCFS 的 head-of-line blocking 才显现(小请求被大的堵)。 Production 里如果有 SLO 要求,再考虑 priority + aging。

对照 OS:Linux 的 IO scheduler 也常用 FCFS 变种(noop、deadline),公平性 vs 简单性的 trade-off 不止 vLLM 在做。
"chunked prefill 总是好的"
几乎是,但有例外:
  1. workload 全是 decode(prefill 已经 cache),chunk 是浪费。
  2. workload 全是短 prefill(< 256 token),chunk 后变更碎,kernel launch 占比上升。
  3. 非常小的模型(< 1B),prefill 本身就快,chunk overhead 相对显著。
vLLM 默认开是因为典型 chat workload(system prompt + 用户输入 + 中等输出)受益最大。
"swap-out 一定比 recompute 好(不浪费算力)"
错。要看请求已生成多少 token
  • 刚开始 decode(生成 < 50 token):recompute 快(重 prefill 短)。
  • 长生成(生成 > 500 token):swap 快(不重算长 prefill)。
更精确的决策需要 scheduler 跟踪每个请求的 tokens_generated 并实时比较两种代价。 vLLM 当前用全局 preemption_mode 配置——这是个简化的工程妥协,自适应版本是个开放问题。

15本月 PR 候选

Month 3 的 KPI:至少 1 个 scheduler 相关 PR。下面是真实可下手的方向:

低难度(一周内可完成)

中难度(两周内)

高难度(一个月,作为 Month 5 候选)

💡 找 issue 的诀窍
vLLM 的 RFC / proposal issue 里经常埋着 "scheduler 应该 X 但还没做"—— 搜 label:scheduler + 按 oldest 排序,前几页通常有真东西。 另一个金矿:commit log + git blame,看 scheduler.py 最近 6 个月的 commit, 从 commit message 反推"这是修了什么",然后看周边有没有类似没修的。

16本页自检

Month 3 结束时这些应该全部 ✓

17延伸阅读

读完本章后,按下面顺序消化论文,每篇花 2-3 小时(读 abstract + intro + 关键 figure + 关键 algorithm,不必逐行)。 这是把"看懂 vLLM"升级到"参与 serving 研究"的桥梁。

论文会议年份核心 idea对 vLLM 的关系
Orca OSDI'22 Iteration-level scheduling(每 token 重组 batch)+ Selective Batching continuous batching 的"祖宗";vLLM 借鉴其 iteration 概念
Sarathi-Serve OSDI'24 Chunked prefill + decode-prefill mixing vLLM 直接采用该方案
Splitwise ISCA'24 把 prefill 和 decode 放不同 GPU(异构集群) vLLM 还没原生支持;社区在做(PR 上有讨论)
DistServe OSDI'24 更激进的 prefill/decode 分离 + SLO-aware scheduling 下一代 serving 设计参考;vLLM 路线图里
Llumnix OSDI'24 多 vLLM 实例间 migration(请求级 live migration) 解 vLLM 做不了的跨 instance 负载均衡
RAGCache 2024 arxiv 给 RAG workload 的 prefix cache 做 multi-tier (GPU/CPU/SSD) prefix cache 演进方向
FlexGen ICML'23 offline LLM serving 的 swap 调度(极端 GPU 受限) 对比理解 online vs offline 的不同 trade-off

读论文的关键问题清单(每篇都带着问):

💡 论文阅读策略
不要从第一段读到最后一段。先读 abstract → 跳到关键 figure(通常是架构图和 trade-off 曲线)→ 看 algorithm box → 回头读 intro 和 motivation。 这样 1 小时能消化一篇 paper 的 80%,剩 20% 看是否需要深挖。