早上十点十七分,机器开始报显存清零超时。到十一点三十八分,三张卡全部要求重启。
诡异的是这一幕:
nvidia-smi → 三张卡整整齐齐,容量正常,温度正常
cuInit() → 返回 3
cuDeviceGetCount() → 0
管理工具说卡好好的,CUDA 说一张卡都没有。而当天撞上这个的是两个完全不相干的负载——一个是扩散模型出图,一个是语言模型推理,都不是训练,唯一的共同点是都把显存推到了接近耗尽。
想直接拿判据的看第 2 章,那是一个除法;想看我在这件事上翻了几次车,看第 5 章。
1. 那 141 MB 是什么
这张卡是 GA100 芯片的挖矿阉割版,原厂只给 8GB 显存。社区有个开源工具能软件解锁到 64GB——改寄存器把被屏蔽的 HBM 重新点亮,跨重启持久,跟超频那种玄学没关系。
解锁这件事本身没问题,出问题的是解锁工具里一个叫 late-pma 的补丁。它做的事很直白:把最顶上那块保留的显存区间也注册进分配器的可用池,不浪费。
在原厂 8GB 的地址空间里这么干是安全的。但解锁把显存顶端从 8GB 挪到了 64GB,而 GPU 固件(GSP)和它的堆跟着搬到了新顶端。于是那块"顺手回收"的区间,装的是固件本体。
分配器日志把账目摊得明明白白:
region[6] base=0xff7300000 limit=0xfffffffff rsvdSize=0x8d00000
WPR meta wprStart=0xff7400000 wprEnd=0xffff00000
这就是一个减法:
区间总长 = limit − base + 1 = 0x8D00000 = 141.0 MiB
固件写保护区 = wprEnd − wprStart = 0x8B00000 = 139.0 MiB
真正可回收 = 141.0 − 139.0 = 2.0 MiB
为了 2 MB 可回收内存,把 139 MB 的固件区域开放给了任意写入。分配器按性能给区间排序,这块排在最末,所以只有主池被彻底榨干才会分到它。写进去就是显存访问违规,然后清零引擎卡死。
修完之后的判据也是一个减法,精确到小数点:推理框架看到的每卡容量从 63.53 GiB 降到 63.39 GiB,差 143.4 MiB,对得上 region 6 的 141.0 MiB。那 141 MB 交回去了,交得干干净净。
2. 我的压测测了个寂寞
这才是这篇文章真正想说的事。
补丁打完之后我要证明它有效,于是跑压力测试:起推理服务,把 KV 缓存打满,看崩不崩。三轮下来峰值分别是 3.8%、75.7%、84.1%,全部 PASS。
然后有人问了一句「84% 还是太少了吧」。
问题不在 84% 太少。问题在于这个指标再大也没用。KV 缓存池是服务启动时一次性从分配器要走的一整块,服务跑起来之后分配器已经收工了。把 KV 打到 84%,只是在自己那块地里划格子,永远碰不到分配器手里还剩什么。
而且就算指标选对了,84% 也差得离谱。算一下这个雷有多深:
故障区间占比 = 141 MiB ÷ 65536 MiB = 0.215%
要碰到它,显存必须用到 99.785% 以上
我停在 84.1%。距离那条线还有 15.7 个百分点,换成绝对量是 10,290 MiB。我离那个雷还隔着 10 GB。
这个除法可以直接搬去用:如果一个故障只在最后 0.2% 的分配里出现,任何以"用掉百分之多少"为指标的压测都是无效的,除非那个百分比大于 99.8%。 而大多数人的压测报告里,那个数字是 80、90,最多 95。
三轮全绿,三轮全白跑。
3. 只分配不写,等于没测
第三版探针换了思路:不看任何百分比,直接对着分配器要内存,一路要到驱动拒绝为止。
但这里还有第二个坑。第一次写的时候我只 cudaMalloc 不写入,跑完零故障。坏区域的指针只有被碰到才会 fault。 拿到一个指向固件区的指针,不写它,它就是个数字,安安静静。
加上写入之后才算真测。最终跑的五组:
| 测试 | 规模 | 新故障 | 新清零超时 |
|---|---|---|---|
| 榨干 + 全写 × 3 卡 | 各 64,576 MiB,剩 5.4 MiB | 0 | 0 |
| 4 MiB 细粒度 | 16,144 块全写 | 0 | 0 |
| 进程内反复申请释放 × 12 轮 | 每轮 58,112 MiB 脏页 | 0 | 0 |
| 跨进程 × 10 轮 | 每轮新进程持 92% 后退出 | 0 | 0 |
| 容器启停 × 3 轮 | 每轮峰值 55,822 MiB | 0 | 0 |
第一行那个"剩 5.4 MiB"才是合格的压测终点。终点是要到系统不肯再给为止,84% 连门都没摸到。
顺带一个量级对照,说明为什么另一个候选修复其实没在承重:显存释放的实测耗时是 0.02–0.04 秒,而超时预算是 4 秒,差两个数量级。健康的卡上根本够不到那条线——只有区间已经被写坏之后才会卡住。所以清零超时是下游症状,不是病因。
4. nvidia-smi 会骗你
回到开头那一幕。三张卡在管理工具里活得好好的,CUDA 说设备数是 0。
机制很简单:nvidia-smi 走的是管理接口,它不需要 CUDA 上下文。驱动的 CUDA 层已经挂死了,管理层还能正常应答,于是它照常列出所有卡、报出容量和温度。
这条可以直接当判据用:判断一张卡是不是还能干活,唯一可信的探针是 cuInit() 的返回值加 cuDeviceGetCount()。 任何基于 nvidia-smi 输出的健康检查,在这类故障面前都是摆设——它会在你最需要确认的那一刻给你一个漂亮的绿灯。
同族的还有一条,那天也踩到了:dmesg 抓关键字返回 0 条,到底是"真没有"还是"你读不到"?主流发行版默认限制普通用户读内核环形缓冲区。换成读 systemd 的内核日志,同一台机器上有 228 行。grep 命中 0 永远要先排除"读不到"这个可能。
5. 我翻的车
按约定把没做干净的部分列出来,不然上面那些数字不该被信。
变量没分离。 当时为了尽快恢复服务,我把两个补丁一起装了。后来上游作者直接问"只装其中一个能不能复现",我答不上来——因为我改了两个东西。只好移除一个重新编译、重启、把上面五组全部重跑一遍,才拿到能回答他的答案。一次本可以一次答出的问题,花了两轮。
引用了一个会改名的结论。 那个修复补丁的 PR 最初标题是「在某个型号上跳过 late PMA 扩展」,后来改成了「保留高位区间不动,它背后是固件写保护区」,适用范围从一个型号扩到了两个。如果你在中间那段时间读到旧标题,会得出"这跟我的卡无关"的结论。引用别人的 PR 之前,先看一眼它的改名和提交历史。
两个修复都不在主分支上。 一个合并进了一个独立分支,另一个至今还是 open 状态。git pull 拿不到任何一个,主分支上那份补丁仍然是会出事的旧版。所以"我更新到最新了"这句话在这里等于没说。
还有一个故障没结案。 修完之后跑一批 15 万 token 的超长 prefill,撞到了另一个错误,虚拟地址落在 117 TiB 附近,和 region 6 完全无关,驱动也保持健康。目前定位在推理框架的某个 kernel 上,怀疑是跨流内存复用少了一次同步声明。没查清之前,本文的结论只覆盖显存分配器这一层。
上面那张表是在只装一个补丁、超时预算保持默认的条件下跑的。 换配置得重跑。
6. 交出去 141 MB,换回来多少
有意思的是,把这块地还回去之后,反而多拿了。
让出 143.4 MiB 之后,剩余显存变得规整,推理框架的显存利用率参数从 0.94 提到 0.95 成立了。KV 缓存池的容量:
1,000,964 tokens → 1,192,267 tokens (+19.1%)
多出来 191,303 个 token 的 KV 空间。交出去 141 MB,换回来的价值远超它本身。
这个账值得单独记一笔:当你在为某个"可回收的碎片"做优化时,先算一下它占总量的比例。 141 MiB 是 64 GiB 的 0.215%。为 0.2% 的收益引入一个会让整台机器要求重启的故障模式,账怎么算都是亏的。而优化真正的杠杆在那个利用率参数上——同一块显存,换个用法多出 19%。
7. 清单
下次遇到"解锁了某个硬件限制"或者"回收了某块保留区间"这类事情,照这个顺序走:
第一,先看分配器把哪些区间注册进池了。别只看总容量数字对不对,要看构成。
第二,验收探针必须写入每一块。只分配不写,坏地址不会 fault,你会拿到一个漂亮的假绿灯。
第三,测一个机制之前先问:我这个负载会不会驱动它。KV 缓存之于显存分配器、日志关键字之于消息投递,都是同一类错误——拿一个够不到被测对象的指标当判据。
第四,把故障区间占总量的比例算出来,用它反推压测必须打到百分之多少才有意义。小于那个数的压测,跑多少轮都是零。
第五,判断硬件活没活着,用 cuInit() 不用 nvidia-smi。管理接口不需要 CUDA 上下文,它会在 CUDA 层挂死的时候继续报绿。
第六,一次只装一个补丁。不然别人问你隔离结果的时候,你只能回去重跑。
第七,引用上游结论前先看它的改名历史。标题会变,适用范围会变,你读到的那一版可能已经过期了。