RunPod CPU Pod + GPU Pod の二段構成でLoRA学習を$1で回した
目次
WAI-Illustrious v17を試す で v16 学習の既存 LoRA が v17 でもギリギリ通用することは確認した。ただ顔が学習素材から少しズレてる感じもしていて、v17ベースで dim 上げて学習し直すついでに、RunPodの使い方も見直すことにした。
前回RunPodでLoRAを回した ときは、4090を借りた状態で apt install も pip install も 6.5GB のモデル DL もやっていた。時間単価 $0.69 の GPU で環境構築に 30-40 分かけるのは明らかに無駄。CPU Pod($0.08/h)で事前構築してから GPU Pod に切り替える二段運用自体は RunPodでQwen-Image-Layeredを回した時 にもやっていて、同じ流れを今回の LoRA 学習にも適用する。
今回初めて試したのは、CPU Pod 側の作業(モデル転送の終盤)を待たずに GPU Pod を起動して Network Volume に attach する運用。通常は CPU 側の作業を全部終わらせてから GPU を借りるが、4090 の Community Cloud 在庫は揮発的で、CPU 終了を待っている間に消えることがある。そこで CPU の最後の数分だけ GPU と並走して 4090 を押さえる。Network Volume は複数 Pod に同時 attach できるので、同じ volume を CPU と GPU の両方から触れる状態にしておけば、CPU 側の転送が終わり次第ほぼロスなく GPU 側で学習を始められる。
結果、学習1本あたり 合計 $1前後 で回った。
前提
対象は「すでにsd-scriptsでLoRA学習やったことあるけど、RunPodの時間単価が気になる」人。学習データ・学習設定・モデル選定の話は最小限にとどめる。
データセットは過去記事「Mac M1 MaxでLoRA学習に13回全敗してからRunPodで成功するまで」で使った59枚のキャラ画像を流用。ベースモデルはWAI-Illustrious v17。
コスト内訳(実測)
| 項目 | 単価 | 使用量 | 小計 |
|---|---|---|---|
| Network Volume 30GB | $0.003/h($0.07/GB・月を時間割り) | 4時間 | $0.01 |
| CPU Pod(2vCPU, 8GB RAM) | $0.08/h | 40分 | $0.05 |
| RTX 4090(Secure Cloud, EU-RO-1) | $0.69/h | 60分 | $0.69 |
| 合計 | 約$0.75 |
RunPodのダッシュボード上は「Monthly cost $2.10」と月額換算で表示されるが、実際は時間割り課金。学習1本分なら volume作成〜破棄まで3-4時間で完結するので、volumeコストはほぼ無視できる($0.01前後)。Community Cloudの4090($0.44/h)が取れるリージョンなら全体で$0.5前後まで下がる。今回はEU-RO-1でCommunity Cloud在庫が出てなかったのでSecure Cloudにフォールバックした。
Network Volume同時attachが効いてるのは、CPU Pod稼働中に4090 Podも並行で確保できたこと。4090のavailabilityは揮発性が高いので、「CPU側で準備してから4090探す」だと取り逃すリスクがある。並行attachできると、環境構築中でも4090を先に押さえられる。
全体ワークフロー
flowchart LR
A[CPU Pod<br/>$0.08/h] -->|venv作成<br/>sd-scripts clone<br/>pip install<br/>モデル転送| V[(Network Volume<br/>30GB)]
V -.共有.-> G[RTX 4090 Pod<br/>$0.69/h]
G -->|学習実行のみ| V
V -->|成果物DL| L[ローカルMac]
CPU Podはvenvとモデル転送が終わったら即停止する。GPU Podは学習開始タイミングで借りて、学習が終わったら即停止。
なぜ二段構成が効くか
GPUの時間料金が走ってる間、計算以外の作業は全部無駄。内訳はざっと以下。
apt update && apt install python3.10-venv→ 数分pip install torch --index-url ... cu124→ 2-3分(2.5GBダウンロード)pip install -r requirements.txt→ 2-3分wgetやrsyncでモデル転送 → 10-20分(6.5GB)git clone sd-scripts→ 数秒(これは誤差)
合計30-40分。時間単価$0.69のGPUでこれやると $0.35-0.45 の無駄。CPU Podでやれば $0.05 で済む。
Phase 1: CPU Pod + Network Volume
Pod作成
RunPodダッシュボードでの設定は次の通り。
| 項目 | 値 |
|---|---|
| Template | runpod/base:1.0.2-ubuntu2204(Ubuntu 22.04はsd-scriptsのデフォルトPython 3.10が入ってる) |
| GPU | なし(CPU Pod) |
| CPU/RAM | 2 vCPU / 8GB(最安) |
| Network Volume | 新規作成、30GB、リージョンは学習時に借りたいGPUのリージョンと揃える |
容量は20GB以下だとギリギリ(venv 8.5GB + モデル6.5GB + データセット100MB + 出力400MB + 作業余裕)。30GBでも時間割り$0.003/hなので学習1本分なら誤差レベル。
環境構築
# システム側
apt update && apt install -y python3.10-venv git wget
# venvをNetwork Volume上に
cd /workspace
python3.10 -m venv venv
source venv/bin/activate
pip install --upgrade pip wheel
# sd-scripts
git clone --depth 1 https://github.com/kohya-ss/sd-scripts.git
cd sd-scripts
# PyTorch cu124(4090で使うCUDAバージョンに揃える)
pip install torch==2.4.0 torchvision --index-url https://download.pytorch.org/whl/cu124
# sd-scripts依存
pip install -r requirements.txt
# xformers(pytorch.org indexからは0.0.28以降しか取れないのでpypi指定)
pip install xformers==0.0.27.post2 --no-deps
xformersは --no-deps がミソ。pytorch.org indexに 0.0.27.post2 が消えてて、依存解決させると新しい0.0.28系を引っ張って、torch 2.5へ強制アップデートされる。torch 2.4固定のまま xformers だけpypiから入れる。
モデルとデータセット転送
ローカルMacから rsync で放り込む。CPU Podのvolumeはそのまま後でGPU Podからも見えるので、ここで全部揃えておく。
# モデル
rsync -avz --progress -e "ssh -p PORT -i ~/.ssh/id_ed25519" \
/path/to/waiIllustriousSDXL_v170.safetensors \
root@CPU_POD_IP:/workspace/models/
# データセット(sd-scripts形式のフォルダ構造)
rsync -avz --progress -e "ssh -p PORT -i ~/.ssh/id_ed25519" \
/path/to/dataset/ \
root@CPU_POD_IP:/workspace/train_data/10_kanachan/
rsync の -z フラグはsafetensors相手だと逆効果(既に圧縮されてる乱数的バイト列なのでgzipが効かずCPUだけ食う)。大容量転送の時は -z を外した方が速い。今回ハマった。
sd-scriptsのディレクトリ構造
sd-scriptsは {num_repeats}_{concept}/ という命名規則のサブディレクトリを探す。59枚を10回繰り返して学習したいなら:
/workspace/train_data/
└── 10_kanachan/
├── kanachan_angry.png
├── kanachan_angry.txt
├── kanachan_smile.png
├── kanachan_smile.txt
└── ...
Phase 2: GPU Podを並行attach
CPU Podでモデル転送してる最中に、別タブで4090 Podを作成する。Network Volume欄で同じボリュームを選択すると、両Podから /workspace が見える状態になる。
同時attachの挙動(MooseFS)
RunPodのNetwork Volumeは MooseFS(Cephと同系統の分散ファイルシステム)ベース。基本の挙動は以下。
- 複数クライアント(Pod)から同時マウントOK
- POSIXセマンティクス準拠 → 違うファイルへの書き込みは並列で問題なし
- 同じファイルへの同時書き込みは最後勝ち or 最悪壊れる
- 書き込み中のファイルを別クライアントが読むと、不完全なデータ(torn read)を読む可能性あり
つまり「同時attach」はファイルシステムとしては安全だが、アプリ側の使い方次第で事故る。今回のCPU→GPU転送がセーフなのは、rsync側が以下の手順で書き込むから。
- 隠しtmpfile(例:
.waiIllustriousSDXL_v170.safetensors.YYsuVX)に書き込み - 転送完了を待つ
- アトミックrenameで正式ファイル名に切り替え
GPU Pod側で ls /workspace/models/ しても、転送中は隠しtmpfileしか見えない。誤って不完全なモデルをloadする事故が起きない。
一方で危ないのはこういうパターン。
cat > model.safetensorsのような直書き(途中経過を読めてしまう)- 両Podから同名の学習output(checkpoint書き潰し)
- 両Podで並行学習(output競合)
Network Volumeは便利だが、書き込み経路はrsync / mv / cpのようなアトミック保証のあるツールを使うのがベスト。
GPU Pod設定
- Template:
runpod/pytorch:2.4.0-py3.11-cuda12.4.1-devel-ubuntu22.04 - GPU: RTX 4090(Community Cloudが取れればベスト、なければSecure Cloud)
- Network Volume: CPU Podと同じもの
- リージョン: volume作成時と同じ
テンプレートはCUDA 12.4付きのものを選ぶ。venvに入れたtorch 2.4.0+cu124がそのまま動く。Ubuntu 22.04ベースなのでPython 3.10もシステムに入ってて、CPU Podで作ったvenvがそのまま動く。
動作確認
GPU Pod起動直後、SSHで入って:
source /workspace/venv/bin/activate
python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"
# True NVIDIA GeForce RTX 4090
これが出ればスタック全部整ってる。
CPU Pod停止
モデル転送が完了したらCPU Podは即停止する。volumeの中身は消えない。
Phase 3: 学習実行
train.sh
#!/bin/bash
set -euo pipefail
cd /workspace/sd-scripts
source /workspace/venv/bin/activate
accelerate launch --num_cpu_threads_per_process=1 sdxl_train_network.py \
--pretrained_model_name_or_path=/workspace/models/waiIllustriousSDXL_v170.safetensors \
--train_data_dir=/workspace/train_data \
--output_dir=/workspace/output \
--output_name=kanachan_v17_dim16 \
--logging_dir=/workspace/logs \
--caption_extension=.txt \
--resolution=1024,1024 \
--network_module=networks.lora \
--network_dim=16 \
--network_alpha=8 \
--learning_rate=1e-4 \
--unet_lr=1e-4 \
--text_encoder_lr=5e-5 \
--max_train_epochs=10 \
--train_batch_size=1 \
--mixed_precision=bf16 \
--save_precision=bf16 \
--optimizer_type=AdamW8bit \
--lr_scheduler=cosine_with_restarts \
--lr_warmup_steps=100 \
--save_every_n_epochs=1 \
--save_model_as=safetensors \
--sample_prompts=/workspace/configs/sample_prompts.txt \
--sample_every_n_epochs=1 \
--sample_sampler=euler_a \
--xformers \
--no_half_vae \
--cache_latents \
--cache_latents_to_disk \
--persistent_data_loader_workers \
--max_data_loader_n_workers=2 \
--seed=42
(sample_* の3行は筆者が初回実行時に入れ忘れた。地雷4に詳細あり。)
バックグラウンド実行
RunPodのSSH(Pod直接、ssh.runpod.io経由ではない)は切断時にSIGHUPで子プロセスを殺す。長時間の学習は nohup でdisownしておく。
nohup bash /workspace/configs/train.sh > /workspace/logs/train.log 2>&1 &
disown
# 進捗確認
tail -f /workspace/logs/train.log
5,900ステップ(59枚 × 10repeats × 10epochs)で、RTX 4090なら約50分。
sd-scriptsで踏んだ地雷4つ
初回実行で3つ同時に踏んだ。4つ目はエラーにならないが、あとで泣く系。
地雷1: --cache_text_encoder_outputs と --text_encoder_lr は両立不可
前回の成功事例では text_encoder_lr=5e-5 が必須だった(Illustriousはtext encoder学習しないとトリガーワードがキャラと紐付かない)。メモリ節約のために text encoder outputsのキャッシュも入れておくか、と両方設定すると:
AssertionError: network for Text Encoder cannot be trained with caching Text Encoder outputs
text encoder出力をキャッシュしてしまうと、text encoderのLoRAネットワークを学習する経路が消える。これはsd-scriptsの明示的な制約。
選択は大きく2択になる。
- text encoder学習を諦めて
--cache_text_encoder_outputsを使う(VRAM節約) - キャッシュを諦めて
--text_encoder_lr=5e-5を有効にする(Illustriousで推奨)
RTX 4090の24GB VRAMなら余裕があるので、今回はキャッシュを外した。実測VRAM使用量は16.4GB。
地雷2: caption_extension のデフォルトは .caption
sd-scriptsのデフォルトキャプション拡張子は .caption であって .txt ではない。何も指定しないでtxtを置いてると、59枚のキャプションが全部無視されて warning が出る:
WARNING No caption file found for 59 images. Training will continue without captions
この warning が怖いのは学習は止まらないこと。キャプションなしで学習が進んでしまい、ただのぼやけたキャラが出来上がる。エラーにしてほしい。
対策: --caption_extension=.txt を明示。
地雷3: SDXL学習で --clip_skip=2 は無視される
Illustrious系は推論時に clip_skip=2 が必須(学習時の設定に合わせる)。同じ感覚で学習スクリプトにも --clip_skip=2 を指定すると:
WARNING clip_skip will be unexpected / SDXL学習ではclip_skipは動作しません
SDXLのtrain_network.pyは clip_skip を無視する実装になってる。害はないが気持ち悪いので削除。推論時には引き続き clip_skip=2 を使う。
地雷4: --sample_prompts と --sample_every_n_epochs を入れないと学習の推移が見えない
sd-scriptsは各エポックでサンプル画像を自動生成する機能があるが、デフォルトで無効。設定しないで学習を走らせると、出来上がるのは .safetensors ファイルだけ。「どのエポックがピークか」「キャラが安定したのは何エポック目か」が後からでないと一切わからない。
入れるべきだった設定:
--sample_prompts=/workspace/configs/sample_prompts.txt \
--sample_every_n_epochs=1 \
--sample_sampler=euler_a \
sample_prompts.txt は1行1プロンプト形式:
kanachan, 1girl, solo, smile, portrait, white background --n low quality, bad hands --w 1024 --h 1024 --l 7 --s 28
kanachan, 1girl, solo, full body, standing, maid dress --n low quality --w 1024 --h 1024 --l 7 --s 28
各エポック終了時に output/sample/ に画像が吐かれる。学習の進み具合を視覚的に追えるので、実質必須の設定。
忘れた場合の救いは「各エポックの .safetensors は残ってる」ので、学習完了後に推論側で10エポック分手動生成すれば同じ比較はできる。ただし工数追加なので、最初から入れるのが正解。
結果
地雷4を踏んで学習中サンプルが出てなかったので、完了後にローカル(Mac M1 Max + ComfyUI)で各エポックのcheckpointを食わせて同じプロンプト・シードで10枚生成した。
プロンプト(全エポック共通):
kanachan, 1girl, solo, smile, portrait, front view, white background
negative: low quality, worst quality, bad hands, bad anatomy, blurry
seed: 42, steps: 28, cfg: 7, sampler: euler_a
Epoch 1 〜 4: キャラ形成前
ep01の時点ですでにキャラ造形はほぼ出ている。最初の数エポックはキャラに寄せていく途中で、ep05以降のように素材と並べて精度を語るフェーズではない、というだけ。ep02でサイドテール+青いシュシュが定着、ep03〜ep04で全体がキャラに寄っていく。
Epoch 5 以降(v16 LoRAとの3枚比較)
ep05から各エポックを、学習素材(kanachan_smile.png)と既存 v16 LoRA(dim=8)の出力と横並びで比較する。v16は同じキャラを以前に学習した既存のLoRAで、今回のv17 dim=16との顔忠実度を見る対象。真ん中に学習元、左右にLoRA出力を置くと差分が見やすい。顔サイズは lbpcascade_animeface で検出して揃えた。左から「v16 LoRA (dim=8) / 学習元 / v17 LoRA (dim=16)」。
v16列は全エポック同じ画像(kanachan-waiv16-05.safetensors を1回推論した結果)。比較対象を固定するため、行ごとに変えてない。学習元列も同様に固定。変動するのはv17列のみ。
Epoch 5
Epoch 6
Epoch 7
Epoch 8
Epoch 9
Epoch 10 (final)
観察
v16(dim=8)より v17(dim=16)の方が明らかに学習素材に近い。目の大きさや笑顔の誇張が v16 で強く出てるのに対し、v17 は素材寄りに収まる。同じ素材で dim とステップ数を増やした効果が出た。
一方でエポック5から10の間の差はほとんど見分けられない。どこかで過学習に倒れるかと思ったが、この粒度の比較では判断できないレベル。強いて選ぶなら ep08 あたりが感覚的にちょうどいい。これを仮のピークとして、次の節で他の表情プロンプトも食わせて検証する。
sample_prompts を入れずに回して最終だけ保存する設定にしてると、仮に途中のエポックの方が良かった場合に取り返しがつかない。各エポック保存しておいて損はない(save_every_n_epochs=1)。
ep08の表情再現テスト
smile close-upだけだと基底モデルのクセが強くて差が見えにくいので、学習データの他の表情プロンプトをそのまま食わせて ep08 LoRA の出力と並べる。左が学習素材、右が ep08 出力。顔サイズは同じく lbpcascade_animeface で揃えた。
angry
素材は口を閉じた抑えめの不機嫌。ep08 は歯を見せる絶叫寄り。キャラの顔は維持できているが、「怒り」の強度解釈はベースモデル(WAI-Illustrious)のクセに引っ張られている。さらに ep08 側にはこめかみや首に小さな汗ドロップが出ていて、素材にはないはずの要素が混ざっている。ここは後で単独で掘り下げる。
surprised
素材は控えめな驚き。ep08 は大きく口を開いて目も見開き、右頬に汗ドロップも出現。angry と同じく、ベースモデルの「驚き」典型表現が前に出てくる。
wink
素材に近い。左右の非対称性も自然に再現できていて、ベースモデルの典型表現と素材が競合しにくい表情はクセが出にくい。
closed eyes
これも素材寄り。単純に目を閉じるだけの静的な表情は、ベースモデルに強い装飾プリセットがないのか、LoRA が素直に再現する。
yandere
素材もそれなりにヤンデレ顔だが、ep08 は目のハイライト消失と狂気の笑顔まで踏み込む。yandere タグ自体がアニメ側で強い典型を持っている分、ベースモデル側のイメージに持っていかれる。
5表情を通して見ると、素材と離れる方向にブレるのは、ベースモデル側に強い典型表現があるタグ(angry / surprised / yandere)。逆に wink / closed_eyes のように典型表現が固まってないタグでは素材に近い出力が得られる。
angry の汗を深掘り
angry の ep08 出力にあった汗ドロップが何に由来するのかを追う。素材 kanachan_angry.png には汗がないので、汗は LoRA が描き足していることになる。学習が進むほどこれが強くなるのか、どこかから紛れ込んでいるのか、ネガで剥がせるのかを確かめる。
エポック進行で汗の出方を追う
ep05/06/07 でも同じ angry プロンプトを回した。左から ep05 / ep06 / ep07 / ep08。
ep05/06 は歯見せはあるが汗ドロップはほぼ出ない。ep07 でこめかみに小さく出始め、ep08 では首や髪にも増える。LoRA 全体の効きが強くなるほど、顔立ち以外の周辺概念まで引き込む傾向が見える。
もう一つ大事な観察として、どのエポックも素材の「口閉じ・抑えめ怒り」は再現できてない。顔立ちは学習できているが、キャラ固有の「感情の抑制スタイル」は 59 枚の訓練量では拾えず、ベースモデルの anime 怒り典型が勝つ。
汗の出所は学習素材にあった
学習データ 28 表情を見直したら、kanachan_nervous.png に明確な汗ドロップがあった。口を開いて目が泳ぎ、額と頬と首に汗が描き込まれた「動揺・緊張」表情。
つまり LoRA は「kanachan + 感情的な負の表情」を学習する過程で、nervous サンプル経由の「kanachan + 汗」の紐付けも刷り込んでいる。angry プロンプトで感情強度が上がるほど nervous の隣接概念が引っ張られ、汗が滲み出る。
ネガで剥がせるか3段階テスト
nervous 経由で紐付いているなら、プロンプト側で剥がせる余地がある。左から: 従来ネガ / ネガに sweat, sweat drop / ネガに nervous, sweat, sweat drop。
nervous を明示的にネガに入れると汗ドロップは減るが、完全には消えない。LoRA 内部で「kanachan + 汗」の紐付けが強く刻まれていて、CLIP の nervous トークンだけでは分離しきれない。
顔アップの学習素材は余計な要素を削る
顔アップの close-up 画像で LoRA を学習させる場合、素材に含まれる非顔要素(汗・涙・効果線・アクセサリー等)は、エポックが進むと不可避でキャラ概念に紐付いてくる。プロンプト側で完全に剥がすのは難しい。
根本対策は素材側にある。
- 顔パーツ以外の装飾要素(汗、涙、エフェクト等)を事前に画像処理で消す
- もしくは素材ごとにキャプションで装飾を明示 (
"sweat drop, open mouth"等) して、概念ごとに切り分けて学習させる - 全身ポーズ画像には装飾があっても干渉しにくいが、顔アップ画像は特に清潔に保つ
今回なら kanachan_nervous.png から汗を消した画像で再学習すれば改善が見込める。通常生成で毎回複雑なネガを組むのは現実的でないので、学習素材の掃除を優先するのが本筋。
改めてエポック選びの再評価
ep08 を感覚ピークと仮定して掘り下げた結果、ep08 には nervous 由来の汗ドロップが滲む副作用があるとわかった。あわせて ep07 は髪のバランスが崩れ気味で、こちらも盤石ではない。副作用を含めた総合評価では ep06 あたりが一番使いやすい。初手の直感(ep08)と検証結果(ep06)がズレるのは、表情違いのテストをやるまで見えない種類の差だった。