前回 → 【止まっちゃう事件の記録 #2】GPUメモリ衝突を潰したのに、まだホストが「無痕跡」で凍りつく話 〜熱暴走という仮説にたどり着くまで〜
最終的な結論はこちら → 【止まっちゃう事件の記録 #4】LoRA学習でホストを落としていたメモリ枯渇の正体は、PyTorchアロケータの断片化だった 〜expandable_segments 1行で空き5GB→78GB〜
結論
-
LoRA 学習中に限って起きたホスト再起動の原因は、熱ではなく「ホストメモリの完全枯渇(空き 0MiB)」でした。
前回の記事で緩和策を打った「熱(または電力スパイク)による無痕跡フリーズ」とは、別の原因です。 - ユニファイドメモリ機(DGX Spark / GB10)では GPU メモリとホストメモリが同じものです。そのため空きが 0 になると、
NVIDIA ドライバが GPU メモリを確保できずにNV_ERR_NO_MEMORYを出し、プロセスではなくホストごと止まります。
earlyoomも OOM killer も間に合いません。 - 手がかりは、最初から永続化されていた
journalctlに残っていました。
1分間隔のearlyoomのログが「2分で 5.4GB → 0MiB」という枯渇の速さを記録していました。
自作の 5 分間隔の監視だけを見ていたため、一度は「完走した学習の方が条件が厳しかった」と逆の結論を出していました。 - 分かれ目は「空きが少ないかどうか」ではなく「0 に到達するかどうか」でした。空き 3,000MiB で 2 時間 20 分張り付いたまま完走した学習もあります。
- では、なぜ 0 まで落ちるのか。正体は PyTorch アロケータの断片化で、環境変数 1 行で解決しました。これは次回 #4 で書きます。
この記事について
この連載では、DGX Spark(NVIDIA GB10 搭載の開発機)でローカル LLM 基盤を運用する中で遭遇した「ホストが止まる」障害を記録しています。
- #1 複数vLLMを同一GPUに常駐させたら原因不明のホストハングに悩まされた話(GPU メモリプロファイリングの衝突)
- #2 GPUメモリ衝突を潰したのに、まだホストが「無痕跡」で凍りつく話(熱・電力スパイク仮説と GPU クロック制限)
前回は、GPU クロックの上限を 2100MHz に制限する緩和策を打ったところで終わりました。その後、26 日 10 時間、一度も再起動せずに動き続け、緩和策として確定させました。
今回はその続きです。ただし先に書いておくと、今回の犯人は前回とは別物でした。
再発:1日に3回
緩和策の確定から約2週間後、業務用モデルのファインチューニング(LoRA 学習)を始めた日に、1日で3回ホストが再起動しました。
| 時刻 | 直前まで動いていた GPU 処理 | 空きメモリ | swap 使用 | GPU 温度 | GPU 使用率 |
|---|---|---|---|---|---|
| 01:59 | Qwen3-14B の LoRA 学習 | 365MiB | 9GB | 64℃ | 96% |
| 14:31 | LLM-jp-4-8B の LoRA 学習 | 5,366MiB | 4GB | 66℃ | 96% |
| 23:41 | LLM-jp-4-8B の LoRA 学習 + vLLM 4台 | 0MiB | 20GB | 58℃ | 5% |
(空きメモリ・温度は、5 分間隔で動かしている自作の監視スクリプトの最終記録です)
症状は前回と同じに見えました。
- 監視ログの uptime が「12時間 → 3分」のように飛んでいて、プロセスではなくホストごと再起動している
- 学習コンテナは終了コード 255、トレースバック無し、
OOMKilled=false - カーネルの OOM killer の記録も無い
熱仮説が崩れる
前回の記事で有力視したのは熱でした。ところが今回は、3回とも GPU 温度が 58〜66℃ しかありません。前回問題視した 80℃ 台にはまったく届いていません。GPU 側のエラーカウンタもすべて 0 でした。
前回と今回の違いを並べると、別の現象だとはっきりします。
| 前回(#2) | 今回 | |
|---|---|---|
| 発生条件 | 数日〜3週間の長時間稼働の後 | LoRA 学習を始めてから 25 分〜3 時間以内 |
| GPU 温度 | 80℃ 台に達する場面があった | 58〜66℃ |
| 平常運転での再発 | クロック制限後 26 日間ゼロ | 平常運転では依然ゼロ(学習中だけ) |
クロック制限は平常運転には効いていて、LoRA 学習には効いていない。そう読むと、熱とは別の原因を探す必要がありました。調査も、前回の続きではなく別件として切り分けました。
一度目の読み違い:「完走した方が条件が厳しい」
同じ日には、最後まで完走した学習も2本ありました。
| 学習 | 所要時間 | 空きメモリ | swap 使用 | 結果 |
|---|---|---|---|---|
| Qwen3-4B | 1時間37分 | 約20,000MiB | 0GB | 完走 |
| Qwen3-14B(比較用) | 3時間13分 | 約3,000MiB | 4GB | 完走 |
3回中2回は空きメモリが 365MiB と 0MiB で、メモリ枯渇が怪しく見えます。ところが 14:31 の回は 5,366MiB も空いていました。一方、完走した 14B は 3,000MiB しか空いていません。
「落ちた回の方が空きが多い。メモリ枯渇だけでは説明できない」。この時点ではそう判断し、「まずは観測の解像度を上げる(1分間隔で記録する仕組みを作る)」という方針を立てました。
結論から言うと、この読みは間違いでした。
新しい監視を作る前に、既存のログを全部見る
監視を作り込む前に、念のため journalctl を確認しました。すると、ジャーナルは最初から永続化されていて(/var/log/journal が存在)、落ちる前の boot の分まで丸ごと残っていました。
journalctl --list-boots # 過去の boot の一覧
journalctl -b -3 | grep NVRM # 3つ前の boot のドライバのエラー
journalctl -b -3 | grep earlyoom # 同じく earlyoom の記録
落ちた boot の最後に記録されていたのは、次の2行でした。
kernel: NVRM: nvCheckOkFailedNoLog: Check failed: Out of memory
[NV_ERR_NO_MEMORY] (0x00000051) returned from _memdescAllocInternal(...)
earlyoom: mem avail: 0 of 122803 MiB (0.00%)
前回の「無痕跡」とは違い、痕跡はちゃんと残っていました。
決め手になったのは earlyoom です。OOM を防ぐために入れていたデーモンですが、1分間隔で空きメモリをログに書いていました。
| 再起動 | earlyoom の最終記録 | 空きメモリの推移 |
|---|---|---|
| 14:31 の回 | 14:19:50 → 14:21:50 | 5,440MiB → 0MiB(2分で枯渇) |
| 23:41 の回 | 23:36〜23:39 | 0MiB が続く |
14:31 の回で自作の監視が拾った 5,366MiB は、枯渇が始まる直前の値にすぎませんでした。実際にはそのすぐ後、2 分で 0MiB まで落ちていました。5 分間隔の監視では、この速さの変化は捉えられません。
分かれ目は「少ない」ではなく「0 に届くか」
1 分間隔の記録で比べ直すと、きれいに分かれました。
| 実行 | 最低の空きメモリ | 結果 |
|---|---|---|
| Qwen3-14B(比較用) | 3,005MiB で安定(2時間20分張り付き) | 完走 |
| Qwen3-4B | 約20,000MiB | 完走 |
| LLM-jp-4-8B | 0MiB | ホスト再起動 |
空きが 3GB しかなくても、0 に届かなければ完走します。0 に届いた瞬間にホストごと落ちる。分かれ目はそこでした。
なぜ「プロセス」ではなく「ホスト」が落ちるのか
普通の Linux マシンなら、メモリが尽きれば OOM killer が重いプロセスを殺して終わりです。今回は殺される前にホストが止まりました。
DGX Spark(GB10)は CPU と GPU が同じメモリを共有するユニファイドメモリ構成です。GPU メモリの確保は、そのままホストメモリの確保になります。空きが 0 になると NVIDIA ドライバ(NVRM)が内部の確保に失敗し、NV_ERR_NO_MEMORY を出したところでシステム全体が応答しなくなる、と考えています。
前回の記事で触れた NVIDIA フォーラムの「ユニファイドメモリで OOM が起きると、OOM killer が動く前にカーネルが飢餓状態になる」という既知の問題とも合っています。
なお、earlyoom がプロセスを殺す前にホストが固まった理由は特定できていません。GPU に確保された分は、プロセスを kill してもすぐには解放されない可能性があります。いずれにしても、ユニファイドメモリ機では「OOM になったら何かが殺してくれる」を当てにできないことは分かりました。
残った謎:なぜ 8B が 14B より多くメモリを食うのか
原因が「空き 0MiB への到達」だと分かり、対策の方針は「0 に到達させない」に決まりました。ただ、腑に落ちない点が1つ残りました。
8B の LLM-jp-4 は 0MiB まで落ち、より大きな 14B の Qwen3 は 3,000MiB で踏みとどまった。 モデルの大きさと必要なメモリが逆転しています。
しかも 2 分で 5.4GB という減り方は、「学習が進むにつれてじわじわ増える」ものではありません。何かが急に確保されています。
この謎を追った結果が、次回の #4 です。結論だけ先に書くと、正体は PyTorch の CUDA アロケータの断片化で、PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True を 1 行足すだけで解決しました。
→ #4 の結論
この回の教訓
-
「無痕跡」と決めつける前に、既存のログを全部探す。 前回の経験から「今回もログは残っていないだろう」と思い込み、新しい監視を作ろうとしていました。実際には
journalctlの永続化とearlyoomの 1 分間隔の記録で足りていました - 監視の間隔より速い変化は、監視には「無かったこと」として映る。 5 分間隔の値だけを見て「落ちた回の方が空きが多い」と逆の結論を出していました。2 分で尽きる現象には、5 分間隔の監視は無力です
- 「少ない」と「尽きる」は別物。 空き 3GB で 2 時間以上安定した例があります。警戒すべきは水準の低さより、0 に向かう速さです
- 同じ「ホストが落ちる」でも、原因が同じとは限らない。 熱対策が効いていたのは平常運転だけでした。発生条件(学習中だけ)と温度(低い)が前回と違うことに気づいた時点で、別件として切り分けたのが正解でした