一、动态批处理概述
什么是动态批处理
动态批处理,说白了就是让推理引擎自己决定「什么时候攒够一批请求,什么时候开始干活」。
我刚开始接触这个概念时,觉得它挺反直觉的。你想啊,传统的批处理都是等数据凑够了再统一处理,但动态批处理不一样——它有个时间窗口,到了时间窗口还没凑够,也得开工。
举个例子。假设你的推理服务每秒能处理 100 个请求。如果采用动态批处理,系统会设置一个最大延迟时间,比如 50 毫秒。在这 50 毫秒内,来的请求会被收集起来,组成一个 batch。50 毫秒一到,不管来了 1 个请求还是 100 个请求,都得开始推理。
核心要点:动态批处理 = 时间窗口 + 最大 batch 大小,两者谁先触发,谁说了算。
为什么需要动态批处理
这个问题,我当年在做一个推荐系统时深有体会。那时候用的是静态批处理,请求量一波动,系统就崩。为什么?
原因有三:
- 请求到达不均匀——你想想看,用户访问是有高峰低谷的。凌晨 3 点可能就几个请求,晚上 8 点可能几千个。静态批处理在这种场景下,要么等太久,要么浪费资源。
- GPU 利用率太低——我记得有一次排查线上问题,发现 GPU 利用率只有 15%。为什么?因为静态批处理要求 batch 必须凑满 64 个请求才推理,但实际流量根本撑不起来。
- 延迟和吞吐的平衡——说白了,静态批处理是「要么延迟高,要么吞吐低」。动态批处理给了你一个调节旋钮,可以按业务需求来权衡。
我的经验:我曾经在一个广告推荐项目里,把静态批处理改成动态批处理,GPU 利用率从 20% 直接飙到 75%,P99 延迟反而降了 30%。这就是动态批处理的魅力。
动态批处理 vs 静态批处理
咱们直接上对比,这样更直观:
| 对比维度 | 静态批处理 | 动态批处理 |
|---|---|---|
| batch 大小 | 固定,比如 32、64 | 可变,1 到最大 batch 之间 |
| 触发时机 | 凑满固定数量 | 时间窗口或凑满最大 batch |
| 延迟表现 | 低流量时延迟高 | 低流量时延迟可控 |
| 吞吐表现 | 高流量时吞吐稳定 | 高流量时吞吐接近静态 |
| 资源利用率 | 低流量时浪费严重 | 自适应,利用率更高 |
| 实现复杂度 | 简单 | 中等,需要调度器 |
嗯,这里要注意一点。静态批处理并不是一无是处。如果你的请求量非常稳定,比如每秒 1000 个请求,那静态批处理反而更简单、更高效。动态批处理适合的是「流量波动大」的场景。
避坑指南:我曾经犯过一个错误——把动态批处理的时间窗口设得太短。结果呢?每个 batch 就 1-2 个请求,GPU 利用率反而比静态批处理还低。后来我把窗口从 10ms 调到 50ms,效果才出来。
动态批处理的核心逻辑
我画了一张流程图,帮你理解动态批处理的决策过程:
这张图其实就讲了三件事:
- 请求来了先判断——当前是否在时间窗口内?在的话就进队列等着。
- 触发条件有两个——要么时间窗口到了,要么 batch 满了。谁先到就听谁的。
- 执行推理——把队列里的请求打包成一个 batch,送进模型推理。
一个小技巧:实际项目中,我习惯把时间窗口设成 P99 延迟的 1/3 左右。比如业务要求 P99 延迟 150ms,那窗口就设 50ms。这样即使最坏情况,加上推理时间也不会超。
动态批处理的实现思路
最后,我简单说下实现思路。其实核心就两个组件:
- 请求收集器——负责接收请求,维护一个等待队列。我一般用 Go 的 channel 或者 Java 的 BlockingQueue 来实现。
- 调度器——负责决定什么时候触发推理。这里需要一个定时器,配合 batch 大小计数器。
伪代码大概长这样:
// 伪代码示例
class DynamicBatcher:
max_batch_size = 64
window_ms = 50
queue = []
timer = Timer(window_ms, flush)
def add_request(req):
queue.append(req)
if len(queue) >= max_batch_size:
flush()
def flush():
if queue is empty:
return
batch = queue.copy()
queue.clear()
model.infer(batch)
嗯,代码看着简单,但实际坑不少。比如并发安全、超时处理、backpressure 等等。这些后面章节会细讲。
总结一下:动态批处理就是「等一会儿,但不等太久」。它让推理服务在延迟和吞吐之间找到了一个动态平衡点。说白了,就是让机器适应人,而不是让人适应机器。