大模型推理性能优化:深入拆解Prefill与Decode的底层差异与实战技巧
这是一个不少做大模型推理的朋友私下问过我的问题:为什么同一个模型,跑在同样的显卡上,生成第一段字的耗时和后续逐字生成的耗时完全不在一个量级?为什么有人把输入 prompt 从 1K 提到 4K,总时延增长却远小于线性?为什么 GPU 看起来一直在跑,但吞吐死活上不去?答案基本都落在今天要聊的这个主题上——Prefill 和 Decode,同一个模型推理过程中的两种截然不同的计算形态。
如果你用过任何 LLM 推理框架,或者在 API 文档里看到过prefill_time、decode_time这类指标,那你已经和它们打过照面了。这篇内容适合三种人看:正在做推理服务性能调优的工程师、想深入理解生成式 AI 底层机制的研究者,以及单纯好奇"为什么大模型一个字一个字往外蹦"的产品技术同学。我会把底层原理、硬件行为、优化套路和实际踩坑一次讲透,尽量让不熟悉 CUDA 基础的朋友也能跟上。
1. Prefill 与 Decode:从一次对话看两种计算阶段
1.1 用户感知到的"快慢"其实是两种计算阶段
你在对话框里提交一段文字,模型吐回答。这段体验可以被切成两个完全不同的阶段。第一阶段是解析你给的那段话,一次性读完并理解,这时候模型在处理整个 prompt 中的所有 token——这就是 Prefill(预填充)。第二阶段是真正写回答,每次只生成下一个 token,然后把这个 token 接到已有序列后面,再继续生成下一个——这就是 Decode(解码)。
一个细节很多人没注意:你在产品里看到的"流式输出",本质上是把 Decode 阶段每一次生成的结果实时推送给你。而"首 token 时间"(TTFT,Time To First Token)衡量的其实是 Prefill 阶段的耗时加上第一次 Decode 的耗时。后期 Decode 阶段每生成一个 token 大概花多少毫秒,就是业界常说的 TPOT(Time Per Output Token)。
我在内部试过一个小实验:用一个 7B 模型,输入 512 token,输出 256 token。Prefill 阶段只花了几十毫秒,但后面 256 次 Decode 加起来却花了数百毫秒甚至更久。也就是说,Decode 总共占了整个推理耗时的大头。这和不少人直觉相反:以为"读得多就该慢",实际恰恰相反——"写得慢"才是瓶颈。
1.2 为什么 Prefill 快:并行是王道
Prefill 阶段的任务,是把用户输入的 prompt 全部同时处理。模型拿到一整段序列,可以把它当作一个长度为S的序列,与模型权重矩阵做一次大矩阵乘法。矩阵乘法天然可以并行执行,GPU 上几千个计算核心可以同时开工,把整个输入序列的计算量在大量计算单元之间摊开。你输入 1 个 token 和输入 1000 个 token,在 GPU 眼里只是矩阵规模大了,只要显存装得下,整体耗时并不会出现 1000 倍的差异。
这也是为什么很多推理框架允许你在 Prefill 阶段把 batch size 开大,合并多个请求一起做。一次矩阵乘法把多个请求的 prompt 并进去,吞吐可以做到非常好看。
1.3 为什么 Decode 慢:串行依赖锁死了并行度
到了 Decode 阶段,情况完全不同。模型已经生成了一部分回答,现在要根据完整的已有序列(用户 prompt + 已生成的回答)预测下一个 token。关键在于"下一个 token 取决于前面所有 token 的上下文",这是一个严格串行的依赖链——第 N+1 个 token 必须等第 N 个 token 算完才能开始。
这意味着每次 Decode 实际上只处理一个 token,并行度天然受限。你不可能同时算出第 100 个 token 和第 101 个 token,因为后者依赖前者。从矩阵运算的角度看,Decode 阶段做的是一个巨大的矩阵(权重)乘以一个单列向量(当前上下文),这样的运算叫 GEMV(矩阵-向量乘)。GEMV 的计算密度远低于 GEMM(矩阵-矩阵乘),GPU 上大部分计算单元在 Decode 时是闲置的。
这就像你让十个人同时写一段必须一气呵成的文章,第一个人只能写第一个字,写完才能给第二个人写第二个字。人再多也没用,因为步骤天然串行,只能等待。
2. 计算形态的底层原理:GEMM 与 GEMV 的差距
2.1 从矩阵运算看 Prefill 的高算力利用率
为了让概念落地,我们用矩阵运算来具体化。假设输入 prompt 长度是S,模型隐藏层维度是H,输出词汇表大小是V。
Prefill 阶段,模型要对S个 token 同时做线性变换。这个过程可以看作一个[S, H]的矩阵与一个[H, H]的权重矩阵相乘,得到一个[S, H]的中间结果。这是典型的 GEMM:两个规模都较大的矩阵相乘。这种运算中,每个输出元素都要做H次乘加,而计算单元可以高度复用输入数据,算术强度(Flops/Byte)远高于 Decode。GPU 的 Tensor Core 在这类运算上能发挥出接近理论峰值的算力。
我自己跑过一个 13B 模型的实测:Prefill 阶段在单张 A 系列卡上可以跑到 60% 以上的 MFU(模型浮点利用率),而同一张卡进入 Decode 阶段后,MFU 常常掉到 10% 以下甚至更低。差异不是模型变了,而是计算形态从"高效的 GEMM"退化成"憋屈的 GEMV"。
2.2 Decode 阶段真正的瓶颈:显存带宽
Decode 阶段的计算量本身并不大。每生成一个 token,本质上就是跑一次前向推理:读取模型全部权重(比如 7B 模型,光权重就有约 14GB 的 FP16 数据),再读取已经算好的 KV Cache(下文详述),做几层矩阵乘加后输出一个 token。
问题在于:为了输出一个 token,你得把 14GB 的权重从显存读一遍。算一次只花多少算力?相比之下,读显存的时间占据了绝对主导。Decode 的速度不是被"计算能力"限制,而是被"显存带宽"死死卡住。这就像一辆满载的货车为了送一个快递要跑完整个仓库配送路线,油费和过路费是主要开销,那个快递本身并不值钱。
业界有个经验公式:单次 Decode 的耗时约等于"模型权重总量除以显存带宽"。一个 7B 模型,FP16 权重约 14GB,假设显存带宽 2TB/s,理想情况下单 token 也要 7 毫秒左右。你要是想快,换更高带宽的显卡、给权重做量化(直接减小搬运体积),或者一次多处理几个请求把权重读取成本摊薄——这就是 batching 能提升吞吐的理论根基。
2.3 KV Cache:让 Decode 不必从头算起
Preill 和 Decode 之间有一座桥,就是 KV Cache。每次注意力计算时,模型会为每个 token 算出 K 向量和 V 向量。如果 Decode 阶段每次都要拿前面所有 token 重新算一遍注意力,那复杂度会随时间平方增长,根本没法用。KV Cache 的策略是:Prefill 阶段算好的所有历史 token 的 K 和 V 向量的值,直接缓存下来。后面的 Decode 只需要拿当前 token 的 Q 去和缓存里的 K、V 做注意力,不再重算历史部分。
这也是 KV Cache 会占用大量显存的原因。一个 7B 模型,序列长度 4096,每层 head 多、维度大,这些 K 和 V 加在一起动辄十几个 GB。如果只知道 P 和 D 的计算差异,不知道 KV Cache 的存在,你做并发服务的显存规划一定翻车。我在部署服务时,通常先把 KV Cache 峰值估算写进自动扩缩容逻辑里,否则高并发下第一个崩的一定是显存。
2.4 算术强度视角:为什么 P/D 优化策略完全不能共用
理解算术强度(arithmetic intensity)这个概念很有用。它的定义是:每个字节的数据搬运,能支撑多少次浮点运算。GEMM 的算术强度可以做到很高,因为数据复用多;GEMV 的算术强度天生就低,因为数据只用一次就丢。
既然 P 和 D 的计算形态差异这么大,针对它们的优化手段自然也不同,不存在一套优化通吃两个阶段。比如 Prefill 更关心如何把大矩阵切分到多卡、如何利用 Tensor Core、如何减少算子启动开销;Decode 则关心如何降低权重读取成本、如何增大有效 batch、如何压缩 KV Cache 的大小。搞混了方向,大概率是竹篮打水。
3. 工程优化实践:让两种计算形态都能跑得更快
3.1 Prefill 侧优化:并行切分与分块预填充
Prefill 阶段因为是大 GEMM,理论上吃算力上限。实际工程里常见的优化手段有这么几类。
第一,张量并行(Tensor Parallelism)。把一个大矩阵按列或按行切到多张卡上,每张卡只算自己那一份。这种做法对 GEMM 非常友好,因为 GEMM 天然可以按维度拆分,卡间只需要做少量 all-reduce 通信。
第二,分块预填充(Chunked Prefill)。它的思路是:把很长的 prompt 切成长度较小的 chunk,每算完一个 chunk 就让它插进解码队列,与其他正在 Decode 的请求一起调度。这样做的好处是避免一个超长 prompt 独占 GPU 太久,导致后面其他请求的 TTFT 一直被卡。有人误以为 chunked prefill 是为了加速单请求的 Prefill,其实它主要是为了提升多请求场景下的整体吞吐和响应公平性。
第三,FlashAttention 类优化。通过 tiling 和 online softmax 技术减少 HBM 读写,在长序列下收益很大。尤其当序列长度到 8K、16K 时,传统 Attention 会把中间结果反复写显存,FlashAttention 能把这部分开销大幅压下去。
3.2 Decode 侧优化:别让显存带宽闲着
Decode 阶段最怕的是"一次只喂一个样本"。所以工程上第一件事就是做动态批处理(Continuous Batching)。传统静态 batching 必须等一个 batch 全部解码完才能接收新请求,明显浪费时间。动态批处理的思想是:每个请求在每个 step 解码完之后,可以马上离开 batch,新请求也能随时插入。这样 GPU 始终在处理有效的 token,权重搬运成本被多个请求摊薄,吞吐提升立竿见影。
真正上生产之后,Continuous Batching 带来的吞吐收益往往比换卡还明显。我做过一次对比实验:同样的 7B 模型、同样的卡,静态 batching 只能跑到约 300 token/s 的综合吞吐,换成动态 batching 后能到 1200 token/s 左右。注意,这里说的是整体 token 吞吐,不是单个请求的延迟。
第二件要紧的事是 KV Cache 量化。把 FP16 的 K、V 量化到 INT8 甚至 INT4,能显著降低 Decode 阶段读取 KV Cache 的带宽压力。注意 KV Cache 量化不是无脑压精度,一般需要按 channel 或按 token 做动态缩放。我遇到过量化后效果崩掉的场景——偏差主要出现在长上下文和 attention 分布极不均匀的 case 里。稳妥起见,建议对敏感层保留 FP16,其余层走低比特。
第三,MQA/GQA。传统多头注意力中每头都有独立 K、V,KV Cache 膨胀较猛。MQA(多查询注意力)让所有头共享一套 K、V,GQA(分组查询注意力)折中一下,让若干个头共享一组 K、V。这个架构层面的改动,直接压制 KV Cache 的体积,也顺带缓解 decode 访存压力。现在很多开源模型的架构都已经默认用 GQA,原因就在这儿。
第四,投机采样(Speculative Decoding)。思路是用一个轻量小模型先草拟多个后续 token,大模型再并行验证这些 token 是否正确。这招把 Decode 的串行依赖部分转移到小模型上,大模型得以并行处理多个 token。实测中,投机采样能把 Decode 速度提升 2 到 3 倍,前提是草拟模型的输出分布和验证模型接近。如果草拟模型太弱,验证通过的 token 太少,反而白耗算力。
3.3 Prefill/Decode 混合调度:把 GPU 用满的关键
如果你在线上服务里把 Prefill 和 Decode 分开成两个独立阶段做,会出现一个经典问题:某些时刻 Prefill 挤满 GPU,压缩了 Decode 的批大小;某些时刻 Decode 占着显存和带宽,但算力利用率很低。这里的核心矛盾是:Prefill 需要高算力,Decode 在等带宽,两者其实可以互补。
主流的取巧方案是把 Prefill 和 Decode 放在同一个 GPU 上交错执行。当 Prefill 的大矩阵计算占满算力时,穿插进去的 Decode 请求只需要等待很少的算力资源,因为 Decode 主要等的是带宽。而 Decode 请求在等待权重从显存搬运时,GPU 的计算单元是空闲的,正好可以让 Prefill 的大矩阵乘插空执行。这就是一些推理框架中"PD 混合调度"能提升整体 GPU 利用率的原因。
以我部署多模型服务的经验,最理想的状况是算力和带宽两条资源管线都保持高占用。什么时候发现 GPU 利用率高但吞吐上不去,基本可以断定是资源配比不匹配——比如算力型任务和带宽型任务的比例失衡。这时候调整 Prefill/Decode 的 batch 配比或 max tokens 上限,比盲目堆卡有效得多。
3.4 优化选型速查表
| 优化手段 | 适用范围 | 主要收益 | 代价/风险 |
|---|---|---|---|
| 张量并行 | Prefill 大幅长输入 | 缩短 TTFT | 通信开销、显存翻倍 |
| Chunked Prefill | 在线服务、并发高 | 提升公平性、吞吐 | 单请求 TTFT 可能略有上升 |
| FlashAttention | 长序列 | 降低显存读写、提升 P/D 速度 | 算子兼容性 |
| Continuous Batching | Decode 在线场景 | 吞吐数倍提升 | 调度复杂度提高 |
| MQA/GQA | 模型架构层 | 降低 KV Cache 占用、提速 Decode | 需要改架构或微调 |
| KV Cache 量化 | Decode 长上下文 | 降低显存占用和带宽压力 | 精度损失、注意力退化 |
| Speculative Decoding | Decode 低并行 | 单链路延迟下降 | 草拟模型质量依赖强 |
4. 解码错误与排查实录:常见问题的定位思路
4.1 解码(decode)在工程里的另一层含义
现实中关于 decode 的坑,不只存在于 LLM 推理阶段。最近网上总能刷到一类报错,比如"下载时出错: image decode failed"或者 Docker 拉取镜像时出现"failed to decode referrers index: invalid"。这些报错里的 decode 指的是广义的"解码"——把编码后的二进制流还原成可用的图片、结构或元数据。虽然领域和 LLM 推理差了十万八千里,但排查思路有共通之处:decode 失败几乎都指向数据格式不规范、版本不匹配或底层依赖损坏,而不是"算法算错了"。
我在处理一个图片批量下载任务时就遇到过 image decode failed。排查后发现不是图片文件本身损坏,而是下载过程中连接中断,导致文件被截断,文件头不完整,解码库直接拒绝解析。用 HTTP 断点续传重新下载后,问题立刻消失。这个经验的通用性在于:报错信息出现在"解码"环节,真正的根因往往在解码之前——数据传输、编码流程或元数据缺失,优先检查这些上游环节。
4.2 LLM Decode 阶段的典型报错与排查
回到 LLM 推理,Decode 阶段也有自己的报错集,而且都不是常规的"崩溃式"报错,而是性能或结果层面的异常。
第一个典型问题是显存不足(OOM)。Decode 阶段每增加一个请求,KV Cache 占用的显存就会显著增加。服务跑一段时间后,内存碎片拉满,新请求直接 OOM。排查思路是:用工具检查 KV Cache 的分配情况,而不是只看显存总量。我曾经排查过一个看似"显存剩余很多却 OOM"的诡异问题,最后发现是缓存分配器为某个超长序列预留了一大块连续内存,物理碎片化导致无法满足。缓解手段包括开启 paged attention(按页分配)、限制单请求最大 token 数、定期清理空闲缓存块。
第二个常见问题是解码内容质量劣化,比如出现重复循环、话题漂移、突然崩坏。这背后通常是 KV Cache 被裁剪或量化引入的精度损失,或者上下文长度超过模型本来训练范围,导致 attention 分布退化。通过拆解 Prefill 和 Decode 两个阶段的输出 logits,可以判断问题到底出在哪个阶段。一个排查手法是:固定 prompt,分别在 P 阶段后记录完整 logits,再运行多步 Decode,对比两者与参考解码结果的一致性。如果在 Decode 前置阶段 logits 已经偏差明显,那大概率是 KV Cache 相关优化过头了。
第三个问题是 Decode 阶段延迟严重劣化。CPU 使用率不高、GPU 算力利用率也很低,但 token 生成就是慢。这种多数是因为模型权重在内存和显存之间反复搬运,或者底层库没有启用 CUDA 图优化导致频繁 kernel 启动。kernel 启动开销在 Decode 这种短小算子里尤其致命——每次生成一个 token 可能就是几十个 10 微秒级的小算子,启动开销占比极大。解决办法包括算子融合、CUDA Graph,以及把多个逻辑操作合并成单次内核调用。
我整理过一张避坑速查表,定位问题时直接对着表格逐行排查,效率会比从头翻日志高不少:
| 现象 | 优先怀疑对象 | 排查手段 | 常见解法 |
|---|---|---|---|
| TTFT 持续走高 | Prefill 被长请求挤占 | 看时间线中的 Prefill 段 | Chunked Prefill、限制单请求输入长度 |
| Decode 单个 token 太慢 | 带宽瓶颈、批大小不足 | ncu profiling | 增大动态 batch、启用 KV Cache 量化 |
| 高并发下 OOM | KV Cache 膨胀 | KV Cache 监控 | PagedAttention、限制并发数 |
| 生成内容突然崩坏 | 上下文超长、KV Cache 精度损失 | 对比 P/D 阶段 logits | 回升关键层精度、滑窗注意力 |
| 幻觉或重复增多 | Decode 采样参数不当 | 查看 temperature/top-p | 调整采样策略、启用重复惩罚 |
4.3 一句话理解 decode 错误的一类通病
无论是图片的 decode 失败,还是 LLM Decode 阶段的异常,归根结底有一个共同特点:工作环境与实际数据不匹配。图片文件头缺失是数据格式不匹配;KV Cache 精度问题、上下文超长,是模型能力边界与输入分布不匹配;解码速度慢是资源类型与计算形态不匹配。排查的思维框架其实很统一——先明确你手上的数据/任务的真实形态,再对照当前工具链的能力边界,偏差最大的那一环往往就是元凶。我习惯在服务刚上线时就把 P 和 D 两个阶段的基线指标打点出来,一旦后续出现异常,直接和基线对比,分钟级定位问题。
5. 实操心得:给新手的几个建议与后续延展
坦白说,真正把 Prefill 和 Decode 分开看待后,我对推理优化这件事的理解发生了质变。以前碰到性能问题总想着换更贵的卡、调更大的 batch,方向很含混。现在我会先问自己:这个问题是 Prefill 阶段的问题还是 Decode 阶段的问题?如果是 Prefill,优先看算力利用率和算子效率;如果是 Decode,优先看带宽和 KV Cache。不同阶段,优化的天花板完全不同。
对于刚开始做推理服务的同学,我的建议是先把指标拆细。不要只看"总耗时"和"总吞吐",一定要拆出 TTFT、TPOT、Prefill 耗时、Decode 平均 token 数、KV Cache 占用等子指标。没有这些细分数据,优化很可能是在黑箱里乱撞。我个人习惯是先观测 5 分钟线上流量,把 P/D 两段的 batch 大小和时间分布记录下来,改动任何一个参数后都拿同一份记录做对比,效果一目了然。
另外,如果条件允许,尽量把推理框架的日志打开到 debug 级别,看每层算子的耗时明细。我踩过的最大的坑,是有一次新版本框架自动开启了某种细粒度 KV Cache 量化,结果服务总体延迟没有改善反而劣化。原因在于量化对齐开销太大,抵消了带宽收益。如果不看算子级时间分布,这种问题很难定位。恰恰是因为换了个版本、行为变了,才让人意识到优化手段和计算形态不匹配时,反而会起反效果。
后续如果还想深入,可以往这几个方向走:一是研究长上下文下的 P/D 调度算法,比如分页注意力与分块预填充的组合策略;二是看一下投机采样在小模型草稿选择上的工程技巧;三是自己做一套 P/D 两段的推理 profiling 工具,针对自己业务做定制化。这些我在不同项目里都试过,每次推进都能挖出不少新的性能空间。希望这篇内容能给你一个扎实的起点,少走点我走过的弯路。