1. 从一次线上推理抖动说起:为什么Prefill和Decode必须拆开看
前段时间帮一个团队排查他们自研推理服务的延迟毛刺问题,现象很典型:P99延迟时不时飙到两三秒,但平均延迟看着还行,GPU利用率却始终上不去,卡在40%上下晃悠。他们一开始怀疑是显存不够、模型太大,甚至动了换卡换机器的念头。我让他们把请求按阶段拆开打点,结果一目了然——问题根本不在算力,而在于Prefill和Decode这两个阶段的资源争抢。
这个场景其实在推理系统里非常普遍。大模型推理从计算特征上天然分成两个阶段:**Prefill(预填充)**负责把用户输入的整段prompt一次性喂进模型,算出所有输入token的KV并生成第一个输出token;**Decode(解码)**则是自回归地一个token一个token往外吐。前者是计算密集型,矩阵乘法规模大、并行度高,能把GPU的算力吃满;后者是访存密集型,每生成一个token都要把整个KV Cache读一遍,算力利用率极低但显存带宽压力巨大。
把这两个阶段混在一个批次里跑,就像让短跑运动员和马拉松选手同场竞技——Prefill的长序列会拖慢Decode的响应,Decode的频繁小批次又会让Prefill的算力利用率下降。所以现在主流的推理引擎,无论是哪家的方案,核心优化方向都是PD分离(Prefill-Decode Disaggregation)加上KV Cache精细化管理。这篇笔记就把我这两年在几个项目里踩过的坑、调过的参数、验证过的方案,系统地梳理一遍。
适合读这篇的人:正在做推理服务部署、被延迟和吞吐折磨的工程师;想搞明白KV Cache到底怎么占显存、怎么优化的同学;以及准备做PD分离架构选型的技术负责人。我会尽量把原理讲透,同时给出可以直接抄的参数和排查思路。
2. Prefill与Decode的本质差异:先搞清楚在优化什么
2.1 两个阶段的计算特征对比
要调优,先得把这两个阶段的脾气摸清楚。我用一个具体的例子来说明:假设模型是70B参数、FP16精度,输入prompt长度2048,输出长度512,batch size为1。
Prefill阶段要处理2048个token,每个token都要和所有历史token做注意力计算。这个阶段的矩阵乘法维度大,GPU的Tensor Core能跑满,计算时间大致和序列长度的平方成正比(注意力部分)加上和序列长度成正比的部分(FFN等)。实测下来,70B模型处理2048长度prompt,在单张高端卡上大概几百毫秒。
Decode阶段每次只处理1个新token,但它需要读取之前所有token的KV Cache。这时候计算量很小,瓶颈在显存带宽——要把几十GB的KV Cache从显存里读出来参与注意力计算。每生成一个token的时间基本恒定,主要取决于KV Cache的大小和显存带宽。
| 维度 | Prefill | Decode |
|---|---|---|
| 计算特征 | 计算密集型 | 访存密集型 |
| 并行度 | 高,整段并行 | 低,逐token串行 |
| 算力利用率 | 高(可达70%+) | 低(常低于10%) |
| 显存带宽压力 | 中等 | 极高 |
| 延迟敏感度 | 影响首token时间TTFT | 影响每token时间TPOT |
| 批次策略 | 大batch提升吞吐 | 小batch降低延迟 |
这张表是我自己实测总结的,不同模型和硬件会有差异,但趋势是一致的。理解了这个差异,后面所有的优化手段就都有了解释的根基。
2.2 为什么混跑会导致互相拖累
很多人会问:既然Prefill算力利用率高、Decode算力利用率低,那把它们放一起,不正好让Decode填空、Prefill吃满吗?理论上很美,实际很骨感。
问题出在调度粒度上。当一个批次里同时有Prefill请求和Decode请求时,调度器必须等整个批次的所有请求都完成才能进入下一步。Prefill请求处理时间长,Decode请求就得干等;而Decode请求每步都要执行,Prefill请求又被迫频繁插入。结果就是两边的延迟都被拉高,而且GPU的流水线频繁被打断,利用率反而下降。
更麻烦的是显存碎片。Prefill阶段需要为长prompt分配大块连续的KV Cache,Decode阶段则是持续的小块增长。混在一起时,显存分配器很难高效管理,容易出现"明明有空间却分配不出来"的碎片问题。我见过一个案例,显存利用率显示还有15GB空闲,但就是无法接收新请求,最后发现是碎片导致的。
提示:如果你的服务同时有长prompt和长输出需求,混跑几乎必然导致延迟抖动。这时候优先考虑PD分离,而不是去调batch size。
2.3 PD分离的核心思路
PD分离的本质是让专业的资源干专业的事。把Prefill和Decode放到不同的实例组上,各自用最适合的批次策略和并行配置。
Prefill实例组可以配大batch、高并行度,追求吞吐;Decode实例组配小batch、优化显存带宽,追求低延迟。两者之间通过KV Cache的传输来衔接——Prefill算完KV后,把KV传给Decode实例,Decode接着往下生成。
这个架构的代价是KV传输开销。KV Cache动辄几十GB,跨实例传输需要高速网络。所以PD分离是否划算,取决于你的网络带宽和请求特征。如果prompt很短、输出也很短,传输开销可能超过收益;如果是长prompt长输出的场景,收益非常明显。
3. KV Cache的显存账本:算清楚才能调得动
3.1 KV Cache到底占多少显存
这是最基础也最容易被忽略的问题。很多人部署时发现显存不够,第一反应是模型太大,其实KV Cache可能才是大头。
KV Cache的大小计算公式:
KV Cache大小 = 2 × 层数 × 注意力头数 × 头维度 × 序列长度 × batch_size × 精度字节数以70B模型为例,假设80层、64个注意力头、头维度128、FP16精度(2字节):
单个token的KV Cache = 2 × 80 × 64 × 128 × 2 = 2.6 MB
如果序列长度2048、batch size 16,那么KV Cache总量 = 2.6MB × 2048 × 16 ≈ 85 GB
这个数字相当惊人。模型权重本身FP16也就140GB左右,KV Cache在长序列大batch下能占到显存的很大比例。所以KV Cache优化不是锦上添花,而是决定服务能不能跑起来的关键。
3.2 PagedAttention为什么有效
传统KV Cache分配是连续的,每个请求预留最大序列长度的空间。这导致两个问题:一是内部碎片,实际用了500长度却占了2048的空间;二是外部碎片,请求来来去去后显存被切得七零八落。
PagedAttention的思路借鉴了操作系统的虚拟内存分页。把KV Cache切成固定大小的block(比如16个token一块),用页表来管理逻辑块到物理块的映射。这样请求的KV Cache不需要连续,按需分配block,碎片问题基本消除。
实测下来,PagedAttention能把显存利用率从60%左右提升到90%以上,意味着同样的显存能支撑更大的batch或更长的序列。这是目前几乎所有主流推理引擎的标配。
3.3 量化KV Cache的收益与代价
KV Cache量化是另一个大杀器。把FP16的KV降到INT8甚至INT4,显存直接减半或减到四分之一。
但这里有个坑:KV Cache量化对精度的影响比权重量化更敏感。因为KV会参与注意力计算,量化误差会被放大。我实测过,INT8量化在大多数场景下精度损失可接受(困惑度上升不到1%),但INT4就需要谨慎,某些任务上会出现明显的质量下降。
| 量化方案 | 显存节省 | 精度影响 | 适用场景 |
|---|---|---|---|
| FP16 | 基准 | 无 | 精度要求极高 |
| INT8 | 50% | 轻微 | 大多数生产场景 |
| INT4 | 75% | 明显 | 显存极度受限、可接受质量下降 |
| FP8 | 50% | 轻微 | 支持FP8的硬件 |
注意:KV Cache量化要和权重量化分开评估。有些团队权重用了INT4,KV还用FP16,结果显存还是不够。两个都要看。
4. PD分离架构的落地细节:从设计到部署
4.1 实例配比怎么定
PD分离后,Prefill实例和Decode实例的数量配比是个关键决策。配比失衡会导致一方成为瓶颈。
我的经验算法是这样的:先测出单个Prefill实例的吞吐(每秒能处理多少prompt token)和单个Decode实例的吞吐(每秒能生成多少output token)。然后根据业务的输入输出比例来算。
假设业务平均输入2048 token、输出512 token,比例4:1。如果单Prefill实例每秒处理10000 prompt token,单Decode实例每秒生成2000 output token,那么处理一个请求Prefill需要0.2秒,Decode需要0.256秒。为了平衡,Prefill和Decode实例数比大约是1:1.3。
实际部署时还要留余量,因为流量是波动的。我一般会按峰值流量的1.5倍来配置,然后通过自动扩缩容来应对日常波动。
4.2 KV Cache传输的优化
PD分离后,Prefill算完的KV要传给Decode。这个传输如果没优化好,会成为新的瓶颈。
传输优化的几个方向:
- 压缩传输:KV在传输前做量化压缩,到Decode端再解压。INT8压缩能把传输量减半,网络压力大幅降低。
- 分层传输:不是等所有层的KV都算完才传,而是算完一层传一层,流水线化。这样Decode能更早开始工作,降低首token延迟。
- 就近调度:调度器尽量把同一个请求的Prefill和Decode分配到网络距离近的实例上,减少传输延迟。
我实测过,不做任何优化的情况下,70B模型2048长度prompt的KV传输要几百毫秒,比Prefill本身还慢。加上压缩和分层传输后,能压到几十毫秒,基本可接受。
4.3 调度器的设计要点
PD分离的调度器比单体架构复杂得多,要同时考虑Prefill队列、Decode队列、KV传输队列的状态。
核心逻辑是:请求进来先进Prefill队列,Prefill完成后KV进入传输队列,传输完成后进入Decode队列。调度器要保证各队列不积压,同时尽量让同一个请求的Prefill和Decode在时间上衔接紧密。
一个容易忽略的点是超时处理。如果KV传输失败或Decode实例挂了,请求要有重试或降级机制。我在一个项目里就遇到过Decode实例OOM导致请求卡死的情况,后来加了超时重试才稳定。
5. 参数调优实战:那些文档不会告诉你的细节
5.1 batch size不是越大越好
很多人以为batch size越大吞吐越高,在Decode阶段这不一定成立。
Decode阶段每个token都要读一遍KV Cache,batch size增大意味着KV Cache读取量线性增长。当batch size超过某个阈值后,显存带宽成为瓶颈,吞吐不再提升,反而因为单批次处理时间变长导致延迟上升。
我实测的经验值是:Decode阶段的batch size控制在8到32之间比较合适,具体取决于模型大小和显存带宽。可以通过压测找到拐点——吞吐不再随batch size增长的那个点。
Prefill阶段则相反,batch size可以开得比较大,因为计算密集,大batch能充分利用算力。但也要注意显存限制,Prefill的KV Cache是瞬时分配的,batch太大会OOM。
5.2 块大小(block size)的选择
PagedAttention的block size是个需要调的参数。block太小,页表开销大,管理复杂;block太大,内部碎片增加。
常见的选择是16或32。我一般先用16,如果发现页表管理开销明显(比如block数量过多导致调度变慢),再调到32。这个参数对性能的影响不是特别大,但值得花十分钟测一下。
5.3 抢占与重计算的权衡
当显存不够时,推理引擎需要决定是抢占(把某些请求的KV换出到CPU内存)还是重计算(丢弃KV,需要时重新算)。
抢占的代价是PCIe传输,重计算的代价是算力。一般来说,如果CPU内存充足且PCIe带宽高,抢占更划算;如果算力有富余,重计算更简单。
我的建议是:优先用抢占,但要设置好换出阈值。不要等到显存完全满了才换出,留10%到20%的余量,避免频繁换入换出导致的抖动。
6. 常见问题排查速查表
调优过程中遇到的问题五花八门,我把高频问题整理成表,方便快速定位。
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| TTFT高但TPOT正常 | Prefill排队或算力不足 | 看Prefill队列长度和GPU利用率 | 增加Prefill实例或优化batch |
| TPOT高但TTFT正常 | Decode显存带宽瓶颈 | 看显存带宽利用率和batch size | 减小batch或量化KV Cache |
| 延迟周期性抖动 | 显存碎片或换入换出 | 看显存分配日志 | 调block size或预留余量 |
| 吞吐上不去 | 批次策略不合理 | 看各阶段利用率 | PD分离或调batch |
| OOM但显存显示有空闲 | 碎片或预留不足 | 看最大连续空闲块 | 重启服务或调分配策略 |
| KV传输慢 | 网络带宽或未压缩 | 看传输耗时占比 | 开启压缩和分层传输 |
这张表是我从多次排查中总结的,基本覆盖了80%的常见问题。遇到新问题时,先对照这张表缩小范围,再深入分析。
实操心得:排查延迟问题一定要分阶段打点。TTFT(首token时间)和TPOT(每token时间)要分开监控,否则根本定位不到是Prefill还是Decode的问题。我见过太多团队只监控端到端延迟,结果排查时两眼一抹黑。
7. 几个容易踩的坑和我的应对经验
第一个坑是盲目追求PD分离。PD分离不是银弹,它引入了KV传输开销和架构复杂度。如果业务是短prompt短输出,或者流量很小,单体架构反而更简单高效。我一般建议先压测单体架构,确认瓶颈确实在PD争抢上,再考虑分离。
第二个坑是KV Cache量化一刀切。不同层对量化的敏感度不一样,有些层量化后精度掉得厉害。可以做分层量化,敏感层保持FP16,其他层量化。这个需要一些实验,但收益明显。
第三个坑是忽略CPU内存。KV Cache换出到CPU内存时,如果CPU内存不够,会触发swap,性能直接崩掉。部署时要确保CPU内存至少能容纳峰值时换出的KV量。
第四个坑是调度器没有背压机制。请求大量涌入时,如果调度器无限制接收,会导致队列爆炸、延迟飙升。要有背压,队列满了就拒绝或降级,保护系统稳定。
最后分享一个我常用的压测方法:用不同长度的prompt和输出组合,画一张二维的性能热力图。横轴prompt长度,纵轴输出长度,颜色表示延迟或吞吐。这张图能直观看出系统在什么区间表现好、什么区间会崩,对容量规划和参数调优非常有帮助。
这套东西我在几个项目里反复验证过,从单体到PD分离,从FP16到INT8 KV,每一步都有明确的收益和代价。核心还是那句话:先搞清楚在优化什么,再动手调参数。盲目调参不如先把账算清楚。