大模型推理性能优化实践:从KV缓存到量化与连续批处理
9月30日这天我们终于把推理服务这块硬骨头做了阶段性的收尾。过去一个月线上接口的耗时波动越来越明显核心链路被高并发一压就打嗝处理完一批任务要等很久。我们把这段时间的优化思路、参数调整和踩过的坑完整复盘了一遍发现很多问题其实可以提前绕开根本不用等到线上告警才动手。这篇文章就围绕这次推理优化做个总结把我们在KV缓存、量化、流水线调度和显存治理几个方向上的实际做法写清楚。适合正在做推理性能调优、对部署和加速方案有需求或者想知道大模型推理瓶颈到底卡在哪的读者参考。1. 优化前的状态推理链路到底哪里不舒服1.1 从现象倒推延迟抖动和吞吐瓶颈并存这次优化的目标不是从零搭建一套推理服务而是针对已经跑了一个多季度的在线推理系统做治理。这个系统承担的主要任务是接收业务方传过来的文本请求经过模型前向计算后返回生成结果。起初流量不大单卡部署就能应付后来请求量翻了几倍开始暴露出不少问题。最典型的两个现象一是P95延迟忽高忽低你以为业务方只传100个字符实际生成序列长度变化非常大。短输出和长输出混在一起GPU的利用率忽上忽下导致每个请求的排队时间相差很多。二是吞吐量上不去显存占用接近上限可GPU算力利用率却只有三成左右。这说明系统瓶颈不在计算而在数据搬运和显存分配。我们把优化前的基线指标拉出来单请求平均生成速度大约是每秒25个token并发从10路增加到30路时平均延迟从1.4秒涨到3.8秒吞吐量只提升了0.9倍。这个数据非常尴尬属于典型的“加并发反而减速”的状态。进一步看监控GPU显存占用率到了92%内存拷贝频繁但SM的活跃度长期在30%以下。这说明推理系统被卡在存储和调度层面而不是运算层面。后续所有优化动作基本都围绕如何降低显存压力、减少等待、提高计算资源的有效利用率来展开。1.2 推理链路全节点排查从接入层到GPU内核排查推理瓶颈时我们习惯把链路拆成接入层、队列层、批处理层、模型计算层和结果返回层五段逐段打点。这次也不例外。接入层主要看网络IO和预处理耗时。请求进来后要先做文本清洗、tokenization和一些特征拼接这部分在Python层运行耗时波动不大单请求大约3毫秒。队列层的问题比较突出。我们最初用的是内置的同步请求队列一旦上游请求到达速率不均匀队列长度就会剧烈波动直到批处理模块凑不满一批整体延迟就被拖上去。模型计算层的瓶颈最典型。一方面计算过程依赖大量的中间张量前向推理时每个Transformer层都要申请临时显存频繁分配的碎片问题让显存利用率进一步下降。另一方面自回归生成是串行解码每个token都要等前一个token算完这个过程无法并行但计算量和访存量又非常大。优化团队在排查这些节点时特别注意到一个关键现象单次采样请求的生成阶段GPU有接近三分之一的时间在做空转等待等待上一轮token的KV向量写入显存。这说明自注意力部分的访存速度跟不上计算速度。后面我们调整KV Cache的存储结构和分配方式后效果非常明显。2. 核心优化方案拆解从访存优化到计算压缩2.1 给KV缓存动手术连续显存和分页管理自回归生成过程中每步生成都需要读取之前所有token对应的键值向量。如果KV缓存不预先分配好模型就得频繁申请内存而且申请出来的块还经常不连续跳来跳去访问缓存会让性能更差。我们的第一件事是把KV缓存改为预分配的连续显存池。具体来说根据模型层数、注意力头数和最大序列长度预先算好需要的缓存量在服务启动时一次性申请到位。这个做法消除了推理过程中的显存动态申请和碎片整理同时也让后续可以按请求动态分配KV区域几百路并发都能各自拿到连续的一整块缓存空间。这里有个关键参数最大序列长度。我们业务场景里偶尔会出现超长文本最长的有几千个token。如果按最大长度给每个请求预分配缓存并发高时显存直接用爆。因此我们采用了按需分页的思路类似操作系统的分页内存管理每个推理请求的KV存储先分配一个基础页不够时再动态追加页块。调完之后显存占用从92%降到74%30路并发下再没有出现显存溢出的告警。同时由于缓存访问跳转减少每个生成步的延迟也下降了10%到15%。这是本轮优化中立竿见影的一个动作。2.2 精打细算的量化压缩从FP16到INT8模型权重和激活值一直用FP16存储好处是精度损失小但显存占用和带宽消耗都比较高。为了在不明显掉点的前提下压缩体积我们在这次优化中引入了INT8量化并对关键层做了混合处理。量化不是简单地把权重转成低精度就完事。直接对整个模型无差别量化容易造成输出质量明显下降尤其是一些对精度敏感的层比如最后的归一化层和特定输出层。我们的方案是先对模型做逐层校准统计每一层权重和激活值的数值范围找出对量化误差敏感度较高的层将这些层保留在FP16其余层用INT8。实际执行时我们按张量粒度做了动态缩放。每个权重矩阵根据其绝对值最大值确定缩放系数将数值映射到[-127, 127]区间计算时再用反量化操作把结果恢复到原有精度。这样做的收益很直接模型权重体积缩小了接近一半显存占用进一步下降。而模型整体精度在这个业务场景下的评分只降低了0.2个百分点。对于生成类的任务这个影响几乎感知不到。优化后模型单次前向计算时访存量减少推理速度提升明显特别是在高并发场景下收益被放大得更明显。2.3 用Flash Attention重排注意力计算自注意力机制在长序列推理时的计算量很大常规注意力实现要把完整的注意力分数矩阵存下来这个矩阵的大小随序列长度平方增长。序列一长显存吃紧计算效率也受影响。这次优化我们把底层注意力实现切换到了Flash Attention的方式把注意力的计算和SoftMax的归一化过程融合起来避免生成和保存完整的注意力分数矩阵。整个计算过程分块执行每块内完成部分SoftMax计算后再合并结果。这样就省去了大矩阵的读写开销也让L2缓存命中率更高。换掉这一步的操作不算复杂底层实现如果框架支持只需要在注意力模块里替换调用接口。但我们业务模型定制过相对位置编码需要对注意力mask做适配。这里至少要跑一轮精度回归GCG样本和标准测试集都过一遍确认生成结果没有跑偏才放行。视觉注意力机制彻底调整后长序列推理时的显存峰值进一步压低同时单次前向的计算时间缩短了约18%。短文本场景提升不大但文本长度超过1K token时效果差异非常明显。3. 调度与执行让GPU尽量保持忙碌3.1 连续批处理替代传统静态批处理很多推理服务在接收请求时习惯等攒够N个请求才组合成一个batch送入GPU。这种做法的问题在于如果流量稀疏等待时间会一直拖下去如果流量密集先到的请求必须陪跑完整个batch里其他所有请求才能拿到结果延迟被拉得很高。我们这轮优化把调度策略换成了连续批处理。核心思路是不再等待固定批次而是GPU每完成一次迭代就把当前已完成输出序列的请求移出空出来的位置立刻插入新的请求。每个时间点GPU处理的都是“刚才还在算的加上刚进来的”设备始终处于满负荷运转状态。改用这个调度策略后最直观的变化是排队的平均耗时从接近900毫秒降到不足200毫秒。同时因为不再有人为制造等待新请求的响应时长也能从毫秒级开始计算用户体验好很多。不过连续批处理对显存池设计要求更高。每个请求的KV存储长度会动态变化调度器需要随时知道哪个请求可以接收新KV页、哪个请求已经终止。因此我们把前文提到的分页KV缓存和连续批处理放在一起设计这两者配合起来才能发挥最大效果。3.2 投机解码引起的“提速错觉”要警惕推理优化领域还有一个常用的手段是投机解码用一个小模型先快速生成一批候选token再用大模型一次校验这些token是否可接受。如果校验通过一次前向可以生成多个token超越原来逐token生成的速度。我们这次也尝试了投机解码但必须提醒一下这条路并不总是一帆风顺。小模型和主模型的能力差距过大时小模型生成的候选token大概率被大模型拒绝等于白白多跑了一遍小模型推理反而拖慢全局。我们实验时的候选token长度设置为3小模型参数量约为主模型的1/5接受率初期只有43%算下来的有效吞吐反而下降了。后来我们调整了采样的温度参数让小模型不那么激进接受率提升到66%最终获得了1.35倍左右的加速收益。投机解码比较适合生成风格相对标准的任务如果业务文案天马行空小模型很难匹配主模型的行为效果就会很勉强。所以这块不能盲目跟风需要针对自身数据的分布做评估。3.3 算子融合和CUDA图消灭内核启动开销GPU上每个算子从触发到结束都有启动开销特别是计算量小的算子启动时间可能超过运行时间本身。推理模型中的激活函数、残差连接、层归一化这一系列小算子频繁启动会给整体性能带来隐形损耗。这次优化用算子融合把这些细碎的核函数合并成一个更大的计算内核。残差连接加层归一化加激活函数被合并成一个融合算子输入输出都在显存内完成不需要反复把中间结果写回显存再重新读取。更进一步我们启用了CUDA图。模型的前向计算图在首次运行时被捕获后续推理请求直接使用编译好的图执行省掉了大量Python层调度开销。这两项动作做完后单次前向的固定开销下降了约30%。从监控上看GPU的空隙时间明显缩短算力利用率从之前的30%提升到了58%。对小批量甚至单条请求的小文本来讲提升尤其明显。4. 参数调优记录和压测验证4.1 关键参数速查直接可参考的配置为了让这次优化可复现我把几组觉得比较靠谱的关键参数整理成表格。这些参数来自我们实际压测结果不一定适配所有场景但值得作为起点实验参数项实选值调整前说明KV Cache块大小16 token/页按序列全量预分配减少按长度申请的浪费动态扩展最大并发请求数64路16路配合连续批处理和显存池调整INT8量化层除LN和输出层外无量化敏感层保留高精度投机解码候选长度3无按小模型接受率动态调批次迭代策略连续批处理静态批次(8)基本消除排队空窗CUDA图开启关闭减少小算子启动耗时这组参数组合下压测结果相对稳定。有个细节值得说明直接抄参数不一定能拿到同样收益。比如KV Cache块大小如果你的模型层数少、序列短16token一页的粒度反而会增加管理开销这种情况下把页块调成32甚至64可能更合适。4.2 梯度式上线先灰度再放量性能优化最尴尬的是在测试环境数据漂亮一上生产就翻车。我们这次采取了梯度式上线先在模拟业务流量的Shadow环境中跑一天对比优化前后的耗时和显存曲线再逐步切换部分线上流量。灰度过程中我们盯了两类核心指标。一类是服务质量包括首Token延迟、Token间延迟和错误率确保优化没有影响到用户体验。另一类是资源状况包括显存峰值、GPU利用率和缓存命中率。优化版本在Shadow环境的P99延迟比旧版本低了很多但我们发现有个别请求的延迟反而升高了。追查后定位到是投机解码的接受率在特定文本风格下异常低小模型预测的token频繁被拒。后来我们给小模型增加了一条规则当文本长度超过一定阈值时自动关闭投机解码整体指标才稳定下来。4.3 压测数据到底怎么读别只看平均值压测最容易犯的错误是只看平均延迟。平均值会被大多数正常请求掩盖掉真正的性能风险往往藏在长尾里。我们这轮压测时记录的是P50、P95、P99三个分位的延迟并观察吞吐量随并发变化的曲线。优化前的数据是这样的单路并发时P95延迟1.3秒P99是2.2秒说明偶发的高延迟现象已经很突出。优化后30路并发下P95降到0.5秒左右P99在0.8秒附近。吞吐量从原先的每秒12次请求增加到每秒36次请求线性扩展性比之前强得多。我还特别留意了显存分配的成功率。旧的静态分配策略在并发高峰期偶尔会因为显存不足而拒绝请求优化后显存池管理方式让分配成功率达到了99.9%。这里的经验就是压测至少要把并发打到日常峰值的3倍否则很难暴露资源竞争问题。5. 常见的坑和排查方法总结5.1 坑一量化校准集与真实数据分布不一致做过模型量化的人都有体会校准集选得好不好直接影响量化误差。我们第一次做INT8量化时用的校准集来自一个开源通用对话数据集结果量产后发现在业务数据的特定表达方式上预测结果开始出现重复词和死循环。排查时逐层比较了量化前后每层的输出差异发现是嵌入层和位置编码部分在校准集上数值分布偏向过大量化后信息损失明显。后面我们把校准集换成从真实业务日志中随机抽样的1万条请求重新计算缩放系数后这个问题基本消失。这个坑不光INT8会遇到做稀疏化和其他压缩方案时都要注意。5.2 坑二显存池碎片化带来隐性性能下降分页KV缓存用了一段时间后我们观察到显存池内可用空间还有很多但新请求分配连续页块的失败率开始上升。这是典型的显存碎片化问题频繁分配和释放不同大小的KV页块会把连续空间切碎。解决手段是引入了空闲列表的合并机制每次请求退出后释放的页块会和相邻空闲页块合并并定期整理碎片。还有一个办法是让页块大小更统一避免出现大量不同尺寸的小块。我们把页块大小标准化之后碎片化问题显著缓解。这轮清理之后显存池在高并发下的分配成功率维持在99.9%以上碎片化导致的隐性延迟也消失了。5.3 坑三请求长度不均匀引发的调度震荡连续批处理虽然提高了吞吐但也带来一个新的问题长短请求混跑时短请求可能被长请求拖累因为一个batch内需要同步等待最慢的请求完成。我们的对策是把请求按预估长度分成两个队列短请求走快速通道优先分配GPU长请求走常规通道。这样短请求的平均延迟大幅下降长请求虽然排队时间略长但整体吞吐反而提升。对长短队列的优先级策略这里有一个经验如果业务里短请求占比很高可以把短请求队列的权重调高。但如果长请求数量较多过度偏向短请求会导致长请求饥饿生成超时。经过几轮实验我们把短请求和长请求的处理占比调到约3比1时整体加权延迟最优。6. 下一步还能做什么6.1 多机扩展与张量并行别等单卡性能榨干再动手单卡优化的空间始终有限预算允许的情况下多卡多机的并行扩展是进一步提升推理能力的主要方向。张量并行可以把大模型的单层计算切分到多张卡上显存和算力同时扩容这对解码阶段的大矩阵乘法尤其友好。我们计划下一步在小流量上实验张量并行切分维度要细心选择。常见是按注意力头数切分也可以按列切分线性层。不同的切分方式会导致通信量的差异而通信开销过大时多卡甚至可能不如单卡快。这个必须做通信带宽测试后才能定方案。6.2 推理反馈闭环让优化数据持续回流推理优化不是做一轮就结束的事。这次优化后我们建立了一个简单的监控面板把请求长度分布、KV缓存命中率、量化层输出差异和生成质量指标放在一起看。每次发布新模型或调整业务策略时都可以快速看出推理性能的组合变化。我的想法是后续把量化校准集改成周期性更新模型每周小版本升级后都自动跑一次量化误差评估如果误差超过阈值就自动重新校准。另外把投机解码的接受率纳入监控如果接受率持续低于50%自动降级关闭投机解码这能避免线上长期带病运行。9月30日的优化只是把我们推理链路里明显不合理的地方补齐了真正长期有效的优化机制一定是在日常反馈中不断迭代出来的。