nano-vllm-omni · 提示 ← P18P17 →
#25 · Track E · 启动与 GPU · 书 p.106

P16 · 启动规划:设备分组、并行初始化、回滚、准入

thinker 独占 GPU0、talker 和 code2wav 共享 GPU1:后两者必须串行初始化(显存测量会互相干扰),但两组之间可以并行;任何一个失败都要把已起来的全部回收。

⏱ 45 min 难度 中 实现 mine/p16_init_planner.py 验收 ./check.sh p16 提示 A-25 · 参考解 C-25 solutions/p16_init_planner.py

题面速览

默认 Qwen3-Omni(2×H100):
  GPU0: thinker(0.9)                →  组 [0]
  GPU1: talker(0.6) code2wav(0.1)   →  组 [1, 2](串行)      两组并行

验收要点:设备重叠的连通分量 · 组间并行、组内串行且按 stage id · 失败短路 + 回滚已成功的 stage · 显存准入检查。

直觉与类比

拼车(设备分组 = 并查集)

每个 stage 是乘客,每张 GPU 是一辆车。坐同一辆车的人必须一起出发,车上一次只能有一个人“量座位”(组内串行)。关键在传递:A 和 B 都坐 2 号车,B 和 C 都坐 3 号车——A 和 C 没共用任何一辆车,但因为 B 同时占着 2、3 号车,三人只能编成一个车队。并查集就是“车队花名册”:每来一位乘客,看他要坐的车有没有人登记过;有就把两个车队合并。

搬家公司撤场(失败回滚)

三个搬家小组同时往三栋楼搬家具。B 组摔坏了一件(init 失败),总调度喊停:① 还没开始搬的不准再开始;② 正在搬的那件不能半空中扔下,搬完再说;③ 等所有人都停手之后,再把已经搬进去的家具全部搬出来——如果在 B 摔坏的那一刻就开始清点,会漏掉 A 组手上那件“稍后才放下”的家具。

餐厅订座(显存准入)

一张 80 座的桌子(一张卡),每桌客人(stage)按“桌子大小的百分比”订座,外加每桌固定几把备用椅(reserve)。开饭前先算一遍 Σ(比例 × 80 + 备用椅) ≤ 80?超了就当场告诉客人“坐不下”。跨两张桌子的大客户(TP=2)每张桌子都按它的比例各算一份。

框架视角 · T · SBWC · LIFE

P16
T 主线程调用 run_init;一个线程池,每个组一个任务(不是 asyncio:init_fn 是阻塞的同步调用)。真实系统里每个 stage 是独立进程,同卡测量的互斥靠跨进程文件锁
S results(成功的 stage → handle)、failure(第一次失败,最多一条)。多个 worker 线程都会写 → 一把 threading.Lock
B init_fn(sid) 很慢——必须在锁外调用,否则组间并行退化成全局串行。主线程在“线程池退出”处等所有组结束
W 每个组一个 worker:按 stage id 顺序 for 循环;每次开始新 init 之前看一眼 failure。不准:组内并行、在失败瞬间做 cleanup
C 没有队列;worker 之间只通过锁保护的共享状态交流(一面“已出事”的旗子)
L 先 join 再回滚:线程池的 with 块退出 = 所有组都结束、results 不再变化,然后统一 cleanup、再 raise
I 成功返回完整 dict;失败只有一种出口:InitError(第一个失败的 stage),原始异常挂在 __cause__ 上
F ① 同组 stage 的 init 永不重叠且按 id 顺序;② 第一次失败之后不再开始新的 init;③ 返回/抛出时所有成功过的 stage 要么在结果里、要么已被 cleanup——恰好一次;④ 失败的 stage 不 cleanup
E 多个组几乎同时失败(只记第一条);正在跑的 init 在失败之后才成功(也要回滚);max_workers < 组数(排队的组轮到时发现已失败直接返回);KeyboardInterrupt 之类也要触发回滚;空卡不报错;need 恰好等于 total 允许

易错点

逐步图:并查集推导(例 1 第一组)

▶ {0: {2}, 1: {0}, 2: {0, 1}, 3: {1}, 4: {5}},按 stage 升序遍历,owner[device] = 第一次占用者
1. stage 0,devices {2}

owner[2] = 0。parent:0→0 1→1 2→2 3→3 4→4

2. stage 1,devices {0}

owner[0] = 1。不变

3. stage 2,devices {0, 1}

dev0 已属 1 → union(2, 1):让较小的当根 → 2→1;owner[1] = 2

4. stage 3,devices {1}

dev1 已属 2 → union(3, find(2)=1) → 3→1

5. stage 4,devices {5}

owner[5] = 4。不变

6. 按 root 收集

{0:[0], 1:[1,2,3], 4:[4]} → 按首元素排序 → [[0], [1, 2, 3], [4]]

union 时让较小的 id 当 root,root 恰好是组内最小值(非必须)。

三级提示

L1I/O 示例与关键问题I/O 示例 + 关键问题 + 细节(轻度剧透)

例 1:init_groups(含传递)

{0: {2}, 1: {0}, 2: {0, 1}, 3: {1}, 4: {5}}    → [[0], [1, 2, 3], [4]]
{3: {7}, 1: {7}, 0: {9}, 2: {9, 4}}            → [[0, 2], [1, 3]]      # dict key 顺序是乱的
{0: {0}, 1: {1}, 2: {2}, 3: {2, 0}}            → [[0, 2, 3], [1]]      # stage3 把 0 和 2 “桥接”起来

