0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RTX 5090 でゼロから言語モデルを1分で学習する — Windows ネイティブと Blackwell (sm_120) の落とし穴

0
Posted at

Windows 11 のネイティブ環境(WSL を使わない)と RTX 5090 で、学習済みモデルを一切使わずに
言語モデルを作って会話するところまでのコードを公開しました。全コード MIT です。

  • リポジトリ: https://github.com/hiroki-abe-58/1LM-Blackwell
  • モデル: 文字レベル ミニGPT / 6層 / 384次元 / 文脈256文字 / 11.53M パラメータ
  • データ: kunishou/oasst1-89k-ja(Apache-2.0)を整形した日本語会話 28,616 件
  • 学習: 61.3 秒 / 3,600ステップ(最良 val loss 1.8677)

この記事は、同じコードの Apple Silicon 版(MLX)を Windows + CUDA に移植する過程で
実際に踏んだ Windows / Blackwell 固有の詰まりどころをまとめたものです。
移植元と移植先で同じ学習を再現し、数値で照合しているので、
「動いた」ではなく「同じ学習になっている」まで確認しています。

環境は次のとおりです。

  • Windows 11 Pro (Build 26200) ネイティブ / WSL2 なし
  • RTX 5090 32GB / compute capability 12.0 (sm_120) / Driver 595.95 (CUDA 13.2)
  • Python 3.13.9 / PyTorch 2.13.0+cu130 / triton-windows 3.7.1.post27

1. torch.cuda.is_available() が True でも学習は始まらない

いちばん最初に踏みます。import torch は通り、torch.cuda.is_available() も True を返し、
torch.cuda.get_device_name() も正しく "NVIDIA GeForce RTX 5090" と出ます。
それでも最初の行列積でこうなります。

RuntimeError: CUDA error: no kernel image is available for execution on the device

これは「GPU が無い」ではなく「この GPU 向けにコンパイルされたカーネルが wheel に
入っていない
」という意味です。PyPI の pip install torch で入る wheel には
sm_120 のカーネルが含まれないことがあります。

uv pip install torch --extra-index-url https://download.pytorch.org/whl/cu130
python -c "import torch; print(torch.__version__, torch.cuda.get_device_capability())"
# 2.13.0+cu130 (12, 0) と出れば正しい

2026年8月時点で cp313 / Windows 向けに実在する wheel を調べると、こうなっていました。

cu130 : 2.9.0 / 2.9.1 / 2.10.0 / 2.11.0 / 2.12.0 / 2.12.1 / 2.13.0
cu128 : 2.7.0 / 2.7.1 / 2.8.0 / 2.9.0 / 2.9.1 / 2.10.0 / 2.11.0

cu128 は 2.11.0 で止まっています。ドライバが CUDA 13.2 を報告しているので cu130 を選びました。

重要なのは、この検査を「行列積を実際に1回回す」で書くことです。
torch.cuda.is_available() を見る診断スクリプトは、この状態を通してしまいます。

# これで初めて no kernel image が出る
a = torch.randn(256, 256, device="cuda")
(a @ a).sum().item()

2. Windows の PyTorch wheel には FlashAttention が入っていない

Linux 前提のコードをそのまま持ってくると落ちます。

with sdpa_kernel(SDPBackend.FLASH_ATTENTION):
    out = F.scaled_dot_product_attention(q, k, v, is_causal=True)
UserWarning: Torch was not compiled with flash attention.
RuntimeError: No available kernel. Aborting execution.

Torch was not compiled with flash attention なので、環境の問題ではなく
公式 Windows ビルドの都合です。全員が踏みます。backend を1つずつ試した結果です。

backend Windows / torch 2.13.0+cu130 スループット (bf16 / batch 64)
flash 使えない
cudnn 使える (cuDNN 9.20) 1,113k tok/s
mem_efficient 使える 1,123k tok/s
math 使える 667k tok/s

