AI推理批量执行优化:从GPU并行到动态批处理与KV Cache实践
发布时间:2026/10/19 7:47:28 锦皓数字建站

刚接手一个文本生成服务的性能优化时后台监控图让我很尴尬GPU利用率只有百分之十几单卡吞吐低得可怜但服务端平均响应时间却不低。翻了下调用链问题不在模型本身而在于线上请求基本是单条推理几乎没有把算力吃满的机会。AI 模型推理的批量执行优化就是在这样的场景下被逼出来的把多个独立请求合并成一批喂给同一个推理进程用并行的方式摊薄计算开销换取吞吐量的大幅提升。这件事做起来并不只是把batch_size调大那么简单里面牵扯到 GPU 并行特性、显存上限、请求调度策略、压测调参方法以及不少上线后才会暴露的坑。这篇文章就围绕批量执行优化的完整方案展开适合正在做推理服务性能优化、或者想把单卡吞吐打上去的工程同学参考。1. 为什么批量推理能快那么多GPU并行的本质与收益边界1.1 单请求推理为什么浪费算力GPU 本质上是一个高度并行的计算设备它的优势在于同时处理大量相同类型的计算任务。你要是只给它一个请求比如一次只推理一个句子绝大部分计算单元其实都在空转真正参与计算的只是很小一部分。换句话说你付了整卡的钱结果只用了半个脑袋。具体到实际表现单请求推理时 GPU 的利用率往往很低但器件的固定开销一点都不会少。矩阵乘法无论是算 1 条还是算 32 条数据kernel 启动、显存读写、算子调度这些成本都要走一遍。既然固定成本躲不掉那就多塞几个请求进去一起算。这就是批量执行优化最原始的动机。我做过一次很直观的测试同一批 64 条文本用循环逐个推理耗时接近 8 秒改成合并成 batch 一次推理后总耗时降低到 1.5 秒左右。这个差距并不是个例越大的模型、越规整的计算形状批量收益越明显。GPU 上的矩阵乘、卷积、attention 这些核心算子天生就喜欢把 batch 维度放在第一维一次性算完远比分多次调用高效。1.2 Batch Size、显存和时延的三角关系批量执行不是没有代价的最大的代价是显存与响应延迟。每次你把 batch size 翻倍中间激活和缓存占用的显存几乎也翻倍。对于 Transformer 类模型batch 增大还会让注意力矩阵的计算量增大单次推理的耗时也会变长。这里要建立一个清晰的量化直觉假设单条数据一次推理耗时为 Tbatch 为 B 时理想情况下单次耗时接近 T但实际上会因为显存带宽、attention 计算量的增长而变成 a * T其中 a 大于 1且随 B 增大而增大。总吞吐量 QPS 近似等于 B / (a * T)在 B 不是特别大时QPS 随 B 近似线性增长所以批量推理对吞吐的提升非常明显。但延迟不会那么友好。单条数据在 batch 里的耗时会随着 batch 的增大而上升因为要等其他请求一起算完才能返回。你可以通过合理的调度把排队时间控制住但 batch 内的计算时间却是实打实增加的。吞吐和时延之间本质上是一对矛盾优化批量执行的第一步就是接受这个矛盾并找到当前业务可接受的平衡点。1.3 把“逐个推理”当成批量是常见误解很多新手优化时写出来的代码是这个样子# 错误示范看起来是批量其实是串行 for sample in batch: output model(sample) results.append(output)这种写法在业务逻辑上确实是“一批请求”但计算层面还是单条推理GPU 利用率依旧很低。真正的批量执行是把多个样本合并成一个张量一次性送给模型# 正确做法合并成 batch 维度 batch_tensor torch.stack(samples, dim0) outputs model(batch_tensor)两行代码看起来差异不大实际性能却有很大差别。前一种只改了业务层的循环没有改变计算形态后一种才是真正利用 GPU 并行能力。做批量优化前先检查自己的推理代码到底是“循环调用”还是“张量级批量调用”很多性能问题在这一步就已经注定了。2. 动态批处理的调度设计从固定Batch到请求级调度2.1 固定Batch的痛点要么等着凑数要么饿着出发最朴素的批量执行方案是固定 batch攒够 N 条请求启动一次推理。这个方案简单但在真实线上环境里很难用。线上请求是随机到达的高峰时可能一瞬间来几百条低峰时半分钟来一两条。如果固定攒 32 条才跑低峰时请求会一直排队响应延迟爆炸如果固定攒 4 条就跑高峰时 GPU 又吃不满。于是很多方案会引入时间窗口最多等 X 毫秒无论是攒够了还是没攒够都要启动一次推理。这个组合其实就是动态批处理的雏形。和固定 batch 相比动态方式同时受两个条件制约一是 batch 数量上限二是最大排队等待时间。任何一个条件满足批次就立即出发。听起来简单但真正工程化时问题就来了窗口设多长batch 上限设多大请求到达不均匀时如何避免少数请求拖累整体的尾部延迟这些问题直接决定优化效果好不好。2.2 请求队列与调度窗口两个核心参数的设定逻辑我先给出一组常见配置和思路实际值需要根据业务的 QPS、模型规模和延迟要求来压测确认。参数常见范围作用max_batch_size8~64限制一次最多合并多少请求防止显存溢出max_queue_delay10ms~100ms限制请求在队列里等待的最长时间调度间隔1ms~5ms多久检查一次队列并形成批次两个参数的组合逻辑用一句话概括让“批次数量”和“等待时间”互相兜底。如果请求到达速率很高那么队列很快就会被填满max_batch_size 会成为主要触发条件吞吐优先如果请求到达速率很低max_queue_delay 会兜底保证请求不会无限等下去延迟优先。这里有一个值得写下来的关系假设请求到达速率为 λ个/秒调度窗口为 T那么平均一个批次能攒到的请求数量大约为 min(λ * T, max_batch_size)。如果 λ 只有每秒 10 个T 设 20ms那么平均一批只有 0.2 个请求大部分批次其实只有 1 条请求动态 batch 等于没生效。要让批量优化真正起作用要么适当调大 T要么提高系统入口的并发能力让更多请求同时涌进来。我曾经在一个低峰时段验证过这个公式窗口从 20ms 调到 200ms 后平均 batch size 从 1.1 涨到了 6.8GPU 利用率明显上来了。代价是 P99 延迟从 300ms 涨到了 500ms但业务 SLO 允许到 800ms所以这个调法是划算的。调参时记住一个原则先理解业务的延迟预算再倒推窗口可以放多大而不是拍脑袋设一个“看起来很合理”的数值。2.3 为什么现代方案要区分预填充和解码阶段如果只是把请求合并进 batch事情还远没结束。生成式模型在推理时有两个阶段预填充阶段处理完整的输入 token计算密集解码阶段逐 token 生成输出访存密集。这两个阶段的计算特征差异很大放在同一个批处理框架里如果不做拆分会出现一个大问题一个长输入请求的预填充可能会让一批解码请求的 token 生成变慢很多。更好的设计是把调度粒度从“请求级”下放到“阶段级”。预填充阶段到了就插入到当前批次解码阶段按照时间片或者迭代步来推进。这样长输入和短生成互相干扰的程度会大幅下降批次里的“计算空洞”也更好填充。实际做动态批处理时调度器需要维护的不只是一个先进先出队列而是多个阶段队列分别计算每个阶段是否可以合并这对吞吐和延迟的稳定性都有明显帮助。3. 显存才是真正的天花板KV Cache与连续批处理3.1 自回归模型的隐形成本KV Cache怎么吃掉显存批量执行时显存瓶颈往往不是模型权重而是 KV Cache。自回归模型在生成每一个 token 时都需要把历史 token 的 Key 和 Value 缓存下来用于后续注意力计算。这批缓存的大小和批次大小、序列长度、模型层数、注意力头数直接相关。估算公式大概是KV Cache 大小 2 × 层数 × KV 头数 × head_dim × 序列长度 × batch_size × 精度字节后面乘的 2 是因为 Key 和 Value 各一份。举个例子一个 28 层、8 个 KV 头、head_dim 为 128 的模型用 FP16 推理序列长度 1024batch 为 16 时KV Cache 大约需要 1.8GB 显存。如果 batch 提到 64这一项直接变成 7.5GB。很多模型权重可能只有几个 GB但 KV Cache 在高并发时会先一步把显存撑爆。所以在设定 max_batch_size 前先算清楚模型权重之外还剩多少显存。预留一部分给中间激活和碎片冗余剩下的再按估算公式定 Batch 上限。显存不够会导致 OOM或者触发底层频繁的显存换页性能反而会断崖式下滑。3.2 连续批处理把“整批结束”改成“序列级结束”传统批处理的问题在于整个批次必须等最慢的那条序列全部生成完毕才能统一释放资源。实际生成任务的长短差异很大有些请求几个 token 就结束了有些要生成几百个 token。长序列一直占着坑短序列哪怕已经生成完了也得陪着等到整个批次跑完这会让 GPU 出现大量气泡。连续批处理的核心思路是把释放和分配资源的粒度从“整批”改成“序列”。每完成一个序列立刻把它从当前批次中移出去腾出的位置马上安排新的请求进来。这样一来批次里的序列一直是动态进出的GPU 几乎不会有长时间空闲的 slot。我实际对比过一次相同请求压力下传统批处理的 GPU 有效利用率大约 60%切换到连续批处理之后能到 85% 以上。而且慢请求的影响被限制在它自己身上不会拖累整批。实现上要付出的代价是调度器需要追踪每条序列的生成状态复杂度增加了不少但收益通常值得投入。3.3 显存池化与块分配降低碎片化的工程手段连续批处理频繁插入和删除序列会让 KV Cache 的内存分配变得非常碎片化。操作系统或者显存分配器频繁申请、释放小块显存不仅性能下降还可能因为碎片导致明明显存总量够用却分配不出连续块。工程上的常见解法是显存池化。启动时按块大小预申请一批显存运行时把 KV Cache 按块分配用索引和引用计数来管理。一个块可以容纳一批 token 的缓存序列增长时追加分配新块序列完成时整块归还到池子里。这样内存分配次数大幅减少碎片自然也少了。其次请求进入时尽量按序列长度相近的原则分组这能显著减少解码阶段的 padding 浪费也让显存块的利用率更高。顺带提醒一句连续批处理不是万能的。如果业务场景大部分请求的生成长度都很短拆分的收益会打折扣如果显存本身已经捉襟见肘块分配的管理开销也可能成为新的瓶颈。做之前先评估自己的请求分布特性。4. 上线前的压测与调参顺序让优化效果真正可交付4.1 别只看吞吐TTFT与TPOT要一起观察批量执行优化最容易犯的错误是只盯着 QPS 看觉得吞吐高了就是成功。但上线后用户真正感知到的是延迟特别是首 token 延迟和后续 token 的生成速度。我通常用三个指标一起作判断指标含义批量优化的影响QPS每秒完成的请求数应随 batch 增大而提升TTFT从请求发出到收到第一个 token 的时间排队等待时间会让它恶化TPOT生成每个 token 的平均耗时batch 内计算量增大会让它恶化优化批量执行本质上是拿 TPOT 和 TTFT 的恶化换 QPS 的提升。如果用户的交互体验要求首 token 在 300ms 内返回那么调度窗口和 batch 上限就必须往保守的方向调。我见过一个团队把 QPS 从 20 涨到 80结果 TTFT 从 200ms 飙到 1.5 秒业务方直接拒绝上线。吞吐再好看延迟不达标也是白搭。4.2 压测的合理姿势渐进加压和服务指标拐点做压测时不要一上来就用最大并发轰而是要渐进加压观察各项指标的变化曲线。我常用的方法是从 1 个并发开始按 2、4、8、16、32、64 的倍数往上加每一步维持几分钟记录 QPS、TTFT、TPOT、GPU 利用率、排队长度。关键是要找到“拐点”。拐点的特征是QPS 不再随并发线性增长TTFT 却开始明显上涨。比如某次压测中并发从 16 加到 32 时QPS 只涨了 10%但 P95 TTFT 从 200ms 跳到 480ms说明系统已经接近饱和。这时继续调大 batch 或并发只会让延迟恶化正确做法是保持当前并发回头优化模型算子或显存分配。压测脚本要固化下来最好做成回归测试。每次调整推理框架版本、模型量化、调度参数后都跑一遍同一套压测这样能快速发现性能回退不用靠猜。4.3 慢请求、超时和失败重试批量下的可靠性设计批量执行有一个隐藏风险当一批请求里混入一个特别慢的请求时整个批次都要陪着它等。对于流式生成来说尤其麻烦一个长序列不结束它占用的 slot 不能释放新请求进不来可能导致后续一连串请求排队超时。工程上可以做的有几件事。一是给单个请求设置生成上限到达最大 token 数后强制截断二是把异常请求从批次中剔除并单独处理避免影响整批三是重试时要加抖动和次数上限否则批量系统在故障时很容易因为重试风暴被打崩。超时收尾也需要单独设计。如果请求已经超时但模型还在生成直接杀掉会浪费已经算完的 token也浪费显存块。比较好的做法是把超时请求标记为“完成”设备资源及时归还同时返回给上游已生成的部分结果或者明确错误。别小看这个细节在高并发场景下超时处理不合理会造成雪崩。5. 批量执行的扩展玩法与几个容易翻车的细节5.1 和量化、编译优化叠加时的取舍批量执行通常不会单独使用它经常和模型量化、算子编译优化一起上。量化把权重从 FP16 压缩到 INT8 甚至 INT4显存占用减少同样的显存就能塞下更大的 batch对批量执行是正向的联动。但量化并不总是白拿收益。有些量化格式在某些硬件上没有高效算子实际计算速度反而不如 FP16还有一些模型量化后精度下降明显导致业务侧结果不可用。我的经验是先跑量化后的单条推理延迟和显存占用再跑量化后的批量压测拿到真实数据后才决定 batch 上限。不要因为显存够了就盲目调大 batch有可能 batch 大了之后量化算子变成瓶颈整体收益被吃掉。编译优化也是同理。算子融合、计算图优化能让单次推理更快但有些优化在动态 shape 场景下会退化成 fallback 路径批量执行时反而比朴素实现更慢。优化的叠加效果必须逐层压测验证每一层都要能回答“这一步到底带来了多少收益”。5.2 多模型混合部署时的批处理策略很多线上推理服务不只有一个模型。不同模型结构不同无法直接塞进同一个 batch因为张量维度对不上。强行 padding 到相同 shape 只会浪费大量计算收益很可能不如分开部署。如果必须做混合部署可以考虑分桶策略把输入大小相近的请求分到同一个桶桶内再做动态批处理不同桶之间用优先级调度避免小请求被大请求长期阻塞。或者按 GPU 分片每个模型占用独立的计算资源互不干扰。不要天真地期待一个统一调度器能把所有模型都塞进同一个 batch现实世界里形状对齐的成本远高于那点吞吐收益。5.3 我踩过的坑与最终建议最早做批量优化时我犯过一个典型的错误为了追求 GPU 利用率把 max_queue_delay 调得很大结果线上请求的排队时间暴涨用户反馈“转圈转太久”。后来才意识到GPU 利用率是为业务服务的不是为了好看的数字。利用率高但延迟超了这个优化就是失败。现在的做法是先定义好 TTFT、TPOT 的硬性 SLO再在这个约束下尽量调大 batch最后才看利用率涨了多少。另一个建议是日志打点一定要细。批量优化的排查过程很依赖调度队列的观测数据包括每个请求的到达时间、入批时间、批内序号、实际开始时间、结束时间。有了这些数据才能精准定位延迟花在哪一段。没有日志压测出了异常只能瞎猜效率极低。最后分享一个对我很受用的原则批量执行优化不是一次性任务而是一条持续迭代的基线。先把当前版本的吞吐和延迟基线记录清楚每次改动只动一个变量对照基线判断效果。按这个节奏做下来整个系统的推理性能会一步步稳定提升而且每一次优化都经得起线上检验。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。