ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Transformer推理加速实战:从KV Cache到投机采样的10个优化技巧

Transformer推理加速实战:从KV Cache到投机采样的10个优化技巧

前阵子帮某个团队调一个生成式模型的线上服务,压测的时候发现吞吐上不去,显存倒是快撑爆了。模型结构没问题,数据链路也正常,问题几乎全出在推理阶段的资源利用上。后来把几个优化手段组合着上,单卡吞吐直接翻了一倍多。这里把我的实践记录整理一下,聚焦在Transformer架构的推理侧优化。文中涉及的技巧,有的是换了推理框架或后端才生效的,有的是改模型结构或调整运行时配置,但都可以在实际部署中独立或组合使用。

先说清楚,这里讨论的不是训练加速,也不是分布式推理的宏大架构,而是单机单卡或单机多卡场景下,把Transformer模型的推理吞吐压上去的10个具体切入点。目标很明确:在不牺牲太多精度的前提下,让单位时间能处理更多请求,或者让显存能容纳更大的batch。

以下内容偏工程实践,涉及原理的地方我会尽量讲透,方便你判断自己的业务场景该怎么选。

1. 先立一个优化主线:KV Cache的命中率与显存账本

吞吐上不去的首要瓶颈,往往不是算力,而是显存和访存带宽。Transformer推理时,每个请求都会在解码阶段反复读写历史token的Key和Value矩阵,这就是KV Cache。它的大小直接决定你能塞多大的batch,也直接影响HBM(显存)带宽的消耗。

1.1 搞清楚KV Cache到底吃了多少显存

KV Cache的内存占用可以用很简单的公式估算:2(K和V两组) × 层数 × 头数 × 每头维度 × token数 × 精度字节数。

举个例子,13B模型、40层、32头、每头128维,用FP16推理,每个token大约占2 × 40 × 32 × 128 × 2 = 655,360字节,也就是640KB。处理2048个上下文token时,单请求的KV Cache就要占用1.25GB。如果并发32个请求,单是KV Cache就是40GB,算力再强也跑不动。

数据来自公开的模型参数信息,公式本身是通用的,你可以直接用这个公式给你的模型记一笔账。

1.2 把KV Cache的分配策略从"预留"改成"按需"

多数推理框架(包括部分开源的项目实现)默认按最大序列长度一次性把KV Cache预留出来。这种静态分配的方式省了动态内存管理的麻烦,但代价是显存浪费相当严重。尤其是在多数请求只有短上下文、短输出的场景里,预留空间的使用率往往不足一半。

我推荐的做法是采用PagedAttention式的按需分配,这个思路和操作系统的分页策略一个道理,你有一块连续的显存,逻辑上用页表按块分配,用到了再真正占用物理显存。实测下来,在用某开源框架部署Chat类型模型时,仅这一项就让显存碎片率降低了接近三成,进而让可用batch容量增加了大约40%。

如果你的框架不支持按需分配,至少把max_seq_len参数往下调,别让模型理论支持长度无节制地去占显存。

1.3 针对长上下文的KV Cache量化

这一步和后面的量化章节有交集,但因为KV Cache本身就是显存大头,单独拿出来说。当前很多框架支持对KV Cache做INT8或INT4量化,直接把这块能耗大户压缩到原体积的1/2或1/4。

量化KV Cache需要额外用心,因为它直接影响attention计算的精度。实践中,我建议先用INT8,相对稳妥,PPL(困惑度)损失一般在0.1以内,很多模型体感上甚至无感;INT4的收益更大,但对激活值分布极度敏感,个别层可能出现异常。可以按层混合量化,让敏感层保留FP16,非敏感层用INT4,能把平均压缩比做到接近3倍。

1.4 用好num_beams=1和repetition_penalty的代价权衡

这个是使用层面的优化,但经常被忽略。贪心解码(num_beams=1)和beam search(比如num_beams=4)对吞吐的影响不是4倍,而是更高的倍数,因为beam search推理时KV Cache也要按beam数翻倍维护。不少业务场景其实不需要beam search,把num_beams从4降到1,能直接换取接近4倍的吞吐提升,代价是部分长文本质量略降。我认为大多数生成类API服务,beam search的收益并不足以抵消开销,能关就关。

2. 权重换精度:架构不动,让显存和带宽翻身

权重数值的精度几乎是推理优化里性价比最高的一环。Transformer推理时,每次前向计算都要读权重,精度降一半,读取的字节数就少一半,带宽压力也直接减半。这种优化不改变任何网络结构,却能同时改善显存占用和计算效率。

