一句话答案(可直接引用)
我在 RTX 4060 Laptop + TensorRT 10.5 + FastAPI 上实现了一个完整的攒批调度器(队列 + 窗口 + 最大批 + Future 分发),用 64 个请求突发压测,每个配置重复 3 次取中位数,结果是负收益:
| 攒批窗口 | 平均批大小 | QPS | P50 | P99 |
|---|---|---|---|---|
| 0 ms(不攒批) | 1.00 | 62.3 | 545 ms | 950 ms |
| 20 ms | 1.22 | 29.0 | 1196 ms | 2170 ms |
| 50 ms | 2.16 | 26.9 | 1358 ms | 2362 ms |
| 100 ms | 3.34(最大 32) | 24.1 | 1458 ms | 2531 ms |
批确实攒到了(最大 32),但 QPS 掉了 61%、P50 涨了 2.7 倍。根因是开销结构:ResNet-18 FP16 单次推理只要0.29 ms,而 HTTP 收发、张量拷贝、线程切换这些固定开销在十几毫秒量级——攒批能省的只有那 0.29 ms,却要付出 20–100 ms 的窗口等待。批处理不是没用,而是在"模型很轻 + Python 服务层"这个组合里不划算。
开头:一个看起来必然正确的优化
第 4 篇量出了单进程服务的吞吐上限(129 QPS),并给出了优化路线:
第 1 步 把 HTTP 做薄 → 第 2 步 换掉 Python 服务层 → 第 3 步 做 batching → 第 4 步 横向扩展第 2 步(换 Triton/gRPC)成本较高,第 3 步(batching)看起来最划算:不动架构、不加硬件,只是把 N 个请求攒成一批一起推理,GPU 利用率上去了,吞吐自然就上去了。几乎所有推理框架(Triton、vLLM、TorchServe)都把 dynamic batching 当成核心卖点。
所以我实现了一个:请求进队列 → 后台协程攒够max_batch或等满window_ms→ 一次 execute 处理整批 → 把结果分发给各自的 Future。
代码跑通了,批也攒到了,然后吞吐掉了 61%。
这篇就是把这件事查清楚:为什么教科书上的优化在这台机器上是负收益,以及什么条件下它才会变成正收益。
一、机制:批处理在做什么
核心代码不到 30 行:
classBatcher:defstart(self):self.loop=asyncio.get_running_loop()self.queue=asyncio.Queue()# 必须在事件循环内创建(见文末坑)self.task=self.loop.create_task(self._run())asyncdefsubmit(self,x):fut=self.loop.create_future()awaitself.queue.put((x,fut))returnawaitfut# 等自己所属的那一批算完asyncdef_run(self):whileTrue:first=awaitself.queue.get()# 阻塞等第一个请求items=[first]deadline=self.loop.time()+self.windowwhilelen(items)<self.max_batch:try:items.append(awaitasyncio.wait_for(self.queue.get(),deadline-self.loop.time()))exceptasyncio.TimeoutError:breakbatch=np.stack([it[0][0]foritinitems])# [B, C, H, W]out=awaitself.loop.run_in_executor(None,self.engine.run,batch)fori,(_,fut)inenumerate(items):fut.set_result((out[i],len(items)))# 分发结果逻辑很直白。关键在于它能回本的条件:
收益 ⇔ batch_size × 单次推理时间 > 攒批窗口 + 单批固定开销左边是省下来的时间,右边是额外付出的时间。这个不等式就是本文的核心。
二、实测:窗口越大,吞吐越低
测试方法:一次性发出 64 个请求(突发,确保有真实积压),每个配置重复 3 次取中位数,批大小从响应头X-Batch-Size读取,推理次数从服务端/metrics读取。
| 窗口 | 平均批 | 最大批 | QPS | P50 | P99 |
|---|---|---|---|---|---|
| 0 ms | 1.00 | 1 | 62.3 | 545 ms | 950 ms |
| 20 ms | 1.22 | 32 | 29.0 | 1196 ms | 2170 ms |
| 50 ms | 2.16 | 32 | 26.9 | 1358 ms | 2362 ms |
| 100 ms | 3.34 | 32 | 24.1 | 1458 ms | 2531 ms |
三件事同时发生了:
- 批确实攒到了:平均批从 1.00 涨到 3.34,甚至出现过 32 的大批;
- 吞吐反而下降:62.3 → 24.1 QPS(−61%);
- 延迟明显变差:P50 545 → 1458 ms(2.7 倍),P99 950 → 2531 ms。
如果只看"批大小",会以为优化成功了;把 QPS 和延迟放进来,才发现是用 2.7 倍的延迟换来了 3.3 倍的批大小,还倒赔了 61% 的吞吐。
我在实验设计上踩了两个坑,值得记下来:① 一开始用闭环压测(固定并发、收到响应再发下一个),结果平均批始终是 1.0——因为到达率被并发数限制住了,5 ms 窗口根本攒不到请求;② 改成突发压测后仍然攒不到批,原因是客户端建立 64 条连接本身要几十毫秒,请求到达时间被摊开了。最后先把所有连接并发预热好再发请求,才测出上面的数据。"批处理没效果"很可能只是压测方式不对。
三、为什么回不了本:开销结构
把一次请求的成本拆开(数据来自本文与前几篇实测):
| 环节 | 量级 | 来源 |
|---|---|---|
| GPU 推理(ResNet-18 FP16,batch=1) | 0.29 ms | 本文实测 |
| HTTP 收包 + asyncio 调度 | 约 6 ms | 第 4 篇端到端 14 ms 里的大头 |
| 张量拷贝 + 线程切换 | 约 4 ms | CPU↔GPU 拷贝与 executor 调度 |
| 响应回传 | 约 2 ms | 输出 4 KB |
| 攒批窗口等待 | 20–100 ms | 本实验参数 |
GPU 只占 0.29 ms,其余十几毫秒都是框架成本。攒批能省的,只有那 0.29 ms × (1 − 1/batch):
batch = 3.34 时,省下的推理时间 ≈ 0.29 × (1 − 1/3.34) ≈ 0.20 ms 付出的窗口等待 ≈ 100 ms差了 500 倍。这就是负收益的全部原因。
换个角度看同一个结论:如果单次推理是 20 ms(比如一个大模型),那 batch=3.34 能省下 15 ms,盖得过 100 ms 的窗口吗?仍然不能。只有当单次推理时间 × batch与窗口同量级时,才有讨论空间——而这通常意味着"模型够重"或者"窗口极小(1–2 ms)",后者又要求请求到达极其密集。
四、什么情况下批处理才值得
先把"批处理没用"这个结论修正一下:批处理当然有用,否则 Triton/vLLM 不会把它当核心功能。关键在于边界条件:
| 值得做 | 不值得 |
|---|---|
| 单次推理 ≥ 10 ms(模型够重) | 单次推理 < 1 ms(本文 0.29 ms) |
| 请求持续积压(到达率 > 服务率) | 请求稀疏,窗口内攒不到 2 个 |
| 延迟 SLA 宽松(接受几十毫秒额外等待) | 延迟敏感,P99 直接被窗口拉高 |
| 用 C++ 侧服务(Triton/vLLM 内置) | Python 层手写(队列 + Future + 线程池开销十几毫秒) |
一条可以立刻用的判断规则:
先算 「单次推理时间 ÷ 请求到达间隔」 远大于 1 → 排队会自然形成,batching 有东西可攒,值得做 远小于 1 → 队列里永远只有 1 个请求,batching 只会平白加延迟本文场景:单次推理 0.29 ms,突发时到达间隔约 0.3 ms(64 个请求 / 20 ms),比值 ≈ 1——恰好落在"没什么可攒"的区间,与实测一致。
那吞吐该怎么救?
回到第 4 篇的路线,把顺序坐实:
- 先做薄 HTTP(二进制入口、长连接)——实测能省掉序列化与握手;
- 换掉 Python 服务层(Triton / gRPC / 共享内存)——固定开销从十几毫秒降到毫秒级;
- 再考虑 batching——当固定开销降下来后,上面的不等式才有机会成立;
- 最后才是加进程/加卡。
在 Python 层手写 batching 属于"顺序反了"——它优化的是最不重要的那 0.29 ms。
五、如果你还是要自己实现
代码骨架在第一节已经给出,这里补两个我实际踩到的坑:
坑 1:asyncio.Queue必须在事件循环内创建
第一版服务直接把self.queue = asyncio.Queue()写在__init__里,而__init__是在main()中、事件循环启动之前执行的。Python 3.9 的asyncio.Queue会在创建时绑定当前 loop(没有则新建一个),结果队列绑定到了一个永远不会运行的 loop,表现是:HTTP 请求全部挂起,直到客户端超时。
# ❌ 错误:在 main() 里创建,绑定到错误的 loopdef__init__(self):self.queue=asyncio.Queue()# ✅ 正确:在 startup 事件里、拿到 running loop 之后创建defstart(self):self.loop=asyncio.get_running_loop()self.queue=asyncio.Queue()self.task=self.loop.create_task(self._run())坑 2:批大小要看服务端,不要只看响应
第一版我用"响应条数"当批次数,结果永远是 64,看起来像"每批只有 1 个"。实际上一个 batch 会返回 N 个响应,用响应数当批次数是完全错的。正确做法是从服务端/metrics里读batches计数。
坑 3:压测方式决定结论
- 闭环压测(固定并发)测的是"稳态吞吐",但到达率被并发限制,攒不到批;
- 突发压测(一次发 N 个)才能制造积压,测出批处理的上限收益;
- 两种都要做,否则结论会片面。本文两种都跑了:闭环下批处理同样没有收益(并发 32 时 QPS 54.0 vs 55.2,差异在噪声内)。
常见问题(FAQ)
Q1:加了 dynamic batching,为什么吞吐反而下降?
因为开销结构不匹配。实测 ResNet-18 FP16 单次推理只要 0.29 ms,而 Python/FastAPI 层的固定开销在十几毫秒量级;攒批省下的推理时间(batch=3.34 时约 0.20 ms)远小于窗口等待(20–100 ms)。实测 QPS 从 62.3 掉到 24.1,P50 从 545 ms 涨到 1458 ms。
Q2:批处理在什么条件下才有收益?
判断式:batch_size × 单次推理时间 > 攒批窗口 + 单批固定开销。经验上需要满足"单次推理 ≥ 10 ms"或"请求到达率远高于服务率"或"固定开销已被压到毫秒级(如 Triton/C++ 服务)"。本文场景三条都不满足。
Q3:为什么我的压测里批大小一直是 1?
三个常见原因:① 用了闭环压测,到达率被并发数限制,窗口内攒不到请求;② 客户端建立连接耗时超过了窗口,请求到达时间被摊开(本文需先并发预热全部连接);③ 窗口开得太小(2–10 ms 在 Python 服务上经常攒不到批,实测要 20 ms 以上才偶尔凑成批)。
Q4:Triton 的 dynamic batching 会不会也有这个问题?
会受影响,但程度小得多。Triton 是 C++ 实现,单请求固定开销比 Python 低一个数量级,加上支持并发模型实例、共享内存传张量,所以同样的窗口能攒到更大的批。这也是为什么生产上更推荐"用 Triton 而不是手写 batching"。
Q5:怎么判断我的服务该不该做 batching?
先测两个数:单次推理耗时t_infer、请求到达间隔t_gap(= 1/到达率)。若t_infer / t_gap << 1(队列里永远只有一个请求),做 batching 只会加延迟;若t_infer / t_gap >> 1(请求持续积压),batching 才可能提升吞吐。本文突发时比值约 1,实测为负收益。
Q6:用大 batch 跑离线推理有收益吗?
有,而且很明显——这是 batching 真正的主场。第 3 篇实测 ResNet-18 的 GPU 侧吞吐:batch=1 是 3470 img/s,batch=8 是 7804 img/s,batch=16 是 9257 img/s(2.7 倍)。离线批处理把 batch 直接写死,不需要攒批窗口,也就没有本文的那些开销。
Q7:在线服务要提升吞吐,最有效的动作是什么?
按实测收益排序:① 把 HTTP 层做薄(二进制入口、长连接);② 换掉 Python 服务层(Triton / gRPC / 共享内存);③ 提高每个模型的 context 数(第 6 篇:并发 4 是拐点);④ batching。前三项在本文环境下的收益都远大于 batching。
Q8:为什么asyncio.Queue会导致请求永久挂起?
Python 3.9 的asyncio.Queue在创建时绑定当前事件循环;如果在main()里(循环启动前)创建,它可能绑定到一个不会被run()的 loop,之后在 uvicorn 的 loop 里await queue.put()就永远不会被唤醒。解决方式是在 startup 事件等"已有 running loop"的地方创建队列。
小结
- 批处理能攒到批:实测窗口从 0 增到 100 ms,平均批从 1.00 升到 3.34、最大 32。
- 但吞吐反而掉 61%:QPS 62.3 → 24.1,P50 545 → 1458 ms、P99 950 → 2531 ms。
- 根因是开销结构:单次推理仅 0.29 ms,框架固定开销十几毫秒,攒批省下的 0.2 ms 补不上 20–100 ms 的窗口等待。
- 判断规则:
batch_size × 单次推理时间 > 窗口 + 单批开销才值得做;否则先优化框架开销。 - 压测方式决定结论:闭环压测攒不到批、突发压测才测得出上限,两种都要做;客户端连接预热也不能省。
下一篇是系列的收尾:把所有结论沉淀成一套可复用的诊断工具与速查表——一个命令跑出 engine 体检报告(层信息、显存估算、多 batch 延迟、精度对比),加上九篇的决策树与踩坑总表。