推理服务并发优化:为什么动态 Batching 比固定批次更省 GPU? 📅 2026/7/13 16:41:08 推理服务并发优化为什么动态 Batching 比固定批次更省 GPU一、GPU 空转账单固定 Batching 的隐性成本生产环境中的推理服务GPU 利用率常年徘徊在 30%50% 是普遍现象。单卡 A100 每小时成本约 35 美元折算下来一年浪费可超过 2 万美元。这背后的核心原因并非模型推理慢而是请求到达模式与批次构建策略之间不匹配。固定 BatchingFixed Batching是最直观的方案预设一个固定的 batch_size如 8当积攒够 8 个请求后一次性送入 GPU。这种策略在请求密集时表现良好GPU 利用率接近峰值。但现实场景中流量往往是波动的。凌晨 3 点时60 秒才来 3 个请求。固定 batch_size8 的推理服务会一直等待凑够 8 个——要么超时释放要么触达最大等待上限后强推不完整批次。两种情况下 GPU 的实际计算密度都打了折扣。更隐蔽的问题是尾端请求效应当请求速率略低于批次填充速率时总有一个请求需要等待新请求到来。这导致 P99 延迟严重劣化——最后一个加入的请求等待时间接近一个完整填充周期。根据某生产集群的埋点数据固定 Batching 方案下 P50 延迟为 42msP99 却飙升至 870ms。基础设施不需要漂亮话排查这类问题时看到的通常是 GPU 利用率曲线呈锯齿状——峰谷之间落差超过 60%。每一段谷底都是金钱在流逝。二、动态窗口机制从等人凑数到异步就绪动态 BatchingDynamic Batching的思路是将以数量为驱动切换为以时间为驱动。其核心逻辑是在固定时间段内如 2ms把到达的所有请求合并为一个批次立即执行。graph TD A[请求到达队列] -- B{动态窗口计时器} B --|窗口溢出| C[合并当前批次请求] B --|批次达到上限| C B --|请求为空| D[等待下一轮] C -- E[Batch Tokenize / Padding] E -- F[GPU 推理执行] F -- G[结果分发回对应请求] G -- H[释放 KV Cache / 显存] H -- D D -- B窗口大小是动态 Batching 最关键的超参数。设得太大会增加延迟设得太小又等于退化回逐请求处理。某 13B 模型的实测数据显示1ms 窗口下 GPU 利用率 52%、P99 延迟 68ms5ms 窗口下 GPU 利用率 78%、P99 延迟 145ms10ms 窗口下 GPU 利用率 89%、P99 延迟 320ms。选择窗口大小的原则是在不突破 SLOService Level Objective的前提下尽可能拉伸。线上需要根据实时 P99 反馈动态调整窗宽——这是纯粹的工程经验没有理论最优解。另一个容易被忽略的点是 batch_size 上限。即便窗口内积聚了 32 个请求核函数启动的 overhead 也并非线性增长。当单批次超过某个阈值时kernel launch 延迟和显存碎片会成为新瓶颈。对于 A100 7B 模型上限通常设在 1632对于 A100 70B 模型上限可能只有 24。三、生产级实现基于 Go Channel 的异步 Batching 调度器以下是在 Go 推理服务中实现动态 Batching 的核心代码。设计要点包括channel 驱动的异步请求收集、可配置的窗口/上限参数、批次超时保护。package batch import ( context sync time ) // InferRequest 表示一次推理请求 type InferRequest struct { ID string Prompt string MaxToken int ResultCh chan *InferResult } // InferResult 推理结果 type InferResult struct { Text string Tokens int Err error } // DynamicBatcher 动态批次调度器 // 设计决策使用 select 的多路复用实现非阻塞的批量收集 // 而不是依赖外部轮询避免 CPU 空转。 type DynamicBatcher struct { reqCh chan *InferRequest // 请求入口 inferFn func([]*InferRequest) // 批量推理函数 maxBatchSize int // 批次上限 windowMs int // 收集窗口毫秒 mu sync.Mutex // 保护 shutdown 状态 closed bool } // NewDynamicBatcher 创建调度器 func NewDynamicBatcher( inferFn func([]*InferRequest), maxBatch int, windowMs int, ) *DynamicBatcher { return DynamicBatcher{ reqCh: make(chan *InferRequest, 1024), // 缓冲队列避免提交阻塞 inferFn: inferFn, maxBatchSize: maxBatch, windowMs: windowMs, } } // Submit 提交单个推理请求非阻塞超出缓冲则快速失败 func (b *DynamicBatcher) Submit(ctx context.Context, req *InferRequest) error { select { case b.reqCh - req: return nil case -ctx.Done(): return ctx.Err() default: // 队列满载拒绝请求而非阻塞调用方 return fmt.Errorf(batcher queue full, reject request %s, req.ID) } } // Run 启动批次收集与调度循环 // 通过定时器控制窗口不依赖外部触发。 func (b *DynamicBatcher) Run(ctx context.Context) { ticker : time.NewTicker(time.Duration(b.windowMs) * time.Millisecond) defer ticker.Stop() // 使用本地切片复用减少 GC 压力 var batch []*InferRequest for { select { case -ctx.Done(): // 优雅退出前清空残留请求 b.drainRemaining(batch) return case req : -b.reqCh: batch append(batch, req) // 一旦批次满立即触发推理不等时钟 if len(batch) b.maxBatchSize { b.flush(batch) batch batch[:0] // 重置切片复用底层数组 } case -ticker.C: if len(batch) 0 { b.flush(batch) batch batch[:0] } // 空批次不做任何事ticker 继续等待 } } } // flush 执行批量推理并分发结果 func (b *DynamicBatcher) flush(batch []*InferRequest) { // 防御性拷贝防止 inferFn 内部修改原始切片 snapshot : make([]*InferRequest, len(batch)) copy(snapshot, batch) // 实际推理调用应包含超时保护这里由 inferFn 内部处理 b.inferFn(snapshot) } // drainRemaining 清空队列中的残留请求 func (b *DynamicBatcher) drainRemaining(batch []*InferRequest) { for { select { case req : -b.reqCh: batch append(batch, req) default: if len(batch) 0 { b.flush(batch) } return } } }代码中几个关键决策点满批次即时触发当批次达到maxBatchSize时不等待窗口结束。这在流量高峰时能有效降低延迟。切片复用batch batch[:0]而非重新分配。减少 GC 压力对延迟敏感服务至关重要。优雅退出ctx.Done()触发 drain确保不丢请求。关闭时遍历 channel 直到清空。队列拒流default分支直接拒绝。生产者应重试或走降级通道。四、超时、抖动与显存碎片的三角关系动态 Batching 并非银弹。在实际落地中有三个必须面对的权衡延迟抖动加剧。虽然平均延迟可能更低但 P99 延迟受窗口大小和批次构建时机双重影响。当一个请求刚好错过窗口截止时间ticker 刚触发它需要等待完整的一个窗口周期。这种错过效应会周期性产生延迟尖刺。缓解方案是为窗口大小设置自适应调整逻辑基于当前队列深度动态伸缩。显存碎片化。动态批次意味着每批次大小不固定。批次 1 处理 3 个请求、批次 2 处理 11 个请求——KV Cache 分配频繁变化会加速显存碎片。CUDA 内存分配器虽然有 caching allocator 缓冲但频繁 resize 仍会触发 cudaFree/cudaMalloc 的系统调用。优化方向是预分配最大 batch 的 KV Cache 空间并内部管理子分配。与请求优先级冲突。当系统需要区分高优/低优请求时简单的 FIFO 队列不够用。需要引入多优先级队列和加权调度——这会显著增加调度器复杂度。在延迟敏感场景下可以考虑双通道架构高优请求走独立通道直推 GPU低优请求走 Batching 汇聚。禁用场景包括请求量极低的应用每月不足百万次调用此时引入 Batching 带来的复杂度不划算对延迟极端敏感的场景如实时对话窗口机制引入的额外延迟不可接受模型推理时间远大于窗口时间的场景如 70B 大模型窗口优化收益有限。五、总结动态 Batching 在生产环境中将 GPU 利用率从 30%~50% 提升到 70%~90% 是可行的。核心价值在于以时间为调度维度替代固定数量维度让请求到达的随机性与 GPU 计算的高效性对齐。实施路径建议分三步第一步在当前推理框架上启用动态 Batching 支持vLLM 默认支持TGI 通过--max-waiting-tokens配置第二步根据实际流量特征校准窗口大小和批次上限初始值建议窗口 25ms、上限 816第三步接入 Prometheus 监控 GPU 利用率、P50/P99 延迟和批次大小分布建立自适应调参回路。长期来看静态超参数终将成为瓶颈。下一步方向是将窗口大小交给 RL强化学习在线决策让系统根据实时负载自动找到最优批处理策略。