backend を明示指定しないのが正解です。PyTorch の自動選択に任せれば cudnn か
mem_efficient が選ばれ、速度はほぼ変わりません(math に落ちると 1.7 倍遅い)。

なお sdpa_kernel() が返すコンテキストマネージャは1回しか入れません
使い回すと2回目から AttributeError になり、「その backend は使えない」と誤診します。
最初に書いたベンチがこれで、cudnn も mem_efficient も math も全部「使えない」と出ました。

3. newline="\n" を書かないと語彙が1つ増えて、学習済みモデルが読めなくなる

Windows で日本語コーパスを扱う人全員に効く話です。移植元のコードはこうでした。

out.write_text("\n".join(lines) + "\n", encoding="utf-8")

encoding は明示されています。しかし Path.write_textnewline は既定で None
つまり \nos.linesep に変換します。Windows では \r\n になります。

実測はこうなりました。

CR (0x0D) の個数 : 28616      <- 会話の件数と同じ
LF (0x0A) の個数 : 28616
文字種 (生)      : 2074
語彙数           : 2078        <- Mac 版の checkpoint は 2077

文字レベルトークナイザは \r を1文字として数えるので、語彙が1つ増えます。何が起きるか。

  1. checkpoints/final/config.jsonvocab_size が 2077 なので、
    語彙 2078 で学習すると学習済みの重みが load_state_dict で読めない
  2. 語彙が違うと loss の単位が変わるので、val loss 1.8571 との比較が成立しない
  3. 検査で弾かれないので気付けない。 コーパスは正常に見えるし、学習も普通に始まる

対処は1引数です。

out.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")

これで文字種が 2,072、語彙が 2,077 になり、Mac 版の tokenizer.json
itos完全一致しました。サブワード(SentencePiece)を使う場合はもっと厄介で、
\r を含むトークンが作られて分割そのものが変わります

ファイル書き出しは全部 encoding="utf-8", newline="\n" を明示する、と決めておくのが安全です。

4. VRAM を超えてもエラーは出ない。「触った瞬間」に静かに遅くなる

Windows の NVIDIA ドライバは、VRAM が足りなくなるとシステムRAM
(タスクマネージャの「共有GPUメモリ」)へ溢れさせて実行を続けます。落ちません。

VRAM 31.8 GB のカードに 35.0 GB を1本のテンソルで要求した実測です。

操作 共有GPUメモリ 書き込み帯域
8.0 GB を確保して書き込み(VRAM 内) +0.000 GB 114 GB/s
35.0 GB を torch.empty() で確保 +0.000 GB
その 35.0 GB に実際に書き込み +6.125 GB 29 GB/s

確保した時点では何も起きません。 torch.empty() は要求を受け付けるだけで
物理ページを割り当てないので、OOM も出ませんし共有GPUメモリも増えません。
実際に触った瞬間に 6.1 GB がシステムRAM へ移り、帯域が 4 分の1 になります。

つまり「torch.empty() が通るか」を見る検査では絶対に見つかりません
検査するなら fill_() などで実際に書き込んでください。

共有GPUメモリは nvidia-smi では見えない

nvidia-smi は専用VRAM しか報告しません。Windows のパフォーマンスカウンタ
\GPU Process Memory(*)\Shared Usage を読む必要があります。
Get-Counter でも取れますが、プロセス起動に1秒以上かかるのでステップごとの監視には使えません。
PDH API を ctypes から叩いてハンドルを開いたまま保持しました。

ここで1つ罠があります。PDH の戻り値は符号なしですが、ctypes は restype
宣言しないと signed int とみなします。

for name in ("PdhOpenQueryW", "PdhAddEnglishCounterW", "PdhCollectQueryData",
             "PdhGetFormattedCounterArrayW", "PdhCloseQuery"):
    getattr(pdh, name).restype = ctypes.c_ulong

これを書かないと PDH_MORE_DATA (0x800007D2) が負の値になり、比較が外れて常に
None が返ります。カウンタは正常に読めているのに値が取れないという形で出るので厄介です。

