我要把一個純離線的 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 直接吐:

text
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 — 要去改 -sys crate 的 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(mallocfree__CxxFrameHandler 重複)。

修法是一個 .cargo/config.toml,把整條鏈統一成 /MT:

toml
[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::stringstd::vectorstd::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_localBS::thread_pool
  • Ruy 的內部執行緒池:int8 的 GEMM 走 Ruy 後端,每條 worker 第一次跑 GEMM,會 lazy 建一個 thread_localruy::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 直接甩我一個連結錯誤:

text
LINK : fatal error LNK1181: cannot open input file 'gomp.lib'

openmp-runtime-comp 在 build script 裡無條件cargo:rustc-link-lib=gomp,而 gompGNU 的 OpenMP runtime,MSVC 工具鏈裡根本沒有這東西。MSVC 有自己的一套:它的 /openmp 會在每個 object 裡塞一條 /DEFAULTLIB:VCOMP 指令,runtime 自動被連進來,你什麼都不用指定。所以在 MSVC 上,那行 gomp 不只是多餘,是會直接讓連結失敗。

修法就是——MSVC 上跳過這行連結,其他工具鏈維持原樣:

rust
// 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_poolthread_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 裡這個:

cpp
ruy::Context *get_ruy_context() {
  static thread_local ruy::Context context;
  return &context;
}

每條 worker 第一次跑 Ruy GEMM 時,lazy 建一個 thread_localruy::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」就會死鎖?把解構順序攤開:

  1. ~ReplicaPool~ThreadPool 關閉佇列,對 worker 呼叫 worker->join()
  2. worker 的 run() 迴圈返回,執行緒開始終止
  3. 執行緒終止時會跑它身上的 thread_local 解構子,於是 ~ruy::Context 去 join Ruy 那組執行緒
  4. 這個 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():

cpp
// 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() 會走到的清理點:

cpp
// devices.cc:在正常執行環境(而非執行緒結束時)釋放這條 worker 的 ruy::Context,
// 避免在 Windows 上 join Ruy 執行緒時卡死 ThreadPool 的關閉。
#ifdef CT2_WITH_RUY
    if (device == Device::CPU) {
        cpu::clear_ruy_context();
    }
#endif

join 現在發生在一個正常 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::Context268 → 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 載入/卸載紀錄,才拍到現場:

text
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 被載入,立刻用 GetModuleHandleExAGET_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、「反正驅動會修」也都很方便。但「方便」的意思常常是,它把代價挪到一個你現在看不到的時機、看不到的環境、看不到的別人身上。而我這一整趟,幾乎都在把那些被挪走的代價,一個一個搬回眼前。

參考資料#