1. 把 Prefill 和 Decode 当成两个物种来看,是这套笔记第一个想讲明白的事
聊到高级调优,核心其实就两个字:边界。Prefill 阶段和 Decode 阶段在计算形态、访存特性、延迟目标上完全是两个物种,却挤在同一套显存和同一个调度器里,这就是性能问题最集中的来源。
先回忆一下自回归生成的过程。用户输入一段 prompt,模型先并行处理整段输入,一次性把所有 token 的中间状态计算出来,这个阶段叫 Prefill(预填充)。得到第一个输出 token 后,后续的每一个 token 都依赖前序全部 token 的信息,只能一个一个生成,这个阶段叫 Decode(解码)。哪怕总输出长度只有 500 token,Decode 也要循环 500 次,其中大部分时间 GPU 算力压根用不满。
这里有两个对性能工程极其重要的延迟指标:TTFT(Time To First Token,首 Token 耗时)和 TPOT(Time Per Output Token,每个输出 Token 的耗时)。前者由 Prefill 主导,决定用户"第一反应快不快";后者由 Decode 主导,决定"连续说话时流不流畅"。不少团队在做压测时只盯总吞吐(tokens/s),结果优化完吞吐很好看,线上用户却普遍抱怨"转圈时间久"或者"一个字一个字蹦出来",就是因为没有把这两个阶段拆开来看。
更关键的是计算形态的差异。Prefill 阶段输入是一个长序列,模型内层是标准的 GEMM,矩阵维度大、计算密度高,GPU 的算力可以发挥到相当高的水平;Decode 阶段输入只有一个 token,同样一层的计算退化成了 GEMV(矩阵乘向量),访存量远大于计算量,GPU 算力被严重浪费,性能瓶颈几乎是纯访存瓶颈。
换句话说:Pre fill 的敌人是算力不够和 batch 不够大,Decode 的敌人是显存带宽不够和 KV Cache 太大。两者放一起优化,就是本篇文章的整体框架。下面分别深挖。
2. Prefill 侧的高级优化:分块预填充、独立部署与注意力实现迭代
2.1 为什么不建议让一个大 prompt 独自占满整张卡
很多人在模拟长上下文场景时发现,一个 8k token 的 prompt 进来,Prefill 部分就要耗掉几百毫秒甚至几秒,期间其他请求的 Decode 全部被卡住。长尾延迟由此产生。解决思路之一就是 Chunked Prefill(分块预填充):把一个长 prompt 拆成若干 chunk,以 chunk 为单位加入调度队列,这时候同一条 prompt 的 Prefill 可以被其他请求的 Decode 插空执行。
这个方案的好处是消除了"长 Prompt 独占算力"的局观,坏处是 Prefill 被拆碎后计算效率略有下降,因为每块之间需要额外保存中间状态,KV Cache 也要提前为整个序列预留显存。它适合高并发、请求长度波动大的在线服务;对应的批处理大小参数也会因此调大不少。
2.2 Prefill 与 Decode 完全分家:PD 分离架构的取舍
当优化走到一定深度,很多团队会把 Prefill 和 Decode 部署到两批独立实例上:负责 Prefill 的实例专门吃大 batch 的 GEMM,负责 Decode 的实例专心做 GEMV。中间通过一个额外的缓存层(KV Cache 传输)把 prefill 结果交给 decode 实例。这种方案业内俗称 PD 分离。
它的核心收益在于:两种阶段可以独立扩缩容。线上如果首 Token 延迟敏感,就多配 Prefill 实例;如果长对话并发高,就多配 Decode 实例。代价则是多一跳网络传输、KV Cache 序列化延迟,以及系统复杂度上升。实操中需要特别注意传输吞吐与 KV 压缩的配合,否则 Prefill 侧省下的时间会全部浪费在传输上。
2.3 注意力实现的代际差异:从 FlashAttention 到 FP8
Prefill 阶段内部的优化,很大程度落到 Attention 实现上。早期推理框架的 Attention 要先把完整的 Q、K、V 矩阵存在显存里,再计算 softmax,显存和带宽消耗极大。后来出现的 FlashAttention 类实现通过分块计算和在线 softmax,把本来 O(n²) 的显存占用降为 O(n),同时大幅减少 HBM 访存,具体原理简要概括就是:不必等完整矩阵,先后台分块算部分 softmax,再用"重缩放"修正全局结果。
到了 FP8 时代,Prefill 阶段的 GEMM 可以用 8 位浮点累加获得更高吞吐,但 Attention 的 softmax 部分保持较高精度,整个过程中 KV 缓存也还可以保留 FP8 或 BF16。这里我必须提醒:FP8 的精度下降因模型和任务而异,不能一刀切。我在某个代码生成模型上试过全层 FP8,质量问题在短回复上完全看不出来,一跑长尾知识问答就明显掉准确率,后来只对 Attention 部分做 FP8,其他层保持 BF16 才平衡好速度与质量。
3. KV Cache 的显存几何:公式、量化与页式管理
3.1 一条公式算清 KV Cache 占用
进入调优之前,先把 KV Cache 显存占用这条基础公式记牢:
KV Cache 大小 ≈ 2(K 和 V) × 层数 × 注意力头数 × 每头维度 × 序列长度 × 每个元素字节数
假设某 7B 级别模型一共有 28 层、40 个注意力头、每头 128 维,每个元素占 2 字节(BF16),一条 4096 token 的序列大约需要:2 × 28 × 40 × 128 × 4096 × 2 字节 ≈ 2.35 GB。这还只是单条序列。实际服务中要同时处理几十条序列,KV Cache 的增速非常恐怖。
这就是为什么很多人在单卡上跑 7B 模型,只要并发请求一多,立刻出现 OOM,因为参数只占 14GB 上下,可 KV Cache 每秒钟都在滚雪球一样变大。因此性能工程的一个核心思路就是提前给 KV Cache 划定上限,而不是让它无节制增长。
3.2 GQA 为什么能凭空砍掉一截缓存
近几年不少新模型把 MHA(多头注意力)改成了 GQA(分组查询注意力)。不要小看这个结构变动,它直接让 KV 头数降到 Query 头数的几分之一,KV Cache 体积随之一同缩小。
举例来说,假设同一层原本有 32 个 Q 头和 32 个 KV 头,改成 GQA 后 KV 头降到 8 个,KV Cache 直接变成原来的四分之一。在同样的显存预算下,并发批处理大小能提上去,单位时间吞吐自然翻倍。也就是说,在做 KV Cache 调优时,模型结构的选型比后端参数调整来得更根本。
3.3 KV Cache 量化:8bit、4bit 与精度损失的拉锯
显存不够,量化来凑,KV Cache 同样可以量化。业界当前常见的三种档位:BF16(2 字节/元素)、FP8 或 INT8(1 字节/元素)、INT4(半字节/元素)。我的实践经验是:FP8/INT8 对大部分任务几乎无损,INT4 则明显有质量回落,尤其长上下文场景,模型对 KV 精度的敏感度会放大。
如果显存极度紧张,可以只量化 K 或只量化 V,或者量化后又把一部分重要 token 的 KV 保持高精度。这类混合策略需要配合具体模型做验证。另外要注意:量化 KV Cache 虽然省显存,但增加了量化与反量化的计算开销,如果实现不好,TPOT 反而变慢。选型时建议用/usr/bin/time这类工具实测阶段耗时,而不是只看显存数字。
3.4 PagedAttention 与"显存仓库"管理
显存管理的第二步是索引式存储。传统方式是用一块连续显存装完一整条序列的 KV,遇到变长请求时内部碎片极多。PagedAttention 类方案把一个序列的 KV 切成多个固定大小的 page,通过页表把物理上不连续的显存块拼成逻辑上连续的序列。
这块内容对于性能调优的意义在于:它决定了你能在同样显存容量里装多少并发请求。页表开得快、分配策略合理,并发吞吐就能上去。实操时把显存使用率、页碎片率和 KV Cache 命中率同时监控起来,注意这三个数字往往互相牵制,找到一个合适的折中区间。
4. 一次典型调优全程:设备、显存核算与实验矩阵
4.1 设定场景:单机单卡到多机部署的起步
我以一个非常常见的起步配置为例:单机 8 卡,单卡显存 80GB,要服务一个 7B 规模对话模型。量化参数为 BF16,权重约占 14GB。如果为了更高吞吐把 batch 并发调到 64 条序列,每条平均上下文长度 2k,那么 KV Cache 需求的粗略估算如下:
单条 2k 序列的 KV Cache ≈ 2 × 28 层 × 8 个 KV 头(假设 GQA)× 128 维 × 2048 × 2 字节 ≈ 1.17 GB。64 条并发需要大约 75GB,已经逼近单卡 80GB 上限,再加上模型权重和激活值,必然放不下。这时候就必须引入量化 KV、扩大 batch 的显存上限或者说调整并发策略。
4.2 显存预算表:给参数、激活和 KV 各划一条线
把显存管理抽象成一张预算表,是生产环境落地的关键方法。一般建议将显存按如下比例划分:模型权重约 30%——这部分几乎固定;激活值与临时张量约 20%——随上下文长度变化,峰值出现在 Prefill 阶段;KV Cache 约 45%——这部分最灵活,可量化、可淘汰、可压缩;剩余 5% 作为安全余量。
表中每个数字都要根据实测调整,而不是拍脑袋。你可以在框架日志里看到各个模块的实际峰值占用,对照预算表分析哪一块超了、哪一块没用到,调优目标也随之清晰。
4.3 实验矩阵:一次只动一个变量
我在调参时习惯做一个实验矩阵,确保每一轮只改一个变量。比如第一轮测不同 KV Cache 上限(35GB、40GB、45GB)分别对吞吐和 TPOT 的影响;第二轮测量化档位(BF16/INT8/INT4)对质量与速度的双重影响;第三轮测并行配置的影响(是否开 PD 分离、Prefill 实例几台、Decode 实例几台)。表格记录每个配置下的 TTFT、TPOT、吞吐、OOM 次数、质量测试分,然后收敛到综合最优。
一个实际跑出来的例子(非真实项目名)大概是:40GB KV 预算配 INT8 量化,TPOT 从 38ms 降到 27ms,吞吐提升 32%;改成 INT4 后吞吐提升 48%,但长上下文质量分下降 3 个点,最终取舍下来还是守住了 INT8。
4.4 从实验室到线上:加压、回落、再压测
调参跑通后不要直接上线,建议按这样的节奏:先在离线环境用两到三倍的线上峰值请求压测,观察显存溢出次数和延迟分位数;如果能撑住,再把部分生产流量灰度导入;灰度期间对比 TTFT、TPOT 是否劣化,以及有无意外 OOM 导致的请求重算。整个过程重点关注"幸存率":新增 KV Cache 上限后系统在极限并发下请求成功比例有没有下降。
这套流程看着保守,但能筛掉九成"调参后立刻上线爆显存"的意外。
5. 系统级联动:前缀缓存、推测解码与调度器策略
5.1 前缀缓存让重复请求根本不走 Prefill
线上大量请求通常共享同一段系统提示词或长文档,不同用户只是在后面追加问题。PKM(前缀缓存 / 语义缓存)就是抓住这一特征,把公共前缀的 KV 结果直接复用,这样新请求的 Prefill 部分彻底消除,TTFT 从几百毫秒降到几十毫秒。
效果取决于命中率。如果系统提示词每次都不一样,缓存命中率自然低。实际收效最好的场景是"固定系统前置 + 个性化后续问题",比如客服机器人、代码补全中的文件前缀。调优时重点关注命中率统计,不要盲信框架默认开启的缓存。另外要注意缓存的容量上限和淘汰策略:KV 缓存长期不淘汰的话,显存会被垃圾数据慢慢撑爆。
5.2 推测解码如何改变 Prefill/Decode 的压力结构
推测解码(Speculative Decoding)用一个小模型先草拟多个 token,再由大模型一次验证,以此把 Decode 阶段的串行步数压缩为更少次数的前向计算。它表面上只影响 Decode,实际上对 KV Cache 和 Prefill 也有很大扰动:草稿模型生成的 KV 是否放入主模型缓存、验错后如何回滚,都会影响显存峰值和调度复杂度。
我的建议是:推测解码要作为整体系统的一部分评估,而不是只看单步延迟收益。实测中它对短回复场景收益最大,长文档生成场景因为草稿模型准确率下滑,收益会被验错概率吃掉。
5.3 调度策略:长短请求如何在队列里和平共处
有些请求 prompt 很短、输出很长,有些则 prompt 很长、输出很短。调度器如果按队列顺序走,短请求很可能会被前面的长输出卡死,于是需要更细的优先级策略。进阶做法是区分"Pre fill 阶段是否必须立即做":短 prompt 的 Prefill 可以在解码间隙穿插完成,长 prompt 的 Prefill 尽量放在负载低谷期。
这里有一个我在实测中遇到的反直觉结论:把过多资源预留给 Prefill 中的大请求,不一定更优;相反,大请求的 Prefill 切片后穿插进 decode 间隙,系统整体尾延迟反而更稳定。这在很大程度上取决于业务场景,不能一概而论。
5.4 过载保护与回退预案
再稳的调优也会遇到突发流量。集群上的各实例必须有一套过载保护机制:比如当 KV Cache 使用率达到一定阈值时,拒绝新请求或把新请求引导到其他实例;当显存接近上限时,提前对旧请求做抢占或把 KV 缓存落盘。这里要重点测试"抢占后重新计算"的开销是否可接受,因为被抢占请求如果是长上下文,重算代价会非常大。
研究 KV Cache 不够用、请求过多时,部分的替代方案是 CPU offload:把一部分 KV 数据搬到主存,用时再回传。这个方法能救急,但回传速度会成为新瓶颈。我通常只把它定位成最后保险,不作为常规手段。
6. 生产环境里摸爬滚打后最想告诉你的事
6.1 显存碎片和活跃序列的动态平衡
PagedAttention 虽然把物理碎片管理得很好,但也不是没有坑。我发现高并发下的页表分配和释放过于频繁,会导致分配器本身出现"软碎片"——空闲 page 总量充足,却因为分布不连续而无法满足大请求。解决办法就是控制单次分配 page 的大小和数量,并定期做显存整理。此处不要迷信某个框架默认值,要实际跑一段时间看看你的服务是不是属于"快速分配和释放"的类型。
6.2 多卡张量并行与 KV Cache 的重复放大
很多人想当然认为张量并行后 KV Cache 占用减半,但实际上 Attention 头被切分到多张卡,每张卡分别缓存自己负责的那部分头,整体的 KV Cache 总量和单卡配置并不完全同步,反而可能因为重复缓存而出现更多占用。实践中要注意部署时给每张卡留出足够的 KV Cache 余量,并检查多卡之间的负载是否均衡,而不是只看整机显存总和。
6.3 指标采集要以"阶段"为单位,而不是只看整体
线上监控建议把 TTFT 和 TPOT 分开记录,并把 Prefill 阶段与 Decode 阶段的耗时分别统计。只盯"平均生成速度"容易掩盖 Prefill 长尾。这一点在长上下文服务里尤其明显:原本 Prefill 慢的请求如果恰好排在了一批解码请求后面,整体耗时会雪上加霜。加上阶段切分的指标后,你才能一眼看出瓶颈到底在预填充还是解码。
6.4 关于"最大 KV 预算"的常识与反常识
不是每个部署都需要把 KV Cache 预算开到最大。我踩过的坑是:为了压榨硬件,把 KV 预算调到接近显存上限,结果激活值一有波动就 OOM,触发流程里最重的重算。因为 KV 缓存可以量化、可以淘汰,但激活值是立即要用、不能省的。所以宁可给 KV 预留 80%,也别让它突破安全线,系统稳定性优先于峰值吞吐。
这套学习笔记写到这里,本质上体现了"性能工程"四个字的分量:它不是单一参数的事,而是把模型结构、推理框架、显存管理、调度策略串起来一起做的系统工程。练好这套拆解方法,比照着教程抄一个调优参数有用十倍。