General Tech

穷人版 PD 分离:在消费级显卡上把 TTS 推理拆成两半

October 12, 2026

过去一周多,我几乎都泡在一件事上:给我们的 TTS 推理服务做 PD 分离(Prefill/Decode Disaggregation)。

说起来,这事的起点其实挺小的。我们早就有一个做 PD 分离的分支,一直卡在 B300 上动不了,理由永远是那一句「Mooncake 不支持 B300」。社区里也确实挂着一个 issue:Mooncake NvlinkTransport cuMemCreate failed #1297。某天我实在忍不了了,心想「不就是一个 issue 吗,我自己修一下总行吧」,就动手去翻。

结果一翻才发现,坑比 issue 本身深多了。我们钉的那个 Mooncake 版本,wheel 里压根就没编进 NVLink 传输;换了新版本,只改协议名的话它会悄悄退回 TCP,慢了将近 300 倍,还一个报错都没有;issue 里那个 cuMemCreate 报错我们也复现了,根子在 IMEX 没配好;好不容易在同一个容器里跑通了,一次 281 MiB 只要 0.4 ms,可 P 和 D 一拆成两个 Pod,又是一句干脆的 invalid argument。

本来只是想修个 issue,修着修着,就修成了一整套 PD。

PD 分离不是什么新鲜玩意儿,DistServe、Mooncake、Dynamo、llm-d,社区里一抓一大把。可真正动手之后我才发现,这些方案都有一个默认前提:你得有 RDMA,最好还有跨机的 NVLink。而我们手里有的是一堆消费级的 RTX 5090,外加一批跨机网络还没到位的 B300。

所以这篇讲的是一个「穷人版」的 PD:P 和 D 只在同一台机器里配对,KV 不出这台机器。路上踩的坑,比我预想的多得多。

PD 到底在干嘛

先给没接触过 PD 的朋友补个课。大模型的一次推理分两段:prefill 把整个 prompt 一次性算完,产出这段 prompt 的 KV cache,顺带吐出第一个 token;decode 拿着这份 KV,一步一个 token 地往下生成,直到结束。两段的脾气完全不一样:prefill 是一大口计算,decode 是很多小步,而且每一步都要把 KV 读一遍。单体部署把它们塞在同一张卡上轮流做;PD 分离则把它们拆到两张卡上,P 算完之后,把 KV 和第一个 token 一起交给 D,D 接着往下生成。

PD 的一次交接

为什么非做不可

先交代一下背景。TTS 是我们的主营业务,用户要的是流式音频:模型一边生成 token,声码器一边把它合成音频推给用户。

这就带来了一条硬底线:每一路流的生成速度都不能低于 42 tok/s。为什么是 42?大约 21 个 token 合成 1 秒的音频,所以 21 tok/s 刚好是实时(RTF 1.0)。我们要求两倍实时,也就是 RTF ≤ 0.5,因为流式播放得留余量:调度抖动、批次大小的起伏、声码器和网络,都会吃掉一点时间。掉到这条线以下,播放缓冲就会被慢慢吃空,用户听到的就是一顿一顿的卡顿。

单体部署(同一张卡既做 prefill 又做 decode)的问题出在 prefill 上。TTS 的 prompt 里塞着一段参考音频,用来克隆音色,动辄上千行。遇到一个没见过的音色,整段参考都要冷算一遍,而这一次 forward 期间,卡上所有正在解码的流都得等着。

一句话:一个冷请求进来,所有正在播放的流一起顿一下。

我们按线上真实请求的样子做了一次压测,单体用 1 张卡,PD 用一对 P 和 D,共 2 张卡:

卡顿流占比:单体 vs PD

单体从 8 路开始就有流卡顿,PD 一直到 40 路都是 0。

这是整件事里我最想说的一点:纸面上的数字都很好看,真实推理却是另一回事。 单看 decode,5090 上一步 15–23 ms,照这个算,一张卡撑 40 路都不会掉到 42 tok/s 以下。可单体实际 8 路就开始卡了。原因就是上面那句话:prefill 隔三岔五插进来打断一下,decode 就被饿着。P 打断,D 饿死。

