WSL2 + torchrun --nproc-per-node=2 は無音でハングする。犯人は mirrored networking下でNCCLのブートストラップがTailscale共有IP(eth0)経由の自己接続でSYN-SENTのまま固まること。NCCL_SOCKET_IFNAME=lo GLOO_SOCKET_IFNAME=loで解決。
ハング解消後に出た RuntimeError: CUDA driver error: device not ready はメモリ不足の誤表示だった。原因はPYTORCH_CUDA_ALLOC_CONF=expandable_segments:True。Falseにすると同じタイミングで正しくOutOfMemoryErrorが出るようになった。PyTorch公式でも既知の紛らわしい挙動(Issue #173049)。
OOMメッセージ中の Process X has 17179869184.00 GiB memory in use という物理的にあり得ない数字は実害のない表示バグ(Issue #116928で同一文言が報告済み)。2^34バイトを誤ってGiBとラベル付けしているだけ。
本物のOOMは680MBの僅差。9種類の対策(バッチサイズ/系列長削減、NCCLバッファ調整、カスタムbackwardのin-place化、max_split_size_mb、ステップ間のempty_cache()等)を試したが、同じ9.8〜10.3GB・同じ680MB不足に収束。最終的に「2GPUデータ並列に伴うNCCL固定オーバーヘッド(実測500〜700MB相当)が、単独GPUの余裕(478MB)を上回っていた」という物理的な壁と判断し、単独GPUに戻して本番続行。
環境: Qwen3.6-27B、GSQ 2bit量子化、RTX5070×2(各12GB)、WSL2 mirrored networking。
- torchrun --nproc-per-node=2 が無音でハングする
bash scripts/run.sh(内部でtorchrun --standalone --nproc-per-node=2)を実行すると、データセット読み込みまでは進むが、その先でログが一切出ないまま無音停止した。GPU使用率もほぼ0%。ハングは複数回・複数の設定変更を挟んでも再現した。
原因: ss -tnpでプロセスのソケット状態を見ると、rank0/rank1とも自分自身のTailscale共有IP(100.87.88.104)宛のSYN-SENT状態で固まっていた。WSL2のmirrored networkingモードでは、socket.gethostname()ベースの自動インターフェース選択がホストと共有される外部IP(eth0)を選んでしまい、同一ホスト内のプロセス間通信ですらループバック往復ができずSYN-SENTで止まる。torchrunのelastic agentランデブー自体(127.0.1.1宛)は正常に成立していたので、詰まっていたのはt.init_process_group(backend="nccl")が使う別のソケットだった。
対処:
NCCL_SOCKET_IFNAME=lo GLOO_SOCKET_IFNAME=lo bash scripts/run.sh
これだけでハングは即座に解消した。同じ現象は以前vLLM TP=2(Gemma4)でも遭遇済みで、NCCL_SOCKET_IFNAME=loが定番の回避策になっている。
教訓: WSL2 mirrored networking環境でtorchrun/dist.init_process_groupを使うプロジェクトは、最初からNCCL_SOCKET_IFNAME=lo GLOO_SOCKET_IFNAME=loを付けておくべき。無音ハングを見たらss -tnp | grep でSYN-SENTの自己接続を疑う。
- 「device not ready」は実はOOMの誤表示だった
ハングを解消して量子化が進み始めたが、layer0のGumbel-Softmax学習ステップ(MLPの3層のうち一番大きいdown_proj)で毎回 RuntimeError: CUDA driver error: device not ready が発生した。何度リトライしても同じ場所で落ちる。
最初は「WSL2のGPU passthrough自体が不安定なのでは」と疑い、torch.randnのような単純な操作でも同じエラーが出ることから、特定コードパスのバグではなく環境側の問題だと当たりを付けた。
原因: リポジトリのデフォルト設定がPYTORCH_CUDA_ALLOC_CONF=expandable_segments:Trueを強制していた。これはメモリ断片化対策の機能だが、実際にはメモリが足りていない場面でも、通常のOutOfMemoryErrorではなくdevice not readyという紛らわしいCUDAドライバエラーを返すことがある。試しにexpandable_segments:Falseにしたところ、全く同じタイミングで今度は正しく
torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 1.33 GiB. ...
という明確なOOMメッセージに変わった。PyTorch公式Issueにも同種の報告がある(#173049: 「expandable_segmentsが既に有効なのに、エラーメッセージがexpandable_segments:Trueを試せと言ってくる」)。
副産物の表示バグ: このOOMメッセージ内にProcess 16448 has 17179869184.00 GiB memory in useという物理的にあり得ない数字(170億GiB)が混入していた。2^34という綺麗な2進数値だったのですぐ怪しいと気づき調べたところ、PyTorch公式Issue #116928に一言一句同じ文言の報告があった。バイト数(2^34バイト=16GiB)をそのままGiB単位のラベルで出力してしまっているだけの表示バグで、実害はない。
教訓: WSL2でCUDA OOM周りの挙動がおかしいと感じたら、まずexpandable_segments:Falseにして「本当にOOMなのか」を切り分ける。エラーメッセージの数字を鵜呑みにせず、既知issueかどうかWeb検索で裏を取る価値がある(検索結果の要約だけを信じず、実際のIssue本文を確認するとより確実)。
- 本物のOOMは680MBの僅差、9種類の対策が全部同じ壁に収束
expandable_segments:Falseにしたことで、本当のOOMだと判明。
Tried to allocate 680.00 MiB. GPU 0 has a total capacity of 11.94 GiB
of which 0 bytes is free. 9.81 GiB is allocated, 1009.30 MiB is
reserved by PyTorch but unallocated.
「reservedのうち1GB近くが未使用」というメッセージから、断片化が主因と判断し、以下を順番に試した。
- batch_size 4→2: 数字が1ミリも動かず(このOOM箇所はバッチサイズと無関係と判明)
- max_length 1024→512→384: 同上、無関係
- gptq.nsamples 128→64: 効果なし、かつ精度に関わる設定なので撤回
- NCCL_BUFFSIZE/NCCL_MAX_NCHANNELS 削減: 効果なし(このクラッシュ地点はNCCL通信バッファ確保より前だった)
- GumbelSoftmaxFunction.backward()のin-place化: 断片化をreservedベースで確認できる形で改善(670MB→1009MB unused)、ただし680MB不足は解消せず
- expandable_segments:Trueに戻す: メモリは足りている(memory_summary上CUDA OOMs: 0)のにdevice not readyが再発。WSL2との相性問題を再確認
- max_split_size_mb:256 + garbage_collection_threshold:0.8: 断片化(unused reserved)を1009MB→302MBまで改善。しかし総必要量自体が微増し、結局680MB不足のまま
- train_stepのマイクロバッチ間・step末尾にtorch.cuda.empty_cache(): 詳細メモリログで「reservedがstep間で完全には解放されず右肩上がり」なことを確認、step末尾での解放自体は改善したが、次stepの計算がその分を食い尽くして同じ壁に到達
詳細ログでの実測(torch.cuda.memory_allocated()を要所に仕込んで取得):
train_layer開始: reserved=6.4GB
step1 backward後: reserved=10.7GB
step1(2周目)backward後: reserved=11.6GB ← 天井間近
step2 backwardでOOM
最終的な判断根拠: NCCLは2デバイスのcommunicator group作成だけで固定して544〜688MBを要求することが実測報告されている(NVIDIA/nccl Issue #964、A100+NVLink環境の数字だが桁は一致)。一方、単独GPU(NPROC=1)では同じ層の処理が11.75GB/11.94GBで実際に完走していた。つまり2GPU化に必要な追加分(NCCL固定オーバーヘッド)を積む余地が、単独GPU時点でほぼ残っていなかった。
さらに調べると、このリポジトリの2GPU対応は「同じ層をデータ並列で処理しdist.all_reduceで勾配を同期する」方式であり、以前試した別プロジェクト(DBF量子化スクリプト)のように層レンジをGPUごとに完全独立処理する方式ではないことが判明した(README「Multi-GPU/Multi-Node Scaling」に明記: denseモデルはexpert-parallelism対象外)。真のテンソル並列(重み行列自体を2GPUで分割)であれば必要メモリを半分にできるはずだが、これは未実装で、実装するにはカスタムautograd Functionのシャーディング対応など本格的な新規実装が必要になる。
対処: 2GPU化を断念し、単独GPU(NPROC=1)で本番投入。ただし副産物として得られた以下の修正はすべて残した(数学的に無害、単独GPUでもわずかに余裕が増える):
- torch.randnをfp32でなく最終的な保存dtype(bf16)で直接確保(無駄なfp32バッファを削減)
- カスタムbackwardのin-place化
- マイクロバッチ間・step末尾のempty_cache()
教訓: 「reservedのうち未使用分が多い」というメッセージは断片化のサインだが、断片化を解消しても総必要量そのものが物理限界を超えていれば意味がない。対策を打つたびに原因の切り分け(今回はメモリログを仕込んでreservedの推移を実測したのが決め手)をしないと、無限に対策リストを消化するだけになる。マルチGPU対応をうたうリポジトリでも、それが「メモリを本当に分散する」実装なのか「同じデータを分担処理するだけ」なのかはコードを読むまでわからない。