我实测过一组对比,同一部署环境下,同一个7B模型,在A100上分别用FP16、INT8和INT4精度加载,当并发请求规模较大时,INT8版本的吞吐约为FP16版本的1.6倍,INT4版本约为FP16版本的2.2倍。这里参考的是团队内部压测数据,不同框架、不同模型会有差异,但趋势基本一致。

2.1 量化感知训练QAT vs. 训练后量化PTQ:谁更适合推理部署

当前的开源模型大多已经过了量化适配,用PTQ(训练后量化)就够了。它的核心是拿一小批校准数据(通常几百条文本)去统计激活值的分布,然后确定缩放因子。优点是完全不碰训练流程,一个下午就能搞定。

QAT(量化感知训练)会在训练过程中模拟量化噪声,让模型对低精度有适应性,精度损失更小,但需要重新走训练管线。对绝大多数推理侧优化场景来说,PTQ已经能满足工程需求。只有在模型对INT8精度损失敏感,或者需要压到INT4且精度告急时,才值得去折腾QAT。

2.2 GPTQ与AWQ:两种主流PTQ方案的差异

这两个都是针对Transformer权重量化的经典方案,核心差异在如何降低量化误差:

  • GPTQ:基于二阶信息(Hessian矩阵)逐层优化量化权重,把量化误差分摊到后续权重上,适合在离线阶段花较长时间把精度尽量压回来。
  • AWQ:是一种激活感知的量化方法,基于"少数关键通道对精度影响更大"的观察,在量化时按通道做缩放保护,校准开销更小,速度也更快。

实操层面,GPTQ常用在7B/13B级别模型的4bit量化上,AWQ的INT4方案在部分场景下精度表现更稳。我自己的习惯是,先用GPTQ快速出一个候选模型,再用校准集做PPL对比,如果PPL劣化明显就切AWQ重来。

2.3 别盲目追求INT4:INT8的兼容性和收益更均衡

网上很多帖子一味强调INT4能把7B模型压到5GB以内,显得很诱人。但如果你真的要上线服务,我会劝你冷静。

INT4推理时,反量化操作本身有开销,而且很多底层算子(比如某些矩阵乘的优化实现)对INT4的支持并不完整,一旦遇到不支持算子的分支,框架会把整段计算退回FP16,收益直接打折扣。INT8则成熟得多,无论CUDA核心还是各种推理框架,支持的完整度都高很多。

我可以给你一个实际参考:部署一个7B模型,FP16下吞吐大约每秒2000 token(服务总吞吐),切到INT8大约能到每秒3500 token,而INT4虽然理论更高,但因为反量化和部分layer退回FP16,实际只比INT8高出10%-15%左右,精度损失却明显得多。我认为对于语言模型的在线服务,W8A8(权重和激活都是INT8)是性价比最高的方案,兼顾了收益和稳定性。

3. 动态batch与投机采样:把GPU的空转时间堵上

权重精度解决的是"显存和带宽",这一节解决的是"算力和并行度"。Transformer解码是逐token串行生成的,每生成一个token就要做一次完整的前向计算。如果你每次只给GPU喂一个请求,那算力利用率一定是惨不忍睹的。把多个请求攒起来一起推理,就是dynamic batching(动态批处理)的基本盘。

3.1 用框架内置的Continuous Batching替代手工攒batch

手工累攒batch的老方案是等一批请求凑够数量再统一推理,响应延迟高、用户体验差。现在像vLLM、TensorRT-LLM这类框架都已经支持continuous batching(连续批处理)或叫in-flight batching,思路是:请求不用等同一批次的其他请求,谁先decode完谁先离开,新请求可以随时插入空位,框架在迭代级别动态调度。

这意味着服务一直处于"有活干"的状态,GPU的空转很少。实测中,从静态batching切到continuous batching,吞吐提升常常是翻倍的,并发越低效果越明显,因为资源利用率提升幅度最大。

3.2 投机采样(Speculative Decoding):用小模型帮大模型"打草稿"

这是一个让我印象深刻的优化手段,应该说效果超过我的预期。核心思路是让一个很小的草稿模型(draft model)快速生成几个候选token,然后大模型并行验证,如果验证通过就一并接受,如果失败就丢弃重来。这样原本要串行跑4次的大模型前向,变成了1次大模型前向+几次小模型前向,整体延迟和吞吐都有显著改善。

