3. Batch Size 调优:为什么不是越大越好?显存、吞吐与延迟的三角平衡
很多人刚接触部署时,都有一个直觉:Batch Size 设大点,吞吐肯定更高。嗯,理论上没错。但实际一跑,显存爆了、延迟炸了、甚至精度还掉了。
我刚开始做线上推理优化时,也踩过这个坑。有一次把 Batch Size 从 8 调到 32,结果 OOM 了三次。后来才明白,Batch Size 不是越大越好,它本质上是显存、吞吐、延迟三者的平衡游戏。
核心结论:Batch Size 调优的目标不是“最大”,而是“最稳”。找到那个让 GPU 利用率最高、又不触发 OOM 的临界点。
3.1 显存:第一个拦路虎
Batch Size 每翻一倍,显存占用几乎线性增长。为什么?因为模型参数、中间激活值、梯度(训练时)都要同时驻留。
我习惯用一个简单公式估算:
显存 ≈ 模型参数大小 + Batch Size × 单样本激活值大小 + 预留缓冲
举个例子。一个 7B 参数的模型,FP16 加载大约 14GB。单样本激活值按 2MB 算(取决于序列长度和模型结构)。
| Batch Size | 激活值显存 | 总显存估算 | 实际可用(A100 80G) |
|---|---|---|---|
| 1 | 2 MB | ~14 GB | 余量充足 |
| 8 | 16 MB | ~14.5 GB | 轻松 |
| 64 | 128 MB | ~15 GB | 还行 |
| 256 | 512 MB | ~16 GB | 注意预留 |
| 1024 | 2 GB | ~18 GB | 可能 OOM |
注意:上面只是简化估算。实际还有 KV Cache、CUDA 上下文、框架开销。我曾经在部署一个 13B 模型时,Batch Size 设到 512 直接 OOM,但按公式算还有余量。后来发现是 KV Cache 没释放干净。
3.2 吞吐:Batch Size 的甜蜜点
吞吐量 = 每秒处理的样本数。Batch Size 增大,GPU 计算单元更饱和,吞吐确实会上升。但不会一直线性增长。
我画过一条曲线:Batch Size 从 1 到 32,吞吐几乎线性翻倍。从 32 到 128,增速放缓。超过 128 后,吞吐基本持平,甚至下降。
为什么会这样?
- 计算瓶颈:GPU 的 Tensor Core 有最佳并行粒度。Batch Size 太大,矩阵乘法反而因为内存带宽受限而变慢。
- 调度开销:大 Batch 的 kernel launch 和同步成本更高。
- 显存瓶颈:显存不够时,系统开始 swap 或触发 OOM Killer,吞吐直接归零。
我的经验:对于大多数 Transformer 模型,Batch Size 在 16~64 之间是吞吐的甜蜜点。具体值需要跑一次压力测试。我习惯用 --batch_size 1,2,4,8,16,32,64,128 扫一遍,看哪个点吞吐最高且显存余量 >20%。
3.3 延迟:被忽略的第三极
吞吐上去了,但每个请求的响应时间呢?
Batch Size 越大,单个请求的排队时间越长。因为 GPU 要等整个 Batch 都凑齐了才开始推理。
举个例子:
- Batch Size = 1:请求来了就处理,延迟 = 模型推理时间(假设 50ms)。
- Batch Size = 32:要等 32 个请求凑齐,或者等超时。如果请求稀疏,延迟可能飙升到 500ms+。
我遇到过线上服务延迟从 100ms 飙到 2s 的情况。查了半天,发现是 Batch Size 设了 128,但并发请求只有十几个,大部分时间都在等。后来改成动态 Batch,延迟才降下来。
| 场景 | 推荐 Batch Size | 原因 |
|---|---|---|
| 实时对话(延迟敏感) | 1 ~ 4 | 优先保证响应速度 |
| 离线批量处理 | 64 ~ 256 | 最大化吞吐,延迟无所谓 |
| 在线服务(混合负载) | 8 ~ 32 | 平衡吞吐和延迟 |
3.4 动态 Batch:实战中的最优解
固定 Batch Size 太死板了。我现在的做法是:动态 Batch。
核心思路:
- 设置最大 Batch Size(比如 64),防止 OOM。
- 设置最大等待时间(比如 50ms),防止延迟过高。
- 当请求到达时,要么凑够 Batch Size,要么超时,立即执行推理。
代码示意(伪代码):
class DynamicBatcher:
def __init__(self, max_batch=64, max_wait_ms=50):
self.max_batch = max_batch
self.max_wait = max_wait_ms / 1000.0
self.queue = []
def add_request(self, request):
self.queue.append(request)
if len(self.queue) >= self.max_batch:
self.execute_batch()
else:
# 启动定时器,超时后执行
schedule(self.execute_batch, delay=self.max_wait)
def execute_batch(self):
batch = self.queue[:self.max_batch]
self.queue = self.queue[self.max_batch:]
# 执行推理
results = model.inference(batch)
return results
关键参数:max_batch 和 max_wait 需要根据实际负载调。我一般先设 max_batch=32、max_wait=30ms,然后观察 P99 延迟和吞吐,再微调。
3.5 避坑指南
这些年踩过的坑,列几个典型的:
- 显存碎片:频繁申请释放大块显存会导致碎片。我曾经连续跑了一周,显存占用从 30GB 涨到 70GB,最后 OOM。解决方案是预分配显存池。
- Batch Size 和序列长度耦合:长序列的激活值更大。如果序列长度不固定,Batch Size 要按最坏情况预留。我习惯用
max_seq_len × batch_size估算。 - 多卡场景:Batch Size 要除以卡数。比如单卡最佳是 64,4 卡每卡就是 16。不要直接设 256,否则每卡还是 64,显存可能爆。
- 精度影响:大 Batch 在训练时会影响收敛,推理时一般不影响。但如果你用了量化或稀疏化,大 Batch 可能触发数值异常。我遇到过 INT8 推理时 Batch Size 超过 128 结果全错的 case。
曾经踩过的坑:有一次我把 Batch Size 从 16 调到 32,吞吐没变,延迟翻倍。查了半天,发现是 CPU 预处理成了瓶颈。GPU 在等 CPU 喂数据。所以调 Batch Size 时,别忘了检查数据加载管线。
3.6 总结:三角平衡的实战策略
说了这么多,其实就三句话:
- 显存是硬约束:先算清楚能装下多大的 Batch。
- 吞吐是目标:在显存允许范围内,找到吞吐最高的点。
- 延迟是红线:根据业务要求,卡死 P99 延迟上限。
我个人习惯的调优流程:
- 用
nvidia-smi看空闲显存,估算最大 Batch Size。 - 从 Batch Size=1 开始,每次翻倍,记录吞吐和延迟。
- 找到吞吐拐点(增速明显放缓的点)。
- 检查延迟是否满足 SLA。如果不满足,降低 Batch Size。
- 上线后持续监控,用动态 Batch 应对负载波动。
嗯,Batch Size 调优没有银弹。每个模型、每个硬件、每个业务场景都不一样。但理解了三角平衡,你就知道该往哪个方向试了。