オフラインの NLLB 翻訳機能を、Python から Rust へ移そうとしていました。モデルは int8 量子化の NLLB-200 600M、ct2rs(ctranslate2-rs)という CTranslate2 へのバインディング経由。Linux ではすべて綺麗さっぱりでした。

ところが Windows へ移した途端、同じ道の上で三つの壁に立て続けにぶつかりました:リンクできない、終了できない、GPU のないマシンでクラッシュする。

この記事はその三つの壁を一つずつ分解します。前の二つは Windows/MSVC ツールチェーンの暗礁あんしょう、三つ目は二年間ひそんでいたシャットダウン・デッドロック、それに結局は自分のせいではなかった NVIDIA ドライバのバグです。

二つの protobuf が衝突する#

一つ目の壁は、動く前に止めてきました——そもそもリンクできない

このデスクトップ・プログラムは翻訳のほかに、いくつかのオフライン ML コンポーネントを静的にリンクしています:一つは sherpa-onnx(自前の protobuf を抱えている)、その下に ONNX Runtime;そして翻訳側の NLLB tokenizer は sentencepiece を通り、これ protobuf(あの lite 版)を内蔵しています。二つの protobuf、MSVC の静的リンクでは共存できません。フルアプリのリンクはこう吐きました:

text
libsherpa_onnx_sys.rlib(parse_context.obj) : error LNK2005:
  "google::protobuf::internal::ReadSizeFallback / InlineGreedyStringParser / ..."
  は既に libsentencepiece_sys.rlib(parse_context.obj) で定義されています
fatal error LNK1169: 1 つ以上の多重定義シンボルが見つかりました

204 個の LNK2005 + 一つの LNK1169、すべて google::protobuf::internal::*。原因は単純:sentencepiece 内蔵の protobuf-lite が名前空間を改名していない namespace google::protobuf を使い、sherpa/ONNX Runtime のフル protobuf と同じ名前空間で衝突;同名の google::protobuf::* シンボルが二組、同じリンクで出会い、リンカはどちらを採るか分からない——これがまさに 違反です。

選択肢を並べ、三つの道を考えました:

  • (a) /FORCE:MULTIPLE — リンカに気にするなと言って強引に繋ぐ。それは黙って一方の protobuf を採る——でも外したら終わり:ORT はフル protobuf の descriptor を必要とし、sentencepiece の lite にはそれがなく、万一 lite が勝てば ORT は実行時に静かに死ぬ。ODR ルーレットです。
  • (b) 両者に一つの protobuf を共有させる-sys crate の C++ ビルドをいじって皆を一つのシステム 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 は最も誘惑的な一行の解——でもそれは「両方の定義を隠して、リンカが正しく選ぶことに賭ける」こと。先に問うべきは:片方をそもそも入れないようにできないか? ここではできた——その protobuf の唯一の出所は sentencepiece で、sentencepiece は純 Rust の tokenizer に置き換えられる。依存を丸ごと取り除くほうが、衝突を抑え込むより上です。

/MT か /MD か、チェーン全体がどちらか一方に立つしかない#

protobuf を片付けると、二つ目の壁がすぐ立ち上がる——今度は CRT(C runtime)

ct2rs は CTranslate2 を /MT(静的 CRT)でビルドし、sentencepiece-sys は元々 /MD(動的 CRT);MSVC のリンカは /MT/MD のオブジェクトを混ぜるのを拒否し、LNK2038(RuntimeLibrary の不一致、ハード拒否)を投げます。sentencepiece を取り除いても、Rust std は既定で /MD、CT2 の /MT と合わず、LNK4098 + LNK2005(mallocfree__CxxFrameHandler の重複)を一山もらいます。

直し方は、チェーン全体を /MT に統一する .cargo/config.toml:

toml
[target.'cfg(target_os = "windows")']
rustflags = ["-C", "target-feature=+crt-static"]

(target 条件で包むので、Linux/macOS の CI は一切影響なし。)

最初はこれが怖かった——ワークスペース全体を +crt-static にしたら、sherpa-onnx や ORT といった C 依存のリンクを全部動かすのでは? 掘ってみると、むしろこれが唯一の正解で、リスクのある妥協ではないと分かりました:

  • 私が使う sherpa の静的ライブラリは既製の static-MT——焼き込まれた指令が 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.)。

問題は、閉じたその瞬間でした。

翻訳が終わり、Translatordrop した途端、プログラム全体がそこでこおる。遅いのではなく、完全に静止:CPU はゼロ、画面は更新されず、Ctrl + C も殺せず、結局タスクマネージャーで強制終了。しかも相手を選ぶ——Windows でだけ、CPU でだけ発症;同じコードを CUDA にすると、綺麗に終了する。

issue を見ると、踏んだのは自分が最初ではありませんでした。この落とし穴はこのバインディングの片隅に丸二年よこたわり、その間ずっと「動く」ワークアラウンドがあった——ただしそのワークアラウンドがやることは、モデルまるごとを leak することでした。

