↓ Skip to main content
  1. Posts/

Prefill/Decode 分离:为什么调度优化还不够?

·5730 words·12 mins
Note: This article is available in Chinese only. 本文暂无英文版本。 View original

PagedAttention 那篇写完之后,我觉得 KV Cache 的内存管理问题算是理清了——分页、按需分配、Block Table 映射,思路很干净。末尾我提到下一步要看 Continuous Batching 和 Chunked Prefill,也就是在多个并发请求之间怎么调度 GPU 的计算资源。

看完之后确实觉得调度这条线已经做得很细了。Continuous Batching 让请求可以动态进出执行集合,Chunked Prefill 把长 Prefill 拆成小块和 Decode 混合执行,Speculative Decoding 从另一个角度加速 Decode 阶段。

然后我看到了 DistServe(OSDI 2024)和 NVIDIA Dynamo 的 PD Disaggregation——直接把 Prefill 和 Decode 拆到不同的 GPU 上去跑。

第一反应是:这也太重了吧?KV Cache 跨 GPU 搬运、请求在不同机器之间流转、资源池要分开管理。我们不是已经有 Chunked Prefill 了吗,为什么还要搞这么大动静?

花了些时间想明白之后,发现这个问题的答案不在于某个调度算法够不够好,而在于共享 GPU 这件事本身有一个结构性的限制。

同一张 GPU 上的两类 SLO
#

先回到一个具体场景。

假设一张 GPU 正在为 20 个请求执行 Decode。每个请求每一步只处理一个新 Token,计算量不大,20 个请求拼在一起做一次 Forward,整体延迟可能在几毫秒量级。用户感受到的是稳定的、逐 Token 的流式输出。

现在来了一个新请求,Prompt 有 8192 个 Token。

之前写过,Prefill 需要一次性处理整段 Prompt——8192 个 Token 要在每一层 Transformer 中全部过一遍。这个计算量比 20 个 Decode Token 加起来大很多。

问题来了:这个 Prefill 和那 20 个 Decode 怎么在同一张 GPU 上共处?

如果先跑完整个 Prefill 再继续 Decode,那 20 个正在等待下一个 Token 的用户就会感受到一段明显的卡顿——Inter-Token Latency(ITL)出现了一个突刺。这 20 个用户可能正盯着屏幕等输出,哪怕只有 50ms 的停顿体验也会变差。

如果严格优先保障 Decode,让新请求的 Prefill 排队等所有 Decode 空闲才执行,那新用户要等很久才能看到第一个输出 Token。Time To First Token(TTFT)被拉长了。

这两个指标——ITL 和 TTFT——对应的是两类不同的用户体验:

  • ITL 衡量的是生成过程中每个 Token 之间的间隔。用户已经开始看输出了,中间突然卡一下很明显。P99 Tail ITL 往往是流式场景的硬约束。
  • TTFT 衡量的是从请求到达到第一个 Token 返回的时间。用户提交了请求,还没看到任何输出,等太久会觉得系统没反应。

两者的 SLO(Service Level Objective)目标经常冲突:压低 ITL 需要 Decode 尽量不被打断,压低 TTFT 需要 Prefill 尽快得到执行。在同一张 GPU 上,这两个目标争夺的是同一套计算资源。

需要说明,Prefill 通常更偏 compute-bound,Decode 在小 Batch 下更偏 memory-bound,但这不是绝对的——大 Batch Decode、短 Prompt Prefill、不同的模型结构和硬件配置都可能改变瓶颈的位置。不过无论具体瓶颈在哪,只要两者共享同一张 GPU,资源竞争就存在。

Chunked Prefill 缓解了什么,没解决什么
#

我最初以为 Chunked Prefill 已经把这个问题处理得差不多了。

思路很直接:既然一整个 8192 Token 的 Prefill 太长会阻塞 Decode,那就把它拆成小 chunk——比如每次只处理 512 个 Token。Scheduler 在每一步可以选择:执行一批 Decode Token,或者执行一个 Prefill Chunk,甚至在同一次 Forward 中把 Decode Token 和 Prefill Chunk 混在一起处理。

