1. 推理优化到底在优化什么
九月底那几天我一直在折腾推理链路的性能问题,起因很简单:线上一个日均调用量七位数级别的服务,单次推理耗时从年初的320ms一路涨到了接近900ms,GPU利用率却只有40%出头。这个数字组合一摆出来,问题就很明显了——不是算力不够,是算力没被用起来。
推理优化这个词听起来很玄,但拆开看无非三件事:让计算更少、让计算更快、让计算更满。让计算更少指的是模型层面的压缩和剪枝,让计算更快指的是算子层面的融合与替换,让计算更满指的是调度层面的批处理与并发编排。这三件事的优先级不是并列的,我的经验是先做调度层,再做算子层,最后才动模型层。原因很实际:调度层的改动风险最低、回滚最快、收益往往最直接;模型层的改动一旦出问题,排查成本极高,而且很容易把精度搞崩。
这次优化的目标定得很具体:在精度损失不超过0.5%的前提下,把P99延迟压到400ms以内,同时把GPU利用率拉到70%以上。定这个目标是因为业务侧的容忍阈值就在那里,再低没有意义,再高做不到。很多人在做优化时容易犯一个错误——不设上限地追求极致性能,结果投入产出比极差。优化是一门关于取舍的手艺,不是关于极限的运动。
提示:优化前一定要先定好“停止线”。没有停止线的优化会无限吞噬时间,而且很容易在某个局部最优解上卡死。
2. 先搞清楚瓶颈在哪里
2.1 profiling不是可选项,是必选项
我见过太多人一上来就改代码,改完发现没效果,然后开始怀疑人生。推理优化的第一步永远是profiling,没有例外。这次我用的是PyTorch自带的profiler加上Nsight Systems做双层分析,前者看算子级别的时间分布,后者看CUDA kernel的调度间隙。
profiling的结果很有意思:整个推理链路中,真正的矩阵计算只占了总耗时的38%,剩下的62%分布在数据预处理、内存拷贝、kernel启动间隙和Python层面的调度开销上。这个比例在中小模型上非常典型——大家总以为瓶颈在算力,实际上瓶颈在数据搬运和调度。
具体到数据上,几个关键指标值得记录:
| 指标项 | 优化前 | 优化后 | 变化幅度 |
|---|---|---|---|
| 单次推理总耗时(P50) | 620ms | 210ms | -66% |
| 单次推理总耗时(P99) | 890ms | 380ms | -57% |
| GPU利用率 | 42% | 74% | +76% |
| 显存峰值占用 | 18.2GB | 11.6GB | -36% |
| 预处理耗时占比 | 31% | 8% | -74% |
| kernel启动间隙占比 | 19% | 4% | -79% |
这张表是优化结束后的复盘数据,放在这里是为了让大家先有个全局印象。后面我会逐项拆解每个数字是怎么来的。
2.2 三类瓶颈的识别方法
瓶颈识别有一套比较成熟的判断逻辑,我把它总结成三个问题:
第一个问题:GPU利用率低但延迟高,瓶颈在哪?这种情况大概率是数据供给跟不上,也就是CPU侧的预处理和内存拷贝拖了后腿。判断方法是看profiler里CUDA kernel之间是否有大量空白间隙,如果有,基本可以确认。
第二个问题:GPU利用率高但延迟也高,瓶颈在哪?这说明计算本身太重,要么是模型太大,要么是算子实现不够高效。判断方法是看单个kernel的耗时占比,如果某个kernel占了总时间的30%以上,那就是重点优化对象。
第三个问题:延迟波动大,P99和P50差距超过2倍,瓶颈在哪?这通常是调度问题,可能是批处理策略不合理,也可能是内存分配导致的碎片化。判断方法是看不同batch size下的延迟曲线,如果曲线不平滑,说明调度层有问题。
这次我们的情况是第一个和第三个问题的混合体:GPU利用率低、P99波动大。所以优化重点放在了数据管线和调度策略上,模型本身没有做任何改动。
3. 数据管线优化:从31%压到8%
3.1 预处理为什么这么慢
预处理耗时占了总时间的31%,这个数字乍看很夸张,但拆开看就合理了。我们的输入是变长序列,预处理包括tokenization、padding、attention mask生成、位置编码计算四个步骤。其中tokenization是在CPU上跑的,用的是Python层面的循环调用,单次耗时约45ms;padding和mask生成用的是numpy,耗时约30ms;位置编码每次都在重复计算,耗时约15ms。
这三个步骤加起来90ms左右,乘以批处理前的单次调用次数,就构成了31%的占比。问题的核心不在于每个步骤有多慢,而在于它们都是串行执行的,而且都在CPU上跑,GPU全程在等。
3.2 三个改动解决大部分问题
第一个改动是把tokenization从Python循环改成批量向量化处理。原来是对每条请求单独调用tokenizer,现在改成攒一个batch后一次性处理。这个改动看起来简单,但效果立竿见影——tokenization耗时从45ms降到了8ms。原理也不复杂,Python循环的开销主要在解释器层面,批量处理把这部分开销摊薄了。
第二个改动是把位置编码从实时计算改成预计算缓存。位置编码只跟序列长度有关,跟具体输入无关,完全可以预计算好存起来。我们预计算了512到4096长度范围内的所有位置编码,存成一张查找表,用的时候直接索引。这个改动把15ms降到了不到1ms。
第三个改动是把预处理放到独立进程池里,跟推理进程解耦。原来预处理和推理在同一个进程里串行执行,现在预处理在独立的进程池里并行跑,推理进程只管消费处理好的数据。这个改动的好处是预处理和推理可以重叠执行,GPU不用等数据。
注意:进程池的大小不是越大越好。我试过4、8、16三种配置,最终8个进程的效果最好。超过8个之后,进程间通信的开销开始抵消并行带来的收益。
3.3 内存拷贝的坑
数据从CPU搬到GPU这一步,看起来只是一行.cuda()调用,实际上坑很多。最常见的问题是用了同步拷贝而不是异步拷贝。同步拷贝会阻塞CPU直到拷贝完成,异步拷贝则允许CPU继续做其他事情。在数据管线里,这个差别可能是几十毫秒级别的。
另一个坑是pin memory没有开。PyTorch的DataLoader默认不开pin memory,这意味着数据从pageable memory拷贝到GPU时需要经过一次额外的中转。开了pin memory之后,拷贝可以直接从锁页内存到GPU,省掉中转步骤。这个改动在我们场景下省了大约12ms。
还有一个更隐蔽的坑:频繁的小块拷贝比一次性大块拷贝慢得多。原来我们的代码是逐个tensor拷贝到GPU,改成先把所有tensor拼成一个大tensor再拷贝,拷贝次数从几十次降到一次,耗时从25ms降到了6ms。
4. 算子融合与kernel优化
4.1 哪些算子值得融合
算子融合的核心逻辑是减少kernel启动次数和显存读写次数。每次kernel启动都有固定开销,大约在5到10微秒之间;每次显存读写都要消耗带宽,而带宽往往是真正的瓶颈。
值得融合的算子有几个特征:计算简单但访存密集、前后算子之间有数据依赖、融合后不会显著增加寄存器压力。典型的例子是LayerNorm后面的激活函数、矩阵乘法后面的bias add、以及attention里的scale和mask操作。
我们这次做了三组融合:
第一组是LayerNorm + GELU。这两个算子原来是分开的,融合后省了一次显存读写和一次kernel启动。单次节省约3ms,但在多层叠加后效果显著。
第二组是QKV投影的合并。原来Q、K、V是三个独立的线性层,合并成一个大矩阵乘法后,kernel启动次数从3次降到1次,而且矩阵乘法的维度更大,GPU的利用率更高。
第三组是attention score的scale和mask合并。这两个操作原来分两步做,合并后省了一次中间结果的显存分配。
4.2 kernel启动间隙的消除
kernel启动间隙是GPU利用率低的罪魁祸首之一。profiler显示,优化前kernel之间的平均间隙是15微秒,看起来不多,但乘以每层几十个kernel、总共几十层,累积起来就是几十毫秒。
消除间隙的方法主要有两个:CUDA Graph和算子融合。CUDA Graph的原理是把整个计算图捕获下来,然后一次性提交给GPU执行,中间不需要CPU干预。这个方案的效果非常好,kernel间隙从15微秒降到了不到2微秒。但CUDA Graph有个限制:它要求计算图是静态的,输入shape不能变。我们的场景里序列长度是变的,所以不能直接用。
替代方案是动态shape下的分桶策略。我们把序列长度分成几个桶,比如128、256、512、1024、2048,每个桶对应一个CUDA Graph。请求进来后先归到最近的桶里,然后用对应的Graph执行。这个方案牺牲了一点灵活性,但换来了稳定的低延迟。
4.3 显存分配的优化
显存分配看起来是个小问题,实际上对延迟影响很大。PyTorch默认的显存分配器是caching allocator,它会缓存已分配的显存块以便复用。但在变长序列的场景下,不同长度的序列需要不同大小的显存块,缓存命中率很低,导致频繁的cudaMalloc和cudaFree调用。
我们的解决方案是预分配显存池。启动时一次性分配好最大可能需要的显存,然后自己管理分配和回收。这个改动把显存分配相关的耗时从平均18ms降到了不到2ms。
提示:预分配显存池的大小要留足余量,建议按峰值需求的1.2倍来分配。留太少会触发OOM,留太多会浪费显存影响并发。
5. 批处理与调度策略
5.1 动态批处理的参数怎么定
动态批处理的核心是在延迟和吞吐之间找平衡点。批越大,吞吐越高,但单次延迟也越大。我们的做法是设置一个最大等待时间窗口,在这个窗口内攒到的请求一起处理,窗口结束或者攒够了最大batch size就立即执行。
窗口大小和最大batch size这两个参数需要根据实际负载来调。我们的经验值是:窗口大小设在10到20ms之间,最大batch size设在32到64之间。窗口太小攒不到足够的请求,窗口太大延迟会明显上升。
具体到数字上,我们试了几组配置:
| 窗口大小 | 最大batch | P50延迟 | P99延迟 | 吞吐量 |
|---|---|---|---|---|
| 5ms | 16 | 180ms | 320ms | 基准 |
| 10ms | 32 | 210ms | 380ms | +45% |
| 20ms | 64 | 280ms | 520ms | +78% |
| 30ms | 64 | 350ms | 680ms | +82% |
最终选了10ms窗口加32最大batch的组合,因为它在P99延迟和吞吐之间取得了最好的平衡。20ms窗口虽然吞吐更高,但P99延迟超过了400ms的目标线。
5.2 优先级队列的必要性
不是所有请求都同等重要。我们的场景里,有一部分是交互式请求,用户在前端等着结果;另一部分是后台批处理请求,晚几秒没关系。如果不加区分地混在一起处理,交互式请求的延迟会被后台请求拖累。
解决方案是双优先级队列:高优先级队列用于交互式请求,低优先级队列用于后台请求。调度器优先消费高优先级队列,只有在高优先级队列为空时才处理低优先级请求。这个改动把交互式请求的P99延迟从520ms降到了380ms,效果非常明显。
5.3 超时与降级策略
再好的调度也架不住突发流量。我们设了两级保护:队列超时和降级执行。
队列超时的逻辑是:请求在队列里等待超过200ms还没被处理,就直接返回超时错误。这个策略看起来粗暴,但比让请求无限等待要好——用户等3秒拿到结果和等200ms拿到错误,体验上后者反而更好,因为用户可以立即重试。
降级执行的逻辑是:当队列积压超过阈值时,自动降低batch size,用更小的批次快速消费积压请求。这个策略牺牲了吞吐,但保住了延迟。
6. 常见问题与排查实录
6.1 优化后延迟反而升高了怎么办
这是最常见的问题。原因通常有三个:批处理窗口设得太大、显存池不够导致频繁分配、进程间通信开销超过了并行收益。
排查顺序建议从批处理窗口开始,先把它调到最小值看延迟是否恢复。如果恢复了,说明窗口设大了,逐步往上调找到平衡点。如果没恢复,检查显存池的命中率,命中率低于90%就说明池子太小。如果还不行,检查进程池的通信开销,用profiler看IPC的耗时占比。
6.2 精度掉了怎么定位
精度问题比性能问题难排查得多,因为它往往不是某个改动单独导致的,而是多个改动叠加后的累积效应。我们的做法是每做一个改动就跑一次精度验证,一旦发现精度下降就立即回滚,而不是等所有改动做完再一起验证。
如果已经叠加了多个改动才发现精度问题,可以用二分法定位:回滚一半改动,看精度是否恢复,然后逐步缩小范围。这个过程很耗时,但比盲目猜测要快得多。
6.3 不同序列长度下的性能差异
变长序列是推理优化里最头疼的问题之一。长序列的延迟可能是短序列的十几倍,如果调度器不加以区分,短序列会被长序列拖累。
我们的解决方案是按长度分桶调度。短序列(小于512)走快速通道,用小的batch size和短的等待窗口;长序列(大于512)走慢速通道,用大的batch size和长的等待窗口。两个通道独立调度,互不影响。这个改动把短序列的P99延迟从450ms降到了210ms。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| GPU利用率低 | 数据供给不足 | 看kernel间隙 | 优化预处理管线 |
| P99延迟波动大 | 调度策略不合理 | 看延迟分布曲线 | 调整批处理参数 |
| 显存OOM | 显存池太小 | 看分配失败日志 | 扩大显存池 |
| 精度下降 | 算子融合引入误差 | 逐层对比输出 | 回滚问题算子 |
| 吞吐上不去 | batch size太小 | 看平均batch大小 | 增大窗口或batch |
| 长序列延迟高 | 未做长度分桶 | 按长度分组统计 | 实施分桶调度 |
7. 一些踩过的坑和实操心得
第一个坑是过早优化。我一开始花了两天时间去做模型量化,结果发现量化后的精度损失超过了阈值,只能回滚。后来才意识到应该先做调度层优化,因为那部分的收益更大、风险更低。这个教训是:优化要按投入产出比排序,不要凭直觉选方向。
第二个坑是忽略了冷启动的影响。优化后的服务在冷启动时延迟很高,因为CUDA Graph需要捕获、显存池需要预分配、进程池需要预热。这些操作在服务刚启动时集中发生,导致前几十个请求的延迟是正常值的3到5倍。解决方案是启动时做预热,用模拟请求把各个组件都跑一遍,等稳定后再接入真实流量。
第三个坑是监控指标不完整。优化前我们只监控了平均延迟,没有监控P99和GPU利用率。优化过程中才发现P99才是真正影响用户体验的指标,而GPU利用率是判断优化方向的关键依据。后来补上了这两个指标,排查问题的效率提升了很多。
第四个坑是回滚方案没准备好。有一次改了一个算子融合的实现,上线后发现精度有问题,但回滚时发现旧版本的代码已经被覆盖了,只能紧急重新部署。从那以后我养成了一个习惯:每次改动前都打好tag,确保随时可以回滚到任意一个历史版本。
提示:优化过程中的每一次改动都要有对应的回滚方案。没有回滚方案的优化是在赌博。
8. 后续还能怎么压
这次优化把P99延迟压到了380ms,距离目标线400ms还有一点余量,但不算宽裕。后续如果还要继续压,我会从两个方向入手。
第一个方向是模型层面的轻量化。不是简单的量化,而是结构性的调整,比如把某些层的注意力头数减少、把FFN的中间维度缩小。这些改动需要重新训练,周期长,但收益上限高。
第二个方向是硬件层面的利用。比如用更高效的attention实现、用FP8精度做部分计算、用TensorRT做图优化。这些方案的收益取决于具体场景,需要逐个验证。
不过说实话,380ms对于我们的业务场景已经够用了。优化的边际收益在递减,继续投入的性价比不高。把精力放在稳定性建设和监控完善上,可能比继续压延迟更有价值。这个判断不一定对,但至少是我目前的真实想法。