NOTE

「終了時に leak」するワークアラウンド——なぜそれで結案にできないのか? この機能は長時間動くデスクトップ・プログラムに入れるからです。翻訳して終了する CLI なら、この leak は一生気づかない——プロセスが終われば OS がメモリを全部回収する。でも常駐プログラムは違う、一巡ごとに少しずつ出血します。

そうしてこの記事の後半があります。辿っていくと、これは実は二年にまたり、二つのデッドロックを隠した推理報告で、最後の真犯人は二度とも同じ名前:スレッド終了時に、Windows の 下でスレッドプールを join する thread_local デストラクタ、です。

スレッドプールが何層積まれているか#

この後の攻防を理解するには、まず「翻訳器を閉じる」の下にスレッドプールが何層隠れているかを知る必要があります。外側から:

  • CTranslate2 自身の ThreadPool:各 Translator の下に ReplicaPool が worker の一群を管理。モデル破棄時、~ThreadPool がキューを閉じ、各 worker に worker->join()。これが最後にデッドロックする層——被害者です。
  • CPU 並列用のスレッドプール:OPENMP_RUNTIME=NONE(このバインディングの既定)下、CT2 の intra-op 並列は static thread_localBS::thread_pool を使う。
  • Ruy の内部スレッドプール:int8 の GEMM は Ruy バックエンドを通り、各 worker が最初に GEMM を走らせる時、thread_localruy::Context を lazy に作る。この 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 を走らせ、翻訳は正常、だが終わった後プロセスが数分固まり、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 は解放されず、プロセス全体が終わってシステムが回収するのを待つ。つまり——これは直したのではなく、「モデルまるごと解放されない」で「固まらない」を交換したもの。走って終わる短命プログラムなら、その請求書は一生来ない;常駐のデスクトップ・プログラムなら、load/unload 一巡ごとに一度漏らす。

そうして案件はそのまま吊るされた。途中で唯一増えた手がかりは、メンテナが後で足した一言:CUDA は固まらず、CPU が固まる、そして「なぜ join が状況によって固まるのかは、まだ謎」。

この CPU/CUDA の非対称を覚えておいてください。後でそれが犯人を直接指します。

OpenMP は一つは回避できても、もう一つは回避できない#

引き継いだ最初の発想は、道全体を迂回すること:この thread_local デストラクタが固まるパターン、触れずに済ませられないか?

済ませられる。前述の通り、CT2 の CPU 並列は既定の OPENMP_RUNTIME=NONE 下であの static thread_local BS::thread_pool を使い、そのデストラクタはスレッド終了時に join、そして Windows で死んで見せる。だが並列に OpenMP を使えば、この thread_local スレッドプールの経路はコンパイル時に丸ごと取り除かれる——thread_local がなければ、固まるデストラクタもない。だから理論上、CT2 を OpenMP runtime でビルドすれば(バインディングでは openmp-runtime-comp feature)、一つ目のデッドロックは消える。

順調に聞こえる。ところが feature を有効にしてコンパイルすると、MSVC がリンクエラーを真正面から投げてきた:

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

openmp-runtime-comp はビルドスクリプトで無条件cargo:rustc-link-lib=gomp、そして gompGNU の OpenMP runtime、MSVC ツールチェーンには存在しない。MSVC には自前のがある:/openmp が各オブジェクトに /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")使ってはいけない。ビルドスクリプトでは cfg!ビルドスクリプトを走らせているホストを反映し、ビルド対象の 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 を走らせる時、thread_localruy::Context を lazy に作る。そして ruy::Context のデストラクタは一つのことをする:Ruy 内部のスレッド群を join。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 が Ruy のスレッド群を join
  4. この join が、もう終わりかけているスレッド上で起きる——デッドロック

鍵は 3 番目の実行環境。Windows では、スレッド終了は thread_local/TLS のデストラクタ・コールバックを引き起こし、この一連は loader lock を握って走る。まさにその瞬間に別のスレッド群を join しに行く——その群も終わりを迎えるのに loader lock が要るかもしれない——と、教科書級の loader lock デッドロックが手に入る:ロックを握る者は他者を待ち、待たれる者はロックを待つ。こうして ~ThreadPoolworker->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 で Ruy スレッドを join する際に 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 バックエンドで、load → translate → unload を 5 巡連続で走らせ、各巡後の RSS を測りました:

手法第 1–5 巡の RSS(MB)
model デストラクタを迂回(二年来の workaround)885 → 1755 → 2624 → 3496 → 4371
あの ruy::Context だけ heap-leak268 → 520 → 772 → 1024 → 1276
この fork(finalize で消す)16 → 17 → 18 → 18 → 19

