静态批处理的原罪
普通框架(transformers / deepspeed)都是 request-level batching:攒够 N 条请求,一起 forward,等最慢那条跑完再返回所有结果。
时刻 batch内情况 ───────────────────────────────────────────── t=0 A:0/50 B:0/200 C:0/2000 ← 3 条一起进来 t=1 A:1/50 B:1/200 C:1/2000 ... t=49 A:49/50 B:49/200 C:49/2000 ← A 快结束了但不能出 t=50 A:done B:50/200 C:50/2000 ← A 占着 batch 槽位空转 t=51 A:pad B:51/200 C:51/2000 ... t=199 A:pad B:done C:199/2000 ← 现在 A、B 都在空转 t=200 A:pad B:pad C:200/2000 ... t=1999 A:pad B:pad C:1999/2000 t=2000 全部返回,用户 A 等了 2000 步!
A 本该在 t=50 就返回,实际用户等到 t=2000。这一批 batch 的 GPU 利用率 = 真 token / (batch × 步数) = 50/(3×2000) ≈ 0.8%——夸张但这就是静态 batch 的病根。
连续批处理怎么想
vLLM 改用 iteration-level batching(最早由 Orca 论文提出):把调度粒度从"整条请求"降到"一步 decode"。
- 每一步只算一个 token,batch 里有多少条就算多少条
- 某条请求生成完 <eos>,立即返回结果,从 batch 移除
- 新请求来了,下一步立即加入 batch,不用等
- GPU 永远在算真实 token,没有 padding 空转
时刻 batch ───────────────────────────────────────────── t=0 [A, B, C] (刚进来) t=49 [A, B, C] t=50 [B, C] ← A 结束返回,新请求 D 也进来了 t=50 [B, C, D] ← 同一步立刻补位 ... t=199 [C, D] ← B 结束 ... t=220 [C, D, E, F] ← 持续有新请求进 ... t=1999 [C] ← 只剩 C 最长那条 t=2000 done ───────────────────────────────────────────── 整个过程 GPU 从没停过。A、B 的用户早就拿到结果走了。
scheduler 的两种状态
vLLM 调度器每一步都在两类任务间切换:
原始 vLLM 采取 prefill-priority 策略:有新请求就先做 prefill,再回来做 decode。副作用:在做 prefill 时所有 decode 序列都卡一下(几十到几百 ms),表现为 p99 抖动。
Chunked Prefill:把大 prompt 切小
vLLM 0.5+ 引入 --enable-chunked-prefill(0.6+ 默认开),解决 prefill 卡顿:
- 一个长 prompt(比如 4000 token)不再一次性 prefill
- 切成 512 token 一块,每步只做一块 prefill,同时和其他请求的 decode 一起 batch
- prefill 和 decode 混在一个 forward pass,GPU 压力平滑
- p99 延迟抖动从几百 ms 降到几十 ms
vllm serve meta-llama/Llama-3-8B-Instruct \ --enable-chunked-prefill \ --max-num-batched-tokens 2048 # 每步最多算多少 token
这个值是每步 forward 的总 token 预算(prefill + decode 共享)。太小浪费 GPU,太大单步变长。经验值:A100 上
2048-4096,H100 上 8192-16384。
Preemption:池子满了的优先级处理
max-num-seqs(默认 256)是同时活跃的最大序列数。撞上限了怎么办?
| 策略 | --preemption-mode | 何时用 |
|---|---|---|
| Recompute | recompute(默认) | kick 掉最后进来的序列,重新 prefill 一遍。适合 prompt 短的场景。 |
| Swap | swap | KV cache 整个挪到 CPU 内存,稍后换回来。适合 prompt 长、重算贵的场景。 |
看 Prometheus 里 vllm:num_preemptions_total 指标,持续上涨说明并发超标,要么加 GPU 要么降 max-num-seqs。
对比:请求到达率与吞吐
用 Llama-13B 在 A100-40GB 跑 benchmark,请求服从 Poisson 分布:
| QPS | HF Static Batch | vLLM Continuous | 提升 |
|---|---|---|---|
| 10 | TTFT 120ms / p95 800ms | TTFT 60ms / p95 400ms | 2× |
| 50 | TTFT 1.8s / p95 4s | TTFT 90ms / p95 700ms | 5× |
| 100 | 拒绝连接 / OOM | TTFT 180ms / p95 1.2s | — |
| 200 | 不可用 | TTFT 400ms / p95 2s | 20× 吞吐 |
本章开头说的 "20×" 就是这里出来的——不是单请求快,而是同样硬件能承载的 QPS 数量级跃升。
TTFT vs TPOT:两个延迟不一样
用户感受 = TTFT + output_tokens × TPOT 聊天场景:TTFT 敏感(想快点看到开头) 批量场景:TPOT 敏感(追求总时长) RAG 场景:两个都要顾(prompt 长 + 输出不短)
调 scheduler 的几个关键参数
| 参数 | 默认 | 建议 |
|---|---|---|
--max-num-seqs | 256 | 并发强就调高,显存不够先降 |
--max-num-batched-tokens | 自动 | A100 2048-4096,H100 8192+ |
--max-model-len | 模型自带 | 按业务最长 prompt 裁,越小 KV 池越富裕 |
--enable-chunked-prefill | True (0.6+) | 延迟抖动大时务必开 |
--scheduler-delay-factor | 0 | > 0 时调度器等一小段聚合 batch,吞吐↑ TTFT↑ |
本章小结
- 静态 batch:一起进一起出,被最慢的拖垮,利用率常常 < 10%
- Continuous Batching 按 iteration 调度,谁结束谁走,新请求随时补位
- Chunked Prefill 把长 prompt 切小,和 decode 混合,消除 p99 抖动
- preemption 两种模式:recompute(默认)/swap,看 prompt 长短选
- 关键指标 TTFT 看 prefill + 队列,TPOT 看 decode + batch 大小
- 相比 HF 静态 batch,QPS 量级提升(20×),延迟还更低