1. 推理优化到底在优化什么
九月底那几天我一直在折腾推理链路的性能问题,起因很简单:线上一个日均调用量七位数的服务,P99延迟从月初的800ms一路涨到了2.3s,GPU利用率却只有40%出头。这种“卡又不忙”的状态最让人头疼,因为它说明瓶颈不在算力本身,而在调度和内存管理上。我把这轮优化过程整理成一份总结,涉及模型推理的批处理策略、KV Cache管理、算子融合、量化选型以及监控体系搭建,适合正在做推理服务部署、模型加速、成本压降的同行参考。不管你是刚接触推理优化的新手,还是已经踩过几轮坑的老手,这里面应该都有能直接拿去用的东西。
先说结论性的判断:推理优化不是单一技术点的堆叠,而是一套从请求入口到GPU kernel的端到端系统工程。很多人一上来就想着换更快的推理框架、上更激进的量化方案,但实际测下来,大部分场景下收益最大的往往是批处理策略的调整和KV Cache的精细化管理。这两个方向不需要改模型结构,不需要重新训练,投入产出比极高。
我这次优化的核心目标有三个:第一,把P99延迟压到1s以内;第二,把GPU利用率从40%提到70%以上;第三,在不明显损失精度的前提下,把单次推理成本降低30%。这三个目标之间有张力——提高批处理大小能提升利用率但会增加延迟,量化能降成本但可能影响精度——所以整个优化过程本质上是在做多目标权衡。
2. 推理链路的瓶颈定位方法
2.1 先搞清楚时间花在哪里
优化之前必须做的一件事是:把推理请求的完整生命周期拆开,逐段计时。一个典型的推理请求会经历网络接收、tokenize、排队等待、prefill、decode、后处理、返回这几个阶段。很多人只关注prefill和decode的耗时,但实际上排队等待在高并发场景下可能占总延迟的50%以上。
我的做法是在请求入口处打一个trace_id,然后在每个阶段结束时记录时间戳,最后汇总成一张火焰图。工具方面,Python侧用py-spy做采样,GPU侧用nsight systems做kernel级别的profiling。两边的时间线对齐之后,瓶颈一目了然。
实测下来发现的问题很典型:排队等待占了总延迟的45%,prefill占20%,decode占30%,其余是tokenize和后处理。排队等待这么高,说明请求调度策略有问题——当时的策略是简单的FIFO,长请求会阻塞短请求,导致大量短请求在队列里干等。
2.2 用数据说话:关键指标采集
定位瓶颈需要采集的指标包括但不限于:
| 指标名称 | 采集方式 | 关注点 |
|---|---|---|
| 请求到达速率 | 入口计数器 | 峰值QPS、突发模式 |
| 队列深度 | 队列监控 | 是否持续增长 |
| 批处理大小分布 | 运行时统计 | 实际batch size vs 设定值 |
| Prefill耗时 | 阶段计时 | 与输入长度关系 |
| Decode每token耗时 | 阶段计时 | 与batch size关系 |
| KV Cache占用 | GPU内存监控 | 是否接近上限 |
| GPU SM利用率 | nvml/DCGM | 是否算力打满 |
| GPU内存带宽利用率 | nvml/DCGM | 是否内存瓶颈 |
这张表看起来简单,但每一项都要持续采集至少一周,覆盖工作日和周末的不同流量模式。我见过太多人只看了一个小时的数据就下结论,结果优化方案上线后效果完全不符预期。
注意:采集指标时一定要区分冷启动和稳态。服务刚启动时KV Cache是空的,prefill耗时和稳态差异很大,混在一起统计会严重误导判断。
2.3 瓶颈分类与对应策略
根据采集到的数据,瓶颈大致可以分为四类:
算力瓶颈:GPU SM利用率持续高于85%,decode每token耗时随batch size线性增长。这种情况下需要做算子融合、量化或者换更高效的attention实现。
内存瓶颈:GPU内存带宽利用率高于80%,SM利用率不高但延迟高。常见于大batch size下的KV Cache读写,需要优化内存布局或者用PagedAttention之类的方案。
调度瓶颈:队列深度持续增长,GPU利用率却不高。说明请求分配不均或者批处理策略不合理,需要引入连续批处理(continuous batching)或者优先级队列。
框架瓶颈:以上都不是,但延迟就是下不来。可能是Python GIL、序列化开销、网络传输等非GPU因素,需要用py-spy逐层排查。
我这次遇到的是调度瓶颈和内存瓶颈的混合体,所以优化方案也是组合拳。
3. 批处理策略的调整与实操
3.1 从静态批处理到连续批处理
原来的方案是静态批处理:攒够N个请求或者等够T毫秒,一起送进GPU,等这批全部生成完毕再处理下一批。这个方案的问题在于,同一批里不同请求的生成长度差异很大,短的早就生成完了,但要等长的生成完才能释放资源,GPU在尾部阶段利用率急剧下降。
连续批处理(continuous batching)的思路是:不等整批完成,某个请求生成结束后立刻把新请求塞进去,让GPU始终有活干。这个方案在vLLM、TensorRT-LLM等框架里都有实现,但自己实现也不复杂,核心是维护一个运行中的请求池,每次decode step后检查有没有完成的请求,有就移出,同时从等待队列里补入新请求。
实测数据:在同样的QPS下,连续批处理把GPU利用率从42%提到了68%,P99延迟从2.3s降到了1.1s。这个提升幅度是最大的,而且不需要改模型。
3.2 批处理大小的动态调整
连续批处理解决了“等长请求”的问题,但batch size设多大仍然是个问题。设太小,GPU利用率上不去;设太大,KV Cache占用高,可能OOM,而且单次decode耗时增加。
我的做法是做一个动态调整器:初始batch size设为8,然后根据GPU内存占用和decode耗时动态调整。具体规则是:
- 如果GPU内存占用低于70%且decode每token耗时低于阈值,batch size加2
- 如果GPU内存占用高于85%或者decode每token耗时超过阈值,batch size减2
- 调整步长设小一点,避免震荡
这个调整器每100个decode step执行一次,实测下来能在不同负载下自动找到合适的batch size,省去了手动调参的麻烦。
实操心得:动态调整器一定要加冷却时间,否则在临界点附近会频繁加减,反而增加开销。我设的冷却是500个step,效果比较稳。
3.3 优先级队列与超时控制
线上服务不可能所有请求同等重要。我把请求分成了三档:交互式请求(用户等待中)、批量请求(后台任务)、预热请求(缓存填充)。交互式请求走最高优先级队列,批量请求走低优先级,预热请求只在GPU空闲时执行。
超时控制也很关键。一个请求如果排队超过2秒还没开始prefill,直接返回超时错误,让客户端重试。这样做的好处是避免无效请求占用队列资源,实测能把有效吞吐提升15%左右。
4. KV Cache管理的细节与坑
4.1 KV Cache为什么会成为瓶颈
KV Cache是自回归生成的核心数据结构,每生成一个token,都要把当前token的Key和Value追加到缓存里,后续token的attention需要读取全部历史KV。随着生成长度增加,KV Cache的显存占用线性增长,而且attention的计算量也线性增长。
在batch size较大、生成长度较长时,KV Cache的显存占用可能超过模型权重本身。我这次遇到的情况就是:模型权重占12GB,KV Cache在峰值时占了18GB,导致GPU内存频繁触发回收,延迟抖动严重。
4.2 PagedAttention的核心思路
PagedAttention的思路借鉴了操作系统的虚拟内存分页管理:把KV Cache切成固定大小的block,每个block存固定数量token的KV,然后用一个block table记录每个请求的block分布。这样不同请求的KV Cache不需要连续存储,显存碎片大幅减少,而且block可以按需分配,不用预留最大长度。
实测下来,PagedAttention把KV Cache的显存利用率从60%提到了90%以上,同样的显存能支撑的并发请求数翻了近一倍。这个方案在vLLM里有成熟实现,直接拿来用就行。
4.3 KV Cache量化与淘汰策略
如果显存还是不够,可以考虑KV Cache量化。把FP16的KV Cache量化成INT8,显存占用直接减半,精度损失在大多数场景下可以接受。再激进一点可以上INT4,但精度损失就比较明显了,需要针对具体任务评估。
淘汰策略方面,对于超长生成任务,可以对早期的KV做淘汰或者压缩。常见的做法是保留最近的N个token的完整KV,更早的KV做均值池化或者直接丢弃。这个策略对摘要类任务影响不大,但对需要长距离依赖的任务要慎用。
注意:KV Cache量化后一定要重新跑一遍评测集,我遇到过量化后在某些长文本任务上精度掉5个点的情况,虽然平均指标看起来没问题,但bad case很致命。
5. 算子融合与量化选型
5.1 哪些算子值得融合
算子融合的核心目的是减少kernel launch开销和显存读写。在推理场景下,最值得融合的是这几类:
- LayerNorm + Residual Add:这两个操作经常连续出现,融合后减少一次显存读写
- QKV Projection:把三个线性层合并成一个,减少kernel数量
- Attention内部的Softmax + Dropout(推理时Dropout可以省略):减少中间结果的显存占用
- FFN中的两个线性层 + 激活函数:融合后减少一次显存往返
融合的实现方式有两种:一是用框架自带的融合算子(如TensorRT的fused ops),二是自己写CUDA kernel或者用Triton。前者省事,后者灵活。我这次大部分用的是框架自带的,只有几个特殊场景用Triton写了自定义kernel。
5.2 量化方案的对比与选择
量化是降成本的大杀器,但选型要谨慎。我对比了几种主流方案:
| 量化方案 | 精度损失 | 速度提升 | 显存节省 | 适用场景 |
|---|---|---|---|---|
| FP16 | 基准 | 基准 | 基准 | 通用 |
| INT8 W8A8 | 很小 | 1.5-2x | 50% | 大多数场景 |
| INT4 W4A16 | 较小 | 2-3x | 75% | 显存紧张场景 |
| INT4 W4A8 | 中等 | 2.5-3.5x | 75% | 对速度要求高 |
| FP8 | 很小 | 1.8-2.2x | 50% | 支持FP8的硬件 |
我最终选的是INT8 W8A8,因为它在精度和速度之间平衡得最好,而且工具链成熟,校准过程简单。INT4虽然显存省得更多,但在我的任务上精度掉了3个点,不能接受。
量化的校准集很关键,一定要用真实业务数据,不能用随机数据。校准集大小一般在500-1000条左右,覆盖各种输入长度和任务类型。
5.3 量化后的精度验证
量化后必须做完整的精度验证,不能只看loss或者困惑度。我的做法是:
- 跑一遍标准评测集,对比量化前后的指标
- 跑一遍业务bad case集,看有没有新增错误
- 做A/B测试,线上小流量对比
- 监控线上指标至少一周
这四步走完才能全量上线。我见过太多人跳过步骤3和4,结果上线后才发现问题,回滚成本很高。
6. 监控体系与持续优化
6.1 必须监控的核心指标
优化不是一次性的,上线后需要持续监控。我搭建的监控体系包括:
延迟指标:P50、P90、P99、P999,按请求类型和输入长度分桶统计。
吞吐指标:QPS、tokens/s、batch size分布。
资源指标:GPU利用率、显存占用、内存带宽、CPU利用率。
质量指标:生成成功率、超时率、错误率、平均生成长度。
成本指标:每千token成本、每请求成本。
这些指标全部接入告警,阈值根据历史数据动态调整。比如P99延迟超过1.5s就告警,GPU利用率持续低于50%也告警。
6.2 告警策略与自动降级
告警不能太敏感也不能太迟钝。我的策略是:
- 延迟类告警:持续5分钟超过阈值才触发
- 资源类告警:持续10分钟超过阈值才触发
- 质量类告警:立即触发
自动降级方面,当GPU内存占用超过90%时,自动降低batch size上限;当队列深度超过阈值时,自动拒绝低优先级请求。这些降级策略要提前演练,确保真的触发时不会手忙脚乱。
6.3 持续优化的节奏
推理优化不是一锤子买卖,业务在变、流量在变、模型也在变。我给自己定的节奏是:
- 每周看一次监控报表,关注趋势变化
- 每月做一次全面的性能回归测试
- 每季度评估一次新技术方案,看有没有值得引入的
这个节奏既能及时发现问题,又不会过度优化浪费精力。
7. 常见问题与排查技巧实录
7.1 延迟突然飙升怎么排查
延迟突然飙升是最常见的问题,排查思路要按层次来:
第一层:看监控大盘。确认是全局问题还是局部问题,是所有请求都慢还是特定类型慢。
第二层:看资源指标。GPU利用率、显存、内存带宽有没有异常。如果GPU利用率不高但延迟高,大概率是调度问题;如果GPU利用率打满,可能是流量突增或者某个大请求阻塞。
第三层:看请求分布。有没有超长请求、有没有异常输入。我遇到过一次是因为某个客户端传了超长prompt,导致prefill耗时暴增,阻塞了整个队列。
第四层:看框架日志。有没有OOM、有没有kernel报错、有没有CUDA错误。
第五层:上profiler。如果以上都正常,用nsight或者py-spy抓现场,看时间到底花在哪里。
7.2 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| P99延迟高但P50正常 | 长尾请求阻塞 | 看请求长度分布 | 引入优先级队列或超时控制 |
| GPU利用率低但延迟高 | 调度瓶颈 | 看队列深度和batch size | 连续批处理+动态batch |
| 显存OOM | KV Cache过大 | 看KV Cache占用 | PagedAttention或量化 |
| 精度下降 | 量化过度 | 对比量化前后指标 | 换更保守的量化方案 |
| 吞吐上不去 | 框架开销大 | py-spy采样 | 换推理框架或优化序列化 |
| 延迟抖动大 | 显存回收频繁 | 看显存占用曲线 | 限制batch size或预分配显存 |
7.3 几个容易踩的坑
坑一:只看平均延迟。平均延迟好看不代表体验好,P99才是关键。我见过平均延迟200ms但P99高达5s的服务,用户体验极差。
坑二:忽略冷启动。服务刚启动时KV Cache是空的,第一批请求的延迟会明显偏高。如果监控不区分冷启动和稳态,会误判。
坑三:量化后不验证。量化对精度的影响在不同任务上差异很大,必须用真实数据验证。
坑四:batch size设太大。batch size不是越大越好,超过某个点后decode耗时增长会超过吞吐收益,反而降低整体效率。
坑五:忽略网络和序列化开销。在高速推理场景下,网络传输和JSON序列化可能占总延迟的20%以上,这部分优化容易被忽略。
8. 这轮优化的实际收益与后续方向
这轮优化从九月中旬开始,到九月底基本完成,前后大概两周时间。最终的数据是:P99延迟从2.3s降到了0.9s,GPU利用率从42%提到了71%,单次推理成本降低了35%。这三个指标都达到了预期目标。
具体到各项优化的贡献度:连续批处理贡献了最大的延迟下降,大概占40%;PagedAttention贡献了最大的显存节省,让并发数翻倍;INT8量化贡献了主要的成本下降;动态batch size和优先级队列则让系统在不同负载下更稳定。
后续还有几个方向可以继续挖:一是投机采样(speculative decoding),用小模型做draft,大模型做verify,理论上能再提速1.5-2倍;二是更激进的量化方案,比如FP8在支持的新硬件上;三是请求级别的SLA保障,对不同用户提供差异化的延迟保证。
不过这些都是下一步的事了。这轮优化最大的体会是:推理优化没有银弹,每个场景的瓶颈都不一样,必须用数据驱动的方法去定位和解决。盲目套用别人的方案,很可能事倍功半。先把监控做好,把瓶颈找准,再针对性地优化,这才是最靠谱的路径。