前阵子帮一个团队排查线上模型的耗时,单请求尾延迟从60ms一路涨到240ms,业务方催得急,团队第一反应是换更高规格的加速卡。结果卡换上去,指标纹丝不动——问题根本不在算力,而在显存分配策略和调度线程的锁竞争上。这种“换硬件解决不了推理瓶颈”的情况,我见过太多次了。很多人一听到推理性能瓶颈,就以为是模型太大、GPU不够快,但实际上,推理性能是个系统工程:模型结构、权重精度、推理引擎、显存管理、请求调度、框架开销,任何一个环节掉链子,都会让延迟和吞吐直接崩掉。
这篇内容适合正在跑大模型服务、做AI应用落地、或者被老板追问“为什么推理这么慢”的开发者。我会从瓶颈的种类讲起,然后把这几年来验证过有效的优化策略逐个拆开:量化怎么做才不掉点、推理引擎怎么选、KV Cache的内存账怎么算、长文本下怎么调度请求,最后给出我常用的性能剖析流程和踩坑记录。通篇是我在实际项目中反复用过的方案,不整虚的。
1. 先搞清楚推理耗时分布:两类指标和三种瓶颈模型
1.1 从两个指标讲起:延迟和吞吐
优化推理性能的第一步,不是急着调参,而是先搞清楚你优化的是哪个指标。因为延迟和吞吐的优化方向经常是打架的。
延迟关注的是单个请求从发出到返回的速度,细分下来又分首Token延迟(TTFT)和后续Token生成速度(TPOT)。首Token延迟决定了用户第一次感觉“有没有反应”,一般在聊天机器人、RAG问答这类交互场景里最重要;后续Token生成速度决定了“读起来流不流畅”,比如每秒生成多少字,这是流式输出的体感核心。
吞吐关注的则是系统整体的处理能力,通常用每秒生成的Token数(tokens/s)或者每秒完成的请求数来度量。离线批量打标、批量内容总结、大规模数据清洗这些场景,你根本不在乎单个请求多快,只在乎一晚上能跑完多少数据。
我见过不少团队一开始就把“降低延迟”和“提高吞吐”混在一起优化,最后把系统调得四不像。一个通用原则是:在线交互服务优先保延迟,离线批处理优先冲吞吐。如果你两类业务都有,架构上最好拆成两套配置,而不是共用一套服务硬扛。
1.2 算力、访存、调度:三类瓶颈的典型特征
把指标定清楚之后,再看瓶颈本身。我习惯把推理瓶颈分成三类:算力瓶颈、访存瓶颈、调度瓶颈。
算力瓶颈出现在计算量特别大的时候,典型场景是大batch的矩阵乘法。模型推理本质上是大量矩阵运算,当输入batch足够大、矩阵足够宽时,GPU的计算单元会被打满。这时候GPU利用率通常接近100%,但显存带宽可能还有余量。这种瓶颈的典型特征就是:增大batch,吞吐几乎不再上升,单请求延迟反而上升。
访存瓶颈则完全相反。自回归生成模型在解码时,每生成一个Token,权重都要从显存里读一遍,但实际算的量却很小。权重加载的时间远大于计算时间,这时候计算单元在“等数据”,GPU利用率看着不高,但显存带宽已经是极限了。一家之言:大模型推理,尤其是小batch单流场景,绝大多数瓶颈是访存而非算力。
调度瓶颈最容易被忽略。Python端的调度开销、多线程锁竞争、动态图逐个算子启动的CPU开销、请求排队不合理,都会让GPU“饿着肚子干活”。典型特征是在GPU利用率不高的情况下,延迟依然高得离谱,加并发之后反而更慢。这一类问题靠换更强的GPU解决不了,必须从引擎和调度层面动刀。
有一个很实用的判断工具叫“计算强度”(Arithmetic Intensity),简单说就是一次运算里,计算量除以外数据读取量的比值。这个值低,系统多半是访存瓶颈;这个值高,才有资格往算力瓶颈上找原因。拿这个先定方向,再去排查具体问题,能省好几个小时。
2. 模型侧的优化策略:先减少计算量,再避免计算浪费
2.1 量化:从FP16到INT4的收益与代价
量化是目前性价比最高的模型侧优化手段,没有之一。原理很直白:把权重从FP16(16位浮点)压缩到INT8(8位整数)甚至INT4(4位整数),权重占用的显存直接砍半或者砍到四分之一。显存占用降下来,访存瓶颈自然缓解,因为每次读权重的时间变短了。
实际项目中,我把量化分成两类:训练后量化(PTQ)和量化感知训练(QAT)。PTQ是拿现成模型直接转换,不需要重新训练,适合快速上线;QAT在训练过程中就模拟量化误差,模型对量化更“耐受”,精度损失更小,但成本高。对绝大多数应用场景,PTQ + 少量校准数据已经能获得可接受的结果。
做PTQ时有几个细节直接影响效果。校准集的选择是关键中的关键——不要随便拿几百条文本就去校准,最好覆盖你的真实业务场景,比如代码模型就用代码做校准,对话模型就用对话样本做校准。另外,不要全模型一刀切用同一种量化精度。我的习惯是:注意力层的Softmax、最后的分类层、位置编码相关的部分保持高精度,因为这几处对误差特别敏感;而FFN层的权重可以放心量化到INT4,掉点通常不明显。
还有一种做法是做混合精度量化,比如大部分层用INT4,个别敏感层用INT8或者BF16。虽然实现复杂度上升,但在一些精度敏感模型上,这是“既想省显存又想不掉点”时很实用的折中方案。我自己试过在一个中等规模模型上把敏感层挑出来,量化后精度只回了零点几个百分点,但显存占用和之前差不多,效果很香。
2.2 剪枝与蒸馏:什么时候值得做
剪枝和蒸馏是另外两条路,但适用场景比量化窄,需要更谨慎。
剪枝的核心是砍掉不重要的权重或结构。非结构化剪枝会把不重要的单个权重置零,模型变“稀疏”,但在常规硬件上加速效果有限,因为计算库对稀疏矩阵的优化并不普遍。结构化剪枝则是按行、按列、按通道整体删除结构,比如删掉某些注意力头,这种剪枝方式能直接减少计算量,但需要重新训练模型才能恢复精度,成本较高。
蒸馏的本质是“以大教小”:用一个大的教师模型输出(包括软标签、中间层特征)来训练一个小学生模型,让小学生模型在更小的参数量下逼近大模型的效果。蒸馏适合的场景很明确——推理延迟要求极高、部署设备资源有限,比如端侧设备、边缘盒子。之前我做过一个图像特征提取项目,把一个大模型蒸馏成原来三分之一大小的模型,速度提升了近三倍,精度损失控制在一个可接受的范围内。
不过我的建议是:如果你没有明确的部署资源限制,优先做量化而不是剪枝和蒸馏。量化的改动最小、见效最快、风险最可控。剪枝和蒸馏意味着重新训练,时间成本和调参成本都是量化的好几倍。真正管用的组合拳是“大模型离线蒸馏出中模型 + 中模型在线量化加速”,既保精度又保速度,这是我在资源受限项目里屡试不爽的组合。
3. 推理引擎与图优化:把计算图加工成更高效的流水线
3.1 引擎选型:通用框架还是专用推理引擎
很多人在PyTorch里把模型推理跑通之后,就以为万事大吉了,直接把eager模式部署上线。实际上,通用训练框架在推理场景的效率并不高:每个算子都要单独启动一个Kernel,CPU要一遍遍下发指令,GPU每次启动还有额外开销,小算子一多,时间全耗在调度上。
所以,生产级推理一般会用到推理引擎或编译优化。不同引擎的定位差别很大,我按实际场景做一个简单的选型对照:
| 引擎方向 | 典型工具 | 适合场景 | 备注 |
|---|---|---|---|
| 通用图优化 | TorchScript / 通用编译器 | 中小模型、快速上线 | 改造成本低,但优化幅度有限 |
| 专用推理引擎 | TensorRT等 | 加速卡部署、高吞吐生产环境 | 算子融合和显存优化很激进,需要额外转换时间 |
| 大模型推理框架 | vLLM / 类SGLang框架 | 大模型在线服务、高并发 | 内置连续批处理、KV Cache管理 |
| 轻量部署 | llama.cpp等 | 端侧、CPU环境 | 对消费级硬件友好,量化模型支持好 |
选型的原则很简单:先在通用框架里跑通,再用专用引擎压测,对比指标再决定上不上。不要听别人说哪个好就直接换,推理引擎的收益和模型结构、部署硬件、请求模式强相关,换了之后到底行不行,只能拿自己的数据说话。
3.2 算子融合、CUDAGraph与连续批处理
专用推理引擎之所以快,几个核心技术功不可没:算子融合(Kernel Fusion)、CUDAGraph、连续批处理(Continuous Batching)。
算子融合的思路,是把多个小算子合并成一个大算子。比如LayerNorm后面跟一个残差连接,在原始计算图里是两个Kernel,中间还要把结果写回显存再读出来;融合之后就变成一个Kernel,中间结果直接留在寄存器或片上缓存里,省了一大笔访存开销。原理很像工厂流水线:原来每个工序都单独搬运原材料,现在把几个工序合成一个工位,搬运时间就没了。
CUDAGraph解决的是CPU与GPU的通信开销。PyTorch eager模式每个算子启动都要经过CPU调度,CPU下发指令的速度成了瓶颈。CUDAGraph的思路是把一串GPU操作预先“拍”成静态图,一次性提交给GPU,省掉中间大量CPU调度时间。对于小batch的延迟优化,CUDAGraph带来的改善往往让人意外,很多时候延迟能下降百分之二三十。
连续批处理则是大模型在线服务吞吐提升的大杀器。传统批处理要等一个batch里所有请求都生成完毕才释放资源,时间浪费在慢请求上;连续批处理则是按Token级别动态调度,哪个请求生成了新的Token,立刻插入batch,生成完了就腾出位置,让别的请求补上。GPU的计算资源几乎一直在被使用,吞吐自然就上来了。我测过一个部署在vLLM上的开源模型,开启连续批处理之后吞吐量翻了一倍左右,而延迟还保持平稳,这是性价比极高的一项优化。
4. 显存管理与长序列优化:从KV Cache说起
4.1 先算出KV Cache占多少显存
长文本场景是推理优化的重灾区,因为自回归模型每生成一个Token,都要把历史Token的Key和Value缓存下来,这个缓存叫KV Cache。序列越长,KV Cache占用越大,而且是不考虑上限的大。
有个很实用的估算公式:KV Cache内存 ≈ 2(Key和Value两份)× 层数 × KV头数 × 头维度 × 序列长度 × 精度字节数 × batch大小。我具体算一个例子:70亿参数模型,假设层数32、KV头数8、头维度128、精度用FP16(2字节),那么单条请求生成2048个Token时,KV Cache占用是 2 × 32 × 8 × 128 × 2048 × 2 = 约536MB。看着好像还行,但如果是8条并发请求、上下文涨到8192,这个数字会直接跳到2GB以上,加上模型权重和中间激活值,显存爆掉是很容易的事。
所以在设计服务时,第一件事就是按公式估算显存分配:确定最大序列长度、最大并发数、模型权重大小,剩下的才留给KV Cache和激活值。很多人上线前不看这个账,结果并发一上来就显存溢出,只能不停重启服务,这种低级事故我见得太多了。
4.2 分页注意力、KV Cache量化与调度策略
KV Cache优化有三种主流手段,每个都值得试。
分页注意力(PagedAttention)是当前大模型推理框架普遍采用的方案。传统方式给每个请求预分配一整块连续显存,长序列空间不够,短序列空间浪费;分页的思路则像操作系统的虚拟内存,把KV Cache切成固定大小的块,按需分配,用完回收。显存利用率能提升不少,可以支持的并发请求数也随之上升。我之前把一个部署服务的batch大小上限提升了近一倍,就是因为换了这类框架,显存碎片问题大幅缓解。
KV Cache量化则是把缓存中的Key和Value从FP16压到INT8甚至更低。权重量化能省显存,KV Cache量化同样能省显存,而且因为每轮生成都要反复读写KV Cache,这项优化对带宽的节省也很明显。精度影响方面,我的经验是KV Cache量化到INT8在大多数模型上可以接受,但要留意长文本下误差累积,必要时保留一部分浮点精度做混合量化。
调度策略同样重要,尤其是请求长短不一的时候。如果长请求和短请求混合在一起,短请求很快完成释放资源,长请求却迟迟占着显存和算力。配合推理框架里的连续批处理机制,再加上合理的最大序列长度限制和队列策略,能在用户可接受范围里尽量腾出吞吐空间。我自己上线服务时一定会在代码里写明最大序列长度,并且对超长请求做截断或提示,这是防止显存被单条请求拖垮的关键防线。
5. 瓶颈定位与性能剖析的实操工具箱
5.1 从基线数据开始:先记录哪些指标
每次做性能优化,我第一件事都是压测并记录基线。没有基线就没有对照,改一个变量根本说不清楚是有效还是无效。
最基础的基线数据包括四类:延迟(平均延迟、P90/P99/TTFT/TPOT)、吞吐(tokens/s、请求数/s)、GPU利用率(均值、瞬时峰值)、显存带宽利用率。GPU利用率和显存带宽利用率这两项特别重要,一个是判断算力紧不紧张,一个是判断访存紧不紧张,一对照就知道瓶颈在哪一侧。
工具方面,我常用的是GPU厂商自带的性能分析工具(比如nsys和ncu)做深度剖析。nsys能看到每个算子的耗时和调用顺序,适合排查调度开销;ncu能给出内核级更细粒度的指标,适合算力和访存比的详细分析。Python层面的耗时可以用py-spy来抓线程栈,排查锁竞争和Python层的卡点也很顺手。这些工具都是命令行式的,跑一次就能生成报告,操作起来不复杂,关键是你得知道从报告里看哪些指标——优先看每个阶段的耗时占比,而不是纠结单个算子的技术细节。
5.2 排查路径:我惯用的四个分析步骤
我一般按下面四步走,逻辑比较固定,推荐你也试试。
第一步,先压测出基线延迟和吞吐,同时记录GPU利用率和显存带宽。第二步,判断瓶颈类型:如果GPU利用率接近100%且带宽利用率不高,多数是算力瓶颈;如果利用率不高但带宽快满了,就是访存瓶颈;如果两边都不高,大概率是调度框架的问题。第三步,用分析工具抓耗时分布:算力瓶颈就找耗时最长的矩阵乘法算子,访存瓶颈就看哪些层读取权重最频繁,调度瓶颈就用线程栈找锁竞争或多余拷贝。第四步,锁定优化目标,一次只改一个变量,改完重新压测,对比基线看有没有变好。
我举一个真实排查过的场景:某个服务GPU利用率才30%,延迟却很高。按照步骤一看,显存带宽已经90%以上,所以不是算力不够,是权重读取太大。后来引入量化把权重从FP16压到INT8,带宽压力降了一半,延迟和利用率同时恢复正常。如果当时只顾着换更高算力的GPU,问题完全不会解决,还白白花了成本。
6. 常见问题与踩坑实录
6.1 高频问题排查表
把这几年遇到的推理性能问题整理成了表,按出现频率排的,适合直接当速查手册用。
| 现象 | 可能原因 | 检查方式 | 解决思路 |
|---|---|---|---|
| GPU利用率低但延迟高 | 调度开销大、小算子太多、锁竞争 | nsys看CPU下发时间占比 | 启用图模式、算子融合、CUDAGraph |
| GPU利用率高但吞吐低 | 算力瓶颈或batch配置不当 | ncu看矩阵算子耗时 | 增大batch看是否线性增长;不行再考虑量化 |
| 并发一高就显存溢出 | KV Cache超出预算 | 估算KV Cache内存占用 | 限制最大序列长度、启用分页注意力、KV Cache量化 |
| 量化后精度明显下降 | 量化策略一刀切 | 分模块对比精度 | 混合精度、敏感层留高精度 |
| 换了更强GPU延迟没变 | 瓶颈不在算力,访存或调度未解决 | 先看带宽利用率和调度耗时 | 优先做权重压缩和图优化,再考虑换卡 |
| 长序列响应越来越慢 | KV Cache增长,访存压力上升 | 对比不同序列长度下的耗时 | 对超长请求做限制或预处理,启用KV量化 |
6.2 几条反直觉的经验
第一,GPU利用率高不等于吞吐就高。利用率分算力利用率和显存利用率,有时候算力满了,但大量时间在等待数据搬运,实际有效产出并不高。我见过很多团队把GPU打到99%,以为性能已经最优,结果一算tokens/s,离理论峰值差得远。真正要盯的,是单位时间生成的有效Token数。
第二,不要一上来就做模型剪枝。剪枝的工程复杂度远高于量化,而且在大模型上,剪枝带来的收益往往不如量化直接。只有当你明确需要降低模型参数量本身(比如端侧部署有硬性体积要求)时,剪枝才值得投入。顺序上,先量化、再蒸馏、最后才考虑剪枝,这是成本从低到高的路径。
第三,在线服务和离线批处理的优化方向要分开。在线服务优先保延迟,需要小batch或者连续批处理;离线批处理优先保吞吐,batch可以往大了压。很多服务慢,是因为离线和在线共用一个配置,互相拖累。最理想的情况是两套部署,分开调参,各自跑出最优值。
第四,改动前一定要留好压测脚本和基线数据。因为优化之后不可能一次成功,大概率要回滚重试。没有基线,出了问题你连“到底什么改动导致变慢”都说不清,等于盲人摸象。
我个人这几年做推理优化的体会是:大部分性能问题都不是硬件不够强,而是软件链条上某个环节没拧紧。先把瓶颈类型定性清楚,再用合理的工具去定量分析,最后针对性地做量化和引擎优化,80%的项目在这个流程下都能拿到非常可观的改善。如果你手头也有一个跑得慢的模型,别急着加预算,先按上面的思路过一遍,大概率能省下一大笔钱。