和没有 Chunked Prefill 的情况相比,改善是明显的。原来一个 8192 Token 的 Prefill 可能一口气占据 GPU 几十毫秒甚至更久,期间所有 Decode 都停着。拆成 512 Token 的 chunk 之后,每个 chunk 的执行时间短得多,Decode 有机会在 chunk 之间插入执行,Tail ITL 被压低了。

三种执行模式对比:无 Chunked Prefill、Chunked Prefill、PD 分离

但我后来意识到一个问题:Chunked Prefill 改善的是调度粒度,不是资源隔离。

拆成更小的 chunk 意味着 Scheduler 有了更多的调度决策点——每处理完一个 chunk 都可以重新决定下一步做什么。但不管 Scheduler 怎么安排,Prefill 和 Decode 执行的时候用的是同一组 SM(Streaming Multiprocessors)、同一块 HBM、同一条内存总线。

而且 chunk 变小有自己的代价。一个 Prefill 请求无论怎么拆,它的全部 Prompt Token 必须处理完才能预测出第一个输出 Token——首个 Token 的 Logits 依赖最后一个 Prompt 位置的隐藏状态。Chunk 越小,单个 chunk 的执行时间越短,但 chunk 的数量越多。每两个 chunk 之间可能被插入 Decode 执行,还有调度本身的开销。这些累积起来,完整 Prefill 的端到端时间(也就是 TTFT)可能反而增加。

这就形成了一个两难:

  • Chunk 太大:Prefill 单次占用时间长,Decode 被阻塞,ITL 抖动。
  • Chunk 太小:调度开销增加,Prefill 被频繁打断,TTFT 拉长。

调参可以找到一个平衡点,但找不到一个让两边都满意的点——因为底层资源是共享的。在同一张 GPU 上,Prefill 执行的每一个 cycle 都是 Decode 无法使用的 cycle,反过来也一样。 Chunked Prefill 让这种竞争变得更细粒度、更可控,但不能消除竞争本身。

想到这里,问题的性质就变了:既然共享 GPU 意味着持续存在资源竞争,那能不能让 Prefill 和 Decode 从架构上不再共享同一套执行资源?

从调度到隔离:PD 分离的架构
#

PD 分离(Prefill/Decode Disaggregation)做的事情很直接:把 Prefill 和 Decode 拆到不同的 GPU 上执行。

系统中有两类 Worker:

  • Prefill Worker:专门负责处理新请求的 Prompt。接收完整 Prompt,执行 Prefill,产生全部层的 KV Cache,并预测第一个输出 Token。
  • Decode Worker:专门负责自回归生成。接收 KV Cache,从第一个输出 Token 之后继续逐步生成,直到完成。

一个请求的生命周期变成了:用户请求到达 Router → Router 分发到一个 Prefill Worker → Prefill Worker 处理完 Prompt 后把 KV Cache 传输到一个 Decode Worker → Decode Worker 接手后续生成 → 流式返回给用户。

PD 分离架构与跨 GPU 的 KV Handoff

这里面最关键的环节是 KV Cache Handoff。

以前 Prefill 和 Decode 在同一张 GPU 上,KV Cache 已经在本地显存里,Decode 阶段直接读就行。现在跨了 GPU,Prefill Worker 算出来的 KV Cache 必须传输到 Decode Worker 的显存中。

结合 PagedAttention 那篇的知识,这个传输过程有一个细节值得注意。两端都使用 Block Table 做逻辑到物理的映射,但 Physical Block ID 在两张 GPU 上是各自独立分配的:

1Prefill GPU:  Logical Blocks [0,1,2,3] → Physical Blocks [7,1,9,5]
2Decode  GPU:  Logical Blocks [0,1,2,3] → Physical Blocks [3,8,2,11]

