Skip to main content

05 - 进程放置与并行

你手上有 2 张 H200,要跑一个 omni 模型,它有 8 个阶段。怎么摆?

第一版摆法很自然:thinker 最大,独占 0 号卡;talker 和声码器放 1 号卡;几个预处理和编码器阶段放 CPU。跑起来没问题。

然后你看利用率:1 号卡长期只有不到 2%

原因不难想:talker 要等 thinker 先吐出内容才有活干,而且比它小一个数量级。那张 H200 绝大部分时间在等。

于是你想把 talker 挪到 0 号卡,跟 thinker 挤一挤。

马上撞上一个麻烦:两个阶段都在启动时申请显存,而它们是先后加载的。先加载的看到「还剩 90 GB」就按九成去要,等第二个加载时已经不够了。这不是参数配错,是「还剩多少」这句话在多阶段场景下本身有歧义。

一、四种放置形态

阶段、进程、GPU 三者不是一一对应的。同一个阶段可能占满一张卡,也可能几个阶段挤在一个进程里共用一张卡;一个阶段还可能横跨四张卡。

实际会用到的组合就下面四种,各自解决的问题不同:

四种形态是正交的,实际部署常常是 ② 和 ③ 的组合① 单进程 · 全塞一起进程 P · GPU 0预处理 + 引擎 + 声码器零传输开销 · 但 CPU 预处理会堵住调度循环② 按阶段拆进程 · 生产默认进程 P1CPU · 预�处理进程 P2GPU 0 · 引擎进程 P3GPU 1 · 声码器按段独立扩缩,声码器与引擎能重叠代价:多一个 CUDA 上下文,进程内缓存重复一份③ 同卡共置 · 省卡GPU 0进程 P2 · 引擎显存预算 0.85进程 P3 · 声码器显存预算 0.05两份预算加起来必须留余量,算错直接 OOM④ 张量并行 · 装大模型thinker 一个阶段rank0 · GPU 0rank1 · GPU 1rank2 · GPU 2卡间 NCCL 全连代价:每层之后都要 all-reduce
这张原来是四个方案竖着堆的 Mermaid 图,344×1190 —— 要滚一屏半才看得完,而四个方案本来就是并列关系、根本不该有先后。摆成宫格之后 980×386,一眼能对照。②③ 常常一起用:先按阶段拆进程,再把吃不满卡的那两个阶段共置到同一张卡上。

四种形态是正交的,实际部署常常是②和③的组合。逐项对照:

① 单进程② 按阶段拆③ 同卡共置④ 张量并行
怎么配所有阶段 process 同名每阶段一个 process不同 process,同一个 gpugpu 写成列表 + tp_size
买到什么边全走同进程直传,零传输开销按段独立扩缩,声码器与引擎重叠利用率低的阶段不再独占一张卡单阶段能装下更大的模型
代价CPU 预处理会堵住调度循环多一个 CUDA 上下文,进程内缓存重复显存预算必须显式声明,算错就 OOM每层之后要 all-reduce
什么时候用本地调试、单请求延迟优先生产默认,多数模型的出厂配置某个阶段明显吃不满一张卡时只给大骨干用,小模块别上

③ 那一列的动机很具体:talker 曾长期在一张 H200 上跑不到 2% 利用率。④ 的反面同样具体:小模块上张量并行,每层的 all-reduce 通信开销会盖过并行收益。

二、放置的校验规则

上一节四种形态摆在那,问题是:怎么告诉框架你要哪一种?

答案是只靠一个字段 —— StageConfig.process。同名即同进程,不同名即拆开。没有第二个地方能影响进程拓扑,它是唯一真相来源

模型的配置类给默认值,配置文件或带点号的 CLI 参数覆盖它,跟其他阶段字段没有区别。但合法的组合是有限的,框架在启动时会校验:

# 把声码器单独放进一个进程
sgl-omni serve --model-path MODEL --vocoder.process vocoder

# 重复同一个进程名即共置:下面这条复现了 Higgs-TTS 内置配置的拓扑
sgl-omni serve --model-path bosonai/higgs-tts-3-4b \
--preprocessing.process tts_frontend \
--audio_encoder.process tts_frontend
# tts_frontend : preprocessing, audio_encoder
# pipeline : tts_engine
# vocoder : vocoder

