GLM-5.3-Flash / 4×A100:mamba align 越界、NCCL pin 的代价、cudagraph 捕获尺寸取整
2026-09-15 记录:把 30001 从 Qwen3.8 Flash Next 换成 GLM-5.3-Flash。换上去就遇到两次 cudaErrorIllegalAddress 崩溃,根因不在显存、不在并发、也不在 MTP,而在 align 模式下 mamba 状态索引的一个除数;修完之后重新审了两条当年留下的“保险”配置,发现它们各自折损了 3–8 倍吞吐和一整个并发区间的图执行。
1. 实际部署的权重
被服务的模型名不必等于权重本身,所以这里只记实际加载的检查点。全部参数、量化配置和分片清单如下。
| 项目 | 实际值 |
|---|---|
| 检查点 | GLM-5.3-Flash-AWQ-W4A16,178 GB |
| 分片 | 13 个:主干 model-00001..00009-of-00009(约 20 GB × 8 + 15.95 GB),MTP model-nextn-00001..00004(约 4.3 GB × 4) |
| 量化 | AWQ W4A16,compressed-tensors / pack-quantized,num_bits=4、group_size=128、targets=Linear、dynamic=false |
| 量化过程 | 256 个校准样本 × 2048 token、n_grid=10、duo_scaling=true、calibrate_all_experts=false,耗时约 59 分钟 |
| 架构 | Glm5NextForConditionalGeneration / glm5_next |
| 层 | 45 层;前 3 层 dense(intermediate 12288),其余 MoE;11 层 full attention(第 3,7,…,43 层),其余为线性注意力(GDN) |
| 注意力 | 64 个 query head 对 64 个 KV head —— 没有 GQA,KV 不做分组共享 |
| MoE | 288 个 routed expert + 1 个 shared,每 token 取 top-8,expert 宽度 2048 |
| 其他 | vocab 154,880;tie_word_embeddings=false(独立 lm_head);1 层 nextn(MTP);config 里带 vision 段 |
2. 上下文与 KV 容量
官方 recipe 允许把 --max-model-len 开到 524288,我们按实际业务走 272,000,换来的是更大的 KV 余量。启动时引擎自报的容量如下:
| 指标 | 实测 |
|---|---|
| 可用 KV 显存 | 23.98 GiB |
| KV 容量 | 1,893,249 token |
| 272K 满上下文并发 | 6.96× |
| KV 组块尺寸 | [1152, 4, 1152, 1152, 1152],lcm block = 1152 |
| 显存账 | 权重 + 非 torch 46.85 GiB,峰值激活 2.09 GiB,CUDAGraph 0.79 GiB,其余给 KV |
有两个必须记下来的隐式调整:indexer 后端(DEEPSEEK_V32_INDEXER)的块尺寸被设为 64,而 attention 块尺寸被抬到 1152 token,目的是让 attention page 不小于 mamba page;随后 mamba page 又被 padding 了 5.11% 以使两者严格相等。这两行日志在第 3 节的崩溃里是关键证据。
3. 服务参数
当前生产启动参数(去掉与本记录无关的通用项):
--tensor-parallel-size=4
--max-model-len=272000
--gpu-memory-utilization=0.92
--max-num-seqs=16
--max-num-batched-tokens=8192
--enable-prefix-caching
--speculative-config={"method":"mtp","num_speculative_tokens":3}
--compilation-config={"cudagraph_mode":"FULL_AND_PIECEWISE",
"cudagraph_capture_sizes":[1,2,4,8,16,32,64],
"max_cudagraph_capture_size":64}
--enable-auto-tool-choice --tool-call-parser=glm47 --reasoning-parser=glm45
--disable-custom-all-reduce
--enable-prompt-tokens-details
容器侧另加 init: true(否则 vLLM 的 worker 子进程会留下 zombie PID,容器停不掉)、shm_size: 32g、memlock: -1。GPU 只用 0–3,同节点的 4–7 未受影响。
4. 崩溃现场
切换完成后约 20 分钟内出现两次致命崩溃,表现完全一致:先是个别请求输出异常,然后 worker 直接死掉,容器自动重启。
torch.AcceleratorError: CUDA error: an illegal memory access was encountered RuntimeError: RPC call to sample_tokens aborted: a worker process died before responding
主机侧内核日志给出了更准的形状——四张卡同时报 Xid 13,以及随后的 Xid 31 MMU fault:
NVRM: Xid (PCI:...): 13 Graphics SM Warp Exception ...: Out Of Range Address
Multiple Warp Errors
NVRM: Xid (PCI:...): 31 MMU Fault ... FAULT_PDE ACCESS_TYPE_VIRT_READ
5. 根因:align 模式下的 mamba 状态索引越界
排除项先说完:不是显存不足(崩溃时 KV 只用了不到两成,1.89M token 的池子远未耗尽),不是并发,也不是 MTP。故障地址落在 CUDA 段之下,是负偏移读——OOM 不会产生这种地址。
启动日志里有三行决定了整个故事:
Mamba cache mode is set to 'align' for Glm5NextForConditionalGeneration by default when prefix caching is enabled Setting attention block size to 1152 tokens to ensure that attention page size >= mamba page size Padding mamba page size by 5.11% ...
我们没有传 --mamba-cache-mode,但只要开了 prefix caching,align 模式就会自动启用。而 align 模式下,恢复一个长前缀时要按 mamba 组的块尺寸去定位运行态状态。缺陷在 mamba_hybrid.py:构造函数在 KV cache 组建立之前就把块尺寸缓存了下来,于是回退到了 attention 的默认值:
self._mamba_block_size = (
self.cache_config.mamba_block_size or self.cache_config.block_size
)
随后 add_request 用它做除数:
(new_req_data.num_computed_tokens - 1) // self._mamba_block_size
按本次实测的数字算:一次 101,376 token 的前缀复用,除数错误地取 16 而不是 1152,列号约 6335,而该 block table 行宽只有约 88。融合的 align pre-copy 于是读到一个垃圾 block id。这个缺陷与上游报告的同类症状一致(vllm#53142 的 sm_121 侧),并且我们的代码里在调用点附近甚至已经留了说明性注释,除数却一直没修。
6. 我们的补丁
修法采用上游 PR #77 的“读取时解析”形状:把缓存属性换成一个 property,优先返回已解析的 mamba spec 块尺寸;在 spec 尚未解析时再走 cache_config.mamba_block_size(此时 KV 组已建好,该值已就位)。改动是单文件、单 hunk、纯 Python,所以不需要重建镜像——把打好的文件只读挂载覆盖到安装目录即可:
<host>/hotfix-pr77/mamba_hybrid.py:<site-packages>/vllm/v1/worker/gpu/ model_states/mamba_hybrid.py:ro
工程上的取舍写清楚:热补丁会随 docker compose 重建保留(挂载写进了 compose),但用一个不含该挂载的 compose 重建容器,修复就没了;镜像本身未打补丁。真正的落地是等上游合并后重拉镜像。回滚 = 删掉那一行挂载并重建。
7. 补丁验证
触发条件是“prefix caching + 长 prompt + 第二个请求续同一前缀”,所以必须把这条路走实,不能只看它没崩。
| 步骤 | 结果 |
|---|---|
| 冷请求,102,621 token prompt | 13.6 s,cached_tokens: 0 |
| 同前缀续用 ×3 | cached_tokens: 101,376,2.8 s / 2.8 s / 1.5 s,无崩溃 |
| 补丁后引擎状态 | 重启计数 0,health 200,illegal memory access 0 次,Xid 0 条 |
一个必须记下来的反面经验:用 llama-benchy --depth 140000 --runs 3 当复现器是无效的——那几轮的前缀命中率只有 0.2%–3.1%,每次都在重新 prefill 整段上下文,压根没走进 resume 路径。判断复现是否有效,看的是 API 返回的 cached_tokens,不是“跑完了没崩”。
8. 自伤配置一:NCCL pin
此前按 fork 的 README 建议固定了 NCCL_ALGO=Ring 与 NCCL_PROTO=Simple,理由是“Ampere 上 FULL cudagraph 重放时 NCCL 的自适应算法切换会崩”。那条建议写于真因未知的时候;真因是上面的 mamba 缺陷,pin 从未被需要。而 Ring + Simple 恰好是最保守的集合通信配置,每个解码 step 都要走 45 层 × 2 次 all-reduce。
撤掉 pin 后,同一套 llama-benchy 矩阵(PP 1024/8192、TG 512、c1/4/8、--no-cache):
| 格子 | pin 时(cg16) | 撤掉 pin(cg32) | 变化 |
|---|---|---|---|
| 1K / c1 解码 | 17.3(峰值 26) | 145.1(峰值 165) | 8.4× |
| 1K / c4 解码 | 64.8(峰值 92) | 251.3(峰值 325) | 3.9× |
| 8K / c1 解码 | 16.8(峰值 27) | 61.8(峰值 123) | 3.7× |
| 8K / c4 解码 | 60.5(峰值 94) | 161.8(峰值 246) | 2.7× |
| 1K / c1 prefill | 2150.6 | 4617.7 | 2.1× |
9. 自伤配置二:cudagraph 捕获尺寸按 token 取整
cudagraph_capture_sizes 里的数字是 token 数,而且会被 adjust_cudagraph_sizes_for_spec_decode 向上取整到 num_speculative_tokens + 1 的倍数。MTP3 时这个除数是 4,于是每个条目都被静默重解释成“4 个 token 一步”:
| 配置 | 实际生效 | 可覆盖的并发序列数 |
|---|---|---|
[1,2,4,8,16] max 16(上游 recipe) | [4,8,16] | 4 |
[1,2,4,8,16,32] max 32 | [4,8,16,32] | 8 |
[1,2,4,8,16,32,64] max 64(现在) | [4,8,16,32,64] | 16 = max_num_seqs |
把这段调整逻辑在容器里跑一遍就能得到上面的解析结果,不需要猜。含义是:任何一步携带的序列数超过最大捕获图,整步就掉回 eager。真实业务把 num_requests_running 顶到 14–16(即每步 56–64 token),所以旧的 cg32 配置下引擎大部分解码步都不在图里——这也解释了为什么一组基准会测出“c8 低于 c4”:那根本不是并发特性,而是整个引擎已经越过了 32 token 的图边界。
代价只有 CUDAGraph 显存从 0.48 GiB 到 0.63 GiB 再到 0.79 GiB,KV 容量没有变化。
10. Benchmark
满上下文 64K × 8 流(修复前采集)
| 并发 | prefill 总量 | prefill 单流 | 末流首字 | 解码单流 | 解码峰值 |
|---|---|---|---|---|---|
| 1 | 7189 t/s | 7189 | 9.5 s | 73.6 t/s | 97 |
| 2 | 8466 | 6094 | 12.1 s | 21.7 | 190 |
| 4 | 8594 | 4251 | 20.3 s | 14.7 | 87 |
| 8 | 8563 | 2713 | 35.6 s | 10.6 | 165 |
prefill 在 c2 之后就饱和在约 8.6k t/s —— 4×A100 对 178 GB AWQ 的算力上限,加并发只是排队:单流从 7189 掉到 2713,8 流 64K 的代价就是末位请求 35.6 秒的首字。
受控突发(cg64 之后,用引擎计数器做地面真值)
因为线上一直有真实流量,bench 客户端的数字会被“份额”污染,所以同时抓 vllm:generation_tokens_total 来剥离:
| 并发 | 单流(cg32) | 单流(cg64) | 聚合(cg64) | 我的请求占引擎生成量 |
|---|---|---|---|---|
| 4 | 4.2 / 16.7(两次) | 25.3 t/s | 101.3 t/s | 52% |
| 8 | 12.9 | 49.2 t/s | 393.9 t/s | 67% |
| 16 | — | 28.1 t/s | 450.0 t/s | 74% |
c8 的单流是 c4 的约两倍——批量摊薄了权重读取,c8 从来就不该比 c4 差。旁证:同一份业务流量下引擎的 num_requests_running 均值从 10.6–14.3 降到 5.9–9.8,也就是同样的活排得更快了。
MTP 接受率
引擎日志的 10 秒窗口内,平均接受长度 2.30–2.95(num_speculative_tokens=3)。注意这是窗口内的均值指标;累计 counter 不能当单请求指标用,本记录不引用累计值。
11. 当前决定与保留项
| 项 | 决定 |
|---|---|
| mamba align 越界 | 保留热补丁,等上游合并后改为重拉镜像 |
| NCCL pin | 已撤除,恢复自适应选择;观察期内重启计数保持 0 |
| cudagraph 捕获尺寸 | 保留 64,覆盖满 max_num_seqs |
| MTP | 不动(num_speculative_tokens=3) |
| 显存利用率 / max-model-len | 保持 0.92 / 272000 |
| 待验证 | --disable-custom-all-reduce 仍然生效,vLLM 自带的 custom all-reduce 未被使用;这是同一族“当年留下的保险”,尚未做 A/B |
聚合解码仍然低于同机此前部署的 Qwen3.8 Flash Next(c8 约 561–936 t/s),但那是结构决定的:GLM-5.3-Flash 每 token 激活的参数约为 12.4B,而对方约 4.6B(288+1 expert × 宽 2048 × top-8 × hidden 4096,对 512 expert × 宽 640 × top-10 × hidden 2560),并且 full attention 层是 64 个 KV head 不做 GQA(对方为 2)。两者磁盘体积都在 170 GB 左右,所以总体积不能用来推断解码速度。