ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

大模型推理中的Prefill与Decode:从卡顿到调优的关键逻辑

大模型推理中的Prefill与Decode:从卡顿到调优的关键逻辑 用过本地大模型的人应该都有这种体感命令发出去之后终端要么卡住不动几秒甚至更久要么突然哗啦啦地开始吐字。我最早以为这只是“模型在思考”直到后来上手给推理引擎做性能分析才发现这段停顿和接下来的吐字根本不是同一件事——它们分别对应大模型推理里最基础的两个阶段Prefill 和 Decode。这两个名字在推理引擎的文档、论文、面试题里反复出现但很多人的理解停留在“服务端先处理输入然后再逐个生成输出”这个层面。真正动手写推理服务、做显存估算、或者只是想把本地部署调快一点的人很快会发现这个划分背后藏着一整套关于自回归依赖、KV Cache、GPU 算力与内存带宽的逻辑。这篇文章就把 Prefill 和 Decode 掰开揉碎讲一遍同时也聊聊理解这两个阶段之后实测和调优的思路会有什么不同。1. 一段生成两种截然不同的工作节奏1.1 一次推理从输入到第一个字的完整路径先把流程走一遍。假设我用一个 7B 模型输入一句话“请用一句话解释什么是注意力机制”模型正常的输出是一个以“注意力机制是……”开头的句子。推理引擎拿到这段输入之后第一件事是把文字切分成 token得到一长串 token id。这些 token 会一次性全部进入模型逐层经过 embedding、attention、FFN 等模块最后在输出端算出一个概率分布从这个分布里采样出整个回答的第一个 token。注意这一步用户输入是整段一次性处理的不是像人一样读一个字想一个字。第一个 token 出来后工作模式立刻变了。下一步想通过“刚才生成的这个 token”来预测第二个 token于是把刚才那个 token 作为输入再完整过一遍模型得到第二个 token再把第二个 token 塞回去得到第三个……一直循环到遇到结束符或者达到最大生成长度。前面这段“整段输入 产出第一个 token”的过程就是 Prefill后面这段“一个 token 进、一个 token 出”的循环就是 Decode。在 llama.cpp 的日志里Prefill 对应 prompt evalDecode 对应 eval time在 vLLM 的调度逻辑里前者叫 prompt processing后者叫 token generation。名字不同指的都是同一件事。1.2 为什么说它是“两段式”而非“一个循环”有人会问Decode 本身不就是循环吗那 Prefill 不也可以看成 Decode 循环的第一步这么说在数学上没错但工程和性能表现上完全不一样。关键差异在于模型每一步做预测时真正看到的不是“当前这一个 token”而是“当前这个 token 之前的所有 token”。Decode 循环里每一步的输入序列都在变长而 Prefill 这一步的输入是“用户一次性给进来的、长度确定的完整 prompt”。这个差异直接决定了计算形态。Prompt 已经是存在的内容里面所有 token 之间的关联可以一次性算完而回答部分是还没发生的内容下一个词依赖前一个词的实际生成结果只能逐个推测。所以在绝大多数主流推理引擎里Prefill 和 Decode 是被分开设计、分开调度、甚至分开写 kernel 的。先放一张对照表方便建立整体印象对比维度PrefillDecode处理对象用户输入的整段 prompt已知逐步生成的 token未知并行度高prompt 里所有 token 可一起算低一次只能推进一个新 token主要计算形态大矩阵乘GEMM向量乘矩阵GEMV对硬件的需求吃算力GPU 利用率高吃访存卡在读取权重和缓存KV Cache一次性写入全部历史 K/V每步 append 新 K/V同时读取全部历史用户感知回车后到第一个字的停顿第一个字之后逐字蹦出的速度这张表后面每个点都会展开讲。先把最核心的判断记住Prefill 是计算密集型Decode 是访存密集型。理解这句话基本就理解了全文的一半。2. Prefill一次吃进整段 Prompt 的并行阶段2.1 Prefill 内部到底做了什么把 Prefill 再拆细一点。输入是 S 个 token假设 1000 个每个 token 先被映射成一个向量维度一般是模型隐藏层宽度7B 级别的模型常见 H4096。于是整个输入在模型内部就是一个形状为 [S, H] 的矩阵。接下来每一层 Transformer 要做的事情核心就三块计算 Q/K/V把输入矩阵分别和三个权重矩阵相乘得到形状为 [S, H] 的 Q、K、V。这一步对 S 个 token 来说是同一个矩阵乘天然并行。Self-Attention用 Q 和 K 计算注意力分数再用分数加权 V 得到输出。这个过程里有一个因果掩码后面单独说。前馈网络 FFN对每个 token 独立做两三次线性变换加激活本质上还是矩阵乘。可以看到S 个 token 在一层里基本都是共享同一组权重、以矩阵形式一起参与计算的。GPU 最喜欢这种形态矩阵越大并行度越高Tensor Core 越能吃饱。用行业里的说法这个阶段是 compute-bound瓶颈在于 FLOPs 能打多满而不在于数据搬运。2.2 因果掩码为什么输入可以全部一起算这里有一个新手容易卡住的点Attention 不是要求“每个 token 只能看它之前的 token”吗既然有依赖怎么能叫并行呢关键在于prompt 里的全部 token 是已知的。token 5 需要看 token 1~5含自己的信息而这些 token 在输入那一刻就已经全部存在了不存在“要先算出 token 4 才能算 token 5”的先后问题。所谓因果性只是通过掩码把注意力矩阵的上三角位置盖住让 token i 在 attend 的时候只能看到 i 以及 i 之前的 key/value。在矩阵层面所有 token 的 Q 和所有 token 的 K 是一次性相乘的得到 [S, S] 的分数矩阵掩码只是把其中“未来位置互相 attend”的格子置成负无穷让 softmax 之后变成 0。计算本身没有串行化掩码也没有降低并行性。整段 prompt 依然可以当成一个大矩阵运算一次性完成。这也是 Prefill 和 Decode 最本质的差别Decode 阶段未来的 token 还没生成出来你想掩码都不知道该掩谁而 Prefill 阶段用户的完整输入就摆在眼前所有已知信息可以一次性结算完毕。2.3 KV Cache 在这里被“种”下去Prefill 做完 Attention 后每一层的 K 和 V 其实就已经按位置存下来了。为什么要存因为后续每生成一个新的 token它都要对“包括 prompt 在内的所有历史 token”做一次 Attention也就是要读每个历史位置的 K 和 V。如果不存每生成一个新 token 就得把整个 prompt 重新算一遍。这批存下来的 K/V就是 KV Cache。这也是 KV Cache 里只有 K 和 V、没有 Q 的原因——Q 只服务当前正在计算的 token算完就扔K/V 会被后续所有 token 反复读取必须长期驻留。KV Cache 的大小可以精确算出来KV Cache 字节数 2K 和 V 两份× 层数 × 序列长度 × KV head 数 × head 维度 × 每个元素字节数以 LLaMA-2 7B 为例32 层、32 个 KV headMHA 结构、head 维度 128、FP16 存储那么每个 token 每层占 2×32×128×2 16KB乘上 32 层就是约 512KB。也就是说上下文窗口每增加 1 个 token模型要多占约 512KB 显存。1K 上下文是 512MB4K 是 2GB32K 直接就是 16GB——比 7B 权重本身还大。很多人在本地跑长上下文爆显存爆的就是这一块不是模型权重。2.4 长 Prompt 的 Prefill 为什么慢为什么吃显存Prefill 本身算得很快因为并行度高。但长 prompt 有两个隐藏成本。第一个是显存峰值。Prefill 时需要同时保存输入激活、中间注意力结果以及刚写出来的完整 KV Cache。FlashAttention 这类技术把 [S,S] 的注意力矩阵放在 SRAM 里算省掉了大量 HBM 往返才让长序列在现代 GPU 上变得可行。没有这些优化之前序列一长显存和速度都很容易崩。第二个是首 token 延迟TTFTTime To First Token。用户从按下回车到看到第一个字中间等待的时间基本就是 Prefill 的时间。prompt 越长这个阶段越久。所以很多人本地部署后觉得“模型刚开始卡了半天”先别急着怀疑采样参数大概率只是 Prefill 在处理你那段几千字的上下文背景材料。3. Decode 阶段真正意义上的“逐字生成”3.1 Decode 一步里发生了什么从第一个 token 开始模型进入 Decode 循环。每一次迭代做的事可以拆成四步把当前最新生成的 token id 转成向量形状是 [1, H]。对每一层 Transformer算出当前 token 自己的 Q、K、V把 K/V 追加到该层的 KV Cache 尾部然后用这个 Q 去和 KV Cache 里所有历史位置的 K/V 做 Attention得到这一步的输出向量再过 FFN。最后一层输出接一个映射到词表大小的线性层得到所有候选词的概率分布采样出下一个 token。把新 token 当作输入回到第 1 步。这里的核心变化是每一层 Attention 的序列维度比上一步多 1因为 KV Cache 里多了一个新位置。但真正参与“生产新输出”的只有当前这一个 token 的 Q。换句话说计算形状从 [S, H] 的矩阵乘退化成了 [1, H] 的向量乘矩阵也就是行业里常说的 GEMV。3.2 瓶颈从“算”变成了“读”一次 Decode 步骤的 FLOPs 其实少得可怜。还是 7B 模型处理一个 token 的前向计算大约需要 2×70 亿 140 亿次浮点运算。这在 H100 的 989 TFLOPSFP16 稠密算力面前不值一提。但 GPU 要干活得先把数据搬到计算单元里。这一步要读什么模型全部权重——7B 参数FP16 就是约 14GB还要读 KV Cache——如果已经生成了 1024 个 tokenKV Cache 约 512MB。H100 的 HBM 带宽约 3.35TB/s光把 14GB 权重读一遍理论上就要 4 毫秒以上实际加总下来每步一二十毫秒很正常。用“算术强度”这个概念量化更直观一次计算需要的 FLOPs 除以需要搬运的字节数。H100 上要让算力和带宽都恰好饱和算术强度大约需要达到 300 FLOPs/Byte 这个量级。Decode 处理一个 token 时14 GFLOPs / 14GB ≈ 1 FLOPs/Byte离平衡点差了三百倍。换句话说GPU 的计算单元大部分时间在等数据从显存搬过来这就是 memory-bound 的含义。这也是为什么“模型很大但推理很慢”很多时候不是算不了而是读不完。70B 模型在同样的内存带宽下每生成一个 token 至少要把 140GB 权重过一遍每步时间的下限大约是 7B 模型的十倍和它实际算得多快关系不大。3.3 KV Cache 越大每个字越“贵”Decode 每步还要读完整 KV Cache所以随着生成继续序列越来越长每一步的访存量也是缓慢增长的。前面算过7B 模型差不多每 1K token 多 512MB 的 KV Cache。对带宽 3.35TB/s 的 H100 来说1K token 的 KV 读一遍只要 0.15 毫秒在 14GB 权重面前几乎可以忽略但在带宽只有几十 GB/s 的 CPU 内存场景下KV Cache 消耗的时间会被明显放大长对话越到后面越慢的体感主要来自这里。还有一个容易被忽略的细节Decode 虽慢但可以同时服务多个请求。把多个请求各自当前的 token 拼成一个 batch一个 step 就能同时推进 B 个请求权重仍然只需要读一遍等于把权重搬运成本摊到了 B 个 token 头上。这也是为什么推理引擎普遍用 dynamic batching / continuous batching宁可让一个 step 的计算形状复杂一点也要把权重读取次数压下去。3.4 工程上为了省“读”做了哪些事理解 Decode 的瓶颈是访存之后再看一系列推理优化思路会非常清晰GQA / MQA把 KV head 数量降下来比如从 32 个减到 8 个KV Cache 直接缩小 4 倍Decode 每步读 KV 的字节数也跟着缩小。LLaMA-2 70B、Mistral 等模型都用 GQA主要动机之一就是推理端的访存和显存压力。KV Cache 量化把 FP16 的 K/V 压成 INT8、INT4存储和读取都成倍下降。代价是精度但很多人在长上下文中实测影响可控。PagedAttention / vLLM 的显存管理KV Cache 按 block 分配避免碎片化从而在同样的显存里塞下更多请求。它没有减少每步读取量但把整卡利用率提上去了。投机采样Speculative Decoding用一个更小的草稿模型先快速猜几个 token再用主模型一次验证多个 token。它把多步串行 Decode 变成更少的验证步骤本质上是“用算力换延迟”。4. 为什么不能一步到位非要拆成两段4.1 数学层并行边界由依赖关系决定回到最本质的问题为什么不能把整段回答也一次性并行生成因为主流大语言模型是自回归模型数学表达式是 P(下一个 token | 前面所有 token)。生成第三个 token 时需要知道第二个 token 是什么而第二个 token 来自抽样分布带有随机性在采样之前谁也不知道它的具体值。这就决定了生成过程只能逐步推进——每一步都依赖上一步的实际输出。而 Prefill 处理的 prompt 没有这个约束输入是用户给定的、已经完全确定的序列里面的因果依赖可以全部“提前结算”。所以 Prefill 能做大规模并行Decode 不能这不是工程偷懒而是模型定义决定的并行边界。用个生活化类比读一篇已经印好的文章可以快速扫视因为所有字都在纸上眼睛可以并行处理但要自己写一篇文章只能一个字一个字往下走因为下一句怎么写取决于上一句已经形成的语义。Prompt 是“读”生成是“写”两者的并行度天然不同。4.2 系统层KV Cache 是连接两段的桥如果只看到“并行度不同”还不算全貌。第二个层面是 KV Cache 让两阶段产生了明确分工。试想如果不做这个划分每生成一个 token就把“prompt 之前生成的所有 token”重新作为输入从头算一遍。从 prompt 到生成第 N 个 token要对前 N 个位置反复计算总计算量是 O(N²) 量级很快就不堪重负。有了两阶段分工之后Prefill 只做一次把所有历史位置的 K/V 存下来Decode 每步只需要算新 token 自己的 Q、K、V然后从缓存里读历史 K/V 即可。整个序列的总计算量基本是线性增长的。KV Cache 就是这座桥——Prefill 负责建桥Decode 负责走桥。这个设计优化的不只是“快慢”而是整个复杂度量级。4.3 硬件层GEMM 和 GEMV 是两种不同的游戏第三个层面在硬件和 kernel 设计上。Prefill 的核心计算是大矩阵乘GEMMM 很大Decode 的核心计算是向量乘矩阵GEMVM1。GPU 的 Tensor Core 针对 GEMM 做了大量优化数据可以分块并行、权重复用充分利用率能做到很高。但 GEMV 是把一个向量和矩阵相乘矩阵的每一行都要读一遍算出的结果却只有一列。在很多 GPU 上GEMV 的算力利用率可能只有个位数百分比因为访存带宽先顶满了。不夸张地说一个推理引擎如果把 Prefill 和 Decode 混用一种 kernel、一种调度策略性能一定很难看。从 CUDA kernel 到显存分配再到 batching 调度业界都是分开设计和优化的。这也是为什么“两阶段”不只是一个理论概念而是写进推理框架代码结构里的现实约束。4.4 那有没有可能“一步到位”严格说确实有一类非自回归模型试图一次并行生成全部输出但代价是输出质量、长距离一致性通常不如自回归模型所以主流大模型很少走这条路。工程上更现实的做法是在自回归框架内“压缩串联步骤”投机采样、Medusa、EAGLE 等思路都是让多个 token 的验证尽量同时完成但底层仍然遵守自回归依赖。它们优化的只是 Decode 阶段的步数并没有推翻 Prefill/Decode 的划分。所以结论是在当前主流技术路线下Prefill 和 Decode 的划分不是历史包袱而是自回归机制、KV Cache 复杂度、以及硬件访存特性共同作用下的必然结构。5. 理解两阶段之后实测和调优才有章法5.1 评测先拆开TTFT 和 TPOT理解了两个阶段第一件事就是把“生成速度”这个模糊概念拆开。TTFTTime To First Token请求发出到收到第一个 token 的间隔主要由 Prefill 决定受 prompt 长度影响很大。TPOT / ITLTime Per Output Token相邻两个 token 的间隔主要由 Decode 的每步耗时决定受模型大小、内存带宽、KV Cache 长度影响。只看总吞吐量token/s会掩盖很多问题一个服务可能总吞吐很高但长 prompt 请求的 TTFT 爆炸另一个服务可能首字很快但后续吐字慢如蜗牛。本地部署和在线评测时我都建议把这两个指标分别记录。llama.cpp 启动日志里的 prompt eval time 和 eval time一般就分别对应 Prefill 和 Decode 的耗时跑一次就能直观看到两阶段差距有多大。5.2 Continuous Batching让两段工作互相填坑在服务端推理引擎里两阶段划分最直接的表现就是调度策略。朴素的做法是一批请求同时 Prefill然后同时进入 Decode步调完全一致。问题在于Decode 阶段 GPU 算力严重闲置而新来的请求只能排队等整批跑完。Continuous Batching 的思路是把调度粒度从“请求”缩小到“token/step”。每个 step 里有些请求在 Prefill 新来的 prompt有些请求在 Decode 已经生成的 token大家被编排进同一次 GPU 计算步算力和带宽都被更充分地利用。这也是 vLLM、TensorRT-LLM 等框架能支撑高并发在线服务的核心原因之一。如果你自己写推理服务不考虑这种调度并发一高就会出现“要么算力空转、要么排队严重”的现象。5.3 Chunked Prefill别让长 Prompt 堵住所有人的嘴Continuous Batching 解决了混跑问题但一个新的麻烦来了如果某个请求的 prompt 特别长它的 Prefill 一次要算几千个 token这个 step 的耗时会把其他请求的 Decode 全挤到一边造成所有人体验波动。为此 vLLM 有 Chunked Prefill把一次很长的 Prefill 拆成多个小块穿插在多个 Decode step 之间执行。代价是单个请求的 Prefill 被拉长TTFT 变大收益是全局吞吐稳定、其他请求的尾部延迟不被打爆。做在线服务的人经常要在“单请求 TTFT”和“整体吞吐”之间权衡Chunked Prefill 就是把旋钮交到运营者手里。5.4 部署前的显存预算和速度估算最后落到本地部署、选型这些日常场景。算清楚两个数字就够了权重显存参数量 × 2 字节FP167B 约 14GB13B 约 26GB70B 约 140GB。KV Cache用前面那个公式按上下文窗口估算。7B FP16 MHA 大约每 1K token 占 512MB想跑 8K 上下文就预留 4GB换 GQA 模型或 INT8 KV Cache可以再除以 4 甚至除以 8。速度上Decode 的下限可以粗略估算成“权重字节数 ÷ 内存带宽”。比如 RTX 4090 显存带宽约 1TB/s跑 7B FP16每步下限约 14ms换算下来上限差不多 70 token/s 出头如果跑 70B 模型每步下限约 140ms上限约 7 token/s。实际会再低一些但估算思路是对的用来选型心里会有底得多。我自己在做推理服务排查时判断“首字慢”和“逐字慢”用的完全是两套工具前者查 prompt 长度、预填充调度、显卡算力后者查内存带宽、KV Cache 量化、上下文长度。跑过几次之后就会发现表面上都是“模型卡顿”根因经常风马牛不相及。以后跟人聊 LLM 推理也别用一个“每秒多少 token”概括一切拆开说 Prefill 多少、Decode 多少对方就知道你是真跑过推理的人。
返回列表