34. GPUメモリを節約する実践テクニック集!単一GPUを最大限に活用しよう
こんにちは!今回は、LLM(大規模言語モデル)のトレーニングで、GPUメモリを効率的に使うための実践的なテクニックを学んでいきます。
前回、混合精度、量子化、LoRA/QLoRAといった大きなメモリ節約ツールを学びましたよね。これらは基礎として非常に重要です!
でも実は、それだけじゃないんです。完全ファインチューニングが必要な場合や、70億〜130億パラメータのモデルに挑戦する場合、さらにメモリを節約できる「小さなレバー」がたくさんあるんですよ。
一つ一つは小さな節約かもしれませんが、組み合わせると大きな違いを生みます。「ほぼ収まる」と「実際に収まる」の差は、これらの最適化の積み重ねなんですね!
はじめに:単一GPUを最大限に活用する
これまでに、トレーニング中のメモリがどこに使われるかを学びました:
– パラメータ
– 勾配
– オプティマイザー状態
– 活性化
そして、最大のメモリ節約ツールも知っています:
– 混合精度
– 量子化
– LoRA/QLoRA
これらが基礎で、日常的なハードウェアで10億〜30億パラメータのファインチューニングを可能にするんですね。
さらに踏み込むためのレバー
でも、その快適ゾーンを超えたらどうでしょう?
- 完全ファインチューニングを行いたい
- 70億や130億パラメータのモデルに挑戦したい
- レンタルしているGPUをもっと倹約的に使いたい
こんな場合、まだ有意義なレバーが残っているんです!
これらのテクニックは量子化ほど劇的ではありませんが、積み重なります。モデル自体は変更せず、トレーニング方法を変更するんです。そして、それが驚くほど多くのメモリを回復できる場所なんですね!
活性化管理によるメモリ削減
LLMをトレーニングすると、順伝播からの「活性化」が静かなメモリキラーになることがよくあります。活性化は、バッチサイズ、シーケンス長、アーキテクチャの深さに応じてスケールするんです。
2つのテクニックが、このフットプリントに直接作用します!
勾配累積(Gradient Accumulation)
大きなバッチはトレーニングの安定性に役立ちますが、大きなバッチはメモリに収まらない…これがジレンマですよね。
勾配累積は、一度にすべてを保持することなく大きなバッチをシミュレートすることで、この問題を解決します!
仕組み:
256サンプルのバッチをロードする代わりに、マイクロバッチに分割します:
- マイクロバッチ1を実行 → 勾配を計算 → 累積
- マイクロバッチ2を実行 → 勾配を計算 → 累積
- …
- N個のマイクロバッチ後 → 1つのオプティマイザーステップを実行
メリット:
– ピークメモリは単一のマイクロバッチと同じまま
– オプティマイザーは全体的に大きなバッチを見る
トレードオフ:
– トレーニングが少し遅くなる(オプティマイザーステップあたりのforward/backward passが増えるため)
– でもメモリはほぼ平坦に保たれる
多くのLLMにとって、これは最もシンプルで効果的なテクニックの1つなんです!
勾配チェックポイント(Gradient Checkpointing)
Transformerモデルは、バックプロパゲーションのために多くの中間テンソルを保持します。勾配チェックポイントは、ストレージを計算とトレードします:
仕組み:
– 順伝播中にいくつかの「チェックポイント」のみを保存
– 残りを破棄
– 逆伝播中に欠落している活性化を再計算
影響:
– 計算が20〜30%増加することがある
– でも活性化メモリを大幅に削減
具体例:
バッチサイズ1の30億パラメータモデルの場合:
– 勾配チェックポイントなし:約8GBの活性化メモリ
– 勾配チェックポイントあり:約2GBの活性化メモリ
これは4倍の削減です!40GB GPUで収まるかクラッシュするかの違いであることがよくあるんですね。
精度選択によるメモリ削減
混合精度が標準であることは知っていますが、実はオプティマイザーが、重みではなく、トレーニング中の最大のメモリチャンクであることが多いんです。
混合精度(要約)
FP16/BF16で保存された重みと勾配は、FP32と比較してメモリを半分に削減します。
これは標準的な実践で、通常「オンにして二度と考えない」ものですね!
8-bitオプティマイザー
Adamオプティマイザーは、すべてのパラメータに対して2つのFP32モーメントテンソル(「m」と「v」)を保持します。
問題:
– オプティマイザーだけでパラメータあたり8バイト
– 大きなモデルでは、すぐにトレーニングで最大のメモリ消費者になる
解決策:8-bitオプティマイザー
計算を安定に保ちながら、これらの状態を低精度で保存します。
メモリへの影響:
10億パラメータモデルの場合:
– 標準Adam(FP32): 1B × 8バイト = 8GBのオプティマイザー状態
– 8-bit Adam: 1B × 2バイト = 2GBのオプティマイザー状態
これは6GBの節約! オプティマイザー精度を切り替えるだけでこれだけ節約できるんです。
これが、bitsandbytesの8-bit AdamWが多くのファインチューニングセットアップでデフォルトになった理由なんですね。
シーケンスレベル効率によるメモリ削減
Transformersは、情報を持たないパディングトークンであっても、トークンごとにメモリレントを請求します。
シーケンスレベルの効率は、メモリを回収する最も簡単な方法の1つなんです!
動的パディング(Dynamic Padding)
固定のグローバル最大値にすべてをパディングするのではなく、各バッチがそのバッチ内の最長シーケンスの長さにのみパディングされます。
これにより、大量の不必要なパディングでメモリを無駄にすることを回避できるんです。
Hugging Faceワークフローでは、データコレーターによって自動的に処理されます。小さな詳細ですが、可変長の例が多いデータセットでは、活性化メモリを削減し、トレーニングを著しく効率的にします!
シーケンスパッキング(Sequence Packing)
パッキングはさらに一歩進みます。
従来の方法:
各サンプルが独自のシーケンス(パディング付き)
パッキング:
複数の短い例を長い連続セグメントに結合
モデルは同じ数のトークンを見ますが、それらのはるかに少ない数がパディングです。これにより、メモリが削減されるだけでなく、スループットも向上するんです!
具体例:
SAMSumのような会話データセット(ほとんどの会話は短く不規則なサイズ)
パッキングなし:
– サンプル1: 120トークン → 512にパディング → 392の無駄なトークン
– サンプル2: 180トークン → 512にパディング → 332の無駄なトークン
– サンプル3: 200トークン → 512にパディング → 312の無駄なトークン
– 合計: 1,036の無駄なトークン
パッキングあり:
– サンプル1 + 2 + 3を1つのシーケンスに: 500トークン → 512にパディング → 12の無駄なトークン
1,036の無駄なトークンがわずか12に!バッチあたりの活性化メモリが大幅に削減されるんですね。
アテンションレベル最適化
アテンションでさえ、モデルを変更せずに最適化できます!
FlashAttentionと効率的なアテンションカーネル
標準のアテンションは、完全なアテンション行列を計算します。すべてのトークンが他のすべてのトークンに注意を払うんですね。
問題:
– 長さ1024のシーケンスの場合、1024 × 1024のアテンションスコア行列
– これは2次的にスケール:シーケンス長を2倍にすると、アテンションメモリは4倍に
FlashAttentionの解決策:
その完全な行列を決して実体化しない!
- 計算をより小さなブロック(タイル)に分割
- 一度に1つずつ処理
- 操作を融合
結果:
– 標準のアテンションと数学的に同一
– メモリフットプリントがシーケンス長で2次から線形に低下
影響:
長いシーケンス(2048+トークン)の場合、FlashAttentionは:
– アテンション関連の活性化メモリを5〜10倍削減
– より良いGPUメモリアクセスパターンのためより速く実行
良いニュース:
多くの最新のトレーニングライブラリは、GPUがサポートしている場合(Ampereアーキテクチャ以降)、FlashAttentionを自動的に有効にします。コードを変更する必要はありません!
これを「無料」の最適化と考えてください:より良いアルゴリズム、同じ結果、はるかに少ないメモリ。
実際にこれらを有効にする方法
Hugging Face Transformersでの設定例:
from transformers import TrainingArguments
training_args = TrainingArguments(
output_dir="./results",
# 混合精度
bf16=True, # または fp16=True
# 勾配累積
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 有効バッチ = 16
# 勾配チェックポイント
gradient_checkpointing=True,
# 8-bitオプティマイザー(bitsandbytesが必要)
optim="paged_adamw_8bit",
# その他のメモリ最適化は自動的に行われる:
# - DataCollatorWithPaddingによる動的パディング
# - FlashAttention(GPUで利用可能な場合)
)
専用フレームワークの利便性
AxolotlやUnslothのようなツールは、これらの最適化の多くを賢明なデフォルトにバンドルしています。
Axolotl:
– YAMLファイルを通じてすべてを構成
– モデルとハードウェアに基づいて自動的に処理:
– 勾配チェックポイント
– 8-bitオプティマイザー
– シーケンスパッキング
– FlashAttention
後のレッスンでAxolotlを広範囲に使用します。これは本番LLMファインチューニングワークフロー専用に設計されており、メモリ最適化を見えなくしてくれるんです!
余裕がある場合でも最適化する理由
「80GBのA100をレンタルしている場合、10GB節約することを気にする必要ある?」
3つの理由があります:
1. コスト効率
- A100 40GB: 約3〜4ドル/時間
- 最適化された実行が40GB層に収まる場合、トレーニング実行あたりのコストを40〜50%削減
2. より速い反復
- メモリ使用量が少ない = より大きなバッチサイズを実行可能
- 収束が改善、トレーニング時間が短縮
- 小さなモデルは実験間でより速くロード
3. 実験のヘッドルーム
- 節約されたメモリは他の場所に使える:
- より長いシーケンス
- 異なるハイパーパラメータ
- 同じGPUで複数のジョブを実行
メモリがハード制約でない場合でも、効率は依然として重要なんですね!
次のステップ:3つの前進パス
単一GPUからより多くのトレーニング容量を絞り出すためのすべての実用的なテクニックを学びました。
でも今、選択に直面しています。実際に解決しようとしている問題に依存するんです。
パス1:モデルが収まる – より速くする
モデルが1つのGPUに快適に収まり、トレーニングがクラッシュせずに実行される場合、次の動きはもはやメモリではなくスループットについてです。
分散データ並列化(DDP):
– GPU全体でモデルを複製
– データセットを分割
– すべてのGPUが並行してトレーニング
– 2つのGPUでほぼ2倍の速度、4つのGPUで4倍のスループット
キャッチ:モデルは各GPUに完全に収まる必要があります。DDPはメモリを助けません。時間を助けます。
パス2:モデルがまだ収まらない
すべての最適化を適用しても、モデルがまだロードされないかもしれません。
その場合、解決策は複製ではなくシャーディング(分割)です。
FSDP(Fully Sharded Data Parallel)とZeRO(DeepSpeed):
– 各GPUにモデルの完全なコピーではなく、モデル自体をデバイス全体に分割
– パラメータ、勾配、オプティマイザー状態が分割される
– 単一のGPUがすべてを保持する必要がない
これらはDDPよりも複雑で、通信オーバーヘッドが伴いますが、他の方法では不可能なトレーニング実行のロックを解除します!
パス3:極端なスケール(オプション)
FSDPやZeROでさえ十分でないほど大きなモデル(70億パラメータ以上)で作業している場合:
テンソル並列化とパイプライン並列化:
– 個々のレイヤーをGPU全体で分割(テンソル並列化)
– ステージ全体でモデルを垂直に分割(パイプライン並列化)
– 大規模な研究とフロンティアモデルトレーニングで使用される専門的なテクニック
3つのパスはすべてマルチGPUトレーニングの形式ですが、異なる問題を解決します:
- DDP:速度について
- FSDP/ZeRO:メモリについて
- テンソル/パイプライン並列化:極端なスケールについて
まとめ
今回の記事では、単一GPUを最大限に活用するための実践的なメモリ最適化テクニックを学びました。
重要なポイント:
- 活性化管理:
- 勾配累積:大きなバッチをシミュレート
- 勾配チェックポイント:メモリと計算をトレード
- 精度選択:
- 混合精度:FP16/BF16で半減
- 8-bitオプティマイザー:最大6GB節約
- シーケンスレベル効率:
- 動的パディング:無駄なパディングを削減
- シーケンスパッキング:複数サンプルを結合
- アテンション最適化:
- FlashAttention:メモリを5〜10倍削減
- 次のステップ:
- パス1(DDP):速度向上
- パス2(FSDP/ZeRO):メモリスケーリング
- パス3(テンソル/パイプライン):極端なスケール
小さな最適化の積み重ねが、「ほぼ収まる」を「実際に収まる」に変えるんですね!
次回は、これらのマルチGPUトレーニング手法について詳しく学んでいきましょう!

コメント