Skip to main content

04 - 控制面与数据面

上一章说过,omni 模型分成两半:一半负责想说什么(thinker),一半负责把它变成声音(talker)。拆开之后它们跑在两张显卡上。

问题来了:想的那一半算出来的东西,怎么交给说的那一半?

先看这东西有多大、多频繁:

一次多大几百 KB 到几 MB
多久一次每吐一个字就要传一次,一秒几十次
持续多久整段话说完为止

所以这不是「偶尔搬个大文件」,是持续不断地搬中等大小的东西。搬得慢一点,用户就要多等一会儿才听到声音 —— 而当初拆成多个进程,本来就是为了让它更快。搬运拖后腿,等于白拆。

最省事的写法是「打包成字节流发过去」。算一下就不行了:

显卡里的数据 → 拷到内存 → 打包 → 过网络 → 解包 → 再拷回显卡
= 四次拷贝 + 两次打包解包 = 几毫秒起步
而模型吐一个字本身才几毫秒

搬运比干活还慢。

而且这条路根本不是一种走法。两端在哪,代价差好几个数量级:

两端在哪该怎么搬
同一个进程根本不用搬,传个引用就行
同一张卡的两个进程数据不动,把「它在显存哪个位置」告诉对方
同一台机器的两张卡走卡与卡之间的直连通路,不用绕内存
两台机器这才真得走网络

一套代码要覆盖这四种,还不能让写模型的人操心用的是哪种。

一、两条通路的要求相反

阶段之间其实要传两种完全不同的东西:

传什么特点要求
控制面「这个请求交给你了」「我算完了」「用户取消了」很小,几十字节一定送到、顺序不能乱、要能一次通知所有人
数据面隐状态、码本这些真实数据很大,几 MB越少拷贝越好,走最短的路

这两组要求是打架的。「一定送到、顺序不乱」意味着要确认、要重传、要排队;「越少拷贝越好」意味着最好连碰都不碰。用一套机制同时满足,只能取交集 —— 结果两边都做不好。

所以分成两条路:小消息走一条可靠的,大数据走另一条快的。

官方给了一张 relay 的结构图,画的是 stage 1 把一个张量交给本地 relay、发出元数据、stage 2 再据此把数据取走的完整往返:

SGLang-Omni relay 结构:两侧各有一个 relay,内含 config、descriptor 与 engine,engine 下挂 mooncake 等后端;stage 之间只传 metadata,数据由 relay 的 put/get 搬运
出处:sglang-omni 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() 的元数据。

四、控制消息必须先于传输完成发出

这是整套数据面里最容易写错、也最难查的一条顺序约束。

同一次传输,两种顺序错的顺序 · 先等完成,再通知在信用额度型后端上死锁发送方put_async等待传输完成 …… 永远等不到接收方没收到通知,不��知道该读,于是不读额度只有接收方读走才会释放,而它在等一个永远不会来的通知对的顺序 · 先通知,再等完成框架采用这一种发送方put_async发 DataReadyMessage此时才等待完成接收方收到通知 → 开始读 → 释放额度发送方的等待因此能结束,整条链路继续推进这类 bug 的特征:在共享内存后端上跑得好好的,一换成 NIXL 之类的后端就整条流水线卡死,而且没有任何报错。它不是性能优化,是正确性要求 —— 顺序写反了,某些后端就是不工作。
把这条写进文档而不是只留在代码里,是有价值的:换后端的人不看代码也知道这个顺序不能动。凡是「换个实现就会坏」的约束,都值得在设计文档里单独占一段。

五、四条容易踩的实现细节

上面四节讲的是设计。真动手时会撞上的是另外四件事 —— 它们都不影响功能正确,但每一条都实实在在坑过人:

  • 池化 CUDA IPC 的槽是分配粒度,不是应用层分页。 槽默认 64 KiB(cuda_ipc_slot_size_kb 可调),一个大张量会占连续多个槽,但它们仍然是一次逻辑传输:一条 DataReadyMessage、一条 DataAckMessage 释放整个范围。按槽去发多条消息是错的。
  • 普通直传的头部是控制面元数据,不受槽大小约束。 一个大头部不会被切成槽,也不会被切成多条应用层消息。
  • local_object 的扇出限制。 只有当每份投影都是「各自独立 data 容器的 StagePayload」时才允许走同进程直传,否则下游会共享可变状态。张量叶子可以刻意共享,但必须只读。
  • 中止和关闭各走各的路。 中止时从阶段的中止路径调 relay.cleanup(request_id);阶段关闭时调 relay.close()。后端各自实现细节:共享内存在接收时 unlink,NIXL 和 Mooncake 在完成后归还内存池额度,NCCL 在 close 时拆进程组。

六、这套数据面的收益

拓扑感知的传输选择落地之后,纯文本同进程路径的平均延迟降了 33%。这个数字的来源不是某个后端变快了,而是原本必须走 relay 的边现在直接传引用了 —— relay 从「必经之路」变成了「兜底路径」

同一批改动里还有一个被回滚的尝试:完整载荷的 CUDA IPC 直传实现了又撤掉了,因为在 Qwen3-Omni 的实际路径上它一次都没触发。这条记录挺有价值 —— 一个优化写完之后发现没有触发场景,正确的处理是撤掉而不是留着「万一以后有用」。

下一篇05 - 进程放置与并行