4. 批处理策略

说到推理成本,批处理是绕不开的核心手段。我见过不少团队,模型精度调得不错,上线后却发现GPU利用率不到10%。说白了,钱花了,算力没跑满。批处理就是要把这些空闲的算力填满。

但批处理不是简单地把请求堆在一起。怎么堆、堆多大、什么时候堆,这里面门道很多。这一章我们就把批处理策略拆开揉碎了讲。

4.1 动态批处理原理

传统的静态批处理,就是等请求攒够一批再一起算。这种做法有个明显的问题:如果请求来得慢,后面的请求就得一直等着。延迟一下子就上去了。

动态批处理不一样。它不要求攒够固定数量,而是设一个最大等待时间。时间到了,不管攒了多少请求,直接开始推理。

核心思想:用时间窗口换吞吐,用弹性大小保延迟。

我在项目中遇到过这样一个场景:线上有个对话模型,白天请求量大,晚上请求量小。如果用固定批大小,白天还好,晚上一个请求可能要等好几秒才能凑够一批。换成动态批处理后,我们设了最大等待时间50ms。白天请求多,50ms内能攒32个;晚上请求少,可能只攒到2个。但不管怎样,用户都不会等超过50ms。

动态批处理的实现要点:

  • 时间窗口:通常设10-100ms,根据业务容忍度调整
  • 最大批大小:受显存限制,不能无限大
  • 最小批大小:低于这个值直接推理,不等待

我的习惯:最小批大小设1,最大批大小根据模型显存占用算出来。比如一个请求占2GB显存,显卡有80GB,那最大批大小就是40。留点余量,实际设32。

4.2 连续批处理(Continuous Batching)

动态批处理解决了等待时间的问题,但还有一个更大的坑:请求长度不一样。

你想想看,一个批里有10个请求,9个已经生成完了,最后一个还在慢慢生成。那前9个占用的显存和算力就白白浪费了。这就是经典的「木桶效应」——整个批的完成时间由最长的那个请求决定。

连续批处理就是来解决这个问题的。它的思路很直接:谁先结束,谁先走,新请求补进来。

具体做法是这样的:

  1. 初始时,一批请求一起开始推理
  2. 每个请求独立计算,生成完一个就检查是否结束
  3. 结束的请求立即从批中移除,释放显存
  4. 新请求立刻补进来,接上释放的位置
  5. 重复这个过程,直到所有请求处理完毕

这样做的好处很明显:GPU始终在处理有效计算,不会因为个别长请求而闲置。

我曾经踩过的坑:连续批处理需要模型支持注意力掩码的动态更新。有些框架的注意力实现是静态的,批大小变了就得重新编译。选框架的时候一定要确认这一点。我当初用了一个不支持的框架,折腾了两天才发现是框架的问题。

下面这张图展示了连续批处理的核心流程:

连续批处理(Continuous Batching)核心流程 初始批次:请求A、B、C、D、E 同时开始推理 步骤1:请求A生成完毕,从批次中移除 步骤2:新请求F立即补入,接替A的位置继续推理 步骤3:重复上述过程,直到所有请求处理完毕 关键:每个请求独立管理生命周期,GPU始终满载运行

4.3 请求合并与调度

有了动态批处理和连续批处理,是不是就万事大吉了?还差一步——请求怎么合并。

请求合并不是简单地把文本拼在一起。要考虑几个因素:

  • 长度差异:短请求和长请求混在一起,短请求会被拖累
  • 优先级:有些请求需要实时响应,有些可以等一等
  • 模型分片:如果模型做了张量并行,请求要尽量分配到同一组GPU上

我常用的调度策略是长度分桶 + 优先级队列:

  1. 把请求按输入长度分成几个桶:短(<128 tokens)、中(128-512)、长(>512)
  2. 每个桶内部按优先级排序
  3. 调度器从高优先级桶开始,尽量把同一桶的请求合并到一起
  4. 如果某个桶等得太久(超过时间窗口),降级处理,直接合并到其他桶

经验数据:长度分桶后,吞吐量提升了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. 先上动态批处理:这是基础,解决等待时间问题
  2. 再上连续批处理:解决请求长度不均的问题
  3. 然后做请求合并调度:长度分桶 + 优先级队列
  4. 最后调批大小:从1开始,逐步增大,找到吞吐和延迟的平衡点

这个顺序是我在多个项目中验证过的。每一步都能带来明显的收益,而且改动成本不高。你想想看,如果一上来就调批大小,可能调了半天,发现瓶颈在别的地方。先解决结构性问题,再优化参数,这才是高效的路径。

嗯,批处理策略就讲到这里。核心就一句话:让GPU忙起来,但别让它等。