PD 用了两张卡,公平起见要按每卡算:同样每卡 16 路,单体有 0.32% 的流会卡,PD 是 0。而线上的单体当时每卡跑着 22 路左右,按这次压测算,意味着有 0.7% 到 5% 的流在卡。

「主营业务就是 TTS,这个不做不行啊。」于是就有了这一周。

第一堵墙:别人的 PD,我们的硬件跑不起

一开始我是这么想的:PD 嘛,抄就完事了。SGLang 自带 PD,KV 用 Mooncake 传,P 和 D 各一组机器,中间走 RDMA,完美。

然后我去看了一眼我们的机器。

  • 5090 集群:消费级显卡,没有 NVLink。GPUDirect RDMA 也没有:驱动里没有 nvidia_peermem,dma-buf 导出直接报 NOT_SUPPORTED,所以想走 RDMA 只能先拷回主机内存。机器之间是 25 GbE。
  • B300:机内有 NVLink,但 IB 卡没到位,100G 的 RoCE 网卡又没暴露给 Pod。跨机只剩 Pod 网络,实测上限 3.4 GB/s 左右。

一个 2k token 的 prompt,KV 是 144 KiB/token × 2048 ≈ 288 MiB。我们实测了一下搬这么一份 KV 要多久:

硬件约束:同机与跨机

同机:B300 走 NVLink 0.54 ms,5090 走 PCIe P2P 5.6 ms。跨机:B300 85 ms,5090 104 ms,分别慢了 160 倍和 19 倍。5090 跨机用 TCP 还是 RoCE 都一样,25 GbE 的线速摆在那里。

那么问题来了:decode 一步也就十几毫秒,首段音频要等 9 步左右。每个请求先花 100 ms 搬 KV,那还拆个什么劲?

后来做社区调研的时候,我还看到 NVIDIA 自己的 PD 决策流程图,要求有 RDMA、输入输出长度比大于 8:1;他们的旧版指南里,1k token 的输入 PD 几乎没有收益。我们 1–2k token 的 prompt,正好落在文献说 PD 最不划算的角落。

但我们图的本来就不是这个。我们要的是两件事:一是每路流速有硬底线,必须把 decode 和 prefill 隔开;二是隔开之后,D 不再被 P 打断、不再挨饿,能跑到接近它的理论性能。

结论很朴素:P 和 D 必须在同一台机器上配对,KV 不出这台机器。 跨机只当降级兜底,不当扩容手段。

第二堵墙:同一台机器,容器之间互相看不见

好,那就同机。同机总简单了吧?

并没有。K8s 的 device plugin 只给每个 Pod 挂它自己申请的那张卡,而 CUDA IPC 要求导出显存的那张卡在导入方的容器里也看得见。我在 B300 上挨个试了一遍:

  • 传统的 cudaIpcOpenMemHandle,两个 Pod 之间:invalid argument。同一个 Pod 里两个容器、开了进程命名空间共享:还是一样。
  • 用 unix socket 传 cuMem 的 POSIX fd:CUDA_ERROR_INVALID_DEVICE。
  • 用 FABRIC 句柄:没有 IMEX 通道时直接 NOT_PERMITTED;手动建了通道,跨容器导入又变成 NOT_SUPPORTED,因为宿主机那个版本的驱动压根没带 nvidia-imex。

官方的 Mooncake 在这里也是同样的结局:它的机内 NVLink 传输只在同一个容器里好使,P 和 D 一拆成两个 Pod,就是同一个 invalid argument。

一圈下来全军覆没。这时候我想起了上一篇《GPU 小偷》里的罪魁祸首:NVIDIA_VISIBLE_DEVICES=all。一个不申请 GPU、但设了这个环境变量的容器,能看见整台机器所有的卡。上次它是事故元凶,这次它成了救命稻草。

