大模型推理中的Prefill与Decode:算力与显存带宽的博弈及优化策略
刚接触大模型推理的时候,我被同一种模型搞得一度很迷惑:同样是跑一个7B参数量的对话模型,输入一长段文字时GPU利用率能冲到95%以上,但生成第一个token之后,利用率反而掉到二十上下,输出速度肉眼可见地变慢。后来把整个推理链路拆开看,才意识到这根本不是“模型状态波动”,而是推理本身就分成两个截然不同的阶段——Prefill 和 Decode。这两个阶段共享同一套权重、同一个模型文件,但一个在疯狂吃算力,一个在拼命等显存带宽。
搞懂这两者的区别,是做大模型服务调优的入门功夫。如果你做过推理框架的参数调整,比如max batch size、KV Cache预留、量化精度、首token延迟优化,一定会反复撞见这两个词。这篇文章就把它们彻底讲透:两种形态是怎么来的、各自瓶颈在哪、对部署优化有什么直接影响,以及我实测中踩过的坑和沿用至今的排查套路。适合正在做推理服务优化、本地部署调参,或刚看完transformer结构想更深入理解模型运行原理的人。
1. 一次请求在 GPU 上到底经历了什么
1.1 从输入到输出,中间发生了什么
先跟随一条对话请求,看看它在GPU内部走一遍的完整路径。假设用户输入了“请帮我介绍一下机器学习的基本概念”,模型返回“机器学习是人工智能的分支,核心是让计算机从数据中学习规律”。
第一步是分词,把这段输入切成语义完整的token序列,比如“请”、“帮我”、“介绍”、“一下”、“机器学习”等等。分词完成后,模型不会像人读书那样一个词一个词地看,而是把这整串token一次性同时送进网络。这一步就是Prefill:模型并行处理全部输入token,产出一批中间状态,包括每个token对应的注意力键值对,以及最后一个token的隐藏状态。
PreFill阶段结束后,模型进入生成环节。它手里握着最后一个输入token的隐藏状态,开始自回归地逐字往下猜:先根据当前状态预测下一个token的概率分布,采样出一个新token“机器”加入序列;接着把“机器”当作新的输入,再走一遍网络,预测下一个词“学习”,如此循环,直到吐出终止符。
每生成一个token,都要完整地跑一遍整个Transformer网络——所有层、所有注意力头、所有前馈网络。区别在于,输入的序列在变长。这一步就是Decode:只处理最新生成的1个token,却要带着之前所有历史token的注意力上下文。
所以一次请求的GPU生命周期可以压缩成一句话:一次PreFill(并行读完整段输入)+ 若干次Decode(循环生成输出token),两者交替出现,直到生成结束。
1.2 同一个模型,为什么会出现两种计算形态
要回答这个问题,得回到Transformer的自回归结构本身。生成式模型的训练目标就是“根据已知文本预测下一个token”。要做预测,模型必须把一句话的所有token都编码成上下文向量,然后在此基础上往后推。这意味着两个必然结果:
第一,输入处理需要并行。一句话里的所有token之间没有先后依赖,完全可以同时过网络,所以PreFill阶段天然适合大规模并行计算。
第二,输出生成必须有先后。每生成一个token,它又会变成下一个token预测的输入,这个链条没法并行化。哪怕访问同一个GPU,你在Decode阶段要做的依然是“投入少量新数据,结合全部历史状态,得到一步结果”的串行过程。
这两条结论直接导致了两种计算形态的诞生:PreFill是“大批量并行算”,Decode是“小步快跑串行读”。从GPU的角度看,这两种形态的瓶颈也完全不同,甚至优化方向都相反——一个怕算力不够,一个怕内存带宽不够。
可以类比读书和回答问题:PreFill像你把一篇文章从头到尾快速读一遍,把内容装进脑子;Decode像合上书本开始口述答题,每说一句话,都要从脑海里把读过的内容调出来补充一次上下文。一个是消化过程,一个是凭记忆表达的过程。
2. Prefill:算力拉满的速读阶段
2.1 大矩阵乘法的算力舞台
PreFill阶段,模型把输入序列的token都组织成一个二维矩阵,形状大致是 batch_size × sequence_length。无论这个矩阵有多大,模型都会把它一次性扔进矩阵乘法里。
回顾一下Transformer层的核心计算:每一个token都会生成Query、Key、Value三个向量,然后计算注意力分数。在PreFill阶段,“所有token的Query矩阵”和“所有token的Key矩阵”之间做的是一个高维矩阵乘。这属于典型的GEMM(General Matrix Multiply)操作,矩阵规模大、并行度高,GPU的事实上在做这么多行并行计算时能打满绝大多数计算单元。
同样,前馈网络两层稠密映射,也是大规模的矩阵乘。整层网络的计算量其实和token数量成正比:token越多,矩阵的“行数”越多,但每一行要做的运算涉及相同的权重参数,因此可以用极高的计算密度去处理。
这就是为什么PreFill阶段GPU的利用率能跑到很高的原因——它是一个天然适合SIMD并行、适合Tensor Core加速的计算任务。GPU的算力在这里几乎都能用上。
2.2 用公式估算一次 Prefill 要多久
我对PreFill的耗时做了个非常粗略但极有用的估算模型。Transformer大概的计算量可以用一个相对准确的经验公式:
FLOPs ≈ 2 × 模型参数量 × 处理的token数
为什么是2倍?因为一次前向传播里,每个权重参数至少参与一次乘法和一次加法。这个公式在绝大多数实证测试里误差不大,可以当作快速估算的基准。
假设用一块FP16算力约312 TFLOPS的数据中心级显卡跑一个7B模型,输入512个token,则有:
FLOPs = 2 × 7×10^9 × 512 ≈ 7.2×10^12
再除以算力 312×10^12,得到理论耗时约 23ms。也就是说,在理想情况下,处理512个输入token的PreFill只需要几十毫秒。实际运行还要算上注意力计算、KV Cache写入、激活值开销等,实测会更接近30~50ms。
不过这个公式最大的价值在于指出方向:PreFill的耗时和输入token数、模型参数量几乎线性相关。输入文本翻倍,计算量就翻倍;模型从7B换到70B,输入同样的token数,耗时也大约放大10倍。遇到首token延迟过高,先别怀疑是框架抽风,大概率就是输入长度或模型规模带来的必然结果。
2.3 Prefill 场景下怎么优化
既然PreFill的瓶颈在计算量,优化的核心思路就是两条:减少无效计算、并行拆分。
算子融合是实测里见效最快的一类优化。传统实现里,注意力计算要先把Q、K、V投影做完,再把注意力分数矩阵单独算出来,中间会经历多次访存和中间矩阵落地。FlashAttention这类技术把整块计算融合在一个kernel里,避免中间结果反复写回显存,既减轻了显存带宽压力,也在PreFill阶段直接砍掉大量访存开销。
如果你有多个GPU,还可以把PreFill的矩阵乘按张量维度切开并行,这也是多数推理框架支持的并行策略。输入token很多时,甚至可以按序列长度切分,每个GPU处理一段子序列,最后合并结果。对大上下文场景,这两种拆分组合使用能把分钟级的处理时间压到秒级。
另一个值得关注的是把过长的输入切块处理。比如用户黏了一万字让其总结,PreFill一次性算完一万个字,TTFT会变得非常难看。先用切块方式处理前两三千字,先让模型吐出部分理解,再补充后续内容,用户体感会好很多。
3. Decode:被显存带宽卡住的口述阶段
3.1 逐字生成背后的访存压力
Decode阶段,模型每次只接受一个新的token,但整个Transformer的权重全都更新了一遍参与运算。问题在于:每次生成一个token,GPU都必须把模型的全部权重从显存里读一遍。
一个7B模型以FP16存储,权重体积大约14GB。一枚数据中心显卡的HBM显存带宽在2TB/s到3TB/s之间。单纯读取这14GB权重,理论耗时在5到7毫秒。也就是说,即使算力接近无限,仅靠显存带宽,每生成一个token的硬件下限也要好几毫秒。
而实际计算量呢?仍是那个经验公式:FLOPs ≈ 2 × 7×10^9 × 1 ≈ 14 GFLOPs。以312 TFLOPS算力算,理想计算时间只有0.045毫秒。两个数字放一起看,结论非常扎眼:Decode阶段的计算需求只占用了GPU算力的极小一部分,绝大部分时间浪费在等权重从显存里搬到计算单元上。
因此Decode是一个典型的带宽瓶颈(memory-bound)任务。它需要的极少的计算穿插在巨大的读取量之间,GPU的算力根本喂不饱。
很多人在这个阶段看到GPU利用率只有百分之十几,以为死机或模型出问题了,其实这是正常的。Decode阶段的低利用率不是故障,是计算模式决定的物理现实。
这也是为什么模型量化在Decode阶段收益那么巨大:权重从FP16压到INT8,读取量直接减半;压到INT4,读取量降到四分之一。权重变小了,带宽压力变小,生成速度几乎线性提升。反过来,量化在Prefill阶段的效果就没那么夸张——因为Prefill的瓶颈是算力,权重读取不是主要矛盾。同一个量化模型,观测到的PreFill加速比和Decode加速比往往差好几个档次,原因就在这。
3.2 为什么 KV Cache 是 Decode 的关键依赖
Decode阶段每生成一个token,注意力计算都需要用到当前token的Query以及历史所有token的Key、Value。如果每次都把所有历史token再过一遍网络重新计算Key和Value,计算量会随序列增长不断膨胀,根本没法用。
所以推理框架会在PreFill阶段把每个token的Key和Value算出来之后缓存起来,让Decode阶段直接读缓存。这个缓存就是KV Cache。算一下它的大小:
KV Cache大小 = 2(Key和Value两组) × 层数 × 注意力头数 × 每头维度 × token数 × 单元素字节数
拿7B模型举例,假设32层、32个注意力头、每头128维,FP16下每个token占用的缓存是:
2 × 32 × 32 × 128 × 2字节 = 512KB
一个2048 token的序列,单个请求的KV Cache就有约1GB。并发8个请求,就要吃掉8GB显存。这就是为什么显存再大也经常不够用的重要原因——KV Cache是按序列长度和并发数线性膨胀的。
KV Cache的存在让Decode阶段的访存模式越来越重。序列越长,单次Decode除了要读权重,还要读越来越多的Cache数据。这也是为什么很多服务在长对话场景下越聊越慢:不仅仅是权重读取,KV Cache读取也在持续叠加。
现代模型普遍引入GQA(分组查询注意力)来压缩KV,例如把Key和Value的头数从32降到8,KV Cache直接缩减四分之三。这不仅省显存,更直接缓解Decode阶段的带宽压力,是构建高并发服务时非常管用的一招。
3.3 Decode 阶段的优化思路
Decode的优化核心就是减少权重读取、提高数据复用。最常见的招数有三个。
第一是提高并发batch。Decode单读一次权重只能算一个token,但如果同时处理8个请求,相同一份权重就能喂给8个不同请求的token,权重读取量不变,产出翻了8倍,单位时间产生的token总量大幅提高。这是为什么推理系统需要更大的batch显著提升Decode吞吐的根本原因。代价是KV Cache显存占用同步增长,需要平衡调度。
第二是模型量化。上文已说过,权重压缩直接拉低带宽需求。实际上对带宽瓶颈的Decode来说,4bit量化往往比更高级的稀疏化或算子优化更省事且见效快。
第三是投机采样(Speculative Decoding)。用一个小模型快速生成一串候选token,再由大模型一次并行验证。失败就用回退机制,成功则大模型一次Decode出多个token,相当于把串行的步数压缩了。这个技术对带宽瓶颈的解药在于:从算法层面减少了真正读取大模型权重的次数。
4. 两种形态同卡混跑,怎么调才不互相拖后腿
4.1 资源争抢:一个嚣张一个脆弱
实际部署中,模型不会傻等一个请求生成完才接受下一个。多个请求同时进来,GPU上往往同时存在某些请求在PreFill、另一些在Decode。麻烦就在这里。
PreFill是计算密集型,明明芯片算得过来,却会疯狂地向GPU灌并行计算,把计算单元占得满满的。而Decode是带宽密集型,特别依赖显存带宽,却又不能长时间独占计算资源。两个阶段挤在同一张卡上时,PreFill的密集计算会打扰Decode本来就脆弱的时序。最直观的现象是:刚进来一个大文本总结请求,正在生成的会话会突然卡顿,下一个token迟迟不出来。PreFill一拥而上,把Decode的调度周期挤得七零八落。
4.2 Continuous Batching 与 Chunked Prefill 的配合
传统推理框架是请求级调度:一个请求完成前,GPU资源几乎被它独占,完成后再切下一个。这对Decode阶段极度不友好,因为每个请求在Decode期间的算力利用率太低了。
现在的推理框架普遍改用Continuous Batching,把调度粒度从请求降到token。一个请求生成一个token后,并不一定要连续跑下去,调度器可以随时穿插其他请求的token计算,或者插入别的请求的PreFill。这样一来,Decode阶段每次读取权重的时候能把更多请求塞进同一个batch,权重复用率提升,GPU的带宽花得更值。
更精细的做法是Chunked Prefill。前面几个Giant请求的PreFill如果一次性算完,会导致整卡计算被占住几十甚至上百毫秒。Chunked Prefill把一个长序列的PreFill拆成一个个小块,比如每次只处理512个token,穿插在连续Decode的间隙里执行。结果就是大请求的首token延迟没有完全消失,但其他流式请求的卡顿下降到几乎不可感知。
这两个机制一配合,实践中可以让GPU长期维持高利用率,同时又是有界延迟——这是最理想的部署状态。调整时我一般盯两个参数:一是参与PreFill的chunk大小,二是调度器的最大batch数。前者控制单次卡顿幅度,后者控制权重复用率。
4.3 更进一步:让两个阶段分家
如果资源充足、业务形态确实允许,终极解法是PD分离:让PreFill和Decode跑在不同的GPU上。Prefill节点配置更多、算力更强的卡,专攻大输入的密集计算;Decode节点配置更大显存的卡,专攻高并发和低延迟。
这种架构下,两个阶段各不干扰,但需要在中间传输KV Cache状态。传输量不小,对组网带宽有要求。线上如果主要是短对话,PD分离的收益没那么明显;如果是大量长文分析、多模态输入的场景,分离的收益就非常可观。
5. 实测技巧:看懂你的模型到底卡在哪
5.1 两个最容易混淆的观察点
调试推理性能时,先确定现在模型处于哪个阶段,再谈优化。我自己常用的办法是看两个指标:GPU利用率曲线和token输出间隔。
当GPU利用率猛然冲到很高,紧接着的首个token间隔(TTFT)又明显偏长,说明卡在PreFill。反之,如果GPU利用率长期低位但token间间隔稳定,那就是Decode的带宽瓶颈在起作用。
实操时可以临时写一段脚本监控响应过程:记录“请求发出时间”和“收到第一个字节的时间”,两者间隔对应PreFill和部分网络开销;再往后每两个token间的时间差,就是Decode的步进间隔。拿到这两个数,再用每秒生成token数、并发数辅助判断,问题基本能卡到具体阶段。
5.2 关键性能指标速查表
| 指标 | 含义 | 反映阶段 | 优化方向 |
|---|---|---|---|
| TTFT(Time To First Token) | 请求发出到返回首个token的耗时 | PreFill为主 | 缩短输入长度、并行切分、算子融合 |
| TPOT(Time Per Output Token) | 每个输出token的平均耗时 | Decode为主 | 量化、增大batch、投机采样 |
| PreFill吞吐 | 每秒能处理的输入token数 | PreFill | 并行拆分、FlashAttention |
| Decode吞吐 | 每秒能生成的输出token数 | Decode | KV Cache压缩、权重复用、量化 |
表格里的几个指标我会让监控脚本固定采集三个:TTFT、TPOT、Decode吞吐。这三个数字一出来,大致方向就清楚了。
5.3 常见问题与排查经验速查表
| 现象 | 原因 | 排查思路与对策 |
|---|---|---|
| 长文本请求导致显存OOM | PreFill阶段瞬时激活值和KV Cache同时占显存 | 限制单次输入长度,开启chunked prefill逐段处理,估算KV Cache容量并预留余量 |
| Decode阶段GPU利用率低 | 带宽瓶颈,权重读取量远超计算量 | 不是故障,不要盲目加算力卡,先做量化或加大并发batch提升权重复用 |
| 并发上来后每个请求都变慢 | KV Cache显存占用加大,batch过大导致资源争抢 | 降低batch上限,开启PagedAttention减少碎片,或压缩上下文长度 |
| 量化后Decode提升明显但PreFill不明显 | 量化主要降低权重读取量,PreFill瓶颈在计算 | 按阶段各自评估量化收益,不因Decode收益高想当然 |
| 首token极慢但后续生成正常 | PreFill计算量随输入长度线性增长 | 缩短输入prompt,应用chunked prefill,或对长文本场景单独做输入截断与摘要 |
5.4 我个人沿用至今的调优顺序
排障路线我基本是固定的:先看TTFT和TPOT双指标确定瓶颈阶段,再根据瓶颈阶段选择手段。
如果是PreFill长——压输入长度和开切块;如果是Decode慢——先量化,再调并发,最后考虑投机采样或PD分离。这套顺序在好几个项目中都帮我避开了“先堆卡、后发现没有卡在算力”的弯路。每次上线一批大模型应用前,我都会让监控脚本固定打出这三个核心指标。它们的意义,比任何一个框架的配置项都来得直观。
回到开头那个困惑——同一个模型,为什么一会算力爆表、一会带宽告急?其实模型没变,变的是它处于PreFill的“速读”形态还是Decode的“口述”形态。理解这一点之后,推理优化的很多操作都不再是盲调,而是有明确的靶心。每次配置调整时,我都会先在心里问自己一句:这个改动,是在帮Prefill减少计算,还是在帮Decode降低读取?问清楚这句,方向就不会跑偏。