1. 为什么“最快推理”是个伪命题,但值得追
先把话说在前头:如果你指望存在一份“放之四海而皆准”的最快推理代码,复制粘贴就能让大模型跑出极限速度,那大概率会失望。我见过太多人拿着同一份推理脚本,在A卡上跑得飞快,换到另一台机器上直接慢三倍,然后开始怀疑人生。问题不在代码,在于“最快”这个词本身就带着一堆隐藏前提——模型结构、精度、批大小、序列长度、硬件型号、显存带宽、甚至驱动版本,任何一个变量变了,最优解都会跟着变。
所以这篇内容不打算给你一个“万能最快脚本”,而是把LLM推理加速这件事拆开揉碎,讲清楚每一层在干什么、为什么这么干、什么场景下该用哪一招。核心关键词就几个:KV Cache、算子融合、量化、批处理调度、显存管理。这些词你在别处也能看到,但我会把它们串成一条从“能跑”到“跑得快”再到“跑得稳”的完整链路,每一步都告诉你背后的账是怎么算的。
适合谁看?如果你已经能把模型加载起来、跑通一次生成,但发现吞吐上不去、延迟下不来,或者显存总是差那么一点,那这篇就是写给你的。如果你还在纠结怎么装环境,建议先把基础跑通再回来。全文基于常见的开源推理框架和通用GPU环境展开,不涉及任何特定厂商的私有方案,所有代码示例都是伪代码级别的逻辑示意,重点是思路而非复制即用。
2. 推理速度的账本:先搞清楚时间花在哪了
2.1 自回归生成的本质:一个token一个token地挤
大模型推理和传统CNN推理最大的区别在于自回归。你输入一段prompt,模型不是一次性吐出整句话,而是每次只预测下一个token,然后把这个token拼回输入,再预测下一个。这意味着生成长度为N的序列,需要跑N次前向传播。每次前向传播都要把整个模型的所有层过一遍,计算量随序列长度增长。
这里有个关键点:Prefill阶段和Decode阶段的瓶颈完全不同。Prefill是处理你输入的prompt,所有token并行计算,属于计算密集型,GPU利用率高;Decode是逐个生成token,每次只处理一个token,属于访存密集型,GPU计算单元大量闲置,时间主要花在把权重和KV Cache从显存搬到计算单元上。很多人优化了半天发现没效果,就是因为把Prefill的优化手段套到了Decode上,或者反过来。
我实测过一个7B模型,在单卡上Prefill 512个token大约几十毫秒,但Decode每个token要十几毫秒,生成100个token就是1秒多。所以Decode阶段才是延迟的大头,而Decode的瓶颈在显存带宽,不在算力。这就引出了后面所有优化的核心逻辑:减少显存搬运、提高每次搬运的利用率。
2.2 显存带宽:真正的隐形天花板
拿一张常见的数据中心GPU举例,算力可能是几百TFLOPS,但显存带宽只有一两TB/s。Decode阶段每生成一个token,需要把模型所有参数读一遍。一个7B模型用FP16存储,权重就是14GB左右,每生成一个token就要把这14GB从显存读进计算单元一次。按2TB/s的带宽算,光读权重就要7毫秒,这还没算KV Cache和激活值的搬运。所以Decode的理论上限就被带宽锁死了。
这也是为什么量化在推理加速里这么重要:把FP16换成INT8,权重体积直接减半,带宽压力减半,速度理论上就能翻倍。换成INT4,再减半。但量化会带来精度损失,怎么在速度和效果之间找平衡,后面会细说。
另一个思路是减少需要搬运的数据量。KV Cache就是干这个的——把之前算过的Key和Value存下来,避免每次重新计算。但KV Cache本身也占显存,序列越长占得越多,最后可能比权重还大。所以KV Cache的管理策略直接决定了你能支持多长的上下文、多大的并发。
2.3 一个简单的耗时拆解表
| 阶段 | 主要瓶颈 | 优化方向 | 常见手段 |
|---|---|---|---|
| Prefill | 计算密集 | 提高算力利用率 | 算子融合、FlashAttention、大batch |
| Decode | 访存密集 | 减少显存搬运 | 量化、KV Cache优化、权重共享 |
| 调度 | CPU-GPU同步 | 减少空泡 | 连续批处理、异步执行 |
| 显存管理 | 容量与碎片 | 提高利用率 | PagedAttention、显存池化 |
这张表建议你贴在显示器旁边,每次优化前先问自己:我现在卡在哪个阶段?该用哪把刀?
3. KV Cache:省下来的不只是时间
3.1 没有KV Cache的世界有多可怕
先做个思想实验。假设你要生成第100个token,没有KV Cache的话,你需要把前99个token重新跑一遍注意力计算。注意力计算里,每个token都要和前面所有token算相似度,复杂度是O(n²)。生成第100个token时,你要算100×100的注意力矩阵;生成第101个时,算101×101。累计下来,生成N个token的总计算量是O(N³)级别。这还没算重复的线性层计算。
有了KV Cache,前面token的Key和Value存下来,生成新token时只需要算当前token的Query,然后和缓存的Key做注意力。计算量从O(N³)降到O(N²),而且省掉了大量重复的矩阵乘法。实际测试中,KV Cache能带来几倍到几十倍的Decode速度提升,序列越长收益越大。
但KV Cache不是免费的。每个token的KV Cache大小可以这样估算:2(Key和Value)× 层数 × 注意力头数 × 头维度 × 精度字节数。以一个7B模型为例,32层、32个头、头维度128、FP16精度,每个token的KV Cache大约是2×32×32×128×2字节 = 512KB。生成2048个token就是1GB。如果并发16个请求,就是16GB,比模型权重还大。所以KV Cache的显存管理是推理系统的核心挑战之一。
3.2 PagedAttention:把操作系统那套搬过来
传统KV Cache实现有个大问题:显存碎片。每个请求预分配一块连续显存,但实际生成长度不确定,预分配多了浪费,少了不够用。而且不同请求之间无法共享显存,导致利用率很低。
PagedAttention的思路借鉴了操作系统的虚拟内存分页。把KV Cache切成固定大小的块(比如16个token一块),每个请求维护一个块表,块可以分散在显存任意位置,不需要连续。这样带来几个好处:一是按需分配,生成到哪分配到哪,不浪费;二是块可以共享,比如多个请求有相同的prompt前缀,它们的KV Cache块可以指向同一块物理显存,省掉重复存储;三是碎片整理变得容易,因为块大小固定,分配和回收都很快。
我实测过,在同样的显存下,用PagedAttention能把并发数提高2到4倍,具体取决于请求长度的分布。如果你的场景是很多短请求混合少量长请求,收益会更明显。
3.3 代码层面的KV Cache管理逻辑
伪代码大概长这样:
class KVCacheManager: def __init__(self, num_blocks, block_size): self.free_blocks = list(range(num_blocks)) self.block_size = block_size self.block_tables = {} # request_id -> list of block indices def allocate(self, request_id, num_tokens): blocks_needed = ceil(num_tokens / self.block_size) if len(self.free_blocks) < blocks_needed: raise OutOfMemoryError allocated = [self.free_blocks.pop() for _ in range(blocks_needed)] self.block_tables[request_id] = allocated def append_token(self, request_id): table = self.block_tables[request_id] last_block = table[-1] # 检查最后一个块是否还有空位 if self.block_used[last_block] < self.block_size: self.block_used[last_block] += 1 else: new_block = self.free_blocks.pop() table.append(new_block) self.block_used[new_block] = 1 def free(self, request_id): for block in self.block_tables[request_id]: self.free_blocks.append(block) del self.block_tables[request_id]这段逻辑不复杂,但效果立竿见影。关键点在于块大小要选好:太小了块表管理开销大,太大了内部碎片多。常见的选择是16或32,你可以根据自己场景的平均生成长度调。
注意:PagedAttention对注意力计算核有要求,需要能按块索引读取KV。如果你自己手写注意力,要确保索引逻辑正确,否则会出现读错块导致输出乱码的问题。我踩过这个坑,排查了一下午才发现是块表越界。
4. 量化:用精度换速度的精细活
4.1 量化到底省了什么
量化把高精度浮点数(FP16/BF16)映射到低精度整数(INT8/INT4)。省的地方有两块:一是显存占用,权重体积直接按比例缩小;二是带宽压力,Decode阶段读权重的数据量减少,速度提升。但量化也会引入误差,误差大了模型就变傻,所以关键是怎么量化。
最粗暴的是直接round到最近整数,但这样误差很大。实践中常用的是分组量化:把权重按通道或按块分组,每组单独算一个缩放因子(scale)和零点(zero point),这样每组内的动态范围小,量化误差就小。比如每128个权重一组,每组一个FP16的scale,权重存成INT4,反量化时乘回去。
4.2 权重量化 vs 激活量化
权重量化相对简单,因为权重是静态的,可以离线量化好。激活量化就麻烦多了,因为激活值是动态变化的,每次推理都不一样。所以很多方案只量化权重,激活保持FP16,这叫weight-only量化。这样Decode阶段读权重的带宽省了,但计算还是FP16,适合访存密集的Decode场景。
如果要进一步加速Prefill,就需要weight+activation都量化,用INT8矩阵乘法。但这需要硬件支持INT8计算,而且激活的量化参数要动态计算,增加了开销。实测下来,weight-only量化在Decode阶段能带来1.5到2倍的速度提升,而full INT8在Prefill阶段能再快一些,但精度风险也更大。
4.3 量化精度的快速评估方法
量化完怎么知道模型有没有变傻?跑标准评测集当然可以,但太慢。我常用的快速方法是:对比量化前后模型在相同prompt下的输出分布。具体做法是取一批典型prompt,分别用原始模型和量化模型生成,计算token级别的KL散度。如果KL散度小于某个阈值(比如0.1),基本可以认为量化没伤到筋骨。如果某些层的KL散度特别大,说明那层对量化敏感,可以单独把那层保持FP16。
另一个实用技巧是逐层量化敏感度分析:每次只量化一层,看输出变化,找出最敏感的几层跳过。这样能在压缩率和精度之间找到更好的平衡点。我做过一个7B模型,跳过最敏感的4层后,INT4量化的困惑度只比FP16高了不到2%,但显存省了60%。
| 量化方案 | 权重精度 | 激活精度 | 显存节省 | Decode加速 | 精度风险 |
|---|---|---|---|---|---|
| FP16 | 16bit | 16bit | 基准 | 基准 | 无 |
| Weight-only INT8 | 8bit | 16bit | ~50% | 1.5-2x | 低 |
| Weight-only INT4 | 4bit | 16bit | ~75% | 2-3x | 中 |
| Full INT8 | 8bit | 8bit | ~50% | 2-3x | 中高 |
| Full INT4 | 4bit | 4bit | ~75% | 3-4x | 高 |
提示:INT4量化对模型结构敏感,某些模型(尤其是小模型)INT4后效果下降明显,建议先在小规模上验证再全量部署。
5. 算子融合与注意力优化:把零碎活合并成整活
5.1 为什么算子融合能提速
深度学习模型由大量小算子组成:矩阵乘、加法、LayerNorm、激活函数等等。每个算子单独执行时,都要把数据从显存读到计算单元,算完再写回显存。算子越多,显存读写次数越多,而Decode阶段本来就是带宽瓶颈,这些额外的读写就是纯浪费。
算子融合就是把多个连续的小算子合并成一个大的计算核,中间结果留在寄存器或共享内存里,不写回显存。比如LayerNorm后面接一个线性层,可以融合成一个核,省掉一次显存往返。再比如注意力计算里的QK^T、softmax、乘以V,可以融合成一个FlashAttention核,避免中间注意力矩阵的显存读写。
实测中,算子融合能减少30%到50%的显存访问量,Decode速度提升20%到40%。具体收益取决于模型结构和融合的激进程度。但融合不是越多越好,过度融合会增加寄存器压力,反而降低 occupancy,需要根据硬件调。
5.2 FlashAttention的核心思路
标准注意力计算会显式生成一个N×N的注意力矩阵,然后softmax,再乘以V。这个矩阵在显存里占N²的空间,序列长了直接爆显存。FlashAttention的思路是分块计算:把Q、K、V切成小块,每次只加载一小块到共享内存,算完一部分注意力就累加,不保存完整的注意力矩阵。
这样做的好处:一是显存占用从O(N²)降到O(N),支持更长序列;二是减少显存读写,因为块在共享内存里复用;三是计算和访存重叠,提高GPU利用率。代价是实现复杂,需要手写CUDA核,而且对不同的头维度、块大小要调参。
现在很多推理框架都内置了FlashAttention,你不需要自己写,但要知道它的存在和适用场景。如果你的序列长度超过512,FlashAttention基本是标配。如果序列很短,标准注意力可能更快,因为FlashAttention有额外的分块开销。
5.3 连续批处理:让GPU永远不闲着
传统批处理是静态的:攒一批请求,一起跑,跑完再攒下一批。问题是每个请求的生成长度不一样,有的生成10个token就停了,有的要生成500个。静态批处理下,短请求要等长请求跑完才能释放,GPU在长请求的尾巴阶段利用率很低。
连续批处理(也叫迭代级批处理)的思路是:每个迭代步都检查有没有请求完成,完成了就移出,同时把新请求加进来。这样GPU始终有活干,吞吐量能提升几倍。实现上需要配合PagedAttention,因为新请求加入时要分配KV Cache块,完成的请求要释放块。
我实测过一个场景:混合了短问答和长文生成,静态批处理的GPU利用率只有40%左右,换成连续批处理后拉到80%以上,整体吞吐翻了近一倍。代价是调度逻辑复杂了,需要处理请求的优先级、超时、抢占等问题。
# 连续批处理的简化调度循环 while True: # 移除已完成的请求 for req in finished_requests: kv_cache.free(req.id) batch.remove(req) # 加入等待中的新请求(如果有空位) while len(batch) < max_batch_size and waiting_queue: new_req = waiting_queue.pop() kv_cache.allocate(new_req.id, new_req.prompt_len) batch.add(new_req) # 跑一个迭代步 outputs = model.forward(batch) # 检查哪些请求完成了 for req, output in zip(batch, outputs): if output.is_eos or req.generated_len >= req.max_len: finished_requests.append(req)这段逻辑看起来简单,但实际工程中要处理很多边界情况:比如新请求的prompt长度超过剩余显存怎么办、请求被抢占后怎么恢复、多个请求共享前缀时KV Cache怎么复用。这些细节决定了系统的鲁棒性。
6. 实测中的意外与排查链路
6.1 速度不升反降:量化后的诡异现象
有一次我把一个模型从FP16量化到INT4,理论上Decode应该快2倍以上,结果实测只快了20%,某些序列长度下甚至更慢。排查过程是这样的:
第一步,确认量化本身没问题,用KL散度检查输出分布,正常。第二步,用profiler看各阶段耗时,发现反量化操作占了大量时间。原来INT4权重读进来后,要先反量化成FP16才能做矩阵乘,这个反量化是在计算核里做的,增加了计算量。第三步,检查计算核实现,发现反量化没有和矩阵乘融合,而是单独一个核,多了一次显存往返。第四步,换成融合了反量化的矩阵乘核,速度立刻上去了,Decode提升到2.5倍。
这个坑的教训是:量化不是把权重存成低精度就完事了,反量化的位置和融合方式同样关键。如果反量化单独成核,省下的带宽又被额外的显存读写吃回去了。
6.2 长序列下的显存爆炸:KV Cache没管好
另一个常见问题是序列一长就OOM。表面看是KV Cache太大,但根因可能有好几个:一是块大小设置不合理,内部碎片太多;二是没有做前缀共享,多个请求重复存了相同的prompt KV;三是没有及时释放已完成请求的块,导致显存泄漏。
排查时我会按这个顺序查:先看块利用率,如果低于70%说明碎片多,调小块大小;再看前缀共享命中率,如果低说明请求之间没有公共前缀,或者共享逻辑没生效;最后查块释放日志,确认完成的请求有没有正确归还块。有一次发现是异常处理分支里忘了调free,导致请求超时后块没回收,跑几个小时就OOM了。
6.3 批处理下的尾延迟:短请求被长请求拖死
连续批处理提升了吞吐,但可能恶化尾延迟。一个短请求如果和长请求批在一起,每个迭代步都要等长请求算完,短请求的延迟就被拉长了。解决办法是分桶调度:把预估长度相近的请求放一批,或者给短请求更高优先级,让它单独成批快速跑完。
我一般会监控P99延迟,如果发现短请求的P99远高于其平均生成时间,就说明被长请求拖了。这时候可以设置一个最大批内长度差,超过阈值的请求不分到同一批。代价是批大小可能变小,吞吐略降,但尾延迟改善明显。
| 问题现象 | 可能根因 | 排查手段 | 解决方向 |
|---|---|---|---|
| 量化后加速不明显 | 反量化未融合 | Profiler看核耗时 | 融合反量化到矩阵乘 |
| 长序列OOM | KV Cache碎片/泄漏 | 查块利用率和释放日志 | 调块大小、修释放逻辑 |
| 短请求延迟高 | 被长请求拖累 | 分长度统计P99 | 分桶调度、优先级 |
| 吞吐上不去 | GPU利用率低 | 看SM occupancy | 连续批处理、调批大小 |
| 输出乱码 | KV Cache索引错 | 对比单请求和批处理输出 | 检查块表越界 |
7. 从能跑到跑得快的落地清单
如果你现在手里有一个能跑但不够快的推理服务,我建议按这个顺序优化,每一步都验证效果再进下一步:
- 先上KV Cache。如果还没用,这是收益最大的一步,没有之一。实现上可以用PagedAttention,也可以先用简单的连续缓存,但一定要有。
- 开启连续批处理。如果并发请求多,这一步能把GPU利用率拉满。注意配合KV Cache的块管理。
- 做权重量化。从INT8开始,验证精度后再试INT4。重点检查反量化是否融合。
- 启用FlashAttention。序列长度超过512就值得开,短序列可以对比测试。
- 调批大小和块大小。这两个参数对性能影响很大,建议做网格搜索,找到你场景下的最优组合。
- 监控尾延迟和显存碎片。上线后持续观察,有问题及时调。
每一步的收益因场景而异,但整体下来,从朴素实现到优化后的系统,吞吐提升5到10倍是常见的,延迟降低一半以上也不稀奇。关键是别指望一步到位,也别盲目抄别人的参数,一定要在自己的数据和硬件上实测。
最后提醒一句:优化之前先建立基线。没有基线的优化都是瞎猜。记录下当前每个阶段的耗时、显存占用、吞吐和延迟,每次改动后对比,才能知道哪招有用、哪招白费。
我在实际项目里最深的体会是,推理加速没有银弹,只有权衡。量化省显存但伤精度,批处理提吞吐但增延迟,融合省带宽但增复杂度。你要做的是搞清楚自己的瓶颈在哪,然后选那个收益最大、代价最小的组合。这个判断能力,比会调几个参数值钱得多。