1. 为什么“推理优化”这件事值得单独写一份总结
九月的最后一天,我把这个月做过的推理优化工作从头到尾捋了一遍。说实话,动手之前我以为这只是一次常规的性能调优,无非是换换算子、调调并发、看看显存占用。但真正扎进去之后才发现,推理优化这件事远比想象中复杂——它不是某一个单点技术的胜利,而是一整套从模型结构、运行时调度、内存管理到硬件适配的系统工程。
这份总结面向的是那些正在做推理服务部署、或者准备把模型从实验环境推向生产环境的同行。不管你是刚接触推理优化的新手,还是已经踩过几轮坑的老手,我都希望这里面的一些实测数据和踩坑经历能帮你少走弯路。核心关键词就一个:推理优化。但围绕这个词展开的东西很多——延迟、吞吐、显存、批处理、量化、算子融合、KV Cache、并发调度,每一个拎出来都够写一篇长文。
我先说一个反直觉的结论:大多数推理优化项目失败,不是因为技术方案不对,而是因为优化目标没定义清楚。很多人一上来就说“我要把延迟降下来”,但你问他降到多少、在什么并发下、什么硬件上、什么模型上,他就说不清了。九月我接手的一个模拟项目就遇到了这个问题——团队花了三周做量化,结果上线后发现端到端延迟只降了不到百分之五,因为瓶颈根本不在计算上,而在数据预处理和调度排队上。
所以这篇总结的第一部分,我想先把“优化目标怎么定”这件事讲透,然后再往下拆具体的优化手段。
1.1 优化目标的三层拆解:延迟、吞吐、成本
做推理优化,目标无非三个维度:延迟要低、吞吐要高、成本要省。但这三个目标之间是互相拉扯的。你把批处理开大,吞吐上去了,但单条请求的延迟可能就炸了;你把模型量化到极致,显存省了,但精度可能掉得没法看。
我在九月做的一个模拟项目里,用了一张对照表来跟团队对齐目标,效果很好:
| 目标维度 | 衡量指标 | 典型场景 | 优先级 |
|---|---|---|---|
| 延迟优先 | P99延迟、首Token时间 | 实时对话、在线客服 | 高 |
| 吞吐优先 | QPS、每秒Token数 | 离线批处理、内容生成 | 高 |
| 成本优先 | 单次推理成本、显存占用 | 大规模部署、边缘设备 | 中 |
这张表的关键在于:你必须明确哪个指标是硬指标,哪个是可以妥协的。比如实时对话场景,P99延迟超过两秒用户就跑了,这时候吞吐低一点没关系;但如果是离线生成场景,你跑一晚上能出多少结果才是关键,单条慢几秒无所谓。
九月我见过一个团队,他们的场景是在线代码补全,用户敲一个字符就要出建议,这种场景下首Token时间必须控制在两百毫秒以内。但他们一开始的优化方向是提高批处理大小来提升吞吐,结果首Token时间直接飙到八百毫秒,用户体验崩了。后来把方向调回来,专注做算子融合和KV Cache优化,才把首Token时间压下去。
1.2 一个容易被忽略的前置动作:建立可复现的基准
在动手优化之前,有一件事比什么都重要:建立可复现的性能基准。我见过太多团队,优化前没测准,优化后也不知道到底提升了多少,最后只能靠“感觉快了”来交差。
九月初我在一个模拟项目里做的第一件事,就是搭了一套基准测试流程。具体包括:
- 固定输入分布:不能随便拿几条数据测,要构造有代表性的输入集,覆盖短文本、长文本、高并发、低并发等不同场景。
- 固定硬件环境:CPU型号、GPU型号、驱动版本、CUDA版本、内存频率,全部记录在案。换一个变量,测出来的数就不一样。
- 固定软件栈:推理框架版本、算子库版本、编译器版本,全部锁定。九月我就遇到过一次,框架小版本升级后,同样的模型延迟波动了百分之十五。
- 多次测量取统计值:不要只跑一次,至少跑一百次,取P50、P95、P99。单次测量受系统噪声影响太大。
这套基准流程搭起来大概花了两天,但后面每次优化都能快速验证效果,省下来的时间远不止两天。而且有了基准之后,团队讨论问题就有了共同语言——不是说“我觉得快了”,而是说“P99从三百二十毫秒降到了两百一十毫秒”。
提示:基准测试一定要在和生产环境尽可能一致的条件下做。如果生产环境是多卡并发,你单卡测出来的数参考价值有限。
1.3 九月优化工作的整体脉络
回头看九月这一个月,我的优化工作大致分成了四个阶段:
第一阶段是诊断瓶颈,搞清楚到底哪里慢。这个阶段我用了一些 profiling 工具,把推理链路拆成预处理、模型前向、后处理三段,分别计时。结果发现很多情况下预处理占了总时间的百分之三十以上,这是很多人没想到的。
第二阶段是模型层面的优化,包括量化、算子融合、KV Cache 优化。这部分是大家最熟悉的,也是九月投入时间最多的。
第三阶段是运行时层面的优化,包括批处理策略、并发调度、内存池管理。这部分往往被忽略,但收益可能比模型优化还大。
第四阶段是验证与回归,确保优化没有引入精度损失或稳定性问题。这个阶段最枯燥,但绝对不能省。
下面我就按这四个阶段,把九月踩过的坑和验证过的方案逐一展开。
2. 诊断瓶颈:别急着换算子,先搞清楚时间花在哪了
优化最忌讳的就是“我觉得这里慢”。九月初我接手一个模拟项目时,团队已经在算子层面折腾了两周,效果甚微。我做的第一件事就是让他们停下来,先做一次完整的 profiling。
2.1 推理链路的完整拆解方法
一个典型的推理服务,从收到请求到返回结果,中间经过的环节远比大多数人想象的多:
- 请求接收与解析:网络传输、协议解析、参数校验。
- 输入预处理:分词、编码、padding、张量搬运到设备。
- 模型前向计算:这就是大家通常说的“推理”,包括 embedding、attention、FFN 等。
- 输出后处理:解码、去 padding、结果组装。
- 响应返回:序列化、网络传输。
很多人做优化只盯着第三步,但实际上在九月我测过的几个模拟项目里,预处理和后处理加起来经常占到总延迟的百分之二十到四十。尤其是当模型本身比较小、计算量不大的时候,这些“周边”环节的占比会更高。
我用的 profiling 方法很朴素:在每一段的入口和出口打时间戳,记录耗时,然后统计分布。工具方面,Python 层面可以用time.perf_counter(),GPU 层面可以用框架自带的 profiler。关键是要把 CPU 时间和 GPU 时间分开看,因为这两者的瓶颈往往不在同一个地方。
import time def profile_pipeline(request): t0 = time.perf_counter() inputs = preprocess(request) t1 = time.perf_counter() outputs = model_forward(inputs) t2 = time.perf_counter() result = postprocess(outputs) t3 = time.perf_counter() return { "preprocess_ms": (t1 - t0) * 1000, "forward_ms": (t2 - t1) * 1000, "postprocess_ms": (t3 - t2) * 1000, }这段代码很简单,但坚持记录之后,你就能清楚地看到瓶颈在哪里。九月我靠这个方法发现了一个很隐蔽的问题:某个模拟项目的分词器在每次请求时都会重新加载词表,导致预处理耗时异常高。修复方法就是把词表加载提到服务启动时做一次,预处理耗时直接降了百分之六十。
2.2 CPU 与 GPU 的瓶颈判断:一个实用的决策树
判断瓶颈在 CPU 还是 GPU,我总结了一个简单的决策树:
- 如果 GPU 利用率长期低于百分之五十,而 CPU 利用率很高,那瓶颈大概率在 CPU 侧的预处理或调度。
- 如果 GPU 利用率接近百分之百,但吞吐还是上不去,那瓶颈在 GPU 计算本身,需要考虑算子优化或量化。
- 如果 GPU 利用率忽高忽低,波动很大,那瓶颈可能在数据搬运或批处理策略上。
- 如果 CPU 和 GPU 利用率都不高,但延迟就是下不来,那要检查是不是有同步等待或锁竞争。
九月我遇到过一个典型案例:一个模拟项目的 GPU 利用率只有百分之三十,但延迟居高不下。排查后发现是数据加载器用了单线程,GPU 经常在等数据。改成多线程预取之后,GPU 利用率拉到百分之八十五,吞吐直接翻了一倍多。
注意:GPU 利用率不是越高越好。如果利用率长期百分之百,说明 GPU 已经饱和,这时候再加并发只会让延迟继续恶化,应该考虑加卡或者做模型压缩。
2.3 九月踩过的一个 profiling 坑:别在热身后立刻测
这个坑值得单独说。九月中旬我在测一个模型的延迟时,发现第一次跑和第二次跑差距巨大,第一次要五百毫秒,第二次只要一百八十毫秒。一开始我以为是缓存命中,后来才发现是 GPU 的时钟频率在冷启动时还没升上去。
所以做 profiling 一定要先热身。我的做法是:正式测量前先跑五十到一百次,让 GPU 频率稳定、缓存预热、内存池分配完成,然后再开始记录数据。如果不热身,测出来的第一批数据会严重偏高,导致你对优化效果产生误判。
另外,测量时要注意隔离环境。如果同一张卡上还有别的任务在跑,测出来的数就没意义了。九月有一次我在共享集群上测数据,结果隔壁任务突然跑了个大 batch,我的 P99 直接翻倍。后来学乖了,要么独占卡,要么至少在测量期间确认没有其他负载。
3. 模型层面的优化:量化、融合与 KV Cache 的实战取舍
诊断清楚瓶颈之后,如果确认瓶颈在模型计算本身,那就要动模型了。九月我在模型层面主要做了三件事:量化、算子融合、KV Cache 优化。这三件事的收益和风险各不相同,下面逐一拆解。
3.1 量化:从 FP16 到 INT8 的收益与代价
量化是推理优化里最常被提到的手段。原理很简单:把模型权重和激活值从高精度浮点数(比如 FP16 或 FP32)转换成低精度整数(比如 INT8),从而减少内存占用和计算量。
九月我在一个模拟项目上做了完整的量化对比实验,数据如下:
| 精度 | 显存占用 | 单条延迟 | 吞吐 | 精度损失 |
|---|---|---|---|---|
| FP32 | 100% | 基准 | 基准 | 无 |
| FP16 | 52% | 降低约35% | 提升约50% | 几乎无损 |
| INT8 | 28% | 降低约55% | 提升约90% | 轻微下降 |
| INT4 | 16% | 降低约65% | 提升约120% | 明显下降 |
从数据看,FP16 几乎是“免费”的优化,精度损失可以忽略,收益却很可观。所以我的建议是:只要硬件支持,FP16 应该是默认选项。INT8 则需要做校准,而且不同模型对量化的敏感度差异很大。九月我遇到过一个模型,INT8 量化后在某些长尾输入上精度掉了百分之十以上,最后只能回退到 FP16。
INT4 的收益确实诱人,但风险也大。除非你的场景对精度要求不高,或者你有成熟的量化感知训练流程,否则我不建议在生产环境直接上 INT4。
量化的实操步骤大致是:
- 准备校准数据集,要覆盖真实场景的输入分布。
- 用推理框架自带的量化工具做校准,生成量化参数。
- 在验证集上对比量化前后的精度,确认损失在可接受范围内。
- 做端到端的性能测试,确认延迟和吞吐确实有提升。
- 上线后持续监控,防止长尾输入导致精度异常。
提示:量化校准集的构造非常关键。如果校准集和真实输入分布偏差太大,量化后的模型在真实场景下可能表现很差。九月的经验是,校准集至少要有几百条样本,而且要覆盖短文本、长文本、特殊字符等不同情况。
3.2 算子融合:哪些融合真正有效
算子融合是指把多个小算子合并成一个大算子,减少 kernel 启动开销和内存访问。这个优化在 GPU 上尤其有效,因为 GPU 的 kernel 启动是有固定开销的,算子越多,开销越大。
九月我实测下来,收益最明显的融合有这么几类:
- LayerNorm 与相邻线性层的融合:这是 Transformer 结构里的经典融合,收益稳定。
- Attention 中的 QKV 投影融合:把三个线性层合并成一个,减少内存访问。
- 激活函数与线性层的融合:比如 GELU 或 SiLU 直接融进前一个线性层。
- 残差连接与 LayerNorm 的融合:减少一次额外的读写。
但也不是所有融合都有收益。九月我试过把一些很小的算子强行融合,结果因为融合后的 kernel 太复杂,寄存器压力增大,反而变慢了。所以融合也要看具体情况,不能盲目做。
实操上,现在很多推理框架都自带算子融合功能,你只需要在导出模型时开启相应选项。但要注意,融合后的模型可调试性会变差,如果出了问题,定位起来会更麻烦。所以我的做法是:先在开发环境验证融合效果,确认无误后再上生产。
3.3 KV Cache 优化:长文本场景的救命稻草
如果你的场景涉及长文本生成,那 KV Cache 优化是绕不开的。KV Cache 的原理是:在自回归生成过程中,把之前算过的 Key 和 Value 缓存下来,避免每次生成新 Token 时都重新计算。
九月我在一个模拟项目上测过,开启 KV Cache 后,生成一百个 Token 的延迟从三点二秒降到了零点八秒,效果非常显著。但 KV Cache 也有代价:它会占用大量显存。序列越长,占用越大。
优化 KV Cache 的方向主要有几个:
- 分页管理:把 KV Cache 分成固定大小的页,按需分配,减少碎片。
- 量化压缩:把 KV Cache 也做量化,比如存成 INT8,显存占用能降一半。
- 稀疏化:只保留重要的 KV 对,丢弃不重要的,适合超长序列。
- 前缀共享:如果多个请求有相同的前缀,可以共享这部分 KV Cache。
九月我主要用了分页管理和量化压缩这两个手段。分页管理用的是推理框架自带的功能,量化压缩则需要自己改一些代码。实测下来,分页管理能把显存碎片降低百分之七十以上,量化压缩能把 KV Cache 显存占用降低约百分之四十五。
注意:KV Cache 量化要小心。如果量化太激进,生成质量会明显下降,尤其是长文本场景下,错误会累积。九月的经验是,KV Cache 用 INT8 一般问题不大,但 INT4 就要谨慎了。
4. 运行时优化:批处理、并发与内存池的细节博弈
模型层面的优化做完之后,很多人就收手了。但九月我的经验是,运行时层面的优化往往能带来更大的收益,而且成本更低。这部分我主要做了三件事:批处理策略调优、并发调度优化、内存池管理。
4.1 批处理策略:动态批处理 vs 静态批处理
批处理是提升吞吐最直接的手段。原理很简单:把多个请求攒在一起,一次性送给 GPU 计算,这样 GPU 的利用率更高。
但批处理有两种策略:静态批处理和动态批处理。
静态批处理是指固定 batch size,比如每次都攒够八条请求再送 GPU。这种方式实现简单,但延迟不可控——如果请求来得慢,用户就要等很久。
动态批处理是指根据当前请求队列的情况,动态决定 batch size。请求多的时候 batch 大一点,请求少的时候 batch 小一点,甚至单条也直接送。这种方式延迟更可控,但实现复杂度更高。
九月我在一个模拟项目上对比了两种策略:
| 策略 | 平均延迟 | P99延迟 | 吞吐 | 实现复杂度 |
|---|---|---|---|---|
| 静态批处理 | 120ms | 450ms | 高 | 低 |
| 动态批处理 | 95ms | 210ms | 高 | 中 |
从数据看,动态批处理在延迟上优势明显,尤其是 P99 延迟。所以我的建议是:如果场景对延迟敏感,优先用动态批处理。现在很多推理框架都内置了动态批处理功能,比如一些主流的推理服务框架,开启之后基本不用自己写太多代码。
但动态批处理也有坑。九月我遇到过一个情况:动态批处理在请求量突增时,会不断攒更大的 batch,导致延迟飙升。后来加了一个最大 batch size 限制和最大等待时间限制,才把延迟稳住。
4.2 并发调度:别让请求在队列里干等
并发调度解决的是“多个请求同时进来怎么处理”的问题。最简单的做法是来一个请求起一个线程,但这样线程数会爆炸,上下文切换开销很大。
更好的做法是用异步调度:用一个事件循环管理所有请求,谁的数据准备好了就送谁去计算。九月我用的方案是基于协程的异步调度,实测下来,在同等硬件上,异步调度比同步多线程的吞吐高了约百分之四十。
调度策略上,我主要关注两点:
- 公平性:不能让某个长请求一直占着 GPU,导致后面的短请求饿死。我的做法是给每个请求设一个最大执行时间,超时就切出去,等下一轮再继续。
- 优先级:有些请求是实时的,有些是离线的,要给实时请求更高的优先级。九月我在一个模拟项目里加了优先级队列,实时请求的 P99 延迟降了百分之三十。
4.3 内存池:减少分配释放带来的抖动
内存池这个事,很多人不注意,但它对延迟稳定性的影响很大。每次推理都要分配和释放显存,如果频繁做,会产生碎片,还会导致延迟抖动。
九月的做法是:在服务启动时预分配一大块显存作为内存池,之后所有推理都从池子里拿,不再向系统申请。这样做的收益是延迟更稳定,P99 和 P50 的差距缩小了约百分之五十。
内存池的实现方式有很多种,最简单的是固定大小分块,复杂一点的是变长分块加碎片整理。九月我用的是推理框架自带的内存池功能,基本够用。如果框架不支持,也可以自己用 CUDA 的显存管理接口实现一个简单的版本。
提示:内存池的大小要留有余量。如果池子太小,请求来了分配不到内存,就会触发回退到系统分配,性能反而更差。九月的经验是,池子大小至少要是峰值需求的1.5倍。
5. 验证与回归:优化之后怎么确认没有引入新问题
优化做完之后,最危险的就是直接上线。九月我见过一个团队,量化之后没做充分验证,上线后发现某些输入下输出全是乱码,紧急回滚,损失了好几个小时的服务时间。
所以优化之后的验证环节绝对不能省。我九月的验证流程分三步:精度验证、性能回归、稳定性测试。
5.1 精度验证:不能只看平均值
精度验证最容易犯的错误就是只看平均指标。比如你测了一百条样本,平均精度只掉了百分之零点五,看起来没问题。但可能其中有三条样本精度掉了百分之三十,只是被平均值掩盖了。
九月的做法是:看分布,不只看均值。具体来说:
- 把验证集按输入长度分组,分别看每组的精度变化。
- 把验证集按输入类型分组,比如问答、摘要、翻译,分别看。
- 找出精度下降最多的那几条样本,人工检查输出,判断是否可接受。
九月我在一个模拟项目上就靠这个方法发现了一个问题:量化后的模型在短输入上表现正常,但在超过五百个 Token 的长输入上,精度掉了百分之八。后来调整了量化策略,才把长输入的精度拉回来。
5.2 性能回归:确保优化没有在别的维度上倒退
性能回归是指:你在某个维度上优化了,但可能在另一个维度上倒退了。比如你把延迟降下来了,但吞吐也降了;或者你把吞吐提上去了,但显存占用增加了。
所以优化之后要做完整的性能测试,把所有关键指标都测一遍,和优化前对比。九月的做法是维护一张性能对比表,每次优化后更新:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| P50延迟 | 150ms | 95ms | -36% |
| P99延迟 | 420ms | 210ms | -50% |
| 吞吐 | 100 QPS | 180 QPS | +80% |
| 显存占用 | 12GB | 8GB | -33% |
| 精度 | 基准 | -0.3% | 可接受 |
这张表能让你一眼看出优化的整体效果,也能发现潜在的倒退。如果某个指标倒退了,就要分析原因,判断是否可接受。
5.3 稳定性测试:跑够时间才算数
稳定性测试是指让服务连续跑一段时间,观察是否有内存泄漏、延迟漂移、错误率上升等问题。九月我的做法是至少跑二十四小时,期间持续发请求,监控各项指标。
九月我遇到过一个内存泄漏问题:服务跑六个小时后,显存占用会慢慢涨上去,最后 OOM。排查后发现是 KV Cache 的释放逻辑有 bug,某些情况下缓存没被正确回收。这种问题只有跑够时间才能发现,短时间测试根本看不出来。
稳定性测试还要关注长尾请求。有些请求因为输入特别长或者特别复杂,延迟会远高于平均值。如果这类请求占比不高,平均指标看不出来,但用户体验会很差。九月的做法是专门构造一批长尾请求,单独测它们的延迟分布。
注意:稳定性测试的环境要尽可能接近生产。如果测试环境是单卡,生产是多卡,那多卡之间的通信开销、负载均衡问题在测试环境是发现不了的。
6. 九月优化工作的几条个人体会
这个月做下来,有几个体会我觉得值得单独拎出来说。
第一,优化是一个迭代过程,不是一次性的。你不可能一次就把所有瓶颈都解决。九月的做法是:每轮优化只聚焦一两个点,做完之后测一遍,确认有效再进入下一轮。这样虽然看起来慢,但每一步都扎实,不会出现“改了一堆东西但不知道哪个起作用”的情况。
第二,不要迷信某个“银弹”技术。量化、算子融合、批处理,这些技术都有适用场景。九月我见过有人不管什么模型都上 INT8,结果精度崩了;也见过有人不管什么场景都开大 batch,结果延迟炸了。优化的核心是匹配场景,不是堆技术。
第三,监控和可观测性要提前做。不要等出了问题才想起来加日志。九月我在项目开始时就搭了一套监控,包括延迟分布、吞吐、显存占用、GPU 利用率、错误率。后来每次优化都能快速定位问题,省了很多时间。
第四,和业务方对齐目标比技术本身更重要。九月我花了不少时间和业务方沟通,确认他们到底要什么。有时候业务方说“要快”,其实他们要的是“稳定”,延迟波动小比平均延迟低更重要。搞清楚真实需求,才能把优化做到点子上。
最后分享一个小技巧:每次优化后,把变更记录、测试数据、结论都写下来。九月我养成了写优化日志的习惯,每次改动都记一笔。后来回头看,这些日志帮我避免了很多重复劳动——有些方案我之前试过不行,日志里写着原因,就不会再浪费时间。
这个月的工作让我对推理优化有了更深的理解。它不是某个单点技术的堆砌,而是一套需要全局视角的系统工程。从诊断瓶颈到模型优化,从运行时调优到验证回归,每个环节都有讲究。希望这些经验对正在做类似工作的同行有所启发。