Prefill/Decode 分离部署
prefill 受算力约束,decode 受带宽约束。挤在同一张卡上,两个 SLO 都达不到;那就把它们拆开,再把 KV cache 传过去。
- P/D 拆分
- KV 传输
- NIXL/RDMA
- SLO 达标
- 实例配比调优
为什么重要
问题
分块 prefill(S11)让 prefill 和 decode 在同一张 GPU 上相安无事了,但没有让它们相容。它们想要的依然是相反的东西:
- prefill 想要大 batch 和纯粹的算力。它每个请求只跑一次,一来就是一阵爆发,占用的缓存很少。
- decode 想要显存带宽和一个大 KV 池。它每个请求要跑成千上万次,而且绝不能被打断。
混合部署时,每个旋钮都是妥协。调高 token 预算来加速 prefill,token 间延迟就变差。调高并发来提升 decode 吞吐,KV 池就挤掉了 prefill 的批处理空间。首 token 时间和 token 间延迟这两个 SLO,通过同一组参数耦合在一起,你没法只修一个而不动另一个。
核心思路
解法
别再共享了。让 prefill 跑在一组机器上、decode 跑在另一组上。prefill 完成后,把 KV 缓存经网络送到某个 decode 实例,然后交接。
现在每个池子只为一件事调优,每个 SLO 有自己的扩容旋钮,两种负载谁也干扰不了谁。TTFT 不达标就加 prefill 节点;token 间延迟不达标就加 decode 节点。
async def serve(request):
p = router.pick_prefill_instance(request) # ideally cache-aware
d = router.pick_decode_instance(request)
# Tell the decode instance to allocate blocks BEFORE prefill starts,
# so the transfer can begin the moment the first layer is done.
handle = await d.reserve_blocks(request.prompt_len)
kv_ref = await p.prefill(request, push_to=handle)
async for token in d.decode(request, kv_ref):
yield token
# The KV never round-trips through the router. It moves GPU to GPU.工程量都在传输上
工作原理
工作原理
把传输藏起来
传输不必等到最后一次性完成。第 i 层的 KV 在第 i 层跑完的那一刻就已定型,因此可以在第 i+1 层计算的同时把它发出去。用逐层流式传输,除最后一层外的所有传输都被藏在 prefill 计算后面。
NIXL 和 vLLM 的 KV connector 接口正是为这件事而生:一个与计算重叠、而不是跟在计算后面的传输引擎。
如何选配比,以及为什么这很难
prefill:decode 的配比取决于负载形态。长 prompt 配短回答(分类、抽取、RAG)是 prefill 重的。短 prompt 配长回答(智能体、推理链)是 decode 重的。早上九点正确的 1:3 配比,下午三点可能就是错的。
因此生产系统会让配比动态化,随流量变化在两种角色之间重新分配实例。这套机制连同 RDMA 网络,正是分离部署只有在一定集群规模以上才划算的原因。几个节点以下它纯粹是额外开销,单池上的分块 prefill 才是更好的答案。
动手观察
动手试试
- TTFT
- 1210 ms
- token 间延迟
- 9.0 ms
- 两个 SLO 都达标
- 是
- KV 传输量
- 512 MB · 10 ms
prefill 池 · 3 张 GPU
KV 传输 · 10 ms
decode 池 · 5 张 GPU
prefill 跑在为算力调优的机器上,decode 跑在为带宽和 KV 容量调优的机器上,缓存则通过互联在两者之间搬运。现在每个 SLO 都由自己的池子单独控制。
- 两个 SLO 都达标
- 违反了某个 SLO
柱高是 TTFT。prefill 的 GPU 太少,队列就会爆炸;decode 的 GPU 太少,token 间延迟就会爆炸。绿色区间很窄,而且负载结构一变它就移动 —— 让部署一直待在这个区间里,才是分离部署真正的运维成本。
在混合模式下设一个 prompt 重的负载:token 间延迟冲破 SLO,因为 prefill 正在吃掉 decode 的步。切到分离部署,调整拆分比例,直到两个指标都变绿;可行区间很窄。然后把 KV 链路带宽降下来,这次换成 TTFT 不达标,因为传输成了瓶颈。这就是这套架构做出的取舍。
亲手实现
实现
class LayerwiseKVSender:
"""Overlap the KV transfer with the prefill that is still running."""
def __init__(self, transport, n_layers):
self.transport, self.n_layers = transport, n_layers
self.inflight = []
def on_layer_done(self, layer: int, k, v, dest_blocks):
# Layer i's KV is final the moment layer i finishes. Send it now,
# while layers i+1..L are still computing.
self.inflight.append(self.transport.send_async(layer, k, v, dest_blocks))
def finish(self):
for fut in self.inflight:
fut.wait() # only the LAST layer is actually exposed在 prefill 开始之前就预留目标 block
$ python code/s18_disaggregation.py只需要 NumPy — 查看环境准备.
生产实践
在生产环境中
- DistServe 和 Splitwise —— 确立这一想法及其 goodput 论证的两篇论文。
- vLLM —— 提供 KV connector 接口,带 NIXL 和 LMCache 两种后端;分离部署由生产栈支持,而不是由单进程引擎支持。
- NVIDIA Dynamo —— 一套完整的分离部署服务框架,支持在 prefill 与 decode worker 之间动态改派角色。
- Mooncake —— 更进一步:以 KV 缓存为中心的架构,用一个任何 decode 实例都能读取的共享缓存池,把分离部署与全局前缀缓存合二为一。
练习
- 1算出交叉带宽:在多快的互联下,KV 传输给 TTFT 带来的代价开始超过它消除掉的 prefill 干扰?答案取决于 prompt 长度,把曲线画出来。
- 2实现逐层流式传输,测量有多少传输被藏在了 prefill 计算后面。
- 3加一个动态控制器,根据实测的 SLO 违规情况在两个池之间改派实例,并展示它如何跟上一个中途从 prefill 重变成 decode 重的负载。
继续学习
接下来
引擎又快又能扩展了,但用户还是没法和它说话。S19 会构建服务层:OpenAI 兼容的流式 API、对话模板、请求取消,以及你真正拿来调优的那些指标。
自测
习题
先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。
为什么有了分块 prefill,还是需要分离部署?