多请求 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 个请求逐个送入模型,每次只算一个请求的矩阵乘法:
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 维度堆叠,变成一个更大的矩阵:
# 先 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 的结构和浪费量;并行加速原理见上方图解。
运行
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 槽位永远被有效请求占满,消灭空转浪费。