CUDA 图捕获与编译开关
本篇是讲两件在部署阶段最容易被"想当然"搞错的事:CUDA 图捕获列表的单位到底是什么 、inductor 编译到底什么时候值得开 。前者的一个错误写法让高并发吞吐直接腰斩(修正后 conc=16 +70%),后者的答案则取决于有没有 MTP。
文章目录
- [CUDA 图捕获与编译开关](#CUDA 图捕获与编译开关)
-
- 结论速览
- [一 捕获列表:单位是 token,不是 batch](#一 捕获列表:单位是 token,不是 batch)
-
- 掉头的高并发曲线
- [根因:round_up 折叠 + max 过滤](#根因:round_up 折叠 + max 过滤)
- [修复:直接写在 token 空间](#修复:直接写在 token 空间)
- [效果:conc=16 +70%](#效果:conc=16 +70%)
- [二「把 max 调大」救不回来](#二「把 max 调大」救不回来)
- [三 图捕获的代价](#三 图捕获的代价)
- [四 inductor开启的时机](#四 inductor开启的时机)
-
- [无 MTP:+1% 是噪声,纯亏](#无 MTP:+1% 是噪声,纯亏)
- [有 MTP:+9~13%](#有 MTP:+9~13%)
- 为什么同一个开关,结论差这么多
- [五 v0.30 会自动关掉 inductor](#五 v0.30 会自动关掉 inductor)
- [六 `--async-scheduling` 不是 no-op](#六
--async-scheduling不是 no-op) - 复现命令
- 小结
结论速览
- CUDA 图捕获列表的单位是 token,不是 batch ,且随 MTP 步数变化。列表照抄batch空间时,高并发(conc≥12)全程没有 CUDA 图裸跑;改成 token 空间后 conc=16 从 189.5 提升到 321.7 tok/s(+70%) 。「把
max_cudagraph_capture_size调大」解决不了这个问题,它是派生量,没有独立入口,而且 batch 空间也折不出需要的 token 数。 - inductor 的收益挂在 MTP 上 :无 MTP 时开编译只有 +1%(噪声),纯亏;有 MTP 时 +9~13%。
- VLLM v0.30 会自动把 inductor 关掉 (启用 breakable CUDA graph 后强制
mode=NONE),必须 A/B。实测BREAKABLE_CUDAGRAPH=0恢复编译:单流 +3.1% 、conc2 +6.8% ,采样质量无差异,代价是高并发略降、启动 +255 s。 --async-scheduling不是 no-op :关掉它单流 −5.5%。正确做法是不显式加、也不要关,让 vLLM 自动开启。
一 捕获列表:单位是 token,不是 batch
掉头的高并发曲线
- 排查从一个异常现象开始:同一产物、同一请求口径,开 MTP 的这一侧,聚合吞吐曲线在 conc=12~16 出现了不该有的低谷:conc=12(151.1)比 conc=8(214.5)还低,conc=16 也只有 189.5。而无 MTP 的对照侧一切正常。两边唯一的差别是推测解码,这条线索最终指向了 CUDA 图。
- 启动日志里其实早就写出了答案的一半:
bash
Profiling CUDA graph memory: PIECEWISE=6 (largest=48), FULL=6 (largest=48)
- 当时的捕获列表是照抄参考项目写法:
1,2,4,8,16,24,32,48,64。看到largest=48,我们以为它对应 batch=48,实际上这个数字的单位是 token。
根因:round_up 折叠 + max 过滤
- MTP 下每个序列一步产出
num_spec+1个 token,所以 vLLM 读取捕获列表时,会把每个元素当作 batch,再按步长向上取整折成 token 数(vllm/config/compilation.py):
python
multiple_of = uniform_decode_query_len # = num_speculative_tokens + 1
rounded_sizes = sorted(set(
round_up(size, multiple_of)
for size in self.cudagraph_capture_sizes
if round_up(size, multiple_of) <= self.max_cudagraph_capture_size # = max(列表)
))
在 num_spec=5(步长 6)下,这一段代码同时造成两个后果:
- 折算 :
1,2,4,...,64变成6,12,18,24,36,48; - 自弃 :顶项 64 折成
round_up(64,6)=66,超过了max(列表)=64,被自己丢掉。
最终只捕到 6 张图,覆盖 batch ≤8。batch≥12 的所有请求都没有 CUDA 图,全程 eager 跑 ,这正是并发曲线掉头的原因。日志里的 6 (largest=48) 与验算逐字吻合。
修复:直接写在 token 空间
- 修法很直接:列表每一项都写成
batch × (num_spec+1),让round_up变成恒等变换,没有任何条目会被过滤:
bash
batch 1,2,4,8,16,24,32,48,64 × 6 = 6,12,24,48,96,144,192,288,384
- 部署脚本不手写这个列表,
resolve_defaults()会按 MTP 步数自动推导,启动时打印出来可直接核对:
bash
[deploy] CUDAGRAPH_CAPTURE_SIZES 未指定 -> 4,8,16,32,64,96,128,192,256(有 MTP,单位=token=batch×4)
效果:conc=16 +70%
| conc | 旧列表(≥12 裸跑) | token 空间(全覆盖) | 变化 |
|---|---|---|---|
| 1 / 2 / 4 / 8 | 66.7 / 103.7 / 148.7 / 214.5 | 64.6 / 106.4 / 149.8 / 212.0 | 持平 |
| 12 | 151.1 | 242.8 | +61% |
| 16 | 189.5 | 321.7 | +70% |
| 24 | 288.7 | 379.4 | +31% |
| 32 | 385.3 | 436.8 | +13% |
| 48 / 64 | 510.4 / 596.0 | 523.4 / 601.3 | 持平 |
低谷消失,曲线从 conc=1 到 64 恢复单调;与参考项目 conc=16 = 359 的差距从 −47% 收窄到 −10%;p50 延迟同步改善(conc=16 从 21.05 s 降到 12.51 s)。
二「把 max 调大」救不回来
- 发现根因后,一个想法是:条目是被
max(列表)过滤掉的,那把 max 设大不就行了?这条路走不通,有两个独立的原因。
其一,max_cudagraph_capture_size 不是可独立设置的参数。 max = rounded_sizes[-1](处理完之后的列表顶项),另有一致性断言 assert cudagraph_capture_sizes[-1] == max_cudagraph_capture_size。它是派生量,想显式传一个更大的值,这条路径上没有入口。
其二,batch 空间产生不出需要的 token 数。 就算 max 无限大,round_up(s, 6) 把 batch 折出来的也是一个稀疏集合:1,2,4,8,12,16,32,64 最多只能折出 6,12,18,36,66,覆盖 batch 1/2/3/6/11。要覆盖 batch 24/48/96,列表里必须出现 24/48/96 这样的 token 数,而能让 round_up(·,6) 恰好等于它们的输入是 19~24、43~48、91~96 这种"不像 batch"的数------batch 空间里根本没有。调大 max 最多把覆盖从 ≤3 抬到 ≤6,反而不如原列表的 ≤8。
由此得到通用规则:
- 列表顶项必须自己就是
num_spec+1的倍数 ,否则必然被round_up > max丢弃(原列表丢掉 64 正是这个原因); - 列表上界要和调度上限对齐:最大 token 数应 ≥
MAX_NUM_SEQS × (num_spec+1); - 最省事的做法是不要手写,交给脚本按 MTP 步数推导,以启动日志打印值为准。
三 图捕获的代价
- 理解了怎么写对列表,还要知道要付什么代价。
启动时间。 CUDA Graph 无法像编译产物那样落盘复用,图里记录的是进程内显存地址,每次启动必须重新捕获。实测也证实了这点:编译缓存命中与否,图捕获耗时持平(64 s vs 61 s)。所以控制启动耗时的唯一手段就是裁剪捕获列表 :vLLM 默认会捕到 batch 256、约 27 张图、耗时 389 s;裁到 9 档后降到 1 分钟以内(piecewise 形态约 61~64 s,mode=none 形态仅 6 s)。
显存。 图池随列表和 MTP 一起变大:MTP 开启后末级 stage 的 graph pool 从 0.76 涨到 1.49 GiB(末级要多背 MTP 层、lm_head 和 draft 缓冲);num_spec 从 3 加到 4 时 KV 池再少 1.4 GiB,因为捕获列表要覆盖到更大的 token 数。做显存预算和 PP 切分时,这部分要单独记账。
后端降级。 我们实际遇到的不是"逐尺寸捕获失败",而是模式级降级:DSA indexer 后端不支持 FULL 图,启动日志会明确写出
bash
CUDAGraphMode.FULL is not supported with DeepseekV32IndexerBackend
(support: AttentionCGSupport.UNIFORM_BATCH); setting cudagraph_mode=FULL_DECODE_ONLY
连带影响:改列表 = 编译缓存失效。 cudagraph_capture_sizes 属于 --compilation-config,列表一变,inductor 缓存即失效,要多付一次约 127 s 的重编译。
四 inductor开启的时机
无 MTP:+1% 是噪声,纯亏
- 补 MTP 头之前,我们做过
mode=none(不编译 + FULL 图)与COMPILE_MODE=default(inductor + piecewise 图)的对照:
| 指标 | 不编译 | inductor | 结论 |
|---|---|---|---|
| 单流 decode | 37.0 tok/s | 37.4 tok/s | +1%,噪声 |
| conc=4 聚合 | 115.1 | 110.7 | −4%,噪声 |
| TTFT | 0.312 s | 0.548 s | 变差 |
| init engine | 54.3 s | 216.1 s(冷) | 差 4 倍 |
| CUDA graph 捕获 | 6 s | 61~64 s | 差 10 倍 |
| KV cache | 19.83 GiB | 19.43 GiB | −0.4 GiB |
- 吞吐在噪声范围内,启动更慢、KV 更少、TTFT 更差------当时结论是"开编译纯亏"。
有 MTP:+9~13%
- 补回 MTP 头之后重测同一对照:conc=1 从 64.6 到 70.0,conc=4 从 127.9 到 143.8,+9~13%。于是缺省配置改成了"有 MTP 头就开编译"。
为什么同一个开关,结论差这么多
-
参考项目报告编译带来 +34%(56.2 → 75.6 tok/s,MTP 下测的);而我们无 MTP 时只有 +1%。两组数字并不矛盾,差别在条件:
-
有 MTP 时,每个 decode step 要并行验证多个候选 token,前向里多出一堆小算子,mask / 采样 / 接受的胶水逻辑也进来了。kernel launch 和框架开销占比高,编译融合的收益自然大。
-
没有 MTP 时,每步就是单 token 的 forward,瓶颈在 MoE 专家权重的访存和 indexer 的 Triton 内核,这些本来就不是 launch 开销,编译无从优化。
一句话:编译的收益挂在推测解码上。据此,部署脚本按产物能力自动选择编译模式,不加参数就是最优:
| 产物 / 场景 | SPEC_METHOD | COMPILE_MODE | 依据 |
|---|---|---|---|
| 有 MTP 头(当前产物) | mtp |
default |
单流 ×1.74,编译再 +9~13% |
| 无 MTP 头 | none |
none |
编译纯亏 |
ngram 投机 |
ngram |
default |
同为"每步多 token",编译同样值得开 |
五 v0.30 会自动关掉 inductor
现象与根因
- VLLM v0.30 后,同一模型、同一套参数,启动日志:
| 镜像 | 编译模式 | cudagraph_mode |
|---|---|---|
| v0.30.0-ubuntu2404 | NONE: 0 |
FULL_AND_PIECEWISE |
v0.30 的日志里一行关键提示:Auto-enabling VLLM_USE_BREAKABLE_CUDAGRAPH=1。根因在源码里:v0.30 的 breakable CUDA graph 架构白名单新增了 GlmMoeDsaForCausalLM 和 DeepseekV32MTPModel,命中后自动启用 breakable 图,并紧接着把编译模式设为 NONE。v0.28 的白名单不含这两个架构,所以拿到的是正常编译。
注意这是"必须 A/B"而不是"必须回退":上游把这个模型家族放进清单是有理由的(这些模型类不带 @support_torch_compile,breakable 图才是被支持的 PIECEWISE 路径),两种结论都有可能,只能实测。
A/B 实测:恢复编译单流 +3.1%
- 两者只差一个容器环境变量
VLLM_USE_BREAKABLE_CUDAGRAPH,且都先在日志里自证编译模式确实变了。共用配置:GPU_MEM_UTIL=0.973、MAX_MODEL_LEN=352000、fp8_ds_mla、MTP k=3。
| 指标 | 缺省 breakable(mode=NONE) | 恢复编译(VLLM_COMPILE) | Δ |
|---|---|---|---|
| 单流纯解码 | 78.4 tok/s | 80.8 tok/s | +3.1% ✅ |
| conc2 聚合 | 114.7 | 122.5 | +6.8% ✅ |
| conc4 聚合 | 165.2 | 168.1 | +1.8% |
| conc8 聚合 | 157.6 | 144.8 | −8.1% ⚠️ |
| conc16 聚合 | 253.4 | 245.8 | −3.0% ⚠️ |
| 退化率(sane 采样 5×200) | 0/200 | 1/200 | Fisher p=1.0,无差异 |
| 启动 | 619 s | 874 s | +255 s ⚠️ |
- 恢复编译在单流和低并发上全面更好,且每步产出从 3.00 token 升到 3.06,增益来自 MTP 接受率改善加上步进略快,不是单纯的 kernel 提速。代价在高并发(conc8 −8.1%)和启动时间。安全性上,两者 200 次采样分别 0 和 1 条真退化,统计上无差异,这 +3.1% 是"白拿的"。按本项目单流速度优先的原则,选择恢复编译。
一个静默无效的坑
- 恢复编译的正确写法:
bash
BREAKABLE_CUDAGRAPH=0 ./deploy.sh start
# 或通用写法:
DOCKER_ENV_EXTRA="VLLM_USE_BREAKABLE_CUDAGRAPH=0" ./deploy.sh start
⚠️ 必须走 BREAKABLE_CUDAGRAPH 或 DOCKER_ENV_EXTRA 透传。 直接写 VLLM_USE_BREAKABLE_CUDAGRAPH=0 ./deploy.sh start 是静默无效 的,脚本只自动转发 NCCL_* 变量,其他 VLLM_* 不会进容器。设置后务必在日志里核对 'mode': <CompilationMode.VLLM_COMPILE: 3> 确实出现。
六 --async-scheduling 不是 no-op
- 我们最初基于源码推断,认为
--async-scheduling无需处理:vLLM 里它是三态配置,缺省走自动决策,而我们的场景(非 pooling、MTP 属于 Eagle 家族、非 ROCm)不在任何禁用清单里,日志里也没有出现禁用告警,所以它已经被自动开启,当时就把这个参数从待办里划掉了。
但这个推断只证明了"它已经开着",没有证明"关掉它没差别"。真正有信息量的是反向对照:
| 口径 | 自动开启(对照) | 显式关闭 | 变化 |
|---|---|---|---|
| 单流 decode(5 次取中位) | 67.7 | 64.0 | −5.5% |
| conc=1 / 4 / 8 | 71.8 / 192.3 / 246.6 | 69.5 / 188.1 / 237.0 | −2~4% |
| conc=16 / 32 / 64 | 382.1 / 531.5 / 668.2 | 387.7 / 532.2 / 688.8 | +0~3% |
| 聚合总和 | 2092 | 2103 | +0.5% |
结论:单流 −5.5% 超出噪声阈值(且是 5 次取中位,比并发档的单次测量可靠),高并发则基本持平。自动开启的推断被直接证据确认,最终决定自动开启。
复现命令
bash
cd deploy
# ── 先看缺省会怎么开(不启动服务)────────────────────────────
./deploy.sh env # 容器 env(含 BREAKABLE_CUDAGRAPH)
./deploy.sh cmd # 最终 serve argv(含 compilation/speculative config)
# ── 捕获列表:交给自适应,别手填 ────────────────────────────
NUM_SPEC_TOKENS=3 ./deploy.sh start # k=3 -> 4,8,16,32,64,96,128,192,256
./deploy.sh wait
docker logs glm53-cybersec-w4a16 2>&1 | grep -E "CUDAGRAPH_CAPTURE_SIZES|Profiling CUDA graph memory"
# 期望:9 张、largest=256(错误写法下是 6 张、largest=48)
# ── inductor:有 MTP 开 / 无 MTP 关 ──────────────────────────
COMPILE_MODE=default ./deploy.sh start # 有 MTP:+9~13%
COMPILE_MODE=none ./deploy.sh start # 无 MTP:+1% 噪声,纯亏
# ── v0.30 恢复编译的 A/B ─────────────────────────────────────
BREAKABLE_CUDAGRAPH=0 ./deploy.sh start # 单流 +3.1% / conc2 +6.8%
docker logs glm53-cybersec-w4a16 2>&1 \
| grep -E "Auto-enabling VLLM_USE_BREAKABLE_CUDAGRAPH|'mode': <CompilationMode\.[A-Z_]+: [0-9]+>"
# 不要写 `VLLM_USE_BREAKABLE_CUDAGRAPH=0 ./deploy.sh start`(静默无效)
# ── async-scheduling 反向对照 ────────────────────────────────
ASYNC_SCHEDULING=0 ./deploy.sh start # 单流 −5.5%
# 生效证据:日志 non-default args 出现 'async_scheduling': False
# ── 自证现网 == 缺省 ─────────────────────────────────────────
./deploy.sh defaultcheck # argv 与容器 env 逐项比对,漂移则 exit 2
小结
- 这两个参数的教训本质上是同一类:部署开关的语义不看文档、只看日志和源码,就会踩坑 。捕获列表的单位藏在
round_up折叠逻辑里,编译模式的切换藏在 v0.30 的自动白名单里,都不会在报错里跳出来提醒。养成两个习惯能避开大多数这类问题:改参数后先看启动日志自证生效(图张数、largest=、mode),再做一次的 A/B 对照。