启动前由放置与拓扑规划器校验四条:

  1. 每个非 TP 阶段都必须声明 process;TP 阶段每个 rank 自动派生一个进程。
  2. 一个进程组可以跨若干 CPU 阶段,但至多只能占一张 GPU。
  3. 多个进程组共享一张 GPU 时,每个涉及的 GPU 阶段都必须声明 gpu_memory_fraction,且每卡总和要装进 placement.max_total_gpu_memory_fraction_per_gpu。校验会点名说出哪些阶段漏了。
  4. TP rank 的进程名不能与其他进程组撞车。

有一类边过不了进程边界,而且不会在配置校验时报错。 某些阶段之间靠进程内注册表交换状态 —— MOSS-TTS 用进程内队列把预处理好的请求交给 AR 引擎,Qwen3-TTS 把准备好的请求放在进程内模块状态里。把这种边拆开,配置校验会通过,到真正服务时才失败。出厂配置把这些阶段放在同一个进程里,别拆。

反例是 Ming-Omni-TTS:它把预处理字段放在 StagePayload.data 里,参考编码器的 spk_embprompt_latenttyped_tensor 线格式序列化,所以它的两条边都能跨进程。能不能跨进程,取决于这条边上的状态是不是可序列化的载荷 —— 这是接新模型时要主动设计的,不是自然就有的。

三、显存预算的语义

多阶段共置有个陷阱:阶段是顺序加载的,先加载的看到的「剩余显存」包含了后面还没加载的那部分

框架专门指出了两种主流语义的差别:vLLM 的 gpu_memory_utilization总显存的比例,SGLang 的 mem_fraction_static权重加载后剩余显存的比例。后者对单模型服务更合理,但在多阶段场景下是含糊的 —— 「剩余」取决于加载顺序。于是放置模型改用一套显式语义,而不是继承这个歧义。

MOSS Local 单卡部署的默认值:进程总预算 0.90、给 codec 预留 0.05、AR 段实得 0.85。相比让 AR 段自己去问「现在还剩多少」,这套显式预算在 SeedTTS 英文集并发 8 上换来吞吐 +8.9%(4.772 → 5.199 req/s)、RTF 均值 −8.4%、p99 延迟 −15.0%。当进程级核算不可用时,它退回上游 SGLang 的空闲显存探测,而不是用一个不安全的共置预算硬上。

四、阶段融合

把相邻的几个逻辑阶段塞进一个进程,配置上是一个 fused_stages 开关。第一版的合法条件故意保守:必须相邻、有序、线性、非 TP、且能放进至多一张卡

它的设计里有一条很克制的决定:融合是进程与拓扑层面的共置,不是新的调度器路径Stage 仍然拥有路由、relay、扇入、流式、中止、终点完成的全部职责;融合之后这些阶段之间的边自然走上了同进程直传。

「不引入新调度器路径」意味着融合不改变任何语义,因此它可以做成一个纯配置开关,出问题时关掉即可。任何声称能提速的拓扑改动,如果做不到「关掉就完全回到原来的行为」,就应该重新设计。

五、同卡数据并行

上面讲的都是「把一个副本的不同阶段摆到卡上」。还有一条正交的路:在一张卡上跑多个完整的副本

它的前提是一个已经被量化的观察 —— 调好参的单副本,GPU 大部分时间是空的

单副本时 GPU kernel 之间存在大量空隙,SM Active 约 29%,瓶颈在主机侧串行工作而不是 GPU
出处:sglang-omni docs/_static/image/same-gpu-dp-host-bound.svg。kernel 本身只有约 3 微秒,kernel 之间 GPU 空闲约 71%,SM Active 约 29% —— 瓶颈是主机侧的串行工作链,不是 GPU 算不过来。这正是 07 篇反复出现的那个结论。

既然一条主机链喂不饱 GPU,就多来几条。多个副本各自绑一组 CPU 核,再用 CUDA MPS 让它们的 kernel 真正并发而不是时间片轮转:

三个副本各绑一组 CPU 核,CUDA MPS 让它们的 kernel 并发填满原本空闲的 SM
出处:sglang-omni docs/_static/image/same-gpu-dp-mps.svg。每个副本有自己的核心块和自己的主机链,MPS 负责把它们的 kernel 合并到同一个 GPU 上下文里。

H100 上 Higgs 的实测(钉住配置、完成的运行):