もう1つ。GPU をまだ触っていないプロセスは、カウンタのインスタンスとして現れません。
基準値を取る前に torch.zeros(1, device="cuda") で CUDA コンテキストを張っておかないと、
基準が 0 になって増分がおかしくなります。CUDA コンテキストを張るだけで 0.074 GB 出ます。

NVIDIA コントロールパネルの設定は自動化できなかった

「CUDA - Sysmem Fallback Policy を Prefer No Sysmem Fallback にする」が根本対処ですが、
この設定は C:\ProgramData\NVIDIA Corporation\Drs\nvdrsdb0.bin という独自形式の
データベースにあり、レジストリでは触れません。公式の入口は NVAPI の DRS API です。

nvapi64.dll を ctypes から叩いた結果です。

NVAPI の初期化と DRS セッション : 成功
ベースプロファイルの取得        : 成功
ドライバに登録されている設定    : 125 件
名前に sysmem / fallback を含む設定はありませんでした。
名前に cuda を含む設定も 0 件です。

API は動きます。しかし NvAPI_DRS_EnumAvailableSettingIds が返す 125 件は全部
3D 描画側(アンチエイリアス、DLSS、G-SYNC、Vsync など)でした。
CUDA 関連の設定は1つも登録されていません。 よって自動設定は不可能で、GUI でやるしかありません。

設定IDを推測して SetSetting を呼べば書き込めますが、当たっている保証がないまま
別のドライバ設定を書き換える危険があるのでやりません。
ドライバ設定に頼らない作りにしておくのが正解でした。
torch.cuda.set_per_process_memory_fraction() で上限を切り、
ステップごとに共有GPUメモリの増分を見て、増えたら例外で止めます。

5. torch.compile(model) の戻り値からメソッドを呼ぶとコンパイルされない

torch.compileOptimizedModule を返します。これは未知の属性アクセスを
元のモジュールへ転送するので、次のように書くとコンパイルが効きません。

compiled = torch.compile(model)
loss = compiled.loss(x, y)   # model.loss が呼ばれ、中の self(idx) は素の forward

例外も警告も出ず、ただ速くならないだけなので気付けません。
損失計算そのものをコンパイルします。

loss_fn = torch.compile(model.loss, dynamic=False)

実測(batch 64 / 語彙 2,077 / 40ステップ / ウォームアップ後)。

条件 スループット 初回コンパイル 実効TFLOPS VRAM
compile なし / fp32 681k tok/s 0.34 秒 45.8 2.95 GB
compile なし / bf16 1,159k tok/s 0.10 秒 77.9 1.98 GB
compile あり / fp32 726k tok/s 1.14 秒 48.8 2.81 GB
compile あり / bf16 1,365k tok/s 0.42 秒 91.8 1.67 GB

bf16 で 1.70 倍、そこに compile で 1.18 倍。合わせて fp32 の 2.00 倍です。

初回コンパイル時間は TORCHINDUCTOR_CACHE_DIR が温まっているかで激変します。
キャッシュが空の初回は 18.30 秒、温まった後は 0.42 秒でした。
「初回コンパイルは1秒未満」と書くと嘘になるので両方記録します。

なお torch.compile はキャッシュパスが 260 文字を超えると FileNotFoundError になります。
TORCHINDUCTOR_CACHE_DIR=E:\ti_cache のような短いパスにするか、
管理者権限で長いパスを有効にしてください。

reg add HKLM\SYSTEM\CurrentControlSet\Control\FileSystem /v LongPathsEnabled /t REG_DWORD /d 1 /f

6. 同じコード・同じデータで M1 Max と RTX 5090 を比べる

この記事のいちばんの中身です。移植元(MLX / M1 Max)と移植先(PyTorch / RTX 5090)で
同じ 3,600 ステップを回しました。