例 2:run_init 失败短路(max_workers=1,让顺序确定):

stage_devices = {0: {0}, 1: {0}, 2: {1}}     → groups [[0, 1], [2]]
init_fn: stage1 抛 RuntimeError("boom"),其它返回 "e<sid>"
组[0,1]: init(0) → "e0" ✓ ; init(1) → 抛异常,记为第一次失败
组[2] : 开始前发现已有失败 → 不调用 init(2)
结束后:cleanup_fn(0, "e0");raise InitError(stage_id=1)
若改成 stage2 失败:调用 [0, 1, 2],cleanup (0,"e0"),(1,"e1"),InitError.stage_id = 2
全部成功:init_fn = lambda s: s*10 → {0: 0, 1: 10, 2: 20}

例 3:admission_check(GiB = 2³⁰,两张 80 GiB 卡):

reserve = 2 GiB, {0: ({0}, 0.9), 1: ({1}, 0.6), 2: ({1}, 0.3)}
  dev0: 72+2 = 74 ≤ 80 ✓    dev1: (48+2)+(24+2) = 76 ≤ 80 ✓        → []
stage2 改成 0.35:dev1 = 50 + 30 = 80 ≤ 80 ✓(恰好相等也允许)     → []
再把 reserve 改成 4 GiB:dev1 = 52 + 32 = 84 > 80                    → 1 条,含 "device 1"
跨卡 stage(TP=2),reserve = 3 GiB, {0: ({0, 1}, 0.5), 1: ({1}, 0.45)}
  dev0: 40+3 = 43 ✓    dev1: (40+3)+(36+3) = 82 > 80                  → 1 条,含 "device 1"
再加一张没有任何 stage 的 dev2 → 不产生错误

关键问题

  1. “共享设备”为什么要按传递闭包分组?举一个只看两两关系会出错的例子。
  2. 组内为什么必须串行?
  3. 第一次失败发生时,另一个组里正在执行的 init_fn 怎么办?能不能强行中断线程?
  4. cleanup 应该在失败那一刻立即调用,还是等所有线程都结束后?
  5. 多个组几乎同时失败,InitError 报哪一个?谁保证“第一个”只记录一次?
  6. 一个 stage 放在 {0, 1} 两张卡上,utilization 按哪张卡的 total 算?算几次?
  7. 比较用 > 还是 ≥?没有任何 stage 的卡要不要输出?

细节

  • 组的规范形式:每组升序;组之间按各自最小 stage 排序——与 dict 插入顺序、set 迭代顺序无关。
  • cleanup_fn(stage, result) 的第二个参数是该 stage 的 init_fn 返回值。
  • 推荐 raise InitError(sid, cause) from cause 保留原始 traceback。
  • 错误字符串每张超额的卡一条,必须包含子串 device <id>。
L2思路与常见坑思路拆分与常见坑(中度剧透)
  • init_groups = 连通分量:① 并查集(记录“这个 device 第一次被谁占”,再遇到就 union);② 二部图 BFS/DFS。规模很小,关键是传递性。
  • run_init 结构:先 init_groups;每组一个任务提交到 ThreadPoolExecutor(max_workers);任务内部按 stage id 顺序调用 init_fn。with 块退出会 join 所有任务——这正是“等正在跑的结束”。
  • 共享状态:results、failure,用一把 Lock 保护。每个组在调用每个 init_fn 之前检查 failure,有就 return。
  • 异常在任务内部 try/except(捕获 BaseException),把 (sid, e) 写进共享状态。
  • cleanup 放在线程池关闭之后。
  • admission_check:外层排序遍历 device_total_bytes,内层找出所有 dev in devices 的 stage,累加 int(util * total) + reserve,need > total 才报错。
L3详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
  1. {0:{0,1}, 1:{1,2}, 2:{2}}:stage0 与 stage2 不直接共享卡,但都与 stage1 共享;若分开就会出现同卡并发。所以整个连通分量串行。
  2. 每个 stage init 会“测空闲显存 → 定 KV block 数”;同卡上另一个 stage 在加载/profile 时测量会偏。不同卡互不影响,所以组间并行缩短启动时间。
  3. 不中断,让它跑完;它成功就进 results,稍后统一 cleanup。
  4. 等线程池全部 join 之后。
  5. 报第一个被记录的失败;锁内 if not failure: failure.append(...) 保证只记一次。
  6. 按每张所在卡各自的 total 算,在每张卡上各算一次(含各自的 reserve)。
  7. need > total 才报错(相等允许);空卡 need=0,不输出。

思考延伸:max_workers 小于组数时,后面的组会排队;排到时发现已有失败就直接 return——这就是例 2 中 stage2 从未被调用的原因。max_workers 足够大时组[2] 可能已在跑,那么它会跑完并被 cleanup——两种行为都符合 spec,所以写单测时要用 Event 控制时序,而不是依赖 sleep。

面试官追问 & 对照源码

对照源码:engine/stage_runtime.py:879-956 · engine/stage_init_utils.py:1738-1762 · engine/stage_admission.py:205 · engine/stage_phase_lock.py:106-238