1. 为什么要在9月30日做一次推理优化复盘
做推理优化这行有个习惯,每到季度末或者长假前,总要把手头跑着的服务做一轮体检。9月30日这个时间点很特殊,一方面是第三季度收尾,另一方面长假期间流量模式会发生变化,很多线上服务会迎来一波访问高峰或者低谷,提前把推理链路的性能压一压、调一调,比等到出问题再救火要划算得多。
这次复盘的核心对象是大模型推理服务的端到端延迟和吞吐。说白了就是两件事:单次请求从进到出要多久,以及一台机器一秒钟能扛多少请求。这两个指标看起来简单,但真正拆开来看,涉及的东西非常多——模型本身的算子效率、KV Cache的管理策略、批处理调度、显存占用、量化精度、并发模型、甚至底层通信库的版本,每一个环节都可能成为瓶颈。
我这次优化的目标很明确:在不明显损失输出质量的前提下,把P99延迟压下来,同时把吞吐提上去。适合阅读这篇总结的人包括:正在做推理服务部署的工程师、对推理性能调优感兴趣的后端开发、以及需要评估推理成本的技术负责人。不管你是刚接触这块还是已经调过几轮,下面这些踩坑经验和参数取舍逻辑应该都能给你一些参考。
2. 推理优化的整体思路与方案选型
2.1 先定位瓶颈,再谈优化
很多人一上来就想着换量化、换推理框架、上各种加速库,结果折腾一圈发现提升有限。我的习惯是先用 profiling 工具把整条链路的时间分布摸清楚。一次完整的推理请求大致经过这几个阶段:请求排队、tokenize、prefill(预填充)、decode(逐token生成)、detokenize、返回。每个阶段的耗时占比不同,优化重点就不同。
这次 profiling 下来发现,prefill 阶段占了大约35%的时间,decode 阶段占了50%,剩下的15%在调度和前后处理上。这个分布说明两件事:第一,decode 是主要瓶颈,因为它是逐token串行生成的,天然难并行;第二,prefill 虽然可以并行,但长 prompt 场景下计算量很大,也不能忽视。
提示:profiling 一定要在真实流量或者接近真实的压测流量下做,用单条短请求测出来的数据参考价值很低,因为批处理行为和显存压力完全不一样。
2.2 方案选型的三个维度
定位完瓶颈之后,方案选型我主要看三个维度:收益、风险、改造成本。收益就是能带来多少性能提升,风险是会不会影响输出质量或者稳定性,改造成本是接入难度和维护成本。
按照这个框架,我把候选方案列了个表:
| 方案 | 预期收益 | 风险 | 改造成本 | 优先级 |
|---|---|---|---|---|
| 连续批处理调度 | 吞吐提升明显 | 低 | 中 | 高 |
| KV Cache 量化 | 显存降低,支持更大batch | 中 | 低 | 高 |
| 算子融合 | 延迟降低 | 低 | 高 | 中 |
| 投机解码 | 延迟降低 | 中 | 中 | 中 |
| 权重量化 | 显存和带宽降低 | 中高 | 低 | 中 |
最终我选择优先做连续批处理和KV Cache量化这两项,因为它们收益明确、风险可控,而且不需要改动模型结构。算子融合和投机解码留到下一轮,权重量化因为对输出质量影响需要仔细评估,暂时只在小流量上灰度。
2.3 为什么不用更激进的方案
有人可能会问,为什么不直接上权重量化到4bit甚至更低?我的考虑是,这次优化的前提是"不明显损失输出质量"。4bit量化在某些任务上确实能保持不错的效果,但在需要精确推理或者长文本生成的场景下,质量下降是肉眼可见的。而且量化后的模型对校准数据很敏感,换一个业务场景可能就要重新校准,维护成本不低。
另一个考虑是稳定性。长假期间值班人手少,如果上了激进方案导致线上出问题,排查和回滚都很麻烦。所以这次的原则是:优先选成熟、可回滚、对质量影响小的方案,把激进优化留到有充分测试时间的时候再做。
3. 核心细节解析与实操要点
3.1 连续批处理到底解决了什么问题
传统的静态批处理是这样的:攒够一批请求,一起送进模型,等这一批全部生成完,再处理下一批。问题在于,同一批里不同请求的生成长度可能差很多,短的早就生成完了,但必须等长的那个结束,GPU 才能空出来处理下一批。这就造成了严重的算力浪费。
连续批处理(continuous batching)的思路是:不等整批结束,只要有请求生成完,就立刻把它移出,腾出的位置马上塞进新请求。这样 GPU 的利用率能一直保持在高位。打个比方,静态批处理像坐满一车人才发车、到终点才下客的班车;连续批处理像随时上下客的公交,座位一空就有人补上。
实测下来,在请求长度分布比较分散的场景下,连续批处理能把吞吐提升2到3倍。但要注意,它并不是万能的。如果所有请求长度都很接近,那静态批处理和连续批处理的差距就不大。另外,连续批处理对调度器的要求更高,实现不好反而会引入额外开销。
3.2 KV Cache 量化的参数选择
KV Cache 是推理过程中显存占用的大头,尤其是长上下文场景。它的作用是缓存已经计算过的 key 和 value,避免 decode 阶段重复计算。但缓存本身占显存,上下文越长、batch 越大,占用越夸张。
KV Cache 量化的核心是把缓存从 FP16 降到 INT8。这里有几个关键参数需要调:
- 量化粒度:按 token 量化还是按 channel 量化。按 token 量化实现简单,但精度损失稍大;按 channel 量化精度更好,但需要额外的缩放因子存储。
- 缩放因子计算方式:用动态还是静态。动态每步都算,精度好但慢;静态提前算好,快但可能不准。
- 是否保留 outlier:有些 channel 的数值特别大,强行量化会拉低整体精度,可以选择对这些 channel 保留高精度。
我这次选的是按 channel 量化 + 动态缩放 + 保留 outlier的组合。实测显存占用降低了大约45%,输出质量用人工评估和自动指标都测过,基本没有可感知的下降。
注意:KV Cache 量化后一定要重新跑一遍质量评估,不能只看困惑度(perplexity)这一个指标。有些任务对数值精度特别敏感,困惑度没变但实际输出会变差。
3.3 批处理大小和延迟的权衡
batch size 这个参数很微妙。调大能提升吞吐,因为 GPU 并行度更高;但会拉高单请求延迟,因为每个请求要等更久才能凑进批次,而且批次大了计算时间也长。
我的做法是画一条曲线:横轴是 batch size,纵轴分别是吞吐和 P99 延迟。找到吞吐还在涨、但延迟已经明显抬头那个拐点,把 batch size 定在拐点稍微靠前一点的位置。这次测下来,拐点大概在并发64左右,我最终把上限设在了48,留一点余量应对突发流量。
另外,batch size 不应该是一个固定值,最好根据实时排队情况动态调整。排队请求多的时候适当放大,排队少的时候缩小以保证延迟。这个逻辑需要在调度器里实现。
3.4 显存碎片化这个隐形杀手
调优过程中遇到一个很隐蔽的问题:服务跑一段时间后,显存占用越来越高,最后 OOM。查了半天发现是显存碎片化。因为不同请求的 KV Cache 大小不一样,频繁分配释放之后,显存里会出现很多小块空洞,虽然总空闲显存够,但没有一块连续空间能放下新的大请求。
解决办法是用显存池 + 分页管理。预先分配一大块显存,然后按固定大小的页来管理,KV Cache 按页存储,不要求连续。这样虽然单次访问可能跨页,但彻底解决了碎片问题。这个思路和操作系统的虚拟内存分页是一个道理。
4. 实操过程与核心环节实现
4.1 压测环境的搭建
优化不能凭感觉,必须有可复现的压测环境。我搭了一套这样的环境:
- 流量生成:用真实请求的采样作为输入,保持 prompt 长度和输出长度的分布和线上一致。这一步很关键,用固定长度的假数据测出来的结果没有参考价值。
- 指标采集:除了吞吐和延迟,还要采集 GPU 利用率、显存占用、队列长度、批处理大小等中间指标。这些指标能帮你判断瓶颈到底在哪。
- 对照实验:每次只改一个变量,其他保持不变。同时改多个参数,出了问题根本不知道是哪个引起的。
压测工具我用的是自己写的一个简单脚本,核心逻辑就是按一定速率发请求,记录每个请求的进出时间。没有用现成的压测框架,因为需要精确控制请求的到达模式和长度分布。
4.2 连续批处理的接入步骤
接入连续批处理大致分这几步:
- 改造调度器:把原来的"攒批-执行-清空"逻辑改成"维护一个运行中的批次,每步结束后检查哪些请求完成,移出并补入新请求"。
- 实现分页 KV Cache:为每个请求分配独立的 KV Cache 页,请求结束时回收。
- 调整采样逻辑:因为批次里每个请求的生成进度不同,采样时要按各自的进度取对应的 logits。
- 处理边界情况:比如请求被中断、超时、或者生成到最大长度,都要能正确清理资源。
第三步是最容易出 bug 的地方。因为不同请求的序列长度不一样,如果采样时索引搞错了,输出就会串味。我在这块调试了挺久,最后是通过给每个请求维护一个独立的 position 计数器解决的。
4.3 关键参数的实测数据
调优过程中记录了一组对比数据,分享出来供参考。测试条件是:单卡,模型规模中等,prompt 平均长度512,输出平均长度256,请求到达速率固定。
| 配置 | 吞吐(req/s) | P50延迟(ms) | P99延迟(ms) | 显存占用(GB) |
|---|---|---|---|---|
| 基线(静态批处理,FP16 KV) | 8.2 | 420 | 1180 | 38 |
| 连续批处理 | 19.5 | 380 | 950 | 40 |
| 连续批处理 + KV量化 | 22.1 | 360 | 890 | 24 |
| 连续批处理 + KV量化 + 调优batch | 24.8 | 340 | 820 | 25 |
可以看到,连续批处理带来的吞吐提升最明显,KV 量化主要贡献在显存上,显存降下来之后又能支撑更大的 batch,进一步提吞吐。这几个优化是相互促进的。
4.4 灰度上线的节奏控制
优化做完不能直接全量上,我采用的是灰度节奏:
- 第一天:1%流量,观察错误率和延迟指标。
- 第二天:10%流量,对比新旧版本的输出质量。
- 第三天:50%流量,重点看稳定性,有没有内存泄漏或者偶发超时。
- 第四天:全量。
每一阶段都设了明确的回滚条件,比如错误率超过阈值、P99延迟反而升高、或者显存持续增长。灰度期间我基本是盯着监控面板过的,虽然累但心里踏实。
提示:灰度期间一定要保留旧版本的实例,不要为了省资源把旧的直接下线。回滚速度决定了故障影响面。
5. 常见问题与排查技巧实录
5.1 吞吐上去了但延迟反而变差
这是调优里最常见的问题。原因通常是 batch size 设得太大,或者调度器为了凑批让请求等了太久。排查思路是看队列等待时间的分布,如果等待时间占了总延迟的大头,那就是调度策略太激进。
解决办法有两个:一是给等待时间设上限,超过就立即执行不等凑批;二是根据实时负载动态调整 batch size。我最后用的是第二种,效果更好但实现复杂一些。
5.2 输出质量下降但指标看不出来
KV 量化或者权重量化之后,自动指标(比如困惑度)可能变化很小,但实际输出质量下降了。这种情况要靠人工评估或者针对性的测试集来发现。我的做法是准备一组覆盖典型业务场景的测试用例,每次优化后都跑一遍,人工对比输出。
如果发现质量下降,优先检查是不是 outlier channel 被量化了。把这些 channel 保留高精度,往往能挽回大部分质量损失。
5.3 显存泄漏的定位方法
服务跑久了显存持续增长,基本可以确定是泄漏。定位方法是定期打印显存分配的快照,对比不同时间点的差异,看是哪类对象在增长。常见原因包括:KV Cache 页没回收、中间张量没释放、或者缓存了不该缓存的东西。
我这次遇到的是请求异常中断时 KV Cache 页没回收。修复方法是在请求的生命周期里加一个 finally 块,确保无论正常结束还是异常退出,资源都能释放。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 吞吐高但延迟差 | batch过大或凑批太久 | 看队列等待时间 | 限制等待时间或动态调batch |
| 质量下降 | 量化精度不足 | 检查outlier channel | 保留高精度channel |
| 显存持续增长 | 资源未回收 | 对比显存快照 | 加finally释放逻辑 |
| 偶发超时 | 长请求阻塞批次 | 看长尾请求分布 | 单独处理超长请求 |
| GPU利用率低 | 调度或通信瓶颈 | 看各阶段耗时占比 | 优化调度或通信 |
5.5 几个容易被忽视的细节
第一个是tokenize 和 detokenize 的开销。这两个操作看起来简单,但在高并发下也会成为瓶颈,尤其是用了复杂的 tokenizer 时。可以考虑把它们放到 CPU 上并行做,或者用更快的实现。
第二个是日志和监控的开销。每步都打详细日志会明显拖慢推理,建议只在采样或者异常时打日志,正常路径保持轻量。
第三个是版本兼容性。推理框架、CUDA、驱动、通信库之间的版本匹配很讲究,升级任何一个都可能引入问题。升级前一定要在测试环境充分验证。
6. 优化后的效果与后续方向
这轮优化做完,整体吞吐提升了大约3倍,P99延迟降低了30%左右,显存占用降低到原来的65%。更重要的是,这套优化方案是可复现、可回滚的,不依赖任何特殊硬件或者闭源组件。
后续我打算继续做的方向有几个:一是尝试投机解码,用小模型草稿加目标模型验证的方式进一步降延迟;二是研究更细粒度的量化方案,在质量和性能之间找更好的平衡点;三是把调度器做得更智能,根据请求特征预测资源需求,提前分配。
不过这些都是后话,眼下最重要的是把这轮优化的经验固化下来,写成文档和 checklist,让团队里其他人也能复用。毕竟推理优化这件事,靠一个人调是调不完的,得让整个团队都掌握方法论才行。
最后分享一个我自己的习惯:每次优化都记录一份"优化日志",写清楚改了什么、为什么改、效果如何、遇到什么问题。这份日志比任何文档都有价值,因为它是真实踩坑的记录,下次遇到类似问题翻一翻就能少走很多弯路。