Skip to content

多请求 Static Batching

一个用户独占整张 GPU 太浪费,但把多个请求拼成 batch 又必须 padding 到同一长度——padding 的计算全是白做。Static Batching 的加速和浪费,同时存在。

这一章做什么?

实现一个 Static Batching 引擎,把多个请求 pad 到同一长度后批量前向。实测两个关键数字:Prefill padding 浪费约 46%、Decode 空转浪费约 29%。这两个数字就是后续 Continuous Batching 和 PagedAttention 要消灭的目标。

上一章我们用 KV Cache 解决了单请求的重复计算。但 GPU 一次只服务一个用户,数千个计算单元大部分在空转。这一章把多个请求合并成 batch,让 GPU 真正忙起来——同时暴露 Static Batching 的两大浪费。


Batch 加速的本质:GPU 并行矩阵乘法

LLM 的绝大部分计算是线性层(Linear),本质是矩阵乘法:

Y = X @ W^T
  X: [seq_len, hidden]   W: [hidden, out]   Y: [seq_len, out]

串行处理:GPU 忙一会儿、闲一会儿

4 个请求逐个送入模型,每次只算一个请求的矩阵乘法:

python
y1 = x1 @ W.T   # x1 形状 [10, 1024],算完再算下一个
y2 = x2 @ W.T   # x2 形状 [30, 1024]
y3 = x3 @ W.T   # x3 形状 [ 5, 1024]
y4 = x4 @ W.T   # x4 形状 [20, 1024]

GPU 有数千个并行计算单元,但 [10, 1024] @ [1024, 1024] 这个矩阵太小, 只能喂饱一小部分计算单元,其余都在空转:

GPU 计算单元利用示意(概念图,非精确数字):
              ┌──────────────────────────────────────┐
  时间片 1:   │ 算请求1 ████░░░░░░░░░░░░░░░░░░░░░░░░ │ ← 大量空闲
  时间片 2:   │ 算请求2 ████████████░░░░░░░░░░░░░░░░ │
  时间片 3:   │ 算请求3 ██░░░░░░░░░░░░░░░░░░░░░░░░░░ │
  时间片 4:   │ 算请求4 ████████░░░░░░░░░░░░░░░░░░░░ │
              └──────────────────────────────────────┘

空闲的原因:GPU 的并行度(A100 有 6912 个计算单元)远超小矩阵能 提供的独立计算任务数量。[10, 1024] @ [1024, 1024] 只有 10 行输出, 而 GPU 可以同时算数千行——多余的计算单元没有任务可做。

还有额外代价:每次启动矩阵乘法都有固定的 GPU 调度开销(约 0.05ms), 串行 4 次 = 4 × 0.05ms = 0.2ms 纯开销。

Batch 处理:把 4 个请求拼成大矩阵,一次算完

把 4 个请求的向量矩阵沿 batch 维度堆叠,变成一个更大的矩阵:

python
# 先 pad 到同一长度,拼成 batch 矩阵
X = stack([x1_padded, x2_padded, x3_padded, x4_padded])
# X 形状: [4, 30, 1024]  ← 4个请求,每个 30 token,每个 token 1024维

Y = X @ W.T   # 一次矩阵乘法,同时算完所有请求!
# Y 形状: [4, 30, 1024]

GPU 接到一个 [4, 30, 1024] @ [1024, 1024] 的大矩阵,可以把 更多计算单元都分配出去同时工作:

GPU 计算单元利用示意(概念图,非精确数字):
              ┌──────────────────────────────────────┐
  时间片 1:   │ 同时算全部4个请求 ████████████████████ │ ← 更充分利用
              └──────────────────────────────────────┘

且只有 1 次 GPU 调度开销(而不是 4 次)。

为什么大矩阵比小矩阵快?

GPU 的计算单元(A100 有 6912 个)擅长大规模并行:

小矩阵 [10, 1024] @ [1024, 1024]:
  输出只有 10 行,GPU 同时能处理数千行
  → 大量计算单元没有任务,空转等待

大矩阵 [4×30, 1024] @ [1024, 1024](batch 合并后):
  输出有 120 行,更多计算单元同时有任务
  → 完成同样 4 倍的总工作量,时间远少于 4 倍

直觉:就像 4 个人分别打 4 次电话,vs 开一次电话会议—— 单次沟通成本(GPU 调度开销)只付一次,且所有人同时说话(并行)。

那为什么不把 batch 设得尽可能大?

直觉上:batch 越大 → 矩阵越大 → GPU 利用率越高 → 越快。

理论上是对的,但实际上有三个硬约束:

1. 显存是硬上限

每个请求的 KV Cache 都要存在显存里:

KV Cache 显存 ≈ batch_size × seq_len × hidden × 层数 × 2(K和V)× 2字节(BF16)