这个方案有个前提:草稿模型和大模型的tokenizer必须一致,否则token映射会乱;另外草稿模型质量不能太差,否则接受率太低,反而白白多耗时间。我在一个场景里用1B草稿模型配合13B主模型,接受率大约在0.7左右,整体延迟下降了约40%,吞吐提升了约70%。数据受场景影响较大,但这个优化方向很值得尝试。

3.3 解码长度的上限管理:别让长输出拖垮整个batch

有一个destream的细节,很多系统上线前不会考虑,就是单个请求的输出上限。如果不限制max_new_tokens,一个用户的超长请求会一直占据batch中的位置,拖慢所有其他请求离开的速度,吞吐直接下降。我见过不少线上事故是某用户疯狂触发长文本生成,整个服务的响应延迟就崩了。

建议在路由层就给每个请求设置输出上限,比如限制512或1024 token,超长请求走异步队列或拆分处理。这个优化看着没什么技术含量,但对稳定性的帮助非常大。

4. 推理框架选型与运行时配置:把改好的方案落到实处

如果模型结构和量化也调完了,batch策略也对了,吞吐还是不够,往往问题出在框架层的算子性能。同样一个模型,在不同推理框架下的吞吐差距可能达到30%-80%,这不是玄学,而是算子实现、显存管理、调度策略的综合差距。

4.1 主流推理框架的取舍逻辑

我目前常用的几个框架,简单排个序供你参考:

特性框架A框架B框架C
吞吐表现高中高中
显存利用率高,动态KV较高,手动配置多中
量化支持INT4/INT8成熟INT8为主支持但算子覆盖一般
自定义算子需要改C++/CUDAPython算子扩展友好Python+CUDA
适合场景高并发线上服务科研/灵活部署原型快速验证

这里的框架A/B/C就是实际常见的开源项目,你可以按需求直接去对标。我的选择经验是:如果只是跑一个demo,无所谓;如果要做高并发的API服务,就直接上框架A这一类自带paged memory和continuous batching的,能省掉大量自己造的轮子。

4.2 torch.compile与图优化:动态图和静态图的差距

如果你的服务还在用PyTorch原生动态图跑推理,那么不管前面做了什么优化,吞吐上限都会被动态图的调度开销卡住。torch.compile在PyTorch 2.x里已经非常成熟,可以通过mode="reduce-overhead"把模型中的算子融合成更少、更大的kernel,减少kernel launch次数。

但要注意,torch.compile并不是推理服务的最终形态,它生成的图还是可能包含很多小算子。如果你追求极致性能,应该考虑用TensorRT或者框架B这类编译型后端做全静态图优化。以我观察,从动态图切到静态图优化,7B模型的单batch latency能减少30%左右,这在实时性要求高的场景里还是值得做的。

4.3 CUDA Graph:捕获小算子爆炸的最直接手段

Transformer模型里的算子数量动辄几百上千,每个小算子的launch开销积累起来非常可观。CUDA Graph的思路是:把一串kernel的启动过程一次性捕获成一个graph,之后每次只需提交一次graph,就能按顺序执行所有算子。

很多框架已经自动集成了CUDA Graph(比如框架A的enforce_eager=False模式就是默认去掉eager执行,走CUDA Graph),但如果你是自己写的推理脚本,可以把解码循环内的前向计算包在一个graph里,实测对短序列场景的延迟改善很显著。有个坑需要注意:CUDA Graph会锁定显存池,如果你后续需要动态分配显存(比如新请求加入batch),会把graph打碎或重建,所以它更适合固定batch或配合continuous batching的场景使用。

5. 算子级优化:不要小看单个矩阵乘的力量

batch和框架的优化,本质上都是"把资源用满",但要真正把速度推上去,离不开算子本身的优化。Transformer推理时最耗时的部分通常是线性层(如QKV投影、FFN的中间层),这类算子性能几乎决定一切。

5.1 核心算子的融合:GELU、LayerNorm与矩阵乘的合并

一个典型的操作链是:矩阵乘 -> bias add -> GELU -> 下一个线性层。如果你逐个函数去调用,cudaMemcpy中间结果和kernel launch的开销会浪费大量时间。正确的做法是写融合算子:在同一个kernel里完成矩阵乘、Bias、激活函数,甚至跨过了激活后直接再乘下一层权重。这就是FlashAttention系列同样采用的"少读少写"哲学——减少显存往返,比降低计算量更有效。

很多框架已经内置了这些融合逻辑,但如果你是在自己定制模型结构,这里值得重点检查。用Nsight Compute或PyTorch Profiler看一遍时间占比,线性层和相关融合机会往往是最先暴露的问题。

