https://yangwenbo.com/articles/reflections-on-prefilling-decoding-disaggregation-architecture.html
PD 分离(Prefilling Decoding Disaggregation)推理是指将大模型推理的预填充阶段(P)和解码(D)阶段分离,以减少预填充与解码相互之间的影响,以便对两个阶段分别进行优化,提升 GPU 硬件的利用率,并减少推理延迟的一种推理技术方案。
在 DistServe、Mooncake 等论文中介绍分离式架构之后,DeepSeek V3 的报告让大家更进一步意识到 PD 分离可能是影响成本和性能的关键技术。
vLLM 对 PD 分离已经有了一个 1P1D 的实验版本。除此之外的开源框架大多还都不支持,不过很多已经在计划、实现中了。但纵览这些实现、文章或者计划,可以看到 PD 分离的架构选型上有很多问题需要思考,我尝试列举一下:
PD 直连就是预填充节点直接将 KV Cache 发送给解码节点,它的好处是延迟低。但也意味着在整个 batch 的计算过程中锁定了P、D 节点的对应关系,一旦解码节点出现了问题,比如压力过大、服务出错、传输阻塞,在重试时无法仅调度 D 节点,需要重新进行整个预填充、解码过程。在 prompt 较长时,或者在 PD 节点数不对等的场景下,例如 2 个 P 对应到 1 个 D,重调度意味着抛弃较长或者多个 prefill batch,重调度的沉没成本较高。
使用 KV Cache Store/Pool 是在 P 和 D 之间增加了一个中间存储,预填充节点先将 KV Cache 写到中间存储,解码节点从中间存储读。这样做数据会多传输一次,增加了延迟,也增加了一些复杂度。但好处是容错性更好,还有就是预填充阶段本身也可以利用这个中间存储做 Prefix Caching。
中间存储也会对其它一些架构变动的复杂度产生影响,参见下面问题 四 和 五。
目前来看,Kimi Mooncake、vLLM 的下一步设计、阿里 RTP-LLM 都使用或者计划使用基于 KV Cache Store/Pool 的方案,DeepSeek V3 报告中没有提到这部分。
在一些计算配比均衡、故障风险较小的场景下,比如同机多卡之间的 PD 分离,PD 直连的方案也有其简单易部署的优势。
预填充最简单的实现是预填充节点完成第一个 token 的生成后,将所有的 KV Cache 传输给解码节点,这也是 vLLM 当前的实现。但这样实现有个问题,因为 KV Cache 的规模有可能非常大(尤其是原始 MHA),一个 batch 的 KV Cache 可能会是 GB 级别,都放在计算完成后传输,传输的延迟开销会比较大。
Kimi Mooncake 和阿里 RTP-LLM 都采取了按层传输的方案,这是利用了 LLM 多层计算的自然特性。在完成一层的计算以后,就将这一层的 KV Cache 发送出去。这样 KV Cache 的发送就呈流式,既能降低延迟,也能使数据的发送更平滑。还存在一个更显著的优势,是 KV Cache 占用显存的时间更短,在显存紧张的情况下显存效率更高。
但按层发送对推理引擎的修改显然更大。我还没有看到开源的实现,猜测按层发送的引入对推理引擎的优化应该会有一定的影响,这里可能还需要一些精巧的设计才能减少影响。另外,按层发送对于 PD 非直连的场景下,中间存储的实现也会显著更复杂,QPS * num_hidden_layers,考虑到连续性可能还需要存储预分配和 session 保持。
因此对于 MLA 这种 KV Cache 偏小的注意力实现,比如 DeepSeek V3 的 KV Cache 是 576B/token/layer,是否要做按层发送,也许要看一下实际收益。
解码阶段和预填充阶段有所不同。解码需要多次迭代,在第一次迭代实现按层解码也没太大意义,而且涉及到计算的编排,应该需要拿到所有层的 KV Cache 才会开始计算。而且解码的计算时间比较长,如果解码的计算能够掩盖接收的延迟,不一定非要实现按层接收。
解码时按层接收,对调度也有一定挑战。从时序上来说,先发请求给预填充,完成后再发请求给解码会更自然。同时请求预填充和解码,需要处理一些同步问题,比如预填充压力大、解码等 KV Cache 超时等等。比如像阿里 RTP-LLM,它会观测预填充的排队情况,当一个请求进入预填充执行阶段时,解码端开始启动显存申请。
通常来说,预填充的同时会顺便把第一个 Token 计算出来,但计算到 hidden states 还是 token id 需要做一个选择。
计算到 hidden states 的好处是,预填充节点完全不需要加载和计算 lm_head 参数。比如 DeepSeek V3 的 lm_head 参数量是 0.9B,如果计算到 hidden states,这部分参数就完全不需要加载了。vLLM 目前就是采取的这个方式,预填充除了需要发送 KV Cache 之外,还需要发送一个 hidden states,解码时引擎也需要能支持加载 hidden states 延续计算。