P16 · 启动规划:设备分组、并行初始化、回滚、准入
thinker 独占 GPU0、talker 和 code2wav 共享 GPU1:后两者必须串行初始化(显存测量会互相干扰),但两组之间可以并行;任何一个失败都要把已起来的全部回收。
mine/p16_init_planner.py
验收 ./check.sh p16
提示 A-25 · 参考解 C-25 solutions/p16_init_planner.py
题面速览
- 在系统中的位置:每个 stage 启动时会:加载权重 → 跑一次最大 batch 的 profile → 用“显存预算 − 已用”算出 KV block 数 → capture CUDA graph。第 2、3 步依赖测量当前空闲显存:同卡上另一个 stage 恰好在加载权重时测量就是错的——KV 算多了会 OOM,算少了浪费。
- 需求:
- 分组:共享设备(含传递关系:{0,1} 和 {1,2} 也是一组)的 stage 属于同一组;组内按 stage id 串行,组间在线程池里并行。
- 失败短路:第一个失败后不再启动任何新的 init;正在进行的让它结束;然后对所有已成功的 stage 调用 cleanup;抛
InitError(stage_id)。 - 准入检查:启动前对每张卡检查
Σ(util × total + reserve) ≤ total,提前拒绝不可能成功的部署。
默认 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 允许 |
易错点
- 组内也并行(把每个 stage 单独 submit)→ 同卡 stage 并发初始化。
- 失败后只检查“本组”是否失败,其他组还会继续启动新的 stage。
- 把失败的 stage 也 cleanup(它没有 result)。
- 只捕获 Exception——想想 init 里的 KeyboardInterrupt / SystemExit。
- 异常留在 Future 里:
ex.map/future.result()在主线程重新抛出,丢失“哪个 stage”以及“先 cleanup 再 raise”的控制。 - 失败瞬间就 cleanup:漏掉之后才完成的 stage。
- 持锁调用 init_fn。
- 只看两两关系不做传递闭包。
- admission 用
≥报错(相等应允许),或对空卡输出错误。
逐步图:并查集推导(例 1 第一组)
owner[2] = 0。parent:0→0 1→1 2→2 3→3 4→4
owner[0] = 1。不变
dev0 已属 1 → union(2, 1):让较小的当根 → 2→1;owner[1] = 2
dev1 已属 2 → union(3, find(2)=1) → 3→1
owner[5] = 4。不变
{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 → 不产生错误
关键问题
- “共享设备”为什么要按传递闭包分组?举一个只看两两关系会出错的例子。
- 组内为什么必须串行?
- 第一次失败发生时,另一个组里正在执行的 init_fn 怎么办?能不能强行中断线程?
- cleanup 应该在失败那一刻立即调用,还是等所有线程都结束后?
- 多个组几乎同时失败,InitError 报哪一个?谁保证“第一个”只记录一次?
- 一个 stage 放在 {0, 1} 两张卡上,utilization 按哪张卡的 total 算?算几次?
- 比较用 > 还是 ≥?没有任何 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详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
- {0:{0,1}, 1:{1,2}, 2:{2}}:stage0 与 stage2 不直接共享卡,但都与 stage1 共享;若分开就会出现同卡并发。所以整个连通分量串行。
- 每个 stage init 会“测空闲显存 → 定 KV block 数”;同卡上另一个 stage 在加载/profile 时测量会偏。不同卡互不影响,所以组间并行缩短启动时间。
- 不中断,让它跑完;它成功就进 results,稍后统一 cleanup。
- 等线程池全部 join 之后。
- 报第一个被记录的失败;锁内
if not failure: failure.append(...)保证只记一次。 - 按每张所在卡各自的 total 算,在每张卡上各算一次(含各自的 reserve)。
need > total才报错(相等允许);空卡 need=0,不输出。
思考延伸:max_workers 小于组数时,后面的组会排队;排到时发现已有失败就直接 return——这就是例 2 中 stage2 从未被调用的原因。max_workers 足够大时组[2] 可能已在跑,那么它会跑完并被 cleanup——两种行为都符合 spec,所以写单测时要用 Event 控制时序,而不是依赖 sleep。
面试官追问 & 对照源码
- 真正并行初始化同卡 stage 时,怎样用跨进程读写锁让“测量空闲显存”独占?
对照源码: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