P17 · CUDA graph bucket runner(CPU 模拟)
decode 的 kernel launch 开销靠 CUDA graph 消除;graph 只认静态 buffer——pad、清零、replay、clone,以及什么时候必须退回 eager。
mine/p17_graph_runner.py
验收 ./check.sh p17
提示 A-26 · 参考解 C-26 solutions/p17_graph_runner.py
题面速览
- 在系统中的位置:decode 时每步计算很少,但要发射成百上千个 kernel,CPU 发射速度成了瓶颈。CUDA graph 把一次 forward 录成图,之后一次 launch 整图重放。代价:图只认录制时的那块内存和录制时的形状。
启动时:为每个 batch bucket(1,2,4,8,...)在同一块静态 buffer 的前 b 行上录一张图
运行时(bs 个请求):
b = 最小的 ≥ bs 的 bucket;没有 → eager
static_in[:bs] = x; static_in[bs:b] = 0 # 上一次更大 batch 留下的脏行
graph[b].replay() # 结果写在 static_out[:b]
return static_out[:bs].clone() # 下一次 replay 会覆盖 static_out!
- 本题用
CpuGraph在 CPU 上模拟这种语义:replay 永远只读静态 buffer、不接受新参数。 - 必须退回 eager:bs 超过最大 bucket;batch 里有请求自带 RNG generator(P08)——replay 不会重新执行 Python 代码,per-row generator 会被悄悄忽略。
验收要点:每个 batch size 结果与 eager 一致 · bucket 选择与两种 eager 回退 · 返回值不别名静态 buffer · padding 行被清零 · 运行时从不重新 capture。
直觉与类比
面包房只有 1 格、3 格、6 格三种烤盘,烤箱程序都提前调好了(capture),开工后不再重新编程。来了 2 个面团:用 3 格的烤盘(向上取整),空着的那一格要先把上一炉的面包屑扫干净(清零 padding 行)。烤好之后必须把面包从烤盘上拿下来装袋再交给客人(clone)——如果直接把“烤盘上那几格”指给客人,下一炉一开烤,客人手里的面包就变成了别人的。来了 7 个面团?最大烤盘只有 6 格 → 手工一个个烤(eager)。
CUDA graph 像一盘录好的伴奏带:按下播放就一模一样地再放一遍。你可以换一个唱歌的人(把新数据拷进同一个话筒 = 静态 buffer),但不能在播放时让乐队改编。客人要求“用我自己的随机节奏”(自带 torch.Generator),伴奏带不会报错,只会照旧播放录制时的节奏——要求被悄悄忽略。所以这种请求必须请现场乐队(force_eager)。
框架视角 · 一次 replay 调用(bs=2、bucket 3、hidden=2)
| # | 张量 | 形状 | 发生了什么 |
|---|---|---|---|
| 1 | x | [2, 2] |
调用方的新 batch(Python 参数,graph 看不到它) |
| 2 | 选 bucket | 标量 3 | 最小的 ≥ 2 的 bucket;没有 / force_eager → 直接 fn(x) |
| 3 | static_in[:2] ← x |
[2, 2] 写入 [6, 2] |
原地拷贝进录制时的那块内存 |
| 4 | static_in[2:3] ← 0 |
[1, 2] |
padding 行清零(之前是上一次 bs=5 留下的 5);第 3..5 行在 bucket 外,不碰 |
| 5 | graphs[3].replay() |
读 static_in[:3],写 static_out[:3] |
输出 5, 5, −1(padding 行 = fn(0)) |
| 6 | static_out[:2].clone() |
[2, 2] |
只取真实行,并且拷贝出来,和静态 buffer 脱钩 |
易错点
- 重新赋值而不是拷贝:把
self.static_in换成新 tensor,graph 还在读旧的那块内存。 - 忘记清零 padding 行:本题 fn 逐行独立,脏行只让 padding 输出变垃圾;真实模型里有跨行交互(MoE 路由统计、NaN 扩散、整 batch 归约),会污染真实行。
- 返回 view:单次调用看不出来,第二次调用后第一次的结果被悄悄改写。
- eager 分支碰了静态 buffer,或者在 eager 时更新了 last_bucket。
- 运行时现场 capture:很慢、要 warmup 和同步,造成延迟尖刺;所有图都必须在启动期录好。
- capture 顺序:应从大到小(CUDA 上多张图共享一个 memory pool,大图先占好内存,小图复用)。
- 忘了删掉 stub 末尾的
raise NotImplementedError,或覆盖了已有字段。
逐步图:例 1 一串调用
bucket 16 · replays 1 · eager 0 · last_bucket 16
static_in[:6,0]:101010101010 static_out[:6,0]:212121212121
bucket 4 · replays 2 · last_bucket 4
static_in[:6,0]:33301010 static_out[:6,0]:77712121
第 3 行被清零(上一次的 10),输出 fn(0)=1;第 4、5 行在 bucket 之外,没人碰。
没有 ≥17 的 bucket → eager:replays 2 · eager 1 · last_bucket 仍是 4 · 静态 buffer 不变
bucket 4 · replays 3 · last_bucket 4
static_in:44441010 static_out:99992121
eager:replays 3 · eager 2 · last_bucket 4 · buffer 不变
bucket 1 · replays 4 · last_bucket 1
static_in:14441010 static_out:39992121
y1 = r.run(x1) # 若直接 return static_out[:bs](一个 view)
y2 = r.run(x2) # replay 把 fn(x2) 写进 static_out
y1 # → 现在等于 fn(x2)!y1 被“悄悄改写”
返回 clone 后,y1 保持 fn(x1)。
三级提示
L1I/O 示例与关键问题I/O 示例 + 关键问题 + 细节(轻度剧透)
例 1、例 2 见逐步图。
关键问题
CpuGraph.replay()不接受参数。新的输入是怎么“进入”这张图的?输出又在哪里拿?- 为什么所有 bucket 的图都建在同一块 static buffer 的前 b 行,而不是每个 bucket 各自分配?
- bs=3、bucket=4 时第 3 行里可能是什么?不清零会导致什么后果?
static_in[bs:]全部清零 vs 只清static_in[bs:b]:哪个是必要的?多清有什么代价?- force_eager 的典型场景是请求带了自己的 torch.Generator。为什么 replay 会“悄悄忽略”它而不是报错?
- last_bucket 在走 eager 时要不要更新?
- run 发现某个 bucket 还没 capture,能不能现场 capture?为什么 spec 说 never?
细节
- CUDA graph 记录的是 kernel 以及它们读写的设备地址;replay 时地址不变,所以必须把新数据
copy_进同一块内存。 static_in[:b]是共享存储的 view;所有图共享同一块内存,所以上一次大 batch 的数据会残留在后面的行里。- “largest bucket first”:CPU 模拟里顺序不影响结果,但请按 spec 来。
- bucket 选择:最小的 b ≥ bs;buckets 在
__init__里已排好序,线性找即可(也可 bisect)。 - stats:replays 与 eager 是计数器;last_bucket 只在 replay 时记录。
- 返回值只含前 bs 行(padding 行的结果是“对 0 做计算”的垃圾)。
L2思路与常见坑思路拆分与常见坑(中度剧透)
__init__:max_bs = buckets[-1];分配 static_in、static_out[max_bs, hidden],用传入的 dtype。初始化成 0 比 empty 更安全(capture 时就会跑一次 fn)。- capture:遍历
sorted(buckets, reverse=True),用static_in[:b]/static_out[:b]构造 CpuGraph,.capture(),存进self.graphs[b]。 - run 的决策顺序:① 算 bs;② force_eager 或找不到 bucket(或该 bucket 不在 graphs 里)→ eager:计数 +1,
return self.fn(x);③ 否则 replay 分支。eager 分支不碰静态 buffer。 - replay 分支四步:copy 输入 → 清零
[bs, b)→ replay → 更新 stats → 返回 clone 的前 bs 行。清零在 replay 之前;clone 在下一次 replay 之前(即返回前)。 self.static_in[:bs] = x其实是原地写(等价于 copy_),没问题;但self.static_in = x是错的。
L3详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
- 输入:先 copy_ 进 static_in;replay 读 static_in、写 static_out;输出从 static_out 读。
- 省内存,而且 run 时只需一份“写入位置”:无论选哪个 bucket,都写 static_in 的前 bs 行。
- 可能是上一次更大 batch 的真实数据(例 1 中的 10)。本题不清零只让 padding 输出变垃圾;真实模型中可能 NaN 扩散、影响 MoE 路由与 expert 负载统计、影响整 batch 归约的算子。
- 只需清
[bs, b):这是这张图会读的范围;b 之后的行这张图根本不读,清它们只是多花带宽。 - replay 只是重放 capture 时录下的 kernel,不重新执行 Python;per-row generator 的逻辑根本不会被执行,所以不报错,只会结果不可复现/不符合 seed。
- 不更新;它的语义是“最近一次 replay 用的 bucket”。
- 不能:capture 很慢(要 warmup、同步),在 CUDA 上还会和正在运行的 stream / 内存池冲突;运行时捕获会造成延迟尖刺。
自检清单:① 连续两次 run,第一次的返回值不变;② 先跑一次大 batch 再跑小 batch,检查 static_in 的 [bs, b) 行全 0;③ capture 之后 self.graphs 里每个对象的 id() 在多次 run 后不变;④ bs > max bucket 和 force_eager 时 static buffer 完全不被修改。
面试官追问 & 对照源码
- graph 的显存要不要计入 KV 预算?vllm-omni 默认路径哪里漏算了?
- talker 的 MTP(M05:15 次小模型 forward)为什么适合整体包成一张 FULL graph?
对照源码:worker/gpu_ar_model_runner.py:634-690 · worker/gpu_model_runner.py:243-272 · worker/base.py:140-147