Chapter 03

Continuous Batching:让 GPU 不空转

静态 batch 是"一起进、一起出",长尾请求拖死吞吐。Continuous Batching 是"哪个跑完哪个走,新请求随时上车"——GPU 每一步都是满的。

静态批处理的原罪

普通框架(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"。

  1. 每一步只算一个 token,batch 里有多少条就算多少条
  2. 某条请求生成完 <eos>,立即返回结果,从 batch 移除
  3. 新请求来了,下一步立即加入 batch,不用等
  4. 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 调度器每一步都在两类任务间切换:

Prefill 阶段
新请求进来,要一次性把 prompt 所有 token 的 K/V 算出来。这步是 compute-bound,能吃满 GPU。
Decode 阶段
生成每个新 token,只算一步,batch 内所有活跃序列一起算。这步是 memory-bound,吃显存带宽。

原始 vLLM 采取 prefill-priority 策略:有新请求就先做 prefill,再回来做 decode。副作用:在做 prefill 时所有 decode 序列都卡一下(几十到几百 ms),表现为 p99 抖动。

Chunked Prefill:把大 prompt 切小

vLLM 0.5+ 引入 --enable-chunked-prefill(0.6+ 默认开),解决 prefill 卡顿:

  1. 一个长 prompt(比如 4000 token)不再一次性 prefill
  2. 切成 512 token 一块,每步只做一块 prefill,同时和其他请求的 decode 一起 batch
  3. prefill 和 decode 混在一个 forward pass,GPU 压力平滑
  4. p99 延迟抖动从几百 ms 降到几十 ms
vllm serve meta-llama/Llama-3-8B-Instruct \
  --enable-chunked-prefill \
  --max-num-batched-tokens 2048   # 每步最多算多少 token
max-num-batched-tokens 怎么调
这个值是每步 forward 的总 token 预算(prefill + decode 共享)。太小浪费 GPU,太大单步变长。经验值:A100 上 2048-4096,H100 上 8192-16384

Preemption:池子满了的优先级处理

max-num-seqs(默认 256)是同时活跃的最大序列数。撞上限了怎么办?

策略--preemption-mode何时用
Recomputerecompute(默认)kick 掉最后进来的序列,重新 prefill 一遍。适合 prompt 短的场景。
SwapswapKV cache 整个挪到 CPU 内存,稍后换回来。适合 prompt 长、重算贵的场景。

看 Prometheus 里 vllm:num_preemptions_total 指标,持续上涨说明并发超标,要么加 GPU 要么降 max-num-seqs。

对比:请求到达率与吞吐

用 Llama-13B 在 A100-40GB 跑 benchmark,请求服从 Poisson 分布:

QPSHF Static BatchvLLM Continuous提升
10TTFT 120ms / p95 800msTTFT 60ms / p95 400ms
50TTFT 1.8s / p95 4sTTFT 90ms / p95 700ms
100拒绝连接 / OOMTTFT 180ms / p95 1.2s
200不可用TTFT 400ms / p95 2s20× 吞吐

本章开头说的 "20×" 就是这里出来的——不是单请求快,而是同样硬件能承载的 QPS 数量级跃升。

TTFT vs TPOT:两个延迟不一样

TTFT (Time To First Token)
从请求到达到收到第 1 个 token 的时间。主要被 prefill 时间和排队时间影响。
TPOT (Time Per Output Token)
相邻两个 token 之间的平均时间。体现 decode 吞吐,取决于 batch 大小和显存带宽。
用户感受 = TTFT + output_tokens × TPOT

聊天场景:TTFT 敏感(想快点看到开头)
批量场景:TPOT 敏感(追求总时长)
RAG 场景:两个都要顾(prompt 长 + 输出不短)

调 scheduler 的几个关键参数

参数默认建议
--max-num-seqs256并发强就调高,显存不够先降
--max-num-batched-tokens自动A100 2048-4096,H100 8192+
--max-model-len模型自带按业务最长 prompt 裁,越小 KV 池越富裕
--enable-chunked-prefillTrue (0.6+)延迟抖动大时务必开
--scheduler-delay-factor0> 0 时调度器等一小段聚合 batch,吞吐↑ TTFT↑

本章小结