逻辑 Block 的顺序(也就是 Token Position 的顺序)必须对应正确——Block 0 里存的是 Token 0-3 的 KV,这个语义两边一致。但物理存放位置完全不同,各自的 Block Allocator 独立管理。传输需要保证的是 KV 内容和逻辑 Token Position 的对应关系,不要求两端的 Physical Block ID 相同。

具体的传输手段——GPU P2P、NVLink、GPUDirect RDMA——取决于硬件拓扑和网络配置,这不是本文要展开的。

PD 分离之后,两类 Worker 拥有独立的 GPU 资源池。Prefill Worker 的 GPU 可以全力跑大 Batch 的 Prompt 处理,不用担心打断任何 Decode。Decode Worker 的 GPU 只做逐 Token 生成,不会被突然插入的长 Prefill 抢占。两个池的容量可以根据工作负载分别配置——如果 Prefill 压力大(长 Prompt 多),多分几张 GPU 给 Prefill;如果 Decode 压力大(并发生成的请求多),多分几张给 Decode。

PD 分离的本质是把调度层面的时分复用,变成了物理层面的资源隔离。KV Transfer 是为这种隔离支付的通信代价。

分离的成本
#

分离不是免费的。我从三个方面想了想它的代价。

KV Transfer 本身有多贵
#

沿用之前文章的配置算一笔账:

  • Transformer Layers = 32
  • KV Heads = 8(GQA)
  • Head Dimension = 128
  • Prompt Length = 8192
  • BF16(2 bytes per element)

一个请求的完整 KV Cache:

$$ M_{KV} = 2 \times 32 \times 8192 \times 8 \times 128 \times 2 = 1{,}073{,}741{,}824 \text{ bytes} \approx 1 \text{ GiB} $$

如果有效传输带宽是 25 GiB/s,理想传输时间下界约 40ms。

这 40ms 不算短。作为对比,一个 Decode Step 在低延迟配置下可能只要几毫秒。

这里顺便可以看到 GQA 的另一层好处:如果用 MHA(32 个 KV Heads),同样的 Prompt 长度下 KV Cache 会是 4 GiB,传输时间也翻四倍。GQA 不仅降低了 KV Cache 的显存占用和读取带宽,也降低了 PD 分离场景下的跨 GPU 通信成本。

需要强调:上面的数字都是教学假设,不是真实系统 benchmark。实际传输带宽受硬件拓扑、协议开销、并发竞争等多种因素影响。

通信与计算的重叠不等于通信免费
#

一个自然的想法是:KV Transfer 可以和计算重叠。

假设 Decode Worker 正在为 20 个请求做 Decode,同时通过网络接收第 21 个请求的 KV Cache。Decode 计算不需要停下来等传输完成——传输和计算可以并发推进。

多请求 Pipeline:Prefill、Transfer、Decode 并发

这确实有效。但需要区分两个概念:

Communication-Compute Overlap 不等于 Communication Cost = 0。

传输虽然可以"藏"在计算时间里不额外增加延迟,但它仍然消耗真实的物理资源:

  • HBM 带宽:接收 KV 数据要写入 Decode GPU 的显存,这些写操作占用 HBM 带宽。如果 Decode 本身已经是 memory-bound 的,额外的内存写入会加剧带宽竞争。
  • GPU 显存容量:在 KV Transfer 完成之前,Decode GPU 需要为即将到来的 KV 预留空间。这减少了可用于服务其他请求的显存。
  • 网络带宽:传输链路本身是有限的。多个请求同时做 KV Transfer 会争夺带宽。

还有一个关于 TTFT 的细节。如果 Prefill Worker 在完成 Prefill 后可以直接把第一个预测 Token 返回给用户,那 TTFT 不需要等待 KV Transfer 完成。但后续 Decode 依赖 KV Cache 已经到位——如果 Transfer 还没完成,Decode Worker 就无法开始生成第二个 Token。