項目 M1 Max (MLX) RTX 5090 (PyTorch)
3,600ステップ 1,907.0 秒 60.8 秒(31.4 倍)
最良 val loss 1.8571 1.8677(差 +0.0106)
スループット 31k tok/s 1,138k tok/s
専用VRAM ピーク (ユニファイド) 1.67 GB

31.4 倍です。32分待っていた学習が1分で終わります。
ここで浮いた時間を「なぜ速いのか」の実測に使えるのが、GPU 側の本当の価値でした。

ただし数字の扱いには注意が必要です。

ベンチ値と通しの実測を混同しないこと。 ベンチは 1,365k tok/s ですが、通しは 1,138k tok/s です。
通しには検証・保存・試し生成が入るので必ず遅くなります。

同じシードでも val loss は実行ごとに動きます。 コードもシードも変えずに3回走らせた結果、
1.8661 / 1.8677 / 1.8688 で幅は 0.003 でした。bf16 と torch.compile を使うと
総和の順序が実行ごとに固定されず、丸め誤差の積み方が変わるためです。
記事や README に小数第4位まで書くなら、必ず「実行ごとに 0.003 程度動く」と併記してください。
そうしないと読者が再現したとき必ず一致しません。

7. おまけ: 移植でいちばん時間を食ったのは optimizer だった

Windows 固有ではありませんが、フレームワーク間の移植をする人には効く話なので書いておきます。

logits の一致(max|diff| < 1e-4)も step 0 の loss も通ったのに、
3,600ステップ通したときの val loss だけが合いませんでした。

条件 最良 val loss Mac (1.8571) との差 判定
bf16 / torch.optim.AdamW 1.9228 +0.0657 不合格
fp32 / torch.optim.AdamW 1.9273 +0.0702 不合格
bf16 / MLX 互換 AdamW 1.8661 +0.0090 合格

最初は bf16 の丸めを疑いました。しかし fp32 で回しても 1.9273 で、bf16 との差は
0.0045 しかありません。精度は原因ではなかった。

原因は MLX の Adam の宣言でした。

class Adam(Optimizer):
    def __init__(self, learning_rate, betas=[0.9, 0.999], eps=1e-8,
                 bias_correction: bool = False):   # <- 既定が False

MLX の AdamW は既定でバイアス補正をしません。 PyTorch の torch.optim.AdamW
常に補正し、切る引数がありません。

共通       m <- β1 m + (1-β1) g
           v <- β2 v + (1-β2) g^2

MLX 既定   p <- p - lr · m / (sqrt(v) + ε)
PyTorch    p <- p - lr/(1-β1^t) · m / (sqrt(v)/sqrt(1-β2^t) + ε)

1ステップ目で比べると、補正なしの更新幅は補正ありの約3倍になります
(m ≈ 0.1g、sqrt(v) ≈ 0.0316|g| なので m/sqrt(v) ≈ 3.16·sign(g))。
β2 = 0.999 の補正係数が 1 に落ち着くまで数千ステップかかるので、
3,600ステップでは最後まで影響が残ります。

同じ名前・同じハイパーパラメータ・同じ論文でも、実装が違う。
フレームワーク間の移植では optimizer の実装まで読む必要があります。

まとめ

Windows ネイティブ + Blackwell で最初に潰すべき点です。

  1. wheel は cu130 以降。判定は「行列積を実際に回す」で書く
  2. FlashAttention は入っていない。backend は明示指定しない
  3. ファイル書き出しは encoding="utf-8", newline="\n" を必ず明示する
  4. VRAM 超過はエラーにならない。共有GPUメモリを PDH から読む(restype に注意)
  5. torch.compile はメソッドを直接コンパイルする。キャッシュパスは短く保つ

コードは全部リポジトリにあります。check_env.py が上記の検査を13項目まとめて行うので、
学習を始める前にこれを通してください。

同じシリーズの続きとして、サブワード化して会話が成立するレベルまで鍛えた
2LM-Blackwell と、
自分でデータを作ってキャラクターを持たせる
2LM-Blackwell-GAL があります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?