一个推理服务把不同长度的请求合成一批,批大小 32。理论吞吐按"批内 token 总和"算,实测只有一半——因为框架把批内所有序列补齐到最长者的长度,于是"32 个短请求 + 1 个长请求"被当成"33 个长请求"来算。
padding 是 batching 的隐含成本:同一批里的序列要"对齐"才能并行算,而对齐的代价是"短序列要补到最长"。这个成本在"平均长度"上完全看不出来:平均 300 token 的负载,可能由"大量 100"与"少量 4000"组成——而批内对齐只看"最长者"。
理解这一点,才知道"混批"为什么可能不如"分组批"。
1. 三种 padding 的形态
| 形态 | 浪费来源 |
|---|---|
| 输入 padding | 短输入补到最长输入 |
| 输出 padding(静态批) | 短输出补到最长输出 |
| KV 空间 padding | 显存按最长序列分配 |
第一项在"请求长度差异大"时最明显:32 个 100 token 的请求 + 1 个 4000 token 的请求,整批按 4000 算,浪费 96%。第一项的"机制"在 GPU 上很直白:矩阵运算要求形状一致,于是"不同长度"必须先"补齐"成一个张量——而补进去的位置要真的参与计算。
第二项的"浪费"是"时间维度"的:短答案的请求被"陪着算"到最长者结束,于是它的延迟被拉长,而它本来早就可以返回。
第二项在"静态批"里:批内所有序列都要生成到最长者的长度,于是"短答案"的请求被"陪着算"到结束。
2. 两种批处理策略
| 策略 | 特点 |
|---|---|
| 静态批(static batching) | 整批同进同出,padding 浪费大 |
| 连续批(continuous batching) | 按"迭代"调度,短序列先完成先退出 |
第二项是"现代推理引擎"的核心:每一步重新组批,于是"完成的序列退出、新请求加入"第二项的"实现难点"在"调度":每步都要判断"谁完成了、谁能加入",于是它有额外的调度开销——而在"批很大"时这点开销可忽略。
,padding 只在"当前活跃序列"之间发生。
所以"用了 continuous batching"时,padding 浪费主要发生在"输入侧"(prefill),而"输出侧"的动态性被消化掉了。
3. "混批"什么时候不划算
| 情形 | 结论 |
|---|---|
| 长度分布集中(如都 200–400) | 混批划算(padding 小) |
| 长度双峰(短 100 + 长 3000) | 混批浪费大 |
| 短请求占多数、长请求少量 | 分池更划算 |
| 请求极少(< 3/批) | 批本身就凑不满 |
第二项的"前提"是"能判断长度":入参的 token 数可以在入口算出来(用 tokenizer 或估算),于是"分池"是"按已知量分流",而不是"猜"。
第二项是"分池"的动机:把"短"与"长"分开组批,于是"短批"的 padding 小、"长批"的并行度也够。
代价是"两池不能互相借容量",于是"某一池空闲时另一池排队"。
// 按长度分池(按 token 数分流)
pools:
short: { max_tokens: 512, batch_tokens: 8192, concurrency: 8 }
long: { max_tokens: 8192, batch_tokens: 16384, concurrency: 2 }
// 效果:短批 padding 小、长批不拖累短批
// 代价:两池容量独立,峰值时可能"一池闲一池等"
4. 量化 padding 的浪费
| 指标 | 算法 |
|---|---|
| 有效 token 数 | 批内各序列实际长度之和 |
| 分配 token 数 | 批大小 × 最长长度 |
| 浪费率 | 1 - 有效 / 分配 |
这个算式很实用:它把"混批有多浪费"变成一个可算的数这个算式的"用法"是"先算一次,再决定投入":若浪费率 < 10%,分池的复杂度不值得;> 30%,就必须改——而"感觉批越大越好"恰恰是浪费率高时的错觉。
,于是"要不要分池"不必靠感觉。
而在 continuous batching 里,"批大小"与"最长长度"每步都在变,所以浪费率要按"步"统计再平均。
// 一次实测(静态批,批大小 32)
有效 token:32 × 平均 300 = 9,600
分配 token:32 × 最长 4,000 = 128,000
浪费率 = 1 - 9600/128000 ≈ 92.5%
// 结论:这种分布下静态批完全不划算,必须按长度分组或改 continuous batching
"长度估算"本身也有误差,值得给一个保守做法:按"实际 token 数"而不是"字符数/4"。因为中文的 token 密度与英文差数倍,而"估算偏低"会让"分池"失效(长请求被分到短池)。
// 分池前的长度估算(保守)
def est_tokens(text: str) -> int:
# 中文按 1.5 token/字,英文按 0.25 token/字符,取较大者
zh = sum(1 for ch in text if '\u4e00' <= ch <= '\u9fff')
other = len(text) - zh
return int(zh * 1.5 + other * 0.25) + 8 # +8 给模板与特殊符
// 保守估计:略高不略低,避免"长请求落进短池"
5. 三种缓解手段
| 手段 | 效果 |
|---|---|
| 按长度分组批(bucketing) | 直接减少 padding |
| 连续批(continuous batching) | 输出侧浪费基本消除 |
| 限制批内长度差(max_len_ratio) | 简单有效 |
第三项的"代价"要说清:批可能装不满(因为短请求被"长度差"挡在外面),于是"batch size"实际下降——但"有效 token 数"上升,总吞吐通常是涨的。
第三项最容易落地:要求"批内最长/最短 ≤ 4 倍",超了就另开一批。它不需要改架构,只是"组批时的约束"。
// 组批时限制长度差(简单规则,收益明显)
def make_batch(queue, max_ratio=4):
batch, longest = [], 0
for req in queue:
if batch and (max(longest, req.len) / min(r.len for r in batch) > max_ratio):
break # 长度差过大 → 另开一批
batch.append(req); longest = max(longest, req.len)
return batch
// 代价:批可能装不满(批大小下降),但 padding 浪费显著减少
"浪费率"要按"请求分布"分层看,而不是看全局平均:
// 分位视角(同一服务)
P50 长度 120、P90 长度 900、P99 长度 3800
批大小 32、最长 4000 时:
典型批(全 P50):浪费 ≈ 1 - 120/4000 ≈ 97%(但实际不会有这种批)
混合批(含 P99):浪费 ≈ 1 - 400/4000 ≈ 90%
// 结论:只要"批里可能混进一条 P99",整批就按它对齐 → 必须"分池/限制长度差"
6. 排错:五种"吞吐不如预期"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 吞吐只有理论一半 | padding 浪费 | 算浪费率 |
| 加批大小没收益 | 最长者拉高分配 | 按长度分组 |
| 短请求延迟高 | 被长请求拖 | 分池 |
| 显存不够开大批 | KV 按最长分配 | 限制长度差 |
| 长请求吞吐低 | 批内并行度低 | 长批单独配 |
第二项最反直觉:"加批大小"在长度差异大时会让"分配 token 数"暴涨第二项的"机理"是"分配 token = 批大小 × 最长长度":批大小翻倍、最长不变,分配翻倍,而"有效 token"只翻一点——于是"浪费率上升"。
,于是"吞吐不升反降"。
最后一个"验收"方式:对比"分组批"与"混批"在同负载下的"有效 token/秒"。这个数比"QPS"更真实,因为 QPS 不含长度信息。
// 两个可比指标(同一负载、同一硬件)
tokens_per_sec_effective = 有效 token 总数 / 时长
qps = 完成请求数 / 时长
// 判据:分组批的 tokens_per_sec_effective 应高 30%+;若差异 < 10%,说明负载长度本来就集中
7. 边界:什么情况不必操心 padding
| 场景 | 为什么 | 做法 |
|---|---|---|
| 长度几乎一致(如固定模板) | padding 很小 | 不必 |
| 单请求推理(批=1) | 无批内对齐 | 不必 |
| 已用 continuous batching | 输出侧已优化 | 只看输入侧 |
| 吞吐不是目标(低流量) | 成本不敏感 | 不必 |
最后一条与"容量规划"有关的边界:显存也按"最长"分配,于是"批内混入一个长请求"会同时抬高"峰值显存"与"计算时间"——这是"混批"在容量上的隐性成本。
判断口径一句话:批内"最长 / 最短"的比值。接近 1(长度集中),padding 可忽略;比值很大(> 4),就该分组或分池。
8. 自测
- 三种 padding 形态分别浪费什么?为什么"输出 padding"在静态批里更明显?
- continuous batching 如何消除"输出侧浪费"?它还剩哪种浪费?
- "长度双峰"的分布为什么让混批不划算?"分池"的代价是什么?
- 浪费率怎么算?为什么"加批大小"可能让吞吐不升反降?
- "限制批内长度差"为什么是最易落地的手段?它的代价是什么?
- 判断"是否需要操心 padding"的那个问题是什么?