同时需要区分单请求的 Handoff 延迟和系统的稳态吞吐。对于单个请求,KV Transfer 的时间确实是额外开销。但从系统整体看,多个请求的 Prefill、Transfer、Decode 可以流水线化:Request A 正在 Decode,Request B 的 KV 正在传输,Request C 正在 Prefill——它们使用的是不同的硬件资源(不同的 GPU 和网络链路),可以并发推进。

但即使 Pipeline 排满了,每一级的吞吐仍然受限于该级最慢的资源。如果每个请求产生 1 GiB 的 KV,网络链路只有 25 GiB/s,那这条 Transfer 链路最多支持每秒 25 次 KV Handoff。Overlapping 可以隐藏延迟,但不能突破 Pipeline 中任何一级的带宽上限。

资源池划分的利用率问题
#

即使假设 KV Transfer 完全免费——零延迟、零带宽占用——PD 分离仍然可能不如混合执行。

用两张 GPU 举个例子:

方案 A(混合):两张 GPU 都能执行 Prefill 和 Decode。

方案 B(分离):GPU 0 专做 Prefill,GPU 1 专做 Decode。

假设某段时间内新请求很少(比如凌晨低谷),但有大量旧请求仍在 Decode。方案 A 可以让两张 GPU 都承担 Decode 工作。方案 B 的 Decode GPU 满载运行,但 Prefill GPU 基本空闲——它只能做 Prefill,而此时没什么 Prefill 可做。

反过来,如果突然涌入大量新请求(长 Prompt 密集),方案 B 的 Prefill GPU 可能忙不过来,而 Decode GPU 因为还没积累太多活跃请求而有余量。

资源隔离带来了确定性,但也牺牲了灵活性。 混合方案的 GPU 可以根据当前负载动态地在 Prefill 和 Decode 之间切换,分离方案的每张 GPU 只能做被分配的那种工作。

这不只是通信成本的问题,而是资源分区与负载匹配的问题。当然,可以通过动态调整 Prefill/Decode Pool 的大小来缓解——但那又引入了一层额外的调度复杂度。

什么时候值得做 PD 分离
#

到这里,我不再觉得 PD 分离是一个"用了就更好"的优化。它更像一个架构决策,需要看具体的工作负载和 SLO 约束。

场景 A:长 Prompt 密集,P99 ITL 要求严格。

比如 RAG 场景,每个请求带几千到上万 Token 的检索上下文。即使用了 Chunked Prefill,高频到达的长 Prompt 仍然会持续抢占 Decode 的执行资源,Tail ITL 很难压到满足 SLO。PD 分离让 Decode Worker 完全不受 Prefill 干扰,P99 ITL 更容易达标。这种场景下分离的收益很可能超过 KV Transfer 的成本。

场景 B:短 Prompt,请求量不大。

Prompt 只有几十到几百 Token,Prefill 本身几毫秒就完成了,对 Decode 的干扰很小。Chunked Prefill 甚至都不一定需要——Prefill 太短了,chunk 都不用拆。这时候引入 PD 分离的通信开销和资源池管理复杂度,大概率是得不偿失的。

场景 C:Prefill 和 Decode 的负载比例随时间明显变化。

比如白天大量用户同时发起请求(Prefill 密集),夜间请求少但长对话仍在 Decode。静态的 Prefill/Decode 资源池划分会导致峰谷错位——白天 Prefill GPU 不够用,夜间又空着。这种场景需要进一步考虑资源配比的动态调整,但那已经超出本文的范围了。

这里有一个概念值得提一下:Goodput——DistServe 论文强调的核心指标。传统吞吐指标(Tokens/s)不区分是否满足了用户的 SLO 要求。一个系统可以跑出很高的 Tokens/s,但如果大量请求的 TTFT 或 ITL 超出了 SLO,那些请求对用户来说是"无效"的。Goodput 衡量的是在 TTFT 和 ITL 的 SLO 约束下,系统能稳定完成多少有效请求。 PD 分离的核心价值不在于提高原始吞吐,而在于通过资源隔离让系统更容易同时满足两个 SLO。