三行、ちょうどこの案件の三段階。一行目はあの「動く」workaround:一巡約 870 MB 漏れ(モデルまるごと未解放)、5 巡で 4.3 GB、傾きは上がり続け、常駐プログラムは持たない。二行目は中継版——あの ruy::Context だけ leak しモデルは leak しなくても、一巡やはり約 250 MB 漏れる、それは Ruy の が context に引きずられて放されないから;「少し漏れを減らす」は答えではないと分かる、context が正しく破棄されない限り、その裏のキャッシュは居座り続ける。三行目が正解: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 のノート PC はだいたい第 4 巡であふれる、CPU 側は少なくとも仮想メモリが支える。

NOTE

公平を期して:今の使い方では、モデルはロードしたら常駐しプロセス終了まで残る、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 at 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);ランダムなのは、それがスケジューリング競合だから。

これは誰のバグか? 一度は当然のように「これは既知のドライバのバグ」と言った——そして自分の手で打たれた。12 行、cudaGetDeviceCount() と待機ループだけの純 C プログラムを書いた:CT2 も ruy も Rust もなし、それでも 3/4〜4/6 でクラッシュ。だからこれは確実に NVIDIA ドライバ自身のバグ、CT2、ct2rs、私の fork とは無関係。

CAUTION

元々は公開 issue をいくつか引いて「このバグは 2024 に報告済みで未修正」と言おうとした——真面目に裏取りして全部撤回した。一つ(chia-gigahorse #336)は報告者自身がディスク故障として閉じ、この機構とは無関係;本当に合致するのは discuss.python.org の一件 だけ(PyTorch + Flask、同じ nvdxgdmal64.dll_unloaded、ドライバ再インストールもロールバックも無効、返信なし)。信頼できる公開記録は一件だけ、しかも未解決——NVIDIA が知っているか、直したかは判断のしようがない。裏取りの結論は「既知で未修正」ではなく「公開情報が薄すぎて判断できない」。綺麗な「既知のバグ」をでっち上げるより、知らないと言うほうがいい。

ドライバ側は当てにならず(あの PyTorch 報告者はロールバックでも救われなかった)、クライアントのドライバ版は制御できないので、やはり自分側で防ぐしかない。直し方は外科的:LdrRegisterDllNotification で DLL ロードを監視し、nvdxgdmal64.dllロードされた瞬間、GetModuleHandleExAGET_MODULE_HANDLE_EX_FLAG_PIN フラグでPIN 留めする——NVIDIA が後で FreeLibrary してもアンマップできず、モジュールは残り、孤児 callback が発火しても打つのはまだマップされたコード——無害。ベースライン 4〜5/6 → PIN 後 0/8、アンロードイベントすら一度もなし、因果の鎖が閉じる。

発火には四つの条件が同時に成立する必要がある:NVIDIA ドライバがインストール済み、プログラムが実際に CUDA を初期化、CUDA が使えるデバイスなしと判明、そしてプロセスが数十秒生き続ける。現実の「ドライバあり、使える GPU なし」は思うより多い:CUDA_VISIBLE_DEVICES の設定ミス、ハイブリッド GPU のノート PC で dGPU を無効化(Optimus/MUX)、runtime に合わないほど古いドライバ、GPU を出さないリモートデスクトップ、他セッションが GPU を占有……クライアント側でどれになるかは当てられない。

最後に#

破棄の連鎖全体を描けば、デッドロック点と、直しが介入する位置は一目瞭然:

三つの壁を片付けて振り返ると、それらは実は同じ形の落とし穴:一つ一つは単独では正しく、この特定の組み合わせに集まって初めて事故る。 二つの protobuf はそれぞれコンパイルできる;/MT は一つの binary の中で快調;thread_local は他のプラットフォームでは素直に破棄される;cudaGetDeviceCount() は完全に正当な呼び出し。痛みはすべて境界組み合わせで起きる——protobuf は同じ link で、CRT は同じ binary で、join は loader lock 下で、callback はアンロード済みモジュールで、それぞれ衝突した。

そしてこの道全体で最も面白いのは、二年寝ていたデッドロックがなぜ本当に解かれなかったか:誰も理解できないほど難しかったからではなく、あの workaround が「十分使えた」から——hang を消し、交換に得た leak がちょうど大多数が気づかない片隅に隠れた:どうせプロセスが終わればメモリは戻る。短命プログラムはその請求書を一生受け取らず、問題は二年「解決」されていた。問題を迂回するのと解決するのはよく似ている——特に迂回のコストが未来へ、他者へ、「どうせ OS が片付ける」へと先送りされる時は。

thread_local は便利、寿命を自動で管理してくれる;/FORCE:MULTIPLE、drop を skip、「どうせドライバが直す」も皆便利。だが「便利」の意味はしばしば、コストを今は見えない時機、見えない環境、見えない誰かへ移すこと。そしてこの一周、私はほとんど、その移されたコストを一つずつ目の前へ運び戻していた。

参考資料#