做推理优化做到一定阶段,迟早会遇到一个诡异的现象:GPU利用率看着挺高,用户还是嫌慢;明明吞吐也上去了,个别请求的延迟却忽高忽低。如果你也在排查这类问题,多半会撞上“PD分离”这个词——也就是 Prefill/Decode 分离。我写这篇东西,是想把 PD 分离的来龙去脉、三种实现形态、KV Cache 传输的隐藏成本,以及在实际部署里踩过的坑一次讲清楚。适合正在做推理服务优化、或者准备把单机服务改造成多机推理集群的工程师参考,读完你至少能判断自己到底需不需要上这套东西。
1. 先算算账:Prefill 和 Decode 为什么注定要打架
1.1 Prefill 是算力密集型,一块 GPU 能被它“吃满”
Prefill 阶段处理的是用户输入的 prompt。模型要一次性把整段 prompt 的所有 token 并行算一遍,生成完整的 Key、Value,填充 KV Cache。这个阶段的特点是:token 多、批量大、矩阵乘法的计算量非常集中。
以 Llama-3-8B 为例,粗略估算,模型每处理一个 token,forward 计算量大约在 16 GFLOPs 量级(约等于 2 倍参数量)。一个 2048 token 的 prompt,prefill 的总计算量大概就是 2048 × 16 GFLOPs ≈ 33 TFLOPs。A100 80G 的 FP16 稠密算力在 312 TFLOPS 左右,理论上一口气算完只要 0.1 秒左右;实际因为 kernel launch、数据搬运、中间激活落显存等开销,通常会到 0.2 到 0.3 秒,但整体依然是把 GPU 的算力按满来跑。
这就像让一个阅读者快速把一整页书扫完,他一口气能吸收大量信息,但这段过程里 CPU 也好 GPU 也好,算力都被高度占用。prefill 是典型的 compute-bound 任务,算力不够就是不够,靠增加带宽解决不了问题。
1.2 Decode 是访存密集型,算力根本用不满
Decode 阶段是逐 token 生成。每生成一个 token,模型都只做一次“小步快跑”的计算:根据当前已生成的 token,取对应 embedding,经过全部 transformer 层,走一遍注意力,最后输出下一个 token 的 logits。
问题在于,每一步计算量不大,但你依然要把完整的模型权重从 HBM 搬进寄存器或片上缓存。还是以 Llama-3-8B 为例,FP16 权重大约 16GB,A100 的 HBM 带宽约 2TB/s,光把权重过一遍的理论时间就到了 8ms 左右。所以 decode 阶段的算力利用率很低,瓶颈在显存带宽,是典型的 memory-bound 任务。
这个阶段更像一个抄写员,一次只能写一个字,写这个字的时间有一大半花在“翻书找素材”上。你试图让抄写员更快,加再多人帮忙算,他手头的纸得先从柜子里拿出来,效率照样上不去。
1.3 混在一起的必然结果:互相拖后腿,P99 失控
如果 prefill 和 decode 混在同一批、共用同一块 GPU,矛盾马上就出来了。一个用户提交了 8K 的长 prompt,prefill 要狂吃算力,可能持续几秒甚至更久;期间其他用户正在 decode 的请求全部排队,等待中的每个 token 都出不来。
我曾经处理过一个线上案例:单机部署、P50 延迟看着只有 200ms,但 P99 能飙到 5 秒以上。查了半天,罪魁祸首就是每过一阵子就有大 prompt 进来做 prefill,算力被抢占后,所有正在生成的请求全部被堵住。长尾延迟就是这么被拉出来的。
混布还会带来第二个问题:显存和 batch 调不动。decode 阶段为了吞吐,希望 batch 尽可能大;但 prefill 阶段一个长 prompt 就要占大量中间激活和 KV Cache,batch 一大显存直接爆。这不是一个参数能救回来的,是任务特征本身就在互相消耗。
2. PD 分离不是一种技术,是一套解耦思路
2.1 核心思想:让计算密集型的活儿和访存密集型的活儿分家
PD 分离的核心思路很简单:把 prefill 和 decode 两个物理特征完全不同的任务拆开,放到不同的资源上执行。这样 prefill 节点可以选高算力卡,decode 节点可以选大显存、高带宽卡,互不拖累,还能各自扩缩容。
这里要先澄清一个常见误区:PD 分离不等于多机部署,也不等于 Chunked Prefill。它是一个广义的解耦思路,下面至少分化出三种形态:实例级完全分离、同节点时间片交错、混合资源池动态调度。不同形态解决的痛点不同,代价也不同。
理解 PD 分离的关键在于:它不是为了让每个请求更快,而是为了让“最慢的那批请求”不再被“最大的那个请求”拖死。换句话说,优化目标是 P99,不是 P50。
2.2 形态一:实例级完全 PD 分离
实例级完全分离是把 prefill 和 decode 部署成两套独立服务。用户请求先到达 Decode 服务,Decode 服务如果没有对应的 KV Cache,就把 prompt 转发给 Prefill 服务;Prefill 服务算完 prompt 的 KV Cache 后,通过网络把 KV Cache 传给 Decode 服务,Decode 再开始逐 token 生成。
这套方案的好处是隔离最彻底:算力竞争被物理隔开,长 prompt 再大,也只影响 Prefill 节点,不会拖累 Decode 节点。两套节点可以独立扩缩容,读多场景给 Decode 多挂实例,写多场景给 Prefill 加算力。
缺点也很直接:增加了网络传输和额外的一跳延迟;KV Cache 的传输如果带宽不够,整个链路反而更慢。另外,小流量场景下多出一批 Prefill 节点,资源开支明显上升,单个请求的生成延迟也未必比混布更好。
2.3 形态二:Chunked Prefill 时间片交错
Chunked Prefill 是我个人最常用的入门方案,它不把任务分配到不同机器,而是把 prefill 请求切成多个 chunk,在同一个 GPU 上按时间片和 decode 请求交错执行。
比如一个 2048 token 的 prompt,如果 chunk size 设为 256,prefill 就会被切成 8 个 chunk。每个 chunk 计算量小,计算完一个 chunk 就把 GPU 让出来给 decode 跑一小段,再继续下一个 chunk。这样即便有大 prefill 进来,decode 的 token 也只是被短暂打断,而不是被堵住几秒钟。
vLLM 里实现 Chunked Prefill 的关键参数是max-num-batched-tokens,它限制的是每个 batch 里参与 attention 的 token 总数。开启 chunked prefill 后,长 prefill 会被切割到不超过这个数字的块里。实际调参时这个值我一般从 128 起试,最长别超过 512,具体原因后面实操部分细说。
2.4 形态三:混合资源池与动态调度
第三种形态介于前两者之间,本质是资源池化管理:你有一批 GPU,一部分偏算力、一部分偏显存带宽,调度器根据请求特征动态决定去哪儿执行。
简单做法是网关层做优先级分流:小请求直接进 decode 优先队列,大 prefill 请求进专门的 prefill 队列。更复杂的做法是像多家推理框架的“disaggregated serving”一样,把节点编排成 P 池和 D 池,再通过 KV Cache 亲和度调度,让相同前缀的请求尽量命中同一个节点。
这个形态的好处是资源利用率最高,坏处是调度和网络组件的复杂度上了一个台阶。如果团队还在单机推理阶段,我通常不建议直接上第三形态;先从 chunked prefill 开始,确认瓶颈点确实在 prefill/decode 互扰后,再考虑投入做完全分离。
2.5 三种形态的选型对比
| 形态 | 隔离程度 | 额外成本 | 典型场景 |
|---|---|---|---|
| 实例级完全 PD 分离 | 最强,算力与访存互不影响 | 显著增加机器与网络开销 | 大并发、长 prompt 占比高、SLO 严格 |
| Chunked Prefill | 同节点时间片交错,降低互扰 | 几乎为零,只需调参 | 单机/少机部署,入门首选 |
| 混合资源池动态调度 | 灵活,可按负载动态配置 | 调度器与网络复杂度较高 | 多机集群、请求特征复杂 |
我的判断标准很简单:如果线上 P99 和 P50 差距极大,先开 chunked prefill;如果开了之后依然压不住 P99,且请求量足够大,再考虑实例级完全分离。不要一上来就堆机器,很多系统的问题并不在多机,而在调度。
3. 实操:把 Chunked Prefill 配置起来并验证效果
3.1 怎么开启、调哪些参数
以 vLLM 为例,chunked prefill 通常不需要改代码,启动服务时加开关、调参数就行。核心参数有两个:--enable-chunked-prefill和--max-num-batched-tokens,前者负责开启 chunked 逻辑,后者负责控制 chunk 大小上限。
不同版本里参数名可能略有差别,有些版本默认开启,有些版本需要显式设置,所以配置前一定先看当前版本的文档。我个人建议不要偷懒,每次调整参数后顺便看一眼启动日志里的配置确认是否生效,这也是排查延迟问题时最容易被忽略的一步。
除了这两个参数,还需要配合调整max-num-seqs,它控制的是单 batch 里最多同时处理多少个序列。chunk size 减小以后,如果不限制序列数,显存里的中间解耦结构可能会被塞爆。一般经验是:max-num-batched-tokens从 256 开始,max-num-seqs保持默认或略降一点,先用压力测试跑一轮,再根据 TTFT 和 TPOT 微调。
3.2 两个关键指标:TTFT 与 TPOT
验证 PD 分离效果,不能只看平均延迟。我建议盯住两个指标:TTFT(Time To First Token,首 token 延迟)和 TPOT(Time Per Output Token,每输出一个 token 的平均耗时)。
TTFT 反映的是 prefill 阶段和排队阶段的整体表现,长 prompt 请求卡顿的时候,TTFT 会明显拉高;TPOT 反映的是 decode 阶段的稳定程度,如果 decode 被 prefill 抢占,TPOT 的波动会非常大。
压测时至少记录 P50、P95、P99 三个分位点。只看 P50 会掩盖长尾问题,只看平均值更是自欺欺人。我在本地压测时习惯输出如下信息:
| 指标 | 分位点 | 说明 |
|---|---|---|
| TTFT | p50 / p99 | 判断 prefill 阻塞程度 |
| TPOT | p50 / p99 | 判断 decode 被干扰程度 |
| 端到端延迟 | p99 | 综合判断用户体验 |
3.3 我跑过的一组基准与参数选择心得
我拿一台 8 卡 A100 做过测试,模型是 Llama-3-8B,prompt 长度 2048,输出长度 256,并发 32 路。混合部署时 P99 TTFT 到了 2.4 秒,TPOT 波动也很厉害;开启 chunked prefill、chunk size 设为 256 后,P99 TTFT 降到了 700ms 左右,TPOT 的 P99 也稳了很多。
后续我又把 chunk size 调到 512,发现 TTFT 略有上升,TPOT 反而变得更不稳,原因是单个 chunk 还是太长,decode 每轮等的时间变久了。调回 256 后整体最均衡。如果你的并发更高、prompt 更长,可以试试把max-num-batched-tokens控制在 128 到 384 之间,不要盲目追求单次算更多 token。
还有一个很实用的小技巧:观察 TTFT 和 TPOT 的比值。正常情况下,对于 2K prompt、8B 模型,TTFT 应该在 TPOT 的十几倍以内;如果 TTFT 是 TPOT 的几十倍甚至上百倍,说明 prefill 阶段的阻塞非常严重,这正是需要进一步做 PD 分离的信号。
4. 进阶:实例级 PD 分离的真实成本——KV Cache 传输
4.1 KV Cache 到底有多大?先会算这笔账
实例级 PD 分离有一个绕不开的硬成本:KV Cache 从 Prefill 节点传到 Decode 节点。很多人低估了这笔开销,我先给个公式,你可以套自己的模型算:
单个 token 的 KV Cache 大小 = 2 × 层数 × KV 头数 × 头维度 × 精度字节数
以 Llama-3-8B 为例:32 层、8 个 KV 头、头维度 128、FP16 占 2 字节,算下来每个 token 的 KV Cache 是 2 × 32 × 8 × 128 × 2 = 131072 字节,正好 128KB。一个 2048 token 的 prompt,KV Cache 就是 256MB。如果并发 32 路,光这批 KV Cache 就接近 8GB。
别小看这个数。8B 模型权重才 16GB,KV Cache 随并发和长度增长后,很容易把显存吃穿。更关键的是,256MB 的 KV Cache 要跨节点传。机房内网如果是 10Gbps,传一次就是 200ms 以上;100Gbps 网络也要 20ms,对于 TTFT 本来就要求几百毫秒的服务来说,这笔开销非常可观。
4.2 为什么不能简单地把 KV 都放在 Decode 节点
有人会问,既然 KV Cache 是 decode 要用的,直接在 decode 节点算不就行了?那就回到了混合部署的起点。另一条思路是 Prefill 节点算完 KV Cache 后把完整 KV 长期缓存在本地,decode 节点需要时再取。这个思路的问题在于显存隔离:如果所有 KV Cache 都堆在 decode 节点,decode 的显存很快就会被长 prompt 的大请求占满,反而限制 decode 并发。
所以实际部署里,KV Cache 需要在节点间流动。要么按需传输,要么做分区缓存。分区缓存又涉及调度:同一前缀的请求要尽量打到一个节点,否则每次都要重新传 KV,网络损耗会被放大。
4.3 降低传输开销的四个手段
第一,前缀缓存复用。如果很多请求共享同一段系统 prompt 或文档前缀,Prefill 节点可以直接复用之前算好的 KV Cache,不需重新计算,也不需重新传输。这就是 Prefix Caching 的意义。vLLM 里开启后,命中缓存时 TTFT 能降低一个数量级。
第二,KV Cache 量化。FP16 换成 FP8,KV Cache 尺寸直接砍半,传输量也砍半。代价是精度略降,但很多场景下对生成质量影响很小,值得做 A/B 对比测试。
第三,流式传输。Prefill 节点不需要等一整段 prompt 全部算完再传 KV,它可以边算边推。decoder 节点边收边开始生成。这样网络延迟被计算时间隐藏掉一截,启动延迟能明显下降。
第四,KV Cache 亲和调度。调度器尽量把相同前缀或相同用户的请求分配到同一个 decode 节点,让复用概率最大化。这个方案在集群规模大时收益明显,但实现复杂度也最高,适合已经上了资源池的团队。
4.4 一个人就能做的成本估算
即使不上完整系统,你也能先估算 PD 分离的收益。我把流程记在这里,方便大家照抄:
- 统计线上请求的平均 prompt 长度(P50、P95、P99);
- 按上面的公式算出平均 KV Cache 大小、P95 KV Cache 大小;
- 用你的机房内网带宽除以 KV 大小,得到单次 KV 传输延迟;
- 再对比当前混布时的 P99 TTFT。如果混布 P99 TTFT 本身就比单次 KV 传输延迟高几倍,PD 分离大概率有收益;如果混布延迟已经很好,纯粹为了“新架构”去做分离,多半会亏。
我当时就是这个方法:线上 P99 TTFT 接近 2 秒,KV 传输估算只有 30ms 左右,差距巨大,才下定决心推进分离。先把这笔账算清楚,比自己拍脑袋上架构靠谱得多。
5. 常见问题排查与踩坑实录
5.1 P99 延迟还是高?一套排查顺序
如果你开了 chunked prefill,P99 依然居高不下,我的排查顺序是这样的:先看 TTFT 高还是 TPOT 高。TTFT 高就往 prefill 侧看:chunk size 是不是调太大、前缀缓存命中率是不是太低、排队请求是不是太多。TPOT 高则往往意味着 decode 节点自身算力或带宽不够,甚至可能被某个异常长请求的资源占用拖住。
另一个容易踩的坑是:chunked prefill 开了,但日志里根本没生效。很多框架的参数名在不同版本里直接变了,或者要同时满足别的条件才会真正启用。我习惯在启动日志里搜索 “chunked” 和 “token” 相关字段,确认实际生效再继续调参。
5.2 显存不够用的处理思路
显存不够时,很多人的第一反应是调低gpu_memory_utilization,把整体显存比例降下来。这个方向没问题,但更值得先检查 KV Cache 限额是不是被长 prompt 地址打满了。你可以为不同长度请求设置不同的并发上限,或者把 KV Cache 量化到 FP8,通常能救回不少显存。
如果显存实在紧张,另一个选择是 offload 冷 KV Cache 到 CPU 内存或远端存储。代价是请求命中冷缓存时需要重新加载,延迟会波动。我建议只在冷热请求比例很悬殊时才做 offload,否则收益不划算。
5.3 PD 分离会影响模型精度吗
早点把这个疑问解决掉:chunked prefill 在数学上不会改变模型生成结果。attention 计算中,对同一个 query,所有 key/value 的加权求和是确定的,chunked 只是沿序列维度切分后用 online softmax 方式合并,浮点累加顺序会有细微误差,但对生成结果的影响可以忽略。实例级完全 PD 分离也只是把 prefill 和 decode 的执行位置分开,模型权重和计算方式没变,结果一致。
要注意的是,如果你同时开了 KV Cache 量化、权重量化那类技术,那属于另外的精度话题,和 PD 分离本身无关。排查问题时不要把两者混为一谈。
5.4 什么时候别上 PD 分离
最后说点反直觉的经验:不是所有系统都适合 PD 分离。如果你服务的是内部低并发场景,prompt 很短,并发不超过 8,那混布可能已经很好了,强行分离只会增加机器成本、网络链路和运维复杂度。
另外,如果请求长度差异不大,prefill 和 decode 的矛盾本身就小,PD 分离带来的收益也很小。我见过一个团队花了三周做实例级分离,最后发现他们 P99 高主要是有一路定时任务在凌晨发大量长 prompt,错峰就能解决。分离翻倍了成本,收益却没体现。
所以我的底线建议是:先做负载画像,统计请求长度分布、到达速率、P99 与 P50 的比值;再决定要不要做、做成哪种形态。很多问题动调度器就能解决,动架构是最后的手段。
聊到最后,分享一下我的个人体会:PD 分离本质上是资源编排问题,不是单纯的技术升级。它把两个物理特征完全不同的计算任务拆开,解决的是互扰,而不是单个请求的速度极限。我自己最失误的一次,就是没先算 KV Cache 传输开销就上了完整分离,结果网络差点成为新瓶颈。如果让我重新来一遍,我一定会先开 chunked prefill、把前缀缓存命中率调上去,再评估是否值得走最后一步。最后送大家一个小技巧:定期把 TTFT 和 TPOT 的 P99 比值画成曲线,一旦发现比值持续拉大,就说明 prefill/decode 互扰又回来了,这时候再去检查配置和调度,方向基本不会错。