1. 推理优化这件事,为什么值得反复复盘
九月底那阵子,我手头正好在压一个线上推理服务的延迟。说是“推理优化”,其实落到日常里,就是一堆琐碎但致命的细节:显存怎么涨的、batch 怎么拼的、算子为什么没融合、量化之后精度掉了多少、并发一上来 P99 为什么直接炸穿。这些问题单看都不复杂,但叠在一起,就会变成一个典型的“模型能跑,但跑得不划算”的局面。
这篇总结面向的是正在做推理部署、模型加速、服务调优的同行,尤其是那些已经把模型跑通、但被成本和延迟卡住的人。我会把这次优化里真正起作用的思路、踩过的坑、以及可以直接抄的参数配置都摊开讲。核心关键词就几个:推理优化、延迟、吞吐、显存、量化、算子融合、批处理、KV Cache。如果你正在为“单卡 QPS 上不去”或者“长文本一进来就 OOM”发愁,那这篇内容应该能帮你省下不少试错时间。
先说结论性的判断:推理优化不是单纯换个更快的算子,也不是无脑上量化。它是一个从请求特征出发,倒推计算图、内存布局、调度策略的系统工程。九月底这轮优化,我们最终把单实例吞吐拉到了原来的 2.3 倍左右,P99 延迟压下去约 40%,显存峰值降了接近三分之一。下面我把整个过程拆开讲。
2. 优化前的基线摸底与瓶颈定位
2.1 先搞清楚“慢”到底慢在哪
很多人一上来就改代码,这是大忌。我习惯先做三件事:压测、采样、拆解。压测是为了拿到稳定的基线数据,采样是为了知道时间花在哪,拆解是为了把端到端延迟拆成可归因的几段。
我们当时的服务是一个典型的自回归生成任务,输入长度分布很不均匀,短的两三百 token,长的能到三四千。基线数据大概是这样:
| 指标 | 优化前 | 说明 |
|---|---|---|
| 平均首 token 延迟 | 480ms | 受 prefill 影响大 |
| 平均单 token 延迟 | 65ms | decode 阶段 |
| P99 端到端延迟 | 4.2s | 长输入拖累明显 |
| 单卡峰值显存 | 38GB | 接近上限 |
| 稳定 QPS | 12 | batch 拼不满 |
这张表一摆出来,问题就很清楚了:首 token 延迟高、decode 慢、显存吃紧、吞吐上不去。四个问题其实是相互纠缠的,不能孤立地看。
2.2 用 profiler 把时间切片看清楚
我用的采样工具是常规的算子级 profiler,把一次完整请求拆成 prefill 和 decode 两段分别看。结果发现几个反直觉的点:
- prefill 阶段里,LayerNorm 和 RoPE 这类逐元素算子占比意外地高,因为它们没被融合,反复读写显存。
- decode 阶段,KV Cache 的读写成了大头,尤其是长上下文场景,attention 的计算量反而不是瓶颈。
- 显存峰值出现在 prefill 的中间激活上,而不是模型权重。
提示:不要凭直觉猜瓶颈。我见过太多人一上来就量化权重,结果发现瓶颈根本不在权重上,白忙一场。
2.3 瓶颈归因的优先级排序
定位清楚之后,我按“收益/成本”排了个优先级:
- 算子融合:改动小、收益直接,先做。
- KV Cache 优化:长上下文场景收益巨大,但要注意实现正确性。
- 批处理策略调整:提升吞吐的关键,但要平衡延迟。
- 量化:收益大但风险高,放最后,且要严格验证精度。
这个顺序不是拍脑袋定的。算子融合几乎不影响数值结果,风险最低;KV Cache 优化需要仔细处理边界;批处理涉及调度逻辑;量化则可能引入不可逆的精度损失。先做低风险高收益的,把基线抬起来,再动高风险的,这样每一步都有可对比的参照。
3. 算子融合与计算图层面的实操要点
3.1 为什么逐元素算子这么“贵”
先解释一个很多人忽略的点:逐元素算子(element-wise)本身计算量极小,但它访存开销大。每做一次 LayerNorm,就要把整个张量从显存读进来、算完再写回去。如果后面紧跟一个残差加法,又是一次读写。这些读写累加起来,带宽就被吃满了。
生活里类比一下:这就像你做饭,每加一种调料都要把菜从锅里盛出来、加完再倒回去。调料本身不值钱,但来回折腾的功夫全浪费了。算子融合要做的,就是把能一起做的操作合并成一次读写。
3.2 融合哪些算子收益最高
我们重点融合了三类:
- LayerNorm + 残差加法:这是 Transformer 里的常客,融合后省掉一次完整的张量读写。
- RoPE 旋转位置编码:原本是多个小算子拼起来的,融合成一个 kernel 后,访存次数明显下降。
- 激活函数 + 逐元素乘:比如 SwiGLU 这类结构,把中间的逐元素操作合并。
实测下来,光这三类融合,prefill 阶段就快了大概 18%。这个数字看起来不夸张,但它是纯赚的,因为数值结果几乎不变。
3.3 融合的注意事项与踩坑记录
融合不是无脑合。我踩过的坑有这么几个:
- 融合后 kernel 太大,寄存器压力上升,反而导致 occupancy 下降。解决办法是控制融合的算子数量,别贪多。
- 某些融合会改变数值精度,比如把 float32 的中间结果压成 float16,长序列下误差会累积。我们的做法是关键路径保留高精度累加。
- 调试变难。融合后出问题不好定位,所以我会保留一个“关闭融合”的开关,方便对比。
注意:融合前后一定要做数值一致性校验。我一般会跑一批固定输入,对比输出的最大绝对误差,超过阈值就回退。
3.4 计算图重排的额外收益
除了算子融合,我们还做了一件容易被忽略的事:计算图重排。把一些可以提前算的东西挪到前面,把依赖关系理清楚,让调度器能更好地并行。比如某些常量计算、mask 生成,完全可以提前算好缓存起来,不用每个请求都重算。
这一步没有改任何算法,纯粹是“把活干得更聪明”,但收益很实在。优化有时候不是让计算变快,而是让不该算的东西别算。
4. KV Cache 优化:长上下文场景的胜负手
4.1 KV Cache 到底占了多少资源
先算一笔账。假设模型有 L 层,每层 KV 头数为 H,头维度为 D,序列长度为 S,数据类型为 float16(2 字节)。那么 KV Cache 的大小约为:
2 × L × H × D × S × 2 字节以一个 32 层、32 头、头维度 128 的模型为例,序列长度 4096 时,单条请求的 KV Cache 大约是:
2 × 32 × 32 × 128 × 4096 × 2 ≈ 2.1 GB这还只是一条请求。并发一上来,显存直接被吃光。所以长上下文场景下,KV Cache 是绝对的资源大户。
4.2 我们采用的优化手段
针对 KV Cache,我们做了三件事:
- 分页管理:把 KV Cache 按固定大小的块来分配,而不是一次性申请连续大块。这样能减少显存碎片,提升利用率。
- 按需分配与回收:请求结束后立即回收,避免缓存泄漏。这里要特别注意异常路径,请求失败时也要保证回收。
- 共享前缀缓存:对于系统提示词这类公共前缀,多个请求可以共享同一份 KV,避免重复计算。
第三点收益特别大。我们的场景里系统提示词很长,共享之后,prefill 的计算量直接砍掉一大截。
4.3 分页管理的参数选择
分页的块大小是个需要权衡的参数。块太小,管理开销大;块太大,浪费显存。我们试了几组:
| 块大小(token) | 显存利用率 | 管理开销 | 结论 |
|---|---|---|---|
| 8 | 高 | 高 | 开销偏大 |
| 16 | 较高 | 中 | 推荐 |
| 32 | 中 | 低 | 长序列浪费明显 |
| 64 | 低 | 低 | 不推荐 |
最终选了 16。这个值不是绝对的,跟你的序列长度分布有关。序列普遍短就选小一点,普遍长可以适当放大。
4.4 KV Cache 量化的取舍
我们还尝试了把 KV Cache 量化到 int8。收益是显存直接减半,但代价是精度。实测下来,短序列几乎无感,长序列在生成后期会出现明显的重复和跑偏。
我的建议是:KV Cache 量化要谨慎,最好只对 Key 做量化,Value 保留高精度,或者对长序列单独关闭量化。这个取舍没有标准答案,得看你的业务对生成质量的容忍度。
提示:量化 KV Cache 后,一定要跑长文本的回归测试。短文本测不出问题,长文本才会暴露。
5. 批处理与调度策略的调优实录
5.1 静态批处理为什么不够用
最早的实现是静态批处理:攒够一批再一起跑。问题是请求长度参差不齐,短请求要等长请求,延迟被拖垮。而且 batch 里只要有一条超长请求,整批的显存和计算都被拉高。
这就是典型的“木桶效应”——一批的耗时由最长的那条决定。静态批处理在请求长度均匀时还行,一旦不均匀就很难看。
5.2 连续批处理的引入
我们换成了连续批处理(continuous batching)。核心思想是:不等整批结束,某条请求生成完就立刻退出,空出的位置马上填入新请求。这样 GPU 的利用率能一直保持在高位。
效果很明显:稳定 QPS 从 12 提到了 27 左右,而且 P99 延迟没有恶化。原因是短请求不再被长请求拖累,整体调度更平滑。
5.3 调度参数的调优过程
连续批处理有几个关键参数:
- 最大批大小:决定显存上限和吞吐。我们一开始设得太大,导致 OOM,后来按显存反推。
- 调度步长:多久调度一次新请求。太频繁开销大,太稀疏利用率低。
- 抢占策略:显存不够时,优先踢掉谁。我们选择优先保留已经生成较多的请求,避免浪费。
最大批大小的估算可以这样来:先算出单条请求的平均 KV Cache 占用,再用可用显存除以它,留出 20% 余量。比如可用显存 30GB,单条平均占 1.5GB,那理论上限是 20,实际设 16 比较稳。
5.4 延迟与吞吐的平衡
这里有个绕不开的矛盾:批越大吞吐越高,但单条延迟也越高。因为一条请求要和更多请求抢计算资源。
我们的做法是分级调度:对延迟敏感的请求走小批快通道,对吞吐敏感的走大批通道。这样两类需求都能满足。具体怎么分,看你的业务有没有明确的 SLA。
注意:不要为了追求吞吐把批大小拉满。我见过为了刷 QPS 把延迟做到十几秒的,用户体验直接崩了。吞吐是手段,不是目的。
6. 量化与精度验证的完整流程
6.1 量化的收益与风险
量化是推理优化里收益最直接的手段之一。权重从 float16 压到 int8,显存直接减半,计算也能用上更快的整数指令。但风险同样明显:精度损失不可逆,且不一定在小样本上暴露。
我的原则是:量化必须配套完整的精度验证流程,没有验证就不上线。
6.2 我们采用的量化方案
我们用的是训练后量化(PTQ),没有做量化感知训练。流程大致是:
- 准备一批有代表性的校准数据,覆盖各种输入长度和类型。
- 统计激活值的分布,确定量化参数。
- 逐层量化,逐层验证,而不是一次性全量化。
- 对敏感层保留高精度。
逐层验证这一步很关键。全量化之后如果精度掉了,你根本不知道是哪一层的问题。逐层来,虽然慢,但可控。
6.3 精度验证的指标与方法
验证不能只看一个指标。我们用了三个维度:
| 验证维度 | 方法 | 阈值 |
|---|---|---|
| 数值误差 | 对比量化前后输出的最大绝对误差 | < 0.05 |
| 任务指标 | 在下游任务上对比准确率/F1 | 下降 < 1% |
| 生成质量 | 人工抽检长文本生成 | 无明显退化 |
三个维度都过了才算通过。只过数值误差是不够的,因为数值误差小不代表任务指标不掉。
6.4 量化踩坑记录
- 校准数据不具代表性:一开始只用短文本校准,结果长文本精度崩了。后来补了长文本校准数据才好转。
- 某些层对量化极敏感:比如第一层和最后一层,量化后误差放大明显。这两层我们保留了 float16。
- 量化后 kernel 不一定更快:有些硬件上 int8 的反量化开销反而拖慢了速度。量化前一定要实测,别想当然。
7. 常见问题与排查技巧速查
7.1 显存相关问题
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 显存缓慢增长 | KV Cache 未回收 | 检查异常路径的回收逻辑 |
| 显存峰值突增 | 中间激活未释放 | 检查计算图的生命周期 |
| OOM 随机出现 | 批大小与序列长度耦合 | 按最长序列估算显存 |
显存问题最烦的是“偶发”。我的经验是加显存监控埋点,把每次分配的峰值记下来,出问题时能回溯。
7.2 延迟相关问题
延迟问题要先分清是 prefill 慢还是 decode 慢。prefill 慢通常是算子或计算图问题,decode 慢通常是 KV Cache 或调度问题。分清楚方向,排查效率能高很多。
7.3 精度相关问题
精度问题最难查,因为它往往是累积的。我的建议是建立回归测试集,每次改动都跑一遍,一旦指标掉了立刻能发现。别等到上线才发现精度崩了,那时候回滚成本很高。
7.4 独家避坑技巧
- 保留开关:每个优化都留一个开关,方便对比和回退。
- 小步快跑:一次只改一个变量,改完立刻验证。同时改多个,出问题都不知道是谁的锅。
- 记录基线:每次优化前记录基线数据,没有基线就没有对比。
- 压测要真实:用真实分布的请求压测,别用均匀分布的假数据,那测不出问题。
8. 优化效果的量化对比与经验沉淀
8.1 最终效果对比
把上面所有优化叠加起来,最终效果如下:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 平均首 token 延迟 | 480ms | 290ms | -40% |
| 平均单 token 延迟 | 65ms | 42ms | -35% |
| P99 端到端延迟 | 4.2s | 2.5s | -40% |
| 单卡峰值显存 | 38GB | 26GB | -32% |
| 稳定 QPS | 12 | 27 | +125% |
这些数字不是某一项优化的功劳,而是多项叠加的结果。单项优化收益有限,叠加起来才可观。
8.2 哪些优化最值得做
如果让我按性价比排序:
- 连续批处理:吞吐提升最明显,改动集中在调度层。
- 算子融合:风险低、收益稳,几乎必做。
- KV Cache 共享前缀:长上下文场景收益巨大。
- 量化:收益大但风险高,放最后。
8.3 经验沉淀
这轮优化下来,我最大的体会是:推理优化没有银弹,只有对瓶颈的准确判断和一步步的验证。很多人追求某个“神奇参数”或“终极方案”,但真正管用的往往是那些朴素的工程手段——把该融合的融合、该回收的回收、该共享的共享。
另外,优化要有度量。没有度量,你永远不知道自己是进步了还是退步了。我习惯每次改动都记录数据,时间长了就形成了一套自己的“优化账本”,下次遇到类似问题能快速定位。
最后分享一个小技巧:优化前先问自己“这个改动会影响什么”。影响延迟还是吞吐?影响精度还是显存?想清楚再动手,比盲目试错高效得多。这套思路我在后续几个项目里复用,基本都能在两周内把推理性能拉到一个可接受的水平。