5.2 FlashAttention与Attention变体

FlashAttention的核心不是数学上的改变,而是通过分块(tiling)把注意力计算拆成小块,避免在显存中实例化完整的S矩阵(即QK^T的结果)。这个矩阵在长序列场景下会非常巨大,FlashAttention通过在线softmax等技术把中间量控制在片上内存(SRAM)中,速度更快、显存占用更小。

如果你的模型仍然使用标准的torch.nn.MultiheadAttention实现,那第一件事就是换成支持FlashAttention的内核(许多框架已经通过attn_implementation="flash_attention_2"选项提供)。这个改动对长上下文推理的帮助几乎是立竿见影的。

5.3 结尾的非关键层:selective layer skipping

这是一个带偏门性质但实际可以有稳定收益的技巧:有些模型尾部层对输出概率分布的影响非常小,推理时可以直接跳过。比如某些13B模型跳过最后2个decoder layer,PPL只涨不到0.5%,但延迟能省2%左右。当模型被量化和batch优化之后,这个2%也会显得有价值。

这个思路不适合无脑尝试,更合理的做法是:使用校准集逐层评估模型输出变化,找出对最终分布影响最小的若干层,然后选择性地跳过它们。这也是"架构优化"里少数真正动模型结构的做法。

6. 小模型替代与蒸馏:如果2倍还不够,那就换思路

前面所有优化都在"同一架构"内做文章,如果2倍吞吐目标在这些手段的组合下还没达成,就需要考虑从模型本身下功夫了。这不属于传统意义的"推理优化",但对线上系统的吞吐影响是决定性的一击。

6.1 用更小的模型做同一件事

模型的参数量几乎和推理速度成正比。7B和13B模型的单次前向延迟比大约就是7:13(在实际算力达到瓶颈的场景更接近)。如果业务逻辑允许一定程度的输出去风格化或简化,将13B模型替换为7B或更小的模型,吞吐提升是倍数级的,远高于任何技巧性优化。现在不少团队的实际做法是:复杂场景用大模型,简单场景用小模型,按难度路由分配合适的模型容量。

6.2 蒸馏:用小模型逼近大模型的效果

有句话说得好:"蒸馏好的小模型,效果不一定输给原来的大模型。"你完全可以拿一个大模型当老师,在大量业务数据上让小模型学习它的输出分布。这个做法在不少中等难度任务上可以做到"7B水平的延迟、13B水平的质量",这是我在实践中觉得性价比高于任何量化技巧的方案。关键是数据配比和蒸馏温度设置,温度不要太高,否则会损失真实分布的尖锐度。

6.3 分层退出(Early Exit):让简单样本走捷径

又一个偏向"长度换时间"的思路。给模型加上分类器分支,当某层已经足够确信输出时,提前结束并返回;只有难样本才走完整个模型。这样做的好处是简单样本的平均层数更低,服务端的吞吐自然更高。

这个方案落地时有个障碍:很多模型架构没有天然的Early Exit分支,需要训练时附加辅助loss。如果拿现成的开源模型直接改,效果会比较勉强。我的建议是,先看自己的业务请求分布,如果大部分请求都属于"简单问题",这个思路值得一试;如果所有请求都很复杂,就跳过它。

7. 排查链路:吞吐上不去时,按什么顺序定位根因

把10个技巧过完,还是要面对现实:不是每个技巧都立即可用。我结合自己的排查经验,按优先级列一个定位清单,供你参考。

7.1 第一步:先看显存和batch容量

如果你的并发稍微一高就OOM(显存溢出),那么所有计算优化都得往后放。先按第1章的方式计算出KV Cache的占用,再决定是否量化KV Cache或降低序列长度。效能的排序是:显存容量 -> batch大小 -> 吞吐。显存不够,后面全是空谈。

7.2 第二步:看time分布占比

利用框架自带的profiler(或Nsight Systems)抓一段推理过程,把时间按算子类别统计。你会发现50%以上的时间往往集中在少数几个kernel上。如果这些kernel中间有大量内存拷贝或小kernel启动,那说明融合不够,这是优化潜力最大的地方。

7.3 第三步:排查框架配置的"隐形开关"

很多团队用框架A部署后吞吐上不去,其实是因为默认配置没有开启某些优势特性。比如enable_prefix_caching、enable_chunked_prefill这些开关,对长上下文或高并发请求有显著影响;gpu_memory_utilization设太低时,KV Cache容量不足,动态batch就会提前打满。把这个值从0.85提到0.95,吞吐常常能上升10%-20%。

