3. Batch Size 调优:为什么不是越大越好?显存、吞吐与延迟的三角平衡

很多人刚接触部署时,都有一个直觉:Batch Size 设大点,吞吐肯定更高。嗯,理论上没错。但实际一跑,显存爆了、延迟炸了、甚至精度还掉了。

我刚开始做线上推理优化时,也踩过这个坑。有一次把 Batch Size 从 8 调到 32,结果 OOM 了三次。后来才明白,Batch Size 不是越大越好,它本质上是显存、吞吐、延迟三者的平衡游戏。

核心结论:Batch Size 调优的目标不是“最大”,而是“最稳”。找到那个让 GPU 利用率最高、又不触发 OOM 的临界点。

Batch Size 三角平衡模型 显存占用 吞吐量 响应延迟 增大 Batch Size → 显存↑ 吞吐↑ 延迟↑;三者互相制约

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。

核心思路:

  1. 设置最大 Batch Size(比如 64),防止 OOM。
  2. 设置最大等待时间(比如 50ms),防止延迟过高。
  3. 当请求到达时,要么凑够 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 延迟上限。

我个人习惯的调优流程:

  1. 用 nvidia-smi 看空闲显存,估算最大 Batch Size。
  2. 从 Batch Size=1 开始,每次翻倍,记录吞吐和延迟。
  3. 找到吞吐拐点(增速明显放缓的点)。
  4. 检查延迟是否满足 SLA。如果不满足,降低 Batch Size。
  5. 上线后持续监控,用动态 Batch 应对负载波动。

嗯,Batch Size 调优没有银弹。每个模型、每个硬件、每个业务场景都不一样。但理解了三角平衡,你就知道该往哪个方向试了。