从单 GPU 优化到分布式 Serving
#

回看这一路的演进:

一开始,我们在同一张 GPU 上用 Continuous Batching 让请求动态进出,用 Chunked Prefill 把长 Prefill 拆碎来降低对 Decode 的干扰。这些都是在共享 GPU 上做更精细的调度——本质是更聪明的时分复用。

后来发现,更好的调度不等于真正的资源隔离。调度可以降低干扰的程度,但只要两类任务共享同一套物理资源,竞争就不可能完全消除。当 SLO 约束足够严格、工作负载差异足够大时,调度层面的妥协空间就不够用了。

PD 分离选择了另一条路:改变资源的组织方式,让不同类型的工作负载在物理上隔离。但这种隔离不是免费的——它把原本的本地资源竞争,转换成了跨 GPU 的通信成本和资源池的容量规划问题。

这个 pattern 在系统设计里其实很常见。微服务拆分也是类似的逻辑:一个 monolith 里不同模块争夺 CPU 和内存,拆成独立服务后各自有自己的资源配额,但引入了网络通信和服务编排的复杂度。CPU 上的 I/O-Compute 重叠也是一样——把计算和数据搬运在时间上重叠确实能隐藏延迟,但搬运本身消耗的带宽和资源不会因此消失。

我觉得这篇最值得记住的三个认知是:

Scheduling ≠ Isolation。 更精细的调度可以降低不同工作负载之间的干扰,但只要共享物理资源,干扰就有下界。调度优化和资源隔离解决的是不同层次的问题。

Overlapping ≠ Free。 通信与计算的时间重叠可以隐藏延迟,但不能消除带宽和资源的实际消耗。Pipeline 的稳态吞吐受限于最慢的那一级。

Disaggregation ≠ Always Faster。 架构分离是一种成本与收益的权衡。它在 SLO 约束严格、工作负载异构性高的场景下可能显著提升 Goodput,但在负载轻、干扰小的场景下,额外的通信和管理开销可能反而拖累系统。

PD 分离不是一个新的 GPU Kernel 优化,而是推理系统在性能目标冲突下,对资源边界做出的一次架构选择。

参考资料
#

Related

PagedAttention:为什么 KV Cache 需要自己的内存管理系统?

前一篇写完之后,KV Cache 在我脑子里的形象已经变了——它不再是一次 forward 里的临时中间量,而是一个跨多轮存活、随 Decode 持续增长、每个请求各自独立的状态。 但"知道它是有状态的"和"知道怎么管它"是两件事。当我开始想具体的内存管理问题的时候,发现事情比想象的复杂。

从一次 Forward 到持续生成:LLM Inference 为什么不一样?

最近在从传统模型 Serving 转向 LLM Inference,一个让我停下来想了很久的问题是:为什么 LLM 的推理系统看起来和我之前熟悉的那套完全不一样? 我之前做推荐模型和 CV 模型的部署,排序模型的 Serving 架构虽然在工程上也有不少复杂性(远程 embedding 读取、稀疏参数缓存等),但核心计算模式不难概括:一个请求进来,跑一次 forward,返回结果。LLM Serving 里冒出来一大堆新概念——推理要分成 Prefill 和 Decode 两个阶段,中间要维护 KV Cache,请求调度要做 Continuous Batching,显存管理要用 PagedAttention——我花了些时间才把它们背后的因果链串起来。

从投机解码到 Rand5 模拟 Rand7:跨越十年的拒绝采样直觉

上一篇理清了 Prefill 和 Decode 的因果链之后,我开始翻 Decode 阶段的加速方案。结论已经很清楚了:在单请求、小 Batch 的典型场景下,Decode 阶段是 memory-bound 的——每生成一个 token 就要把整个模型权重从显存搬一遍,计算单元大部分时间在等数据(Batch 较大或模型结构不同时,瓶颈也可能转向 compute-bound,但单次请求的延迟优化场景下 memory-bound 是常态)。