我先写了个原型:一个这样的「搬运工」容器,替 P 和 D 打开双方的 IPC 句柄,在 B300 上 281 MiB 拷贝 0.37 ms,744 GiB/s。路是通的。

官方 Mooncake 走不通,那就自己改。我们 fork 了一份 Mooncake,在上面动了不少地方:下面 5090 用的代理和它的 gather kernel,还有多线程 TCP、更严格的传输失败语义,都在这个 fork 里。

后面两种卡走了两条不同的路。

B300:让 P 直接写 D 的显存

后来 B300 的驱动升级了,宿主机上的 IMEX 也跑起来了,FABRIC 句柄跨 Pod 导入终于能用,而且不需要特权,Pod 只申请一张卡就行。于是方案变成:D 用 FABRIC 内存分配自己的 KV 池并导出句柄,P 导入之后,用一个 gather kernel 把散落的源行直接写进 D 的页里。

效果:2000 条散落的行(281 MiB)0.47 ms。作为对比,上游 Mooncake 的 NVLink 路径是逐块拷贝,同样 2048 条散落的行在我们 Pod 里测出来要 296 ms。端到端跑了两万一千多次传输,全部走 fabric,KV 校验 0 失配。64 路并发下 TTFT p50/p95 是 54/140 ms,同样的负载走 TCP 是 12.8 s/19.1 s,基本已经崩了。

5090:一个看得见所有卡的代理

5090 没有 FABRIC,就只能靠那个搬运工了。我们在 fork 的 Mooncake 里加了一个 nvlink_proxy:一个不申请 GPU、但看得见所有卡的守护进程,P 和 D 通过 unix socket 把自己的显存登记给它,它打开双方的 CUDA IPC 句柄,在目标卡的上下文里做 P2P 拷贝。5090 上第一次测,286 MiB 6.0 ms,47.6 GiB/s。

纸面上,这已经快得飞起了:一个 2k token 的 prompt,KV 几毫秒就搬完。然后我们接上了真实流量。

KV 传输平均 21 ms。过了一会儿再看,44.75 ms。越跑越慢。

一查,每个请求要拷的根本不是一整块 KV,而是大约 7,660 个只有几 KB 的碎块:KV 池按 token 分页,一个请求的 KV 散得满池子都是。我们先在守护进程里加了 gather kernel 专门对付小块,单批从 11.71 ms 降到 3.86 ms;又把 gather 挪到源卡那一侧,kernel 再快 2.4 倍。

本以为这就完事了,结果它还是越跑越慢。这回是池子本身碎了:刚重启的池子,一次冷传输只有 72 个块,3.84 ms;在真实流量下跑上一阵子,同样一次传输变成 105,660 个块,48.5 ms,慢了十几倍。「合并传输吧,不然全是碎片操作。」我当时是这么拍板的:P 先把要发的行 gather 到一块连续的 staging 区,一次传过去,D 再 scatter 回自己的页。同样碎掉的池子,传输回到 3.79 ms、72 个块,TTFT p50 从 142 ms 降到 100 ms。

碎片之后是排队。最早守护进程是每台机器一个(DaemonSet),这台机器上所有 PD 单元的拷贝都挤在它这儿,一个等一个。于是我们把它拆进了每个单元自己的 sidecar:一个单元一个,谁也不用等谁,顺带还能跟着单元一起滚动升级。

这一拆,还顺手破除了一个迷信。早期测到跨 CPU 插槽的 P→D 拷贝 p99 是 109 ms,同插槽只有 17.5 ms,我们一度以为是 NUMA 的问题,都准备把 P 和 D 钉在同一个插槽上了。换成 sidecar 之后再看线上:跨插槽 p99 18.8 ms,同插槽 19.1 ms,差距没了。罪魁祸首是共享守护进程的排队,不是插槽。

先测量,再动手:PD 的时间都去哪了

PD 跑通之后,下一个问题是:为什么它比单体还慢?B300 上接真实流量时,稳态下 PD 的 TTFA p50 比单体高 40 ms。

一开始我也想拍脑袋:是不是传输慢?是不是 LB 多了一跳?后来我给自己定了个规矩:「先在代码里加分阶段的计时日志,跑一遍看时间花在哪,再针对性地修。」不许猜着优化。

具体做法是给 LB、P、D 三个进程在每个请求的关键节点都打上时间戳,再把线上流量(7.8 万个请求,按线上的到达时间、prompt 长度和缓存命中长度合成)回放到一个隔离的测试组里。所有 Pod 在同一台机器上、同一个时钟,各段首尾相接,加起来正好等于客户端实测的时间。

最慢 1% 请求的时间瀑布

照着这张瀑布图往下挖,结果和我的直觉差得挺远:

第一名是 Prefill 的调度准入,不是传输。 P 的调度循环在 GPU forward 期间不收新请求,收的时候又是一个一个串行地反序列化。TTS 的 prompt 是一个「一千多个、每个 11 个 int 的 list」,反序列化慢,还制造了大量短命对象,突发时 P 的调度进程直接打满一个核。修法是让 HTTP 进程在 C 里校验 prompt,打包成一个 int64 数组传给调度器。光这一项,突发时 P 准入的 p99 就从 667 ms 降到 149 ms,首 token p99 从 904 ms 降到 256 ms;再加上「forward 期间也收请求」,突发时 P 收请求的 p99 又从 119 ms 压到 43 ms。D 那边同理,打包之后 D 收请求的 p99 从 36 ms 降到 0.2 ms,最慢 1% 的流速从 29.7 翻到 58.2 tok/s。

Decode 的步子被主机拖住了。 我们移植的 PD 循环里,decode 只有非 overlap 的版本,GPU 算完一步要等主机处理完才开始下一步。把 overlap 调度移植到 D 上之后,同样负载下步时从 12.6 ms 降到 9.0 ms,GPU 忙碌率从 79% 到 98%。为了确认 overlap 不改变输出,我们跑了两组各十万条流的自然结束对照,输出长度和结束原因在统计上分不出差别,0 错误。

最后剩下的是物理:2k 上下文时,每多一路,一步大约多 54 µs,其中 42 µs 是 attention 在读这一路自己的 KV(每路约 295 MB),读带宽已经到了 HBM 标称的 88%。这一块没什么好修的。

部署形态:越简单越好

P 和 D 怎么配比,我们也绕了一圈。

按理说,TTS 是 decode 重、prefill 轻,数据也是这么说的:1P1D 跑到 40 路时,P 的 GPU 只有 31% 在忙,七成时间闲着。那一个 P 带两个 D 不就好了?5090 上的压测也支持这个想法:1P2D 三张卡,按 42 tok/s 的线能扛 64 路,放宽到 35 tok/s 能到 72 路,每张卡比 1P1D 还多扛几路。反过来 2P1D 就亏了:同样三张卡,按 42 tok/s 的线只有 32 路,多出来的那个 P 只把首包时间压低了一点点。

B300 上我们拿真实流量也走过一遍。1P1D 的时候,一次突发把唯一的那个 D 顶到 500 路上下,一步要 50 ms 左右,TTFA 的 p99 冲到 1.3 秒;加上第二个 D 变成 1P2D 之后,稳态 TTFA p99 回到 250–305 ms,后来再把流量压到 90 req/s,p99 549 ms,0 错误。

那为什么最后还是选了 1P1D?

因为一旦多 P 多 D,复杂度就全跑进了 LB。LB 得自己做路由:同一个音色尽量打到同一个 P,不然前缀缓存就白算了;再从这个 P 所在的机器上挑一个 D,KV 才能走同机拷贝;P 和 D 都得是 StatefulSet,每个 Pod 有自己的 DNS 名字,LB 才能点名找到它们。爆炸半径也跟着变大:组里任何一个 Pod 出问题,牵连的是整组的流量。滚动升级更麻烦:B300 上那次给线上的 PD 组换镜像,为了保证任何时候都有两个 D 在服务,还得临时借一张卡起第三个 D,再一个一个排空、替换。扩缩容也说不清该加什么:加一个 P,还是加一个 D?

而 1P1D 简单到不用想:一个 Pod 就是一个单元,P 和 D 天生在同一台机器上,调度、扩缩、滚动都按整体来。LB 只管把请求转给自己单元里的那一对 P 和 D,多 P 多 D 的路由逻辑全删了。代价就是 P 七成时间闲着。我想了想,觉得值:「浪费一点资源,换部署简单。」

部署架构

  • 固定 1P1D 单元:一个 Pod 里放 LB、P、D 和两个 sidecar。
  • Router 只管分发到单元,按会话亲和,不关心单元里面是什么。
  • KEDA 按 decode 的在途路数扩缩单元个数。

扩缩容这块,我坚持要留出突发余量,KEDA 的目标值只设到实测上限的五六成。原因很简单:扩容不是瞬间的事,要等调度、拉起 P 和 D、加载模型,而突发是一下子涌进来的。余量不够,B300 上那种「一个 D 被顶到 500 路」的场面就会在线上重演,直接把服务打趴。

最难的一个问题:传输失败之后,KV 页什么时候能还?

想象这样一个场景。P 正在往 D 的显存里写请求 A 的 KV,写到一半,P 卡住了几秒。D 左等右等等不到结果,判定这次传输失败,把给 A 预留的那几页 KV 释放掉,转手分给了下一个请求 B。B 拿着这些页开始解码。这时候 P 缓过来了,那笔卡在半路的写入终于落地,不偏不倚,正好落在 B 的页里。

没有任何报错。B 的音频只是听起来不太对。

迟到的写入

所以问题就变成了:D 到底什么时候才能确定,P 不会再往这些页里写东西?这个问题我们前后答了三次,前两次都被独立 review 打穿了。

第一版,我们的答案是「等」。D 给每次写入定一个截止时间,过了截止时间,或者判定 P「失联」了,就把页收回来。听起来很稳。review 的意见很不客气:你所谓的失联,只是连续两次健康检查没通过,而且这个标记一旦打上就永远不会撤销;一个活得好好的 P 完全可能只是慢了一点,然后把迟到的数据写进别人的页里。至于截止时间,TCP 的进度计时器是会一直往后滑的,一笔写入到底什么时候结束,根本没有上界。

第二版,我们不再靠猜,只认证明:要么 P 明确回一个 ABORT_ACK,说「这笔我不写了」;要么 P 的实例 ID 变了,说明它已经重启过,旧进程的写入不可能再来。为了让 P 有底气说出这句话,我们在 Mooncake 里加了一个「这批传输已经静止」的接口。结果这个接口的 review 又把它打穿了:如果对端还是老协议,一笔被切断的写入已经报告「静止」,之后居然还能再落下 2 MiB 多的数据。而且这不是纸上推演,是实打实复现出来的。

第三版,我们干脆从底下把这条链路补齐:Mooncake 的批次静止接口,加上 TCP 协议 v3,每个请求带上 ID 和剩余时间,接收端会丢掉迟到的数据块,并确认这笔传输的终态。P 只有在拿得出证明时才回 ACK,拿不出就一直扣着。扣着也不能扣到天荒地老,所以又加了两道兜底:P 扣着的 ACK 攒到 3 个,就自己退出,重启后的新实例本身就是证明;D 被扣住的容量超过 25%,就自己摘流排空,不再接新请求。客户端那边则不用陪着等,传输一失败,第一时间就会收到错误。

最后我们在一个 5090 单元上做了 GPU 故障注入:暂停 P、杀掉 P、暂停守护进程、暂停 D 的输出。第一轮还真抓到两个恢复相关的 bug:P 暂停后再恢复,D 已经不再重发 ABORT,那些页就一直扣着回不来;D 在排空状态下没有新流量,也就不会去发现已经重启的 P。修完重跑,全部通过,一万三千多次 KV 校验,0 失配。

结果

容量:同样不卡,PD 每卡多扛一倍

还是开头那次 5090 压测。我们定的线是「0 卡顿,并且每一路都 ≥ 42 tok/s」,在这条线以内,每种形态最多能扛多少路:

形态 卡数 最多路数 每卡路数 相对单体
单体 1 8 8 1×
PD 1P1D 2 32 16 2×
PD 1P2D 3 64 21 约 2.7×

同样不卡,PD 每张卡扛的路数是单体的两倍;1P2D 把闲着的 P 也用起来,接近三倍。如果把流速底线放宽到我们早先用的 35 tok/s,1P2D 能跑到 72 路,每卡 24 路,这时单体大约每卡 10 路。

而且这个比法其实是偏袒单体的:单体在 8 路时就已经有 0.12% 的流在卡了,严格按「0 卡顿」算,它连 8 路都守不住。

首包时间上 PD 也没吃亏。同样的并发下,8 路以上 PD 的 TTFA p95 一直比单体低,40 路时是 406 ms 对 507 ms:

首段音频时间随并发的变化

代价也写在这儿:1P1D 里 P 有七成时间是闲的,低并发的时候 PD 并不省卡。

线上

先是金丝雀和线上单体在同一时段的对比(5090,1P1D,32 分钟):

PD 1P1D 线上单体
TTFT p50 / p99 28 / 121 ms 29 / 173 ms
最慢 1% 流速 55.2 tok/s 47.5 tok/s
低于 42 tok/s 的流 0.00% 0.32%

注意 PD 这一列是 LB 口径,包含了 LB、网络和 KV 传输;单体那一列是服务端口径,只算排队加 prefill。这个比法对 PD 是吃亏的,PD 照样不输,p99 还低了 50 多 ms。

之后 5090 集群上金丝雀所在的那条产品线,流量全部切到了 PD 单元,由 KEDA 扩缩。最后一版(故障路径加固 + Mooncake 新版本)上线前后各看了 30 分钟:网关口径的 TTFT p50 基本不变(277 → 275 ms),p99 从 779 ms 降到 638 ms;单元重启 0 次,KEDA 缩容两次都是正常排空;KV 校验 138,395 次,0 失配。

B300 上我们用一个 PD 组扛过一段时间的真实流量,前面那些修复都上之后,5 分钟窗口的 TTFA p90/p95/p99 是 171/183/217 ms。

正确性上,我们一直跑着一个金丝雀:把 PD 和单体合成的音频成对拿去做 ASR 转写,比字错率(CER)。同语种累计 26,259 对,PD 减单体的 CER 差是 +0.023 个百分点,95% 置信区间 [−0.027, +0.073],在 ±0.5 个百分点的等价区间内,各个长度、语种、音色的子组也都等价。

什么时候值得做单机 PD

回头看,如果你也在考虑要不要上单机 PD,可以对着下面几条看看。

值得做:

  • 每一路流有硬性的速度底线,掉下去用户立刻就能感知到,比如流式音频;
  • 每张卡上同时跑着很多路。我们的收益,来自一张 decode 卡上有三十多路流的场景;
  • decode 重,prefill 又时不时插进来,也就是「P 打断,D 饿死」;
  • 同一台机器里有 P2P 或 NVLink 可用,哪怕没有 RDMA、跨机网络很一般。

不值得做:

  • 主要想靠 PD 降首包时间,而 prompt 又短:prefill 本来就快,拆开还要多搬一次 KV,NVIDIA 的旧版指南里,1k token 的输入 PD 几乎没有收益。

说到底,单机 PD 买的是高并发下 decode 不再挨饿。如果你的瓶颈不在这儿,单体多加几张卡往往更省事;如果正好在这儿,没有 RDMA 也不是放弃的理由。

最后回到开头:这件事起初只是想修 Mooncake 的一个 issue,修着修着,一周之后变成了一整套单机 PD,现在扛着线上的流量。


P.S. 本文由 Opus 生成,经人类精校。人类在这个过程中的主要贡献是:把它写的一半段落删掉,然后说了一句「感觉有点单薄」。