前回 → 【止まっちゃう事件の記録 #3】熱対策で止まったはずのホストが、LoRA学習中に1日3回落ちた話 〜犯人は熱ではなくメモリ枯渇だった〜
結論
-
LoRA 学習中にホストメモリを 0MiB まで食い尽くしていた正体は、PyTorch の CUDA キャッシングアロケータの断片化でした。
-
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:Trueを環境変数で渡すだけで解決しました。対策前 対策後 定常時の空きメモリ 5,059〜7,004MiB 78,127〜78,353MiB 空きの減り方 毎時 約1,068MiB 毎時 約28MiB 開始20分後の急落(約13GB) 毎回発生 発生しない -
モデルが小さい方(8B)がメモリを多く食う逆転も、これで説明が付きました。語彙が大きいモデル(LLM-jp-4 は 196,608)ほどロジットの山が高く、断片化が進みやすいためです。
原因不明のまま保留にしていた「LLM-jp-4-8B の学習がログ無しで死ぬ」問題も、このフラグを付けると完走しました。 -
途中で**「長いサンプルの活性化メモリ」「ロジットの一時領域」と2回推定を外しています**。どちらも数字の上では筋が通って見えたのが厄介でした。
-
学習中の監視は、残量の水準だけでも、落差だけでも判断できません。両方を組み合わせて、正常なスパイクは通し、枯渇だけを止める形に落ち着きました。
この記事について
前回(#3)では、DGX Spark(GB10)で LoRA 学習中にだけホストが再起動する原因が、熱ではなく**ホストメモリの完全枯渇(空き 0MiB)**だと突き止めました。ユニファイドメモリ機なので、GPU メモリの確保がそのままホストメモリを削り、0 に届いた瞬間にドライバごと止まります。
残った謎は2つでした。
- 2 分で 5.4GB が消えるような急な確保は何なのか
- なぜ 8B のモデルの方が 14B より多くメモリを食うのか
今回はこの2つを解いた記録です。
再現:開始20分、step 7 で必ず落ちる
まずは前回の教訓に従い、監視を細かくしました。5 秒間隔で空きメモリを記録するスクリプトを横で動かし、Qwen3-4B のフル学習(418 step)を回します。
すると、開始から約 20 分、step 7 前後で必ずメモリが急落しました。監視の設定を変えながら3回走らせ、3回とも同じ位置で再現しています。
| 実行 | 急落前の空き | 急落後 | 落差と速度 |
|---|---|---|---|
| 1回目 | 15,752MiB | 5,122MiB | 10.6GB / 30秒以内 |
| 2回目 | 19,986MiB | 6,879MiB | 13.1GB / 5秒(約2.6GB/秒) |
| 3回目 | 19,223MiB | 6,374MiB | 12.8GB / 5秒 |
約 2.6GB/秒 です。前回 earlyoom の記録から見積もった「2分で 5.4GB」(約 45MB/秒)の約 58 倍で、1 分間隔の earlyoom も 5 分間隔の監視も追いつけないはずです。
なお、この検証はカーネル更新(6.17 系 → 7.0 系)の後に行っています。新しいカーネルでも、フラグ無しなら急落は毎回起きたということです。
外れた推定その1:「長いサンプルの活性化メモリ」
最初に疑ったのは、学習データの中に特に長いサンプルがあり、その活性化メモリが一気に確保されるという筋書きでした。step 7 で毎回というのも「特定の長いバッチに当たった」と考えれば説明が付きます。
そこで、学習データの系列長分布を Qwen3-4B のトークナイザで実測し、問題なく完走したスクリーニング用の小さいデータと比べました。
| 完走した方(スクリーニング) | 落ちる方(フル学習) | |
|---|---|---|
| 系列長 p90 | 7,952 | 6,011 |
| 8,192 トークン超の割合 | 7.4% | 1.3% |
| 最大 | 12,148 | 12,148 |
完走した方が長い系列を多く含んでいました。 長いサンプルが原因なら逆になるはずなので、この推定は外れです。
外れた推定その2:「ロジット行列の一時領域」
次に疑ったのは、出力層のロジットです。語彙数 × 系列長の行列は大きくなります。
語彙 151,936 × 系列長 12,148
fp32: 約 6.9 GiB
bf16: 約 3.4 GiB
合計: 約 10.3 GiB
実測の落差(約 13GB)とほぼ一致します。今度こそ当たりに見えました。
ところが、学習時の最大系列長(cutoff_len)を 10,240 から 8,192 に下げても、落差は 13.1GB → 12.8GB とほとんど減りませんでした。ロジットが主因なら、2 割程度は減るはずです。
さらに決定的だったのは、「一時領域なので、そのステップを越えれば解放される」という前提も崩れたことです。急落の後もメモリは戻らず、そこから毎時 1GB 前後のペースで減り続けました(109 分で 7,004MiB → 5,059MiB)。このペースでは、約 1.7 時間で前回「完走の下限」とした 3,000MiB に届きます。
ロジットの確保が急落の引き金の一部である可能性は残ります。ただし主因ではない、と判断しました。
効いた対策:expandable_segments
「確保したものが返ってこず、積み上がっていく」という振る舞いから、PyTorch の CUDA キャッシングアロケータの断片化を疑いました。
PyTorch は cudaMalloc を毎回呼ばず、確保したブロックをキャッシュして使い回します。サイズの違う確保と解放を繰り返すと、キャッシュの中が細切れになります。新しい確保が既存の隙間に収まらなくなるたびに新しいセグメントを取るため、使っている量以上にメモリを抱え込みます。
これを緩和するのが expandable_segments です。セグメントを固定サイズではなく伸縮できるものとして扱い、確保と解放の噛み合いを良くします。
docker run ... -e PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True ...
途中のチェックポイントから、この環境変数だけを足して再開しました。
| 対策前 | 対策後 | |
|---|---|---|
| 定常時の空き | 5,059〜7,004MiB | 78,127〜78,353MiB |
| 減少速度 | 毎時 1,068MiB | 毎時 28MiB |
| step 7 の急落 | 毎回発生(12.8〜13.1GB) | 発生しない(最低でも 77,724MiB) |
| 3,000MiB に届くまでの見込み | 約 1.7 時間 | 約 2,600 時間 |
空きが 5GB 台から 78GB 台に増えました。 学習はそのまま最後(418 step)まで完走しています。
ユニファイドメモリ機では、アロケータが抱え込んだ分がそのままホストの空きを削ります。断片化は普通の GPU なら「GPU メモリが足りない」というエラーで済みますが、ここではホストを落とす問題になっていました。
「8B が 14B より食う」謎と、保留にしていた「無言の死」
前回の謎の2つ目、「LLM-jp-4-8B は 0MiB まで落ち、Qwen3-14B は 3,000MiB で踏みとどまった」も説明が付きました。
| モデル | 語彙サイズ |
|---|---|
| Qwen3 系 | 151,936 |
| LLM-jp-4 | 196,608(約 29% 大きい) |
語彙が大きいほどロジットの山が高くなり、断片化が進みやすくなります。効いていたのはパラメータ数ではなく、語彙サイズと断片化の相互作用でした。
実はもう1つ、未解決のまま保留にしていた事象がありました。LLM-jp-4-8B の学習が、終了コード 255、トレースバック無しで死んでいたことです。ホスト再起動に巻き込まれた形の終わり方だったため、モデル側(テンプレートやデータ)に問題があるのかどうかを切り分けられないままでした。
expandable_segments:True を付け、当時と同じ設定(500 件・1 エポック・cutoff_len 10,240)で回し直したところ、1 時間 24 分で完走しました。最低の空きは 55,924MiB です。
正直に書くと、当時と今回ではカーネルのバージョンも違います。ただ、新しいカーネルでもフラグ無しなら急落が毎回起きることは確認済みです。そのため、効いたのはフラグの方だと判断しています。
監視の閾値設計で2回失敗した話
再発防止として、学習中に空きメモリを監視し、危なくなったら学習コンテナを止めるスクリプトを用意しました。ただ、閾値の決め方で2回失敗しています。
1回目:警戒線 10,000MiB → 正常なスパイクで毎回止まる
「0 に落ちる直前は 2 分で 5.4GB 消える」ので、余裕を見て 10GB を警戒線にしました。ところが、step 7 の急落(正常に完走するケースでも起きる)で毎回 5GB 台まで下がるため、正常に進んでいた学習を止めてしまいました。完走実績のある 3,000MiB より明らかに高い線でした。
2回目:警戒線を 2,500MiB に下げる → 速度に間に合わない
では下げればいいかというと、急落の速度は 2.6GB/秒です。2,500MiB の線は 1 秒で突破されます。数秒間隔の監視で「線を割ったら止める」設計では、検知した時にはもう手遅れになりえます。
最終形:落差と残量を組み合わせる
最終的に、次の2つの条件で止める形にしました。
- 5 秒間隔で空きを測る
- 1 サンプルで 2,500MiB 以上落ち、かつ残量が 3,000MiB 未満 → 即停止(枯渇に向かう急落)
- 残量が 2,500MiB 未満を連続 2 回 → 停止(じわじわ型の枯渇)
正常なスパイクは「落差は大きいが、残量はまだある」ので通過します。枯渇に向かう落ち方だけを止めます。
水準だけ見ると正常なスパイクで誤停止し、落差だけ見ると正常なスパイクと区別できない。 この環境では両方を見る必要がありました。
その後
対策を入れた後、16 時間級のフル学習(途中のチェックポイントから再開)と LLM-jp-4-8B の追試がどちらも完走しました。その後の計画的な再起動から、この記事を書いている時点(2026 年 10 月上旬)まで 16 日間、想定外の再起動は 0 回です。
#2 で入れた GPU クロック制限は、平常運転向けの緩和策としてそのまま残しています。今回の件とは原因が別だからです。
この回の教訓
-
数字が合う推定ほど疑う。 ロジットの計算(10.3GiB)は実測(約 13GB)とよく合いました。それでも、条件を変えて(
cutoff_lenを下げて)結果が動かなければ主因ではありません。数字が合うことと、原因であることは別です - 「一時的なもの」という前提は観測で確かめる。 急落の後にメモリが戻るかどうかを見たことで、一時領域説が崩れ、断片化に辿り着けました
-
ユニファイドメモリ機では、GPU 側の小さな非効率がホストの死に直結する。 普通の GPU なら OOM エラーで済む断片化が、ここではホスト再起動になります。LoRA 学習では
expandable_segments:Trueを既定にしてよいと考えています - 監視の閾値は、現象の速度から逆算する。 落ちる速さ(2.6GB/秒)とサンプル間隔(5 秒)を並べれば、水準だけの判定が間に合わないことは事前に分かりました
- 保留にした「謎」は、別件の解決で一緒に解けることがある。 「無言の死」を無理に追わず保留にしていましたが、断片化が分かった時点で追試し、一緒に片付けられました
同じようにユニファイドメモリ機で学習を回している方の参考になれば幸いです。