我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

大模型推理性能优化:深入拆解Prefill与Decode的底层差异与实战技巧

大模型推理性能优化:深入拆解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 时间TTFTTime To First Token衡量的其实是 Prefill 阶段的耗时加上第一次 Decode 的耗时。后期 Decode 阶段每生成一个 token 大概花多少毫秒就是业界常说的 TPOTTime 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 的上下文这是一个严格串行的依赖链——第 N1 个 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、VKV Cache 膨胀较猛。MQA多查询注意力让所有头共享一套 K、VGQA分组查询注意力折中一下让若干个头共享一组 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 BatchingDecode 在线场景吞吐数倍提升调度复杂度提高MQA/GQA模型架构层降低 KV Cache 占用、提速 Decode需要改架构或微调KV Cache 量化Decode 长上下文降低显存占用和带宽压力精度损失、注意力退化Speculative DecodingDecode 低并行单链路延迟下降草拟模型质量依赖强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 量化高并发下 OOMKV 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 工具针对自己业务做定制化。这些我在不同项目里都试过每次推进都能挖出不少新的性能空间。希望这篇内容能给你一个扎实的起点少走点我走过的弯路。
返回列表