4. 批处理策略
说到推理成本,批处理是绕不开的核心手段。我见过不少团队,模型精度调得不错,上线后却发现GPU利用率不到10%。说白了,钱花了,算力没跑满。批处理就是要把这些空闲的算力填满。
但批处理不是简单地把请求堆在一起。怎么堆、堆多大、什么时候堆,这里面门道很多。这一章我们就把批处理策略拆开揉碎了讲。
4.1 动态批处理原理
传统的静态批处理,就是等请求攒够一批再一起算。这种做法有个明显的问题:如果请求来得慢,后面的请求就得一直等着。延迟一下子就上去了。
动态批处理不一样。它不要求攒够固定数量,而是设一个最大等待时间。时间到了,不管攒了多少请求,直接开始推理。
核心思想:用时间窗口换吞吐,用弹性大小保延迟。
我在项目中遇到过这样一个场景:线上有个对话模型,白天请求量大,晚上请求量小。如果用固定批大小,白天还好,晚上一个请求可能要等好几秒才能凑够一批。换成动态批处理后,我们设了最大等待时间50ms。白天请求多,50ms内能攒32个;晚上请求少,可能只攒到2个。但不管怎样,用户都不会等超过50ms。
动态批处理的实现要点:
- 时间窗口:通常设10-100ms,根据业务容忍度调整
- 最大批大小:受显存限制,不能无限大
- 最小批大小:低于这个值直接推理,不等待
我的习惯:最小批大小设1,最大批大小根据模型显存占用算出来。比如一个请求占2GB显存,显卡有80GB,那最大批大小就是40。留点余量,实际设32。
4.2 连续批处理(Continuous Batching)
动态批处理解决了等待时间的问题,但还有一个更大的坑:请求长度不一样。
你想想看,一个批里有10个请求,9个已经生成完了,最后一个还在慢慢生成。那前9个占用的显存和算力就白白浪费了。这就是经典的「木桶效应」——整个批的完成时间由最长的那个请求决定。
连续批处理就是来解决这个问题的。它的思路很直接:谁先结束,谁先走,新请求补进来。
具体做法是这样的:
- 初始时,一批请求一起开始推理
- 每个请求独立计算,生成完一个就检查是否结束
- 结束的请求立即从批中移除,释放显存
- 新请求立刻补进来,接上释放的位置
- 重复这个过程,直到所有请求处理完毕
这样做的好处很明显:GPU始终在处理有效计算,不会因为个别长请求而闲置。
我曾经踩过的坑:连续批处理需要模型支持注意力掩码的动态更新。有些框架的注意力实现是静态的,批大小变了就得重新编译。选框架的时候一定要确认这一点。我当初用了一个不支持的框架,折腾了两天才发现是框架的问题。
下面这张图展示了连续批处理的核心流程:
4.3 请求合并与调度
有了动态批处理和连续批处理,是不是就万事大吉了?还差一步——请求怎么合并。
请求合并不是简单地把文本拼在一起。要考虑几个因素:
- 长度差异:短请求和长请求混在一起,短请求会被拖累
- 优先级:有些请求需要实时响应,有些可以等一等
- 模型分片:如果模型做了张量并行,请求要尽量分配到同一组GPU上
我常用的调度策略是长度分桶 + 优先级队列:
- 把请求按输入长度分成几个桶:短(<128 tokens)、中(128-512)、长(>512)
- 每个桶内部按优先级排序
- 调度器从高优先级桶开始,尽量把同一桶的请求合并到一起
- 如果某个桶等得太久(超过时间窗口),降级处理,直接合并到其他桶
经验数据:长度分桶后,吞吐量提升了30-50%,尾部延迟降低了60%。因为GPU不再被长短不一的请求「拖后腿」了。
下面是一个简单的请求合并调度器的伪代码:
class RequestScheduler:
def __init__(self, max_batch_size, max_wait_ms):
self.buckets = {'short': [], 'medium': [], 'long': []}
self.max_batch_size = max_batch_size
self.max_wait_ms = max_wait_ms
def add_request(self, request):
bucket = self._classify(request)
self.buckets[bucket].append(request)
def _classify(self, request):
length = len(request.tokens)
if length < 128: return 'short'
if length < 512: return 'medium'
return 'long'
def get_next_batch(self):
# 优先处理高优先级桶
for bucket in ['short', 'medium', 'long']:
if len(self.buckets[bucket]) >= self.max_batch_size:
return self._pop_batch(bucket)
# 检查等待时间
if self._wait_time(bucket) > self.max_wait_ms:
return self._pop_batch(bucket)
# 如果所有桶都不满足条件,返回None
return None
4.4 批处理大小对延迟和吞吐的影响
批处理大小是个经典的权衡问题。我直接说结论:
| 批大小 | 吞吐量 | 延迟 | 显存占用 |
|---|---|---|---|
| 1 | 低 | 最低 | 低 |
| 4-8 | 中等 | 较低 | 中等 |
| 16-32 | 高 | 中等 | 高 |
| 64+ | 饱和 | 高 | 极高 |
为什么会这样?
批大小从1增加到16,吞吐量几乎线性增长。因为GPU的矩阵计算单元被充分利用了。但再往上加,增长就变慢了。因为内存带宽成了瓶颈——数据搬进搬出的速度跟不上计算速度。
延迟方面,批大小每翻一倍,延迟大约增加20-50%。这不是线性的,因为注意力计算是O(n²)的。批大小越大,注意力矩阵越大,计算量增长更快。
我建议的调优方法:先从小批开始,逐步增大,观察两个指标——GPU利用率和P99延迟。当GPU利用率达到90%以上时,再增大批大小对吞吐的提升就很有限了,反而会显著增加延迟。这时候就该停了。
还有一个容易被忽略的点:批处理对显存的消耗不是线性的。KV Cache的大小和批大小成正比,但注意力计算的中间结果增长更快。我遇到过批大小从32调到64,显存直接爆了的情况。所以调批大小的时候,一定要留足显存余量。
我曾经犯过的错:为了追求吞吐,把批大小设得很大。结果显存不够,触发了OOM,整个推理服务挂了。后来我加了一个显存预检的逻辑:每次调整批大小前,先估算显存占用,超过阈值就拒绝。这个习惯救了我好几次。
4.5 综合策略:怎么选?
说了这么多,到底怎么落地?我总结了一个决策流程:
- 先上动态批处理:这是基础,解决等待时间问题
- 再上连续批处理:解决请求长度不均的问题
- 然后做请求合并调度:长度分桶 + 优先级队列
- 最后调批大小:从1开始,逐步增大,找到吞吐和延迟的平衡点
这个顺序是我在多个项目中验证过的。每一步都能带来明显的收益,而且改动成本不高。你想想看,如果一上来就调批大小,可能调了半天,发现瓶颈在别的地方。先解决结构性问题,再优化参数,这才是高效的路径。
嗯,批处理策略就讲到这里。核心就一句话:让GPU忙起来,但别让它等。