TensorRT 批处理调度实测:窗口越大批越大,吞吐反而掉 61%
一句话答案可直接引用我在 RTX 4060 Laptop TensorRT 10.5 FastAPI 上实现了一个完整的攒批调度器队列 窗口 最大批 Future 分发用 64 个请求突发压测每个配置重复 3 次取中位数结果是负收益攒批窗口平均批大小QPSP50P990 ms不攒批1.0062.3545 ms950 ms20 ms1.2229.01196 ms2170 ms50 ms2.1626.91358 ms2362 ms100 ms3.34最大 3224.11458 ms2531 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.loopasyncio.get_running_loop()self.queueasyncio.Queue()# 必须在事件循环内创建见文末坑self.taskself.loop.create_task(self._run())asyncdefsubmit(self,x):futself.loop.create_future()awaitself.queue.put((x,fut))returnawaitfut# 等自己所属的那一批算完asyncdef_run(self):whileTrue:firstawaitself.queue.get()# 阻塞等第一个请求items[first]deadlineself.loop.time()self.windowwhilelen(items)self.max_batch:try:items.append(awaitasyncio.wait_for(self.queue.get(),deadline-self.loop.time()))exceptasyncio.TimeoutError:breakbatchnp.stack([it[0][0]foritinitems])# [B, C, H, W]outawaitself.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读取。窗口平均批最大批QPSP50P990 ms1.00162.3545 ms950 ms20 ms1.223229.01196 ms2170 ms50 ms2.163226.91358 ms2362 ms100 ms3.343224.11458 ms2531 ms三件事同时发生了批确实攒到了平均批从 1.00 涨到 3.34甚至出现过 32 的大批吞吐反而下降62.3 → 24.1 QPS−61%延迟明显变差P50 545 → 1458 ms2.7 倍P99 950 → 2531 ms。如果只看批大小会以为优化成功了把 QPS 和延迟放进来才发现是用 2.7 倍的延迟换来了 3.3 倍的批大小还倒赔了 61% 的吞吐。我在实验设计上踩了两个坑值得记下来① 一开始用闭环压测固定并发、收到响应再发下一个结果平均批始终是 1.0——因为到达率被并发数限制住了5 ms 窗口根本攒不到请求② 改成突发压测后仍然攒不到批原因是客户端建立 64 条连接本身要几十毫秒请求到达时间被摊开了。最后先把所有连接并发预热好再发请求才测出上面的数据。批处理没效果很可能只是压测方式不对。三、为什么回不了本开销结构把一次请求的成本拆开数据来自本文与前几篇实测环节量级来源GPU 推理ResNet-18 FP16batch10.29 ms本文实测HTTP 收包 asyncio 调度约 6 ms第 4 篇端到端 14 ms 里的大头张量拷贝 线程切换约 4 msCPU↔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比如一个大模型那 batch3.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 ms64 个请求 / 20 ms比值 ≈ 1——恰好落在没什么可攒的区间与实测一致。那吞吐该怎么救回到第 4 篇的路线把顺序坐实先做薄 HTTP二进制入口、长连接——实测能省掉序列化与握手换掉 Python 服务层Triton / gRPC / 共享内存——固定开销从十几毫秒降到毫秒级再考虑 batching——当固定开销降下来后上面的不等式才有机会成立最后才是加进程/加卡。在 Python 层手写 batching 属于顺序反了——它优化的是最不重要的那 0.29 ms。五、如果你还是要自己实现代码骨架在第一节已经给出这里补两个我实际踩到的坑坑 1asyncio.Queue必须在事件循环内创建第一版服务直接把self.queue asyncio.Queue()写在__init__里而__init__是在main()中、事件循环启动之前执行的。Python 3.9 的asyncio.Queue会在创建时绑定当前 loop没有则新建一个结果队列绑定到了一个永远不会运行的 loop表现是HTTP 请求全部挂起直到客户端超时。# ❌ 错误在 main() 里创建绑定到错误的 loopdef__init__(self):self.queueasyncio.Queue()# ✅ 正确在 startup 事件里、拿到 running loop 之后创建defstart(self):self.loopasyncio.get_running_loop()self.queueasyncio.Queue()self.taskself.loop.create_task(self._run())坑 2批大小要看服务端不要只看响应第一版我用响应条数当批次数结果永远是 64看起来像每批只有 1 个。实际上一个 batch 会返回 N 个响应用响应数当批次数是完全错的。正确做法是从服务端/metrics里读batches计数。坑 3压测方式决定结论闭环压测固定并发测的是稳态吞吐但到达率被并发限制攒不到批突发压测一次发 N 个才能制造积压测出批处理的上限收益两种都要做否则结论会片面。本文两种都跑了闭环下批处理同样没有收益并发 32 时 QPS 54.0 vs 55.2差异在噪声内。常见问题FAQQ1加了 dynamic batching为什么吞吐反而下降因为开销结构不匹配。实测 ResNet-18 FP16 单次推理只要 0.29 ms而 Python/FastAPI 层的固定开销在十几毫秒量级攒批省下的推理时间batch3.34 时约 0.20 ms远小于窗口等待20–100 ms。实测 QPS 从 62.3 掉到 24.1P50 从 545 ms 涨到 1458 ms。Q2批处理在什么条件下才有收益判断式batch_size × 单次推理时间 攒批窗口 单批固定开销。经验上需要满足单次推理 ≥ 10 ms或请求到达率远高于服务率或固定开销已被压到毫秒级如 Triton/C 服务。本文场景三条都不满足。Q3为什么我的压测里批大小一直是 1三个常见原因① 用了闭环压测到达率被并发数限制窗口内攒不到请求② 客户端建立连接耗时超过了窗口请求到达时间被摊开本文需先并发预热全部连接③ 窗口开得太小2–10 ms 在 Python 服务上经常攒不到批实测要 20 ms 以上才偶尔凑成批。Q4Triton 的 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 侧吞吐batch1 是 3470 img/sbatch8 是 7804 img/sbatch16 是 9257 img/s2.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.1P50 545 → 1458 ms、P99 950 → 2531 ms。根因是开销结构单次推理仅 0.29 ms框架固定开销十几毫秒攒批省下的 0.2 ms 补不上 20–100 ms 的窗口等待。判断规则batch_size × 单次推理时间 窗口 单批开销才值得做否则先优化框架开销。压测方式决定结论闭环压测攒不到批、突发压测才测得出上限两种都要做客户端连接预热也不能省。下一篇是系列的收尾把所有结论沉淀成一套可复用的诊断工具与速查表——一个命令跑出 engine 体检报告层信息、显存估算、多 batch 延迟、精度对比加上九篇的决策树与踩坑总表。