Qwen3-0.6B(hidden=1024,28层,seq_len=512):
  每个请求占用约 57MB
  A100(80GB)≈ 最多同时放约 1400 个请求

实际可用显存还要减去模型权重(0.6B × 2字节 ≈ 1.2GB)、
激活值等,真实可用批量远小于理论上限。

2. 延迟会随 batch 增大而上升

batch 越大,每个请求需要等更多"队友"凑齐才能开始处理:

batch=1:   请求到达 → 立刻处理 → 延迟低
batch=64:  请求到达 → 等待凑满64个 → 再处理 → 延迟高
                        ↑ 排队等待时间(对实时对话场景不可接受)

这就是吞吐量 vs 延迟的根本矛盾:

  • 追求高吞吐(tok/s)→ 用大 batch
  • 追求低延迟(TTFT)→ 用小 batch 甚至 batch=1

3. GPU 利用率在某个点之后就饱和了

batch= 1: 利用率低,增大 batch 收益大
batch= 8: 利用率中等,还有提升空间
batch=32: 利用率已经很高,继续加 batch 收益递减
batch=64: 利用率接近上限,再加 batch 速度不再提升,但显存和延迟还在增加

实际生产系统的做法:

不是固定一个大 batch,而是动态调度—— 有多少请求就用多大的 batch,不够就不等,这就是 Continuous Batching 调度器 的 Continuous Batching。

方式矩阵乘法的规模典型耗时量级
串行,4 次分开算4 × [30, 1024] @ [1024, 1024]4 × T
Batch,1 次合并算1 × [4×30, 1024] @ [1024, 1024]约 1~2 × T(GPU 越强,倍数越大)

实际加速倍数取决于 GPU 型号、矩阵大小、显存带宽等因素, 通常在 2×~10× 之间,矩阵越小串行浪费越严重,加速越明显。

Padding 的必要性与代价

不同长度的序列要放进同一矩阵,必须补齐(padding)到最长长度:

请求A prompt: [t0 t1 t2 t3 t4 t5 t6 t7 t8 t9]              长=10
请求B prompt: [t0 t1 ... t29]                                长=30(最长)
请求C prompt: [t0 t1 t2 t3 t4]                              长=5
请求D prompt: [t0 t1 ... t19]                                长=20

Pad 到 max_len=30 后的矩阵 [4, 30]:
  请求A: [██████████░░░░░░░░░░░░░░░░░░░░]  10个有效 + 20个填充
  请求B: [██████████████████████████████]  30个有效
  请求C: [█████░░░░░░░░░░░░░░░░░░░░░░░░░]  5个有效 + 25个填充
  请求D: [████████████████████░░░░░░░░░░]  20个有效 + 10个填充
           ↑ GPU 并行算这些    ↑ 也被 GPU 算了,但结果被屏蔽丢弃(浪费!)

填充位置(░)的计算结果被 attention_mask 屏蔽、不使用, 但 GPU 已经花时间算了——这就是 padding 浪费的根源。

本步 run.py 实测:Prefill padding 浪费约 46%

Decode 阶段的空转浪费

Decode 阶段的 batch 如何工作

Prefill 阶段各请求的 prompt 长度不同,需要 padding 对齐。但 Decode 阶段每步每个请求只产生 1 个新 token,因此 batch 输入的形状天然对齐:

Decode 第 k 步的输入(batch_size=4):

  请求A: [token_A_k]   ← 1 个 token,形状 [1, hidden]
  请求B: [token_B_k]   ← 1 个 token,形状 [1, hidden]
  请求C: [token_C_k]   ← 1 个 token,形状 [1, hidden]
  请求D: [token_D_k]   ← 1 个 token,形状 [1, hidden]

  拼成 batch: [4, 1, hidden]  ← 不需要 padding!

然后每个请求从各自的 KV Cache 中读出历史 K/V,完成注意力计算:

请求A: Q_Ak 对 [K_A0...K_Ak] 做注意力  → 输出 token_A_{k+1}
请求B: Q_Bk 对 [K_B0...K_Bk] 做注意力  → 输出 token_B_{k+1}
请求C: Q_Ck 对 [K_C0...K_Ck] 做注意力  → 输出 token_C_{k+1}
请求D: Q_Dk 对 [K_D0...K_Dk] 做注意力  → 输出 token_D_{k+1}

注意:各请求的 KV Cache 长度不同(历史不同长),注意力计算无法严格合并为同一个矩阵乘法,但线性层(Embedding、Q/K/V 投影、输出投影)可以批量计算,仍有加速。

Static Batching 的问题:等待最长请求完成

Static Batching 要求所有请求同步推进,直到最长请求生成完毕,整个 batch 才结束:

Decode 推进(max_new_tokens = 20):
  请求A: [███████████████░░░░░]  实际=15步 空转=5步
  请求B: [██████████░░░░░░░░░░]  实际=10步 空转=10步
  请求C: [████████████████████]  实际=20步 空转=0步(最长)
  请求D: [████████████░░░░░░░░]  实际=12步 空转=8步
           ↑ 有效 decode         ↑ 已完成但占着 GPU 槽位(浪费!)

空转具体在做什么?

以请求B(max_new=10)在第11步之后为例——它已经生成完了, 但 Static Batching 要等请求C(max_new=20)跑完,所以 batch 还在继续。

每一步 decode,GPU 对 batch 里每个位置都要做完整的计算:

Decode 第11步(请求B已完成):

  输入 token 矩阵: [请求A的token, 请求B的占位token, 请求C的token, 请求D的token]

                              用什么填这里?
                              只能用上一步的输出(或 PAD),
                              但这个位置的结果根本不会被使用。

  GPU 仍然完整执行:
    1. Embedding 查表            ← 4个位置都查,包括请求B
    2. 28层 × (注意力 + 线性层)  ← 请求B的位置全程参与矩阵乘法
    3. 采样下一个token           ← 请求B也采样了一个token,直接丢弃

这就是空转的本质:GPU 为已完成的请求做了完整计算,但结果没有任何用处。

真实 vLLM 的解决方式:Continuous Batching

Static Batching 的根本问题是"批次粒度太粗"——一整批请求捆绑在一起,短请求完成后不能及时释放槽位。

真实 vLLM 使用 Continuous Batching(也叫 iteration-level scheduling)

Static Batching(本步实现):
  step 1~20:[请求A, 请求B, 请求C, 请求D]  ← 固定 4 个槽位,等到最长完成
  step 11 之后:请求B 已完成,但槽位空转

Continuous Batching(Continuous Batching 调度器 引入):
  step 1~10:[请求A, 请求B, 请求C, 请求D]
  step 11:  请求B 完成 → 立刻释放槽位,新请求E 填入
  step 11+:[请求A, 请求E, 请求C, 请求D]  ← 槽位持续被占满

效果:GPU 的 decode 槽位永远被有效请求占满,不存在空转。这是 vLLM 高吞吐的核心机制之一,将在 Continuous Batching 调度器 详细实现。

本步 run.py 实测:Decode idle 浪费约 29%

两大问题总结

问题原因浪费量(本步实测)后续解决方案
Prefill padding 浪费长度不同必须补齐~46%PagedAttention:分页内存管理 PagedAttention
Decode idle 空转短请求等长请求~29%Continuous Batching 调度器 Continuous Batching

代码结构

engine.py
  SerialEngine          ← 逐个请求串行处理(对照组)
  BatchPrefillWrapper   ← 把 padded [batch, max_len] 拆回逐条处理
  BatchKVCacheEngine    ← 构造 padding 矩阵,暴露浪费统计数据

教学注BatchPrefillWrapper 内部仍逐条处理(保持 model 代码不变)。 真实 vLLM 直接用 [batch, seq_len, hidden] 的批量矩阵乘法 + attention_mask 一次完成,GPU 上才体现本节描述的并行加速。 本步重点展示 padding 的结构和浪费量;并行加速原理见上方图解。

运行

bash
python run.py

示例输出:

Prefill padding(pad 到最长 prompt = 30 tokens):
  请求0: [██████████░░░░░░░░░░░░░░░░░░░░]  实际=10 pad=20
  ...
  Prefill padding 浪费: 46%  (55/120 slots)

Decode padding(等最长完成 = 20 decode steps):
  请求0: [███████████████░░░░░]  实际=15 idle=5
  ...
  Decode idle 浪费:     29%  (23/80 steps)

Static Batching 的两大问题:
  1. Prefill padding:46% 的 prefill 计算是无效填充  ⚠️
  2. Decode idle:    29% 的 decode 步骤是空转等待  ⚠️

小结

Static Batching 把多个请求 pad 到同一长度后批量前向,用 GPU 并行矩阵乘法换取吞吐提升。代价有两个:Prefill 阶段短请求被 pad 到最长长度,填充位置的计算全部浪费(实测约 46%);Decode 阶段短请求先完成但占着 GPU 槽位空转,等最长请求跑完才能释放(实测约 29%)。两个数字的根因是同一个——Static Batching 的调度粒度太粗:一整批请求捆绑在一起,中途不能增减。


下一步

Decode 空转浪费 29%,因为短请求完成后不能及时让出槽位给新请求。如果调度器能在每一步 decode 后检查哪些请求已完成、哪些新请求可以插入,槽位就能一直被有效请求占满。这种"逐步调度"能做到吗?

Continuous Batching——每步 decode 后动态增删请求,GPU 槽位永远被有效请求占满,消灭空转浪费。