GLM5.3 CyberSec w4A16本地部署[10]:CUDA 图捕获与编译开关

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开启的时机)
    • [五 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. 折算 :1,2,4,...,64 变成 6,12,18,24,36,48;
  2. 自弃 :顶项 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 对照。
相关推荐
m0_547486664 小时前
《Ubuntu Linux操作系统与实验教程》全套PPT课件2026
linux·ubuntu·操作系统
John_Hui4 小时前
第 1 篇:VMware 安装 Ubuntu 24.04.3 Server
linux·运维·ubuntu
缘友一世4 小时前
GLM5.3 CyberSec w4A16本地部署[15]:抗退化采样与思考档
ubuntu·vllm·glm5.3 cybersec
心随_风动6 小时前
Ubuntu系统安装Apache服务教程
linux·ubuntu·apache
John_Hui6 小时前
第 3 篇:配置 JDK 17 环境
java·开发语言·ubuntu
Zevalin爱灰灰6 小时前
Linux入门第一部分(Ubuntu入门篇) 第三章 shell脚本入门(一)
linux·ubuntu·嵌入式
hhhjhl7 小时前
Zod 跨端三方库仓颉适配全流程
linux·运维·ubuntu
西索斯coding8 小时前
qwen3.8-max 和 minimax-m3 改后端仓库实测复核:补丁通过率与调用成本别急着排冠军
linux·运维·ubuntu·ai
todoitbo20 小时前
在 Ubuntu ARM64 上运行 Q1 V3.0 MuJoCo 模型:加载、步进和单执行器响应
linux·运维·ubuntu·ai·机器人·仿真教程·启元