🔑 关键洞察
✦ AI·GEN这篇文章记录了作者把一个离线 NLLB 翻译功能从 Python 搬进 Rust、透过 ct2rs 接 CTranslate2 时,在 Windows/MSVC 上接连撞到的三面墙与各自的根因与修法。第一面是「连不起来」:sentencepiece 与 sherpa-onnx 各自静态内嵌一份 protobuf,在 MSVC 下撞出 204 个 LNK2005——最干净的解不是 /FORCE:MULTIPLE 或 sidecar,而是直接关掉 sentencepiece feature、改用纯 Rust 的 tokenizers,让那份 protobuf 从依赖图消失。第二面是 CRT 之争:ct2rs 把 CTranslate2 编成 /MT、Rust std 却是 /MD,靠 .cargo/config.toml 的 +crt-static 统一,并厘清这其实是与预编 /MT 的 sherpa 对齐的唯一正确解,不是有风险的回归。第三面是一个躺了两年的关闭死锁——thread_local 的 ruy::Context 在 Windows loader lock 底下、于执行绪结束时去 join Ruy 执行绪池而卡死,最终把销毁提前到 finalize() 手动进行,一次解掉死锁与泄漏(RSS 从 4371 压回 19 MB,CUDA 的 VRAM 泄漏同样被堵住)。最后还意外抓到一个 NVIDIA 驱动 bug:CUDA 版在没有可用 GPU 的机器上翻译后随机崩溃,根因是 nvdxgdmal64.dll 卸载后遗留的 thread-pool callback 打到已卸载位址,以 12 行纯 C 重现证实是驱动问题,并用 PIN 住 DLL 的手法挡下。全篇也反思了验证假设、以及诚实收回一个查证不足的「已知 bug」宣称。
我要把一个纯离线的 NLLB 翻译功能,从 Python 搬进 Rust。模型是 int8 量化的 NLLB-200 600M,透过 ct2rs(ctranslate2-rs)这套 binding 接 CTranslate2。Linux 上一切干净俐落。
但一搬到 Windows,我在同一条路上,接连撞了三种墙:它连不起来、它关不掉、它在没有 GPU 的机器上崩溃。
这篇把三面墙一个个拆开。前两面是 Windows/MSVC 工具链的暗礁,第三面是一个躲了两年的关闭死锁,外加一个其实不干我事的 NVIDIA 驱动 bug。
两份 protobuf 撞在一起#
第一面墙,在它能跑之前就挡住了——它根本连不起来。
这个桌面程式除了翻译,还静态连进了另外几个离线 ML 元件:一个是 sherpa-onnx(它自己带了一份 protobuf),底下还压着 ONNX Runtime;而翻译这侧的 NLLB tokenizer 走 sentencepiece,它也内嵌了一份 protobuf(那个 lite 版)。两份 protobuf,在 MSVC 的静态连结下水火不容。full app link 直接吐:
libsherpa_onnx_sys.rlib(parse_context.obj) : error LNK2005:
"google::protobuf::internal::ReadSizeFallback / InlineGreedyStringParser / ..."
已在 libsentencepiece_sys.rlib(parse_context.obj) 中定义过了
fatal error LNK1169: 找到有一或多个已定义的符号204 个 LNK2005 + 一个 LNK1169,全是 google::protobuf::internal::*。根因很单纯:sentencepiece 内建那份 protobuf-lite 用的是没改过命名空间的 namespace google::protobuf,跟 sherpa/ONNX Runtime 那份完整 protobuf 撞在同一个命名空间;两份同名的 google::protobuf::* 符号在同一个 link 相遇,linker 不知道该收哪一份——这就是踩到 。
我把选项摊开,想了三条路:
- (a)
/FORCE:MULTIPLE— 叫 linker 别计较、硬连。它会默默挑一份 protobuf 留下——但挑错就完了:ORT 需要完整 protobuf 的 descriptor,sentencepiece 那份 lite 没有,万一 lite 版胜出,ORT 会在执行期安静地死。这是 ODR 轮盘。 - (b) 让两边共用同一份 protobuf — 要去改
-syscrate 的 C++ build,强迫大家连同一份系统 protobuf。干净,但工大;而且 ORT 要新版、sentencepiece 卡在古老的 built-in lite 版,版本本身就不相容。 - (c) 把 NLLB 拆成独立行程/sidecar — 两份 protobuf 各自待在各自的 binary,连结冲突直接消失(顺带连下一面 CRT 的墙也一起免了),代价是失去 in-process、要自己扛 IPC 序列化跟生命周期。
最后我三条都没走。 真正的解是第四条、也是最干净的:根本不要那份 protobuf。 sentencepiece 只是拿来做 tokenize,我把 ct2rs 的 sentencepiece feature 整个关掉,改用纯 Rust 的 tokenizers crate 读 tokenizer.json。这一关,sentencepiece-sys、它内嵌的 protobuf-lite、连 prost 一起从依赖图上消失——冲突不是被压住,是不存在了。而模型资料夹本来就带着 tokenizer.json,所以零部署改动。移掉之后,LNK2005 归零,exe 干净连出来。
TIP
遇到符号重复冲突,/FORCE:MULTIPLE 是最诱人的一键解——但它是「把两份定义藏起来、赌 linker 挑对」。真正该先问的是:能不能让其中一份根本别进来? 我这里刚好可以——那份 protobuf 的唯一来源是 sentencepiece,而 sentencepiece 可以被纯 Rust 的 tokenizer 取代。移掉一整个依赖,胜过压住一个冲突。
/MT 还是 /MD,整条链只能站一边#
protobuf 收掉,第二面墙立刻顶上来——这次是 CRT(C runtime)。
ct2rs 把 CTranslate2 编成 /MT(静态 CRT),而 sentencepiece-sys 那边原本是 /MD(动态 CRT);MSVC 的 linker 拒绝把 /MT 跟 /MD 的目标档混在一起,直接甩 LNK2038(RuntimeLibrary 不匹配,硬拒)。就算把 sentencepiece 移掉,Rust std 预设也是 /MD,跟 CT2 的 /MT 还是对不上,换来一批 LNK4098 + LNK2005(malloc/free/__CxxFrameHandler 重复)。
修法是一个 .cargo/config.toml,把整条链统一成 /MT:
[target.'cfg(target_os = "windows")']
rustflags = ["-C", "target-feature=+crt-static"](用 target 条件包住,Linux/macOS 的 CI 完全不受影响。)
这里我一开始很怕——全 workspace 改 +crt-static,不是会把 sherpa-onnx、ORT 那些 C 依赖的连结全动到吗?查下去发现,这反而是唯一正确解,不是有风险的妥协:
- 我用的 sherpa 静态库是预编好的
static-MT版,它 baked 进去的指令就是RuntimeLibrary=MT_StaticRelease——那份 CRT 是改不了的(下载解压、不在本地编)。所以/MT是唯一能对齐它的值;反过来「把 ct2rs 改成/MD」在这个专案里物理上不可能,只会去撞碎 sherpa 的/MT。 - ONNX Runtime 走
load-dynamic(执行期LoadLibrary)——它根本不参与连结,CRT 对它零影响。
换句话说,+crt-static 不是把大家拖下水,是跟本来就是 /MT 的 sherpa 站到同一边。
那 /MT 跟 /MD 到底差在哪?差的不是那几百 KB 的程式码重复,是状态。每个 /MT 模组都带自己一份私有的 ,其中 heap 最致命。经典死法是:在 A 模组的 heap malloc、拿到 B 模组去 free → heap 损坏,几分钟后在毫不相干的地方引爆。/MD 之所以没事,是因为大家共用 ucrtbase.dll 那一个 heap。
WARNING
危险的其实不是 /MT,是混用。同一个 binary 里,一个静态库被编进 exe、跟 exe 共用同一份 CRT,/MT 一点事都没有。真正危险的状态,是我还没加 +crt-static 之前那个——Rust std 的 /MD 跟 CT2 的 /MT 同时待在一个 binary 里,两份 heap。+crt-static 做的事,正是把两份 heap 并回一份。
顺带戳破一个我当时的误解:/MT 不代表「不能有 sidecar」。CRT 只在模组边界才要对齐——同 binary 必须一致;DLL↔EXE 同行程可以不同,只是 CRT 资源(malloc 出来的指标、FILE*)不能跨;而行程↔行程(sidecar)什么都不共享,CRT 根本无关。sidecar 反而是 CRT 完全不重要的那一层:两个位址空间,你物理上就没办法在 A malloc、在 B free。
这也解释了为什么 sherpa 一路没有 CRT 的痛、CT2 却满地雷:sherpa 用的是 C API(POD、opaque 指标、成对的 Create*/Destroy*,C ABI 几十年稳定),所以它当 DLL、配任何 CRT 都安全;CT2 用的是 C++ API(cxx,std::string/std::vector/std::future 直接跨边界,而 C++ 没有稳定 ABI),所以它只能静态连进来、还得 CRT 对齐。FFI 是「怎么呼叫」,CRT 是「呼叫完谁拥有那块状态」——两件事正交,你可以 FFI 完美却死在 free()。
墙一墙二拆完,它终于在 Windows 上编得出来、跑得起来,翻译又快又准(今天天气很好 → The weather is nice today.)。
问题出在「关掉」的那一刻。
翻译做完、我一 drop 掉那个 Translator,整个程式就冻在那里。不是慢,是彻底不动:CPU 归零、画面不再更新、Ctrl + C 也杀不掉,最后只能去工作管理员把它强制结束。而且它挑对象——只在 Windows、只在 CPU 上发作;同一份程式码换到 CUDA,退得干干净净。
翻了一下 issue,发现我不是第一个踩到的人。这个坑在这套 binding 的角落里躺了整整两年,期间有一个「能动」的 workaround——只是那个 workaround 做的事情是,把整个模型 leak 掉。
NOTE
一个「跑完就 leak」的 workaround,为什么不能当结案?因为这功能要塞进一个长时间跑的桌面程式。如果只是翻完就退出的 CLI,这个 leak 你一辈子都不会发现——反正 process 一结束,OS 就把记忆体全收回去了。但常驻的程式不一样,它会一轮一轮地流血。
于是就有了这篇的下半场。往下追,会发现这其实是一场横跨两年、藏了两个死锁的破案报告,而最后的真凶是同一个名字:一个在执行绪结束时、于 Windows 底下去 join 执行绪池的 thread_local 解构子。
叠了几层执行绪池#
要看懂后面的攻防,得先知道「关掉一个翻译器」底下藏了几层执行绪池。由外而内:
- CTranslate2 自己的
ThreadPool:每个Translator底下有一个ReplicaPool管着一组 worker。模型解构时,~ThreadPool会把伫列关掉、然后对每条 worker 呼叫worker->join()。这是最后被卡死的那一层——受害者。 - CPU 平行运算用的执行绪池:在
OPENMP_RUNTIME=NONE(这套 binding 的预设)下,CT2 的 intra-op 平行用的是一个static thread_local的BS::thread_pool。 - Ruy 的内部执行绪池:int8 的 GEMM 走 Ruy 后端,每条 worker 第一次跑 GEMM,会 lazy 建一个
thread_local的ruy::Context,这个 context 内部又自己养了一组执行绪。
Rust 那侧则是用 cxx 把 C++ 物件包成 UniquePtr,drop 时由 cxx 帮你呼叫 C++ 解构子。
所以「关掉翻译器」这个看似无害的动作,其实是一串连锁解构:Rust drop → C++ 解构子 → ~ReplicaPool → ~ThreadPool → 对每条 worker join()。而 worker 在真正结束前,还得先把自己身上那些 thread_local 物件的解构子跑完。
死锁,就藏在「worker 一边要结束、一边还在跑 thread_local 解构子」这道夹缝里。
两年前,这个坑是用「leak 一整个模型」换走的#
把时间倒回两年前。最早的回报很单纯:有人拿官方的 nllb.rs 范例跑 facebook/nllb-200-distilled-600M,翻译正常,但翻完之后 process 卡住好几分钟,Ctrl + C 无效。反复缩小范围,凶手定位在一行——卡在 drop(t),t 就是那个 translator。
维护者重现了问题,也很快指到现场:worker->join() 永远回不来。但这里有一句话,是整个案子两年没破的关键——那条 worker 执行绪看起来明明都正常结束了,那个 join 到底在等什么?一个「看起来已经结束」的执行绪,却让 join 它的人等到天荒地老。这不合理。
当时的处理方式,是绕过去。既然是解构时卡住,那就干脆别让解构子跑:在 Rust 这侧加一个 #[cfg(windows)] impl Drop,把 UniquePtr 的 drop 给 bypass 掉,C++ 解构子不执行,自然也就不会卡在 join。
hang 是不见了。但代价很诚实地写在维护者自己的结语里:Translator 就算被 drop,RAM/VRAM 也不会释放,要等整个 process 结束才由系统回收。换句话说——这不是修好,是拿「整个模型不释放」换来的不卡死。对一个跑完就收工的短命程式,这笔帐你永远不会收到;对一个常驻的桌面程式,它每 load/unload 一轮就漏一次。
然后案子就这么挂着。中间唯一多出来的线索,是维护者后来补的一句:CUDA 不会卡,CPU 才会卡,而「为什么 join 在某些情况下会被卡住,仍然是个谜」。
先记住这条 CPU/CUDA 的不对称。后面它会直接把凶手指出来。
OpenMP 绕得掉一个,绕不掉另一个#
我接手的第一个念头,是想整条路直接绕掉:这个 thread_local 解构子卡死的 pattern,能不能连碰都别碰?
能。前面提过,CT2 的 CPU 平行在预设 OPENMP_RUNTIME=NONE 下用的是那个 static thread_local BS::thread_pool,它的解构子会在执行绪结束时 join、然后在 Windows 上死给你看。但只要改用 OpenMP 来做平行,这条 thread_local 执行绪池的路径会在编译期就被整段拿掉——没有那个 thread_local,就没有那个会卡死的解构子。所以理论上,把 CT2 建成 OpenMP runtime(binding 里就是 openmp-runtime-comp 这个 feature),第一个死锁就消失了。
听起来很顺。结果一开 feature 编译,MSVC 直接甩我一个连结错误:
LINK : fatal error LNK1181: cannot open input file 'gomp.lib'openmp-runtime-comp 在 build script 里无条件地 cargo:rustc-link-lib=gomp,而 gomp 是 GNU 的 OpenMP runtime,MSVC 工具链里根本没有这东西。MSVC 有自己的一套:它的 /openmp 会在每个 object 里塞一条 /DEFAULTLIB:VCOMP 指令,runtime 自动被连进来,你什么都不用指定。所以在 MSVC 上,那行 gomp 不只是多余,是会直接让连结失败。
修法就是——MSVC 上跳过这行连结,其他工具链维持原样:
// build.rs:gomp 是 GNU 的 OpenMP runtime,MSVC 上不存在(LNK1181)。
// MSVC 的 /openmp 会自动发出 /DEFAULTLIB:VCOMP,runtime 自动连结。
if env::var("CARGO_CFG_TARGET_ENV").as_deref() != Ok("msvc") {
println!("cargo:rustc-link-lib=gomp");
}WARNING
这里藏了一个一不小心就会踩的细节:判断平台不能用 cfg!(target_env = "msvc")。在 build script 里,cfg! 反映的是跑 build script 的那台 host,不是你要编出来的 target;要看 target 得读环境变数 CARGO_CFG_TARGET_ENV。这种「host 与 target 撞名」的陷阱在交叉编译时特别阴——它不会报错,只会安静地给你错的答案。(windows-gnu/MinGW 没有那条 VCOMP 指令,所以照样要连 gomp;改动只针对 MSVC。)
feature 能连了,OpenMP 路径接上,那个 BS::thread_pool 的 thread_local 解构子被编译期拿掉。我很有把握地重跑一次 load → translate → drop。
还是卡。
同一个位置、同一个 worker->join()、同一个永远回不来。OpenMP 明明已经把第一条路径铲平了,怎么还卡?——这才意识到,我拆掉的只是其中一个死锁。这条路上有第二个,它跟 CPU 平行用哪套 runtime 一点关系都没有,OpenMP 碰不到它。而它,才是这整个案子真正的主角。
CUDA 不卡,CPU 才卡#
回到那条被我搁着的线索。
这句话其实已经把答案写在脸上了。CPU 跟 CUDA 这两条路径差在哪?差在 GEMM。int8 在 CPU 上的 GEMM 走 Ruy 后端,CUDA 走的是 cuBLAS 那套——两边摸的东西完全不同。那 CPU 的 GEMM 摸到了什么 CUDA 摸不到的?src/cpu/backend.cc 里这个:
ruy::Context *get_ruy_context() {
static thread_local ruy::Context context;
return &context;
}每条 worker 第一次跑 Ruy GEMM 时,lazy 建一个 thread_local 的 ruy::Context。而 ruy::Context 的解构子会做一件事:join 掉 Ruy 内部那组执行绪。因为它是 thread_local,这个解构子的执行时机是——worker 正在结束的那一刻。
真相到这里开始收拢。CUDA 上,GEMM 从头到尾不会呼叫 get_ruy_context(),worker 身上根本没有这个 thread_local、没有东西要 join,执行绪干干净净地退出。CPU 上正好相反:只要跑过一次 int8 GEMM,这个 thread_local ruy::Context 就挂在 worker 身上了,等着在最糟的时机解构。挂了两年的「CPU/CUDA 之谜」根本不是谜——不是 join 本身时好时坏,是 CPU 路径上多了一个 CUDA 路径上不存在的 thread_local 解构子。
那为什么「在 worker 结束时 join」就会死锁?把解构顺序摊开:
~ReplicaPool→~ThreadPool关闭伫列,对 worker 呼叫worker->join()- worker 的
run()回圈返回,执行绪开始终止 - 执行绪终止时会跑它身上的
thread_local解构子,于是~ruy::Context去 join Ruy 那组执行绪 - 这个 join,发生在一条已经在结束的执行绪上——死锁
关键在第 3 步的执行环境。在 Windows 上,执行绪结束会触发 thread_local/TLS 的解构回呼,而这整套流程是握着 loader lock 跑的。就在这个当口去 join 另一批执行绪——那些执行绪可能也需要 loader lock 才能收尾——你就拿到一个教科书等级的 loader lock 死锁:持锁的在等别人,被等的在等锁。于是 ~ThreadPool 永远卡在 worker->join()。
这也回答了两年前那句「worker 看起来明明正常结束」的悖论:run() 确实返回了,执行绪看起来在收尾——但它卡死在 thread_local 解构子里的那个 join,永远走不完最后一哩,所以外层 join 它的人也永远等不到。
看懂根因,修法的方向就只有一个。把 ruy::Context 的销毁,从「执行绪结束时自动触发」,挪到「一个正常的执行环境里手动做」。
CAUTION
别在 thread_local 解构子里 join 执行绪。 thread-local 解构子的执行时机由执行绪自己的生命周期决定——在 Windows 上那正好是握着 loader lock、执行绪自身正在终止的时刻;此时再去 join 另一批同样要收尾的执行绪,就等于自己锁死自己。要 join,挪到一个你控制得了的正常执行点做。
CT2 刚好有现成的挂勾——ReplicaWorker::finalize() 会在 worker 的 run() 里、执行绪真正退出之前被呼叫,本来就是拿来做这类 per-thread 收尾的。第一步,把 ruy::Context 从「自动解构的 thread_local 物件」改成「一个可以手动清掉的 heap 指标」,并加一个 clear_ruy_context():
// backend.cc:context 改成 heap 配置,生命周期不再绑在 thread-local 解构上。
static thread_local ruy::Context* ruy_context = nullptr;
ruy::Context *get_ruy_context() {
if (!ruy_context)
ruy_context = new ruy::Context();
return ruy_context;
}
void clear_ruy_context() {
delete ruy_context;
ruy_context = nullptr;
}第二步,在 destroy_context() 里、针对 CPU 装置呼叫它——这个函式正是 finalize() 会走到的清理点:
// devices.cc:在正常执行环境(而非执行绪结束时)释放这条 worker 的 ruy::Context,
// 避免在 Windows 上 join Ruy 执行绪时卡死 ThreadPool 的关闭。
#ifdef CT2_WITH_RUY
if (device == Device::CPU) {
cpu::clear_ruy_context();
}
#endifjoin 现在发生在一个正常 context 里——没有 loader lock、执行绪也还没开始终止——顺利跑完。而且因为解构子现在跑得完了,那个为了避开 hang 而 leak 整个模型的 Rust 端 Drop bypass,也可以一起拿掉。两个死锁,一个用 OpenMP 绕过(并顺手修好它在 MSVC 上连不起来的毛病),一个从根上解掉。
验尸报告:CPU 漏 RAM,CUDA 漏 VRAM#
死锁修掉是一回事,我还想确认「不再 leak」是真的。于是在 Windows/MSVC 14.44/x64、int8 NLLB-200 600M、Ruy 后端下,连续跑 5 轮 load → translate → unload,量每一轮之后的 RSS:
| 做法 | 第 1–5 轮 RSS(MB) |
|---|---|
| 绕过 model 解构子(两年来的 workaround) | 885 → 1755 → 2624 → 3496 → 4371 |
只 heap-leak 那个 ruy::Context | 268 → 520 → 772 → 1024 → 1276 |
| 这个 fork(finalize 时清掉) | 16 → 17 → 18 → 18 → 19 |
三行,刚好是这个案子的三个阶段。第一行是那个「能动」的 workaround:每轮漏约 870 MB(整个模型都没释放),5 轮吃掉 4.3 GB,斜率一路往上,常驻程式撑不了多久。第二行是一个中继版本——就算我只 leak 那颗 ruy::Context、不 leak 模型,每轮还是漏约 250 MB,那是 Ruy 的 被 context 拖着不放;这说明「少漏一点」不是答案,只要 context 没被正确销毁,它背后那包 cache 就一直躺在那。第三行是正解:16、17、18、18、19,基本是平的,finalize 时把 context 清掉,连带把 prepacked cache 一起释放。而且销毁后重建 context 完全没问题——多轮呼叫之间翻译输出逐字一致,clear 掉再用,结果不变。
把三种做法的每一轮 RSS 叠在同一张图上,谁在一轮一轮流血、谁是一条贴着底的平线,一眼就分出来:
还有一件事我一开始没测、只从程式码推理:这个 leak 不挑装置。 那个为了避开死锁而 skip 掉解构子的 workaround,#[cfg(target_os = "windows")] 看的是「是不是 Windows」,不是哪个装置——所以不管 CPU 还是 CUDA,只要在 Windows、drop 就被跳过,整个模型照样不释放。CUDA 上漏的是 VRAM:未修版连跑 5 轮,VRAM 从 5516 一路爬到 8911 MB(每轮约 840 MB、5 轮吃掉 4.65 GB),而 fork 版两条路都干干净净释放。CUDA 甚至更凶——一台 8 GB VRAM 的笔电大概第 4 轮就爆了,CPU 那边至少还有虚拟记忆体顶着。
NOTE
说句公道话:在我目前的用法里,模型是一载入就常驻、直到 process 结束,unload 其实是死路,这个 leak 两条路都不会现形。这组数据是为了回答一个未来的问题——哪天要做「切换模型」或「闲置时卸载省资源」,fork 在 CPU 跟 CUDA 上都得用上,不只 CPU。
885 跟 19 之间的差距,不是量测误差能解释的。它是「让它别卡住」和「真正修好」之间的距离——两者在冒烟测试里都一样绿,程式都会正常退出,只有把它跑满五轮、盯着记忆体,才看得出来一个在流血、一个没有。
有 GPU 版,却在没 GPU 时崩溃#
死锁修好、leak 堵住,我以为 ct2rs 这关过了。结果编出 CUDA 版之后,冒出一个更诡异的:在没有可用 GPU 的机器上,翻译完之后随机 SIGSEGV(exit 139)。 而且条件刁钻到不像话——我把各种组合跑成一张表:
只有一种组合会炸:用 CUDA 编、却跑在 CPU 上、而且真的翻译过——6 次里崩 4~5 次。其他全 0。
要抓这种「几十秒后才炸、堆叠还跟凶手无关」的鬼,我挂了一个 VEH 例外处理 + backtrace 符号化 + DLL 载入/卸载纪录,才拍到现场:
ACCESS_VIOLATION 于 0x7ffec97a8190:执行(DEP) — 该位址 state=FREE(未映射)
RIP = 0x7ffec97a8190 ;堆叠只有 ntdll 的 thread-pool 框:TpSetWaitEx …
[dll] 载入 nvdxgdmal64.dll 0x7ffec97a0000..0x7ffec9834000
[dll] 卸载 nvdxgdmal64.dll 0x7ffec97a0000..0x7ffec9834000 ← 卸载后,崩在 +0x8190一个 执行违例——CPU 跳去执行一块已经被卸载、根本没映射的位址。两次崩溃都落在同一个 RVA +0x8190(base 因 ASLR 不同)。堆叠里一个 CTranslate2/ruy 的框都没有,全是 ntdll 的 thread-pool。
凶手是 NVIDIA 驱动。nvcuda.dll 初始化会拉进 nvdxgdmal64.dll(驱动的 DXG DMA 配置器);当 cudaGetDeviceCount() 发现没有可用装置(err=100 cudaErrorNoDevice),这个 DLL 被 FreeLibrary 卸载——但它注册在 Windows thread-pool 上的一个 wait callback 没被反注册。 那个孤儿 callback 在 ~18–29 秒后被触发,跳进已经卸载的模组 → DEP 违例。这一条解释了每一个观察:只有 WITH_CUDA 才会载 nvcuda;只有 CPU 装置才会走到「没有可用 GPU」;只有翻译过才活得够久让 callback 有机会开火(只 load→drop 的版本 2.9 秒就退了,比 callback 还快,所以 0/12);随机,是因为那是排程竞态。
这算谁的 bug?我一度想当然耳地说「这是已知的驱动 bug」——结果被自己打脸。我写了一支只有 12 行、只呼叫 cudaGetDeviceCount() 加一个等待回圈的纯 C 程式:没有 CT2、没有 ruy、没有 Rust,它照样崩 3/4~4/6。所以这确定是 NVIDIA 驱动自己的 bug,跟 CT2、ct2rs、我的 fork 都无关。
CAUTION
我原本引了几个公开 issue,想说「这 bug 2024 就有人回报、还没修」——认真查证后全部收回。其中一个(chia-gigahorse #336)原 po 自己关掉了、结案理由是坏硬碟,跟这个机制无关;真正对得上的只有 discuss.python.org 一篇(PyTorch + Flask、一样的 nvdxgdmal64.dll_unloaded、重装跟回滚驱动都没用、无人回复)。只有一笔可靠的公开纪录、还没解——根本无从判断 NVIDIA 知不知道、修没修。查证的结论不是「已知未修」,是「公开资料太少,不能下判断」。宁可讲不知道,也不要凑一个好听的「已知 bug」。
驱动端指望不上(那位 PyTorch 回报者连回滚都没救),而客户端的驱动版本我又控制不了,所以还是得在自己这边挡。修法很外科:用 LdrRegisterDllNotification 监看 DLL 载入,一侦测到 nvdxgdmal64.dll 被载入,立刻用 GetModuleHandleExA 的 GET_MODULE_HANDLE_EX_FLAG_PIN 旗标把它钉住——NVIDIA 之后那个 FreeLibrary 就卸不掉它,模组留在原地,孤儿 callback 开火时打到的是还映射着的程式码,无害。基线 4~5/6 → 钉住后 0/8,连一次卸载事件都没有,因果链闭合。
触发要四个条件同时成立:装了 NVIDIA 驱动、程式真的初始化了 CUDA、CUDA 发现没有可用装置、然后 process 又活了几十秒。现实中「有驱动、没可用 GPU」的情境比想像多:CUDA_VISIBLE_DEVICES 设错、混合显卡笔电把独显停用(Optimus/MUX)、驱动太旧配不上 runtime、远端桌面没带 GPU、GPU 被别的 session 独占……客户那端你猜不到是哪一种。
最后想说#
把整条解构链画出来,死锁点与修法介入的位置就一目了然:
三面墙拆完,回头看,它们其实是同一种形状的坑:每一份东西单独看都没错,是凑在这个特定组合里才出事。 两份 protobuf 各自都能编;/MT 自己一个 binary 里好好的;thread_local 在别的平台上乖乖解构;cudaGetDeviceCount() 是完全合法的呼叫。全部的痛,都发生在「边界」跟「组合」上——protobuf 撞在同一个 link、CRT 撞在同一个 binary、join 撞在 loader lock 底下、callback 撞在已卸载的模组上。
而这整条路上最有意思的,是那个躺了两年的死锁为什么没被真正解掉:不是因为难到没人看得懂,而是因为那个 workaround「够好用」——它让 hang 消失了,换来的 leak 又刚好躲在一个大多数人不会注意的角落:反正 process 结束记忆体就回收了。短命的程式永远收不到这张帐单,于是问题被「解决」了两年。绕过一个问题和解决一个问题,长得很像,尤其当绕过的成本被推迟到未来、推给别人、推给「反正 OS 会清」的时候。
thread_local 很方便,它帮你自动管生命周期;/FORCE:MULTIPLE、skip 掉 drop、「反正驱动会修」也都很方便。但「方便」的意思常常是,它把代价挪到一个你现在看不到的时机、看不到的环境、看不到的别人身上。而我这一整趟,几乎都在把那些被挪走的代价,一个一个搬回眼前。
参考资料#
- CTranslate2 #2076 — Fix Windows shutdown deadlock and leak with the Ruy backend
- ctranslate2-rs #144 — Don't link gomp on MSVC
- ctranslate2-rs #74 — Windows 上 skip 掉 Drop 的那个 leak workaround
- ctranslate2-rs #64 — Process doesn't exit / hangs at the end on Windows(最初的 issue)
- discuss.python.org —
nvdxgdmal64.dll_unloaded的公开回报
还没有留言
✨ 成为第一个留言的人吧