H100 · Higgs · 横条按 qps 等比,深色段是区间下界,浅色延伸段是上界02040 qps单副本 · c9621.7 ~ 22.11.0x 基准DP2 + MPS · 2 × c6431.5 ~ 37.71.4 ~ 1.7xDP3 + MPS · 3 × c6439.9 ~ 46.91.8 ~ 2.1x注意单副本用的是并发 96 而多副本是每路 64:比的是「各自调到饱和之后的单卡吞吐」,不是同一并发下的对比。这是对的比法,但引用数字时要带上这个前提。
官方把这个特性明确标为「有条件的、仍在进行中的优化」,并且公开的是完整区间而不是最好那一次 —— 表格里同时给了完成运行的名义区间,失败和降级的运行放在案例研究里。报性能时给区间不给单点,是很值得学的习惯。

三个必须知道的坑

① 相同的 mem-fraction-static 不等于相同的 KV 容量。 这个比例是按「每个副本启动那一刻的空闲显存」算的,而副本是顺序启动的,后启动的看到的空闲更少。实测里三个都写 mf=0.27 的副本分别拿到了 97,503 / 53,149 / 20,961 个 KV token —— 相差近五倍。

同卡数据并行的显存视图:权重被复制 N 份,KV cache 被切成更小的 N 块,DP 并不省显存,换来的是更高的计算利用率
出处:sglang-omni docs/_static/image/same-gpu-dp-vram.svg。这张图把代价说得很直白:DP 不省显存 —— 权重复制 N 份、KV 池切成 N 小块,买到的只是计算利用率。

正确做法是给每个副本一个确定的容量:要么用 engine.kv_cache_bytes 按字节定死,要么要求所有副本解析出同一个 max_total_tokens,并在启动时校验。两个旋钮互斥 —— 同时写会让 token 上限静默地把按字节算出来的池缩小。

② MPS 要验证「进程真的挂上去了」。 四件事极易混淆:环境变量设了、守护进程在跑、存在一个 MPS server、你启动的那些副本真的作为客户端挂上去了。只有最后一条成立,对比才有效。而一个没找到管道目录的副本会静默退回时间片轮转,不报任何错。启动器为此会逐个核对 MPS 客户端列表、把 server 到 client 的 PID 映射写进文件,任何一个没挂上就直接启动失败。

③ 权重共享是可选项,而且门槛很高。 每个副本默认加载自己那份完整骨干(Higgs 3-4B 是 7.60 GiB,约占 DP3 总占用的三分之一)。既然都是同一张卡上的同一份只读权重,可以只留一份走 CUDA IPC 共享。但它默认关闭,且只对已验证过的架构开放:一个会往共享参数里写每请求状态的模型,会直接污染同卡的其他副本。而且「架构审计通过」还不够 —— 必须跑完端到端验证(共享启动、健康检查、挂载核对、并发请求正确性、干净退出)才算支持。

这条门槛设得对:共享可变状态的错误是跨副本的、非确定性的、极难复现的,宁可默认关掉。

六、张量并行的实现细节

最后补一下第四种形态的内部长相,因为它和前三种有个根本区别:前三种是不同阶段之间的事,张量并行是一个阶段内部的事

一个 TP 阶段的 StageGrouptp_size 个进程,每个跑完整的调度器加模型 worker,tp_rankgpu_id 各不相同,靠模型前向里的 NCCL 集合通信保持步调一致。

  • 协调器对 TP 无感知,只跟 rank 0 说话。
  • 组内 rank 0 从控制面收消息后广播给其余 rank,所有 rank 做相同的调度决定,只有 rank 0 往下游发结果。
  • 每个阶段分到自己的 NCCL 端口。
  • TP 阶段必须独占自己的 worker 进程,每个 rank 进程只带一份启动记录。

顺带一个待改的形状:tp_size 目前是顶层字段,但 RFC 自己指出这只是若干并行轴之一 —— thinker 是 MoE 可能想要专家并行,吞吐型阶段可能想要数据并行。正确的长期形状是收进一个 ParallelismConfig,但现在只有一个轴,一个单属性的配置类只增加视觉负担不增加能力,所以等第二个轴落地时再一起改

识别出正确的终局形状、同时明确「现在改的收益是负的」、并写下触发迁移的条件 —— 这是个不错的工程判断范例。

下一篇06 - 接一个新模型