7.4 第四步:用小规模实验验证假设

无论你从理论推断哪个技巧更有效,我都建议先在单请求低并发下跑一遍,先看latency,再看并发升高时的throughput曲线。这样做的好处是能快速分离出是计算瓶颈还是调度瓶颈。例如在并发1时延迟很高,那问题大概率在算子效率;并发1时延迟正常,但并发16时吞吐上不去,那问题大概率在显存或batch策略。

8. 综合部署案例参考:组合10个技巧的一个可达方案

前面分章节讲了技巧,这里给一个相对完整的组合部署示例,方便你直接参考操作顺序。

8.1 目标与初始准备

假设目标:把一个13B模型在单张A100(80GB)上部署,希望吞吐从初始状态的每秒约1800token提升到3600token以上,同时保证PPL(困惑度)不劣化超过0.5。

初始状态:FP16精度,无量化,无动态batch,框架为原始PyTorch推理脚本。

8.2 分阶段优化过程

  1. 先把推理后端从原始PyTorch脚本换成框架A(支持PagedAttention、continuous batching、CUDA Graph)。

    • 预期吞吐:约3500token/s。
    • 这一步就能把基础目标完成大半。
  2. 开启INT8权重量化(W8A8),保持显存余量,让batch容量变大。

    • 预期吞吐:约4500token/s。
    • 此时已超过2倍目标。
  3. 开启KV Cache INT8量化,配合gpu_memory_utilization=0.92。

    • 预期吞吐:约5000-5500token/s。
    • 有余力还能开投机采样进一步提升。
  4. 对输出层做选择性跳过或蒸馏一小段,作为最后的补充。

需要说明,这个数值是我在一个特定业务请求分布下测出来的,换场景会有波动。但顺序的重要性和提升幅度的量级,是可以复用的。

8.3 实际应用时的注意事项

  • 每次改动后都要回到校准集上做PPL或下游任务评测,不能只看吞吐数字涨了多少就上线。
  • 对API服务来说,延迟的尾部(P99)和吞吐同样重要,动态batch和连续批处理在某些极端情况下会拉大延迟抖动,需要额外监控。
  • 上线时建议做A/B对比:同一批请求,分别走老配置和新配置,对比返回质量,再决定是否全量切流。

9. 我的实践心路:哪些技巧被高估,哪些被低估了

优化做过一轮之后,总会有一些判断修正。这里分享几个比较强烈的个人感受。

被高估的:INT4量化。并不是说它不好,而是实际收益并没有宣传中那么夸张。反量化的开销、算子支持不完整、个别层精度崩塌,这些都会在工程落地时突然冒出来。除非你的唯一目标是"塞进显存",否则INT4带来的麻烦往往比收益多。

被低估的:连续批处理(continuous batching)。很多人把它和普通batch混为一谈,但实际效果差距很大。静态batch是"攒够一桌开饭",continuous batching是"流水线自助餐",GPU的利用率完全是两种量级。如果你只能做一件事,我建议优先做这个。

被低估的:投机采样。初看总觉得"多跑一个小模型会更慢",实际落地后效果真香。它本质上是把"算力空闲"和"延迟敏感"这两个矛盾点用一个小模型给调和了,是我近期最推荐的推理加速手段。

被低估的:CUDA Graph。它不需要改模型结构,也不需要额外训练,只要框架支持,配置两个参数就能见效。对延迟优化来说它比很多花哨的算法都更实在。

10. 下一步还能怎么压榨:多卡和更深的系统优化

如果单卡的2倍吞吐已经满足需求,那恭喜你,这套组合拳可以收工了。如果还不够,还有几条路可以继续走:

一是把多卡并行从张量并行(Tensor Parallelism)切到更细粒度的流水线并行,让更多计算并行化。二是做prefix caching,当多个请求共享同一段前缀(比如系统提示词或长文档)时,缓存住这部分计算结果,能省掉大量重复计算。三是在架构层面引入early exit或更激进的分层剪枝,对特定领域模型效果突出。

但我要提醒一句,优化的边际收益是递减的。当单卡吞吐已经到三四倍之后,再想翻倍通常意味着重新设计服务架构、多卡调度和数据分片,这些工作的复杂度和维护成本会一下子高很多。对多数业务来说,把2-4倍提升稳定吃下来,已经比很多团队跑得更好了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进