04 - 控制面与数据面
上一章说过,omni 模型分成两半:一半负责想说什么(thinker),一半负责把它变成声音(talker)。拆开之后它们跑在两张显卡上。
问题来了:想的那一半算出来的东西,怎么交给说的那一半?
先看这东西有多大、多频繁:
| 问 | 答 |
|---|---|
| 一次多大 | 几百 KB 到几 MB |
| 多久一次 | 每吐一个字就要传一次,一秒几十次 |
| 持续多久 | 整段话说完为止 |
所以这不是「偶尔搬个大文件」,是持续不断地搬中等大小的东西。搬得慢一点,用户就要多等一会儿才听到声音 —— 而当初拆成多个进程,本来就是为了让它更快。搬运拖后腿,等于白拆。
最省事的写法是「打包成字节流发过去」。算一下就不行了:
显卡里的数据 → 拷到内存 → 打包 → 过网络 → 解包 → 再拷回显卡
= 四次拷贝 + 两次打包解包 = 几毫秒起步
而模型吐一个字本身才几毫秒
搬运比干活还慢。
而且这条路根本不是一种走法。两端在哪,代价差好几个数量级:
| 两端在哪 | 该怎么搬 |
|---|---|
| 同一个进程 | 根本不用搬,传个引用就行 |
| 同一张卡的两个进程 | 数据不动,把「它在显存哪个位置」告诉对方 |
| 同一台机器的两张卡 | 走卡与卡之间的直连通路,不用绕内存 |
| 两台机器 | 这才真得走网络 |
一套代码要覆盖这四种,还不能让写模型的人操心用的是哪种。
一、两条通路的要求相反
阶段之间其实要传两种完全不同的东西:
| 传什么 | 特点 | 要求 | |
|---|---|---|---|
| 控制面 | 「这个请求交给你了」「我算完了」「用户取消了」 | 很小,几十字节 | 一定送到、顺序不能乱、要能一次通知所有人 |
| 数据面 | 隐状态、码本这些真实数据 | 很大,几 MB | 越少拷贝越好,走最短的路 |
这两组要求是打架的。「一定送到、顺序不乱」意味着要确认、要重传、要排队;「越少拷贝越好」意味着最好连碰都不碰。用一套机制同时满足,只能取交集 —— 结果两边都做不好。
所以分成两条路:小消息走一条可靠的,大数据走另一条快的。
官方给了一张 relay 的结构图,画的是 stage 1 把一个张量交给本地 relay、发出元数据、stage 2 再据此把数据取走的完整往返:

docs/developer_reference/assets/relay-arch.png。关键在于图中那个虚线框 metadata:阶段之间传的是它,真正的张量由两侧 relay 的 put / get 直接搬,从不经过控制面。二、五种 传输与各自的代价
上一节说这条边有四种形态。真做起来是五种实现 —— 同节点 GPU 之间还分「能不能直连」两档。
谁来决定走哪条?你可能以为是配置项。不是 —— 没有任何开关能指定后端,CommRouter 完全从阶段的局部性和放置自己推导出来。
这是刻意的。一旦「走哪条路」可配,它就会和拓扑各说各话:拓扑说这两段在同一个进程,传输配置却写着走共享内存,谁对?把它拿掉之后,拓扑成为唯一真相来源。
CommRouter 从上到下依次判定,命中即停 —— 没有公开的后端选择开关,走哪条完全由阶段的局部性和放置推导:
| 传输 | 命中条件 | 实际搬的是什么 | 谁负责释放 | 它的坑 |
|---|---|---|---|---|
local_object | 两端在同一个 OS 进程 | 什么都不搬,直接传 Python 引用 | 没有后端可清理,靠只读约定 | 最脆:接收方改了对象,发送方莫名其妙出错 |
| 直连 CUDA IPC | 同一放置、不同进程,且能证明 CUDA 序号兼容 | CUDA 存储句柄,接收方映射生产者的显存 | 无 relay 回执,靠 PyTorch 的 IPC 所有权托管 | 内联元数据有 64 KiB 上限,且不收 CPU 张量 |
| 池化 CUDA IPC | 同节点 GPU 到 GPU,但不走直连 | 打包好的张量缓冲,发送方持有一个 GPU 池 | 一条回执释放整段槽(槽是分配粒度,不是分页) | 池满就阻塞,槽大小要按载荷调 |
| SHM | 同节点,但这条边不是 GPU 到 GPU | 整份缓冲经共享内存 | 接收时 unlink 块,最省心 | GPU 张量要先下来,白白一次拷贝 |
| Mooncake | 被标为远端的跨节点边 | 由 Mooncake 自选协议(RDMA 等) | 完成后归还额度 | 配置最复杂,故障最难查 |
把「走哪条路」从配置面拿掉,是为了让拓扑成为唯一真相来源 —— 否则拓扑和传输配置会各说各话。CommConfig 只能调槽位大小、信用额度这类旋钮。
除了这五种,还有两条「小块直接塞进控制消息」的旁路,避免为几 KB 的东西建一次 relay:
- 跨进程的 CPU 流式块:序列化后的张量加元数据不超过 16 KiB、且元数据里不含张量,就直接搭
DataReadyMessage走。内联的块自带字节,所以不需要回执。 - 直连 CUDA IPC 的内联元数据:单独的 64 KiB 上限,可以含 CUDA 张量和普通内联值,但不能含 CPU 张量。
超过阈值的自动落回选中的 relay。这类阈值的意义是:让小消息不必付大机制的代价,而不是给用户一个旋钮。
三、一次传输的六步
前面说的都是「走哪条」。这一节看一次传输具体分几步,因为其中一步的顺序搞反会直接死锁。
先说清楚接收方拿到的是什么。控制面传过去的不是张量本身,是一张取货凭证(DataRef):对象 id、数据种类、传输方式、布局、后端缓冲引用、张量布局表(每个张量的路径、形状、dtype、偏移、字节数),以及可选的流式元数据。格式是后端中立的 —— 后端只需要能搬一段扁平的张量缓冲,并返回另一个后端实例能用来 get_async() 的元数据。
四、控制消息必须先于传输完成发出
这是整套数据面里最容易写错、也最难查的一条顺序约束。