QLoRAの理論と実装 — 4bit量子化+LoRAで大規模LLMを効率的にファインチューニング

65Bパラメータのモデルを単一の48GB GPUでファインチューニングできる — そう言われたら信じられるでしょうか。通常、65Bモデルの重みだけで260GBのメモリが必要であり、学習に必要なオプティマイザ状態や勾配まで含めれば1TB近くに達します。A100 80GBを4枚以上並べなければ手も足も出ない世界です。

2023年にDettmersらが発表したQLoRA(Quantized Low-Rank Adaptation)は、この常識を覆しました。4bit量子化とLoRAを巧みに組み合わせることで、メモリ使用量を劇的に圧縮しながら、フルファインチューニングに匹敵する性能を達成したのです。

QLoRAを理解すると、以下のような場面で大きな力を発揮します。

  • ローカルLLMのカスタマイズ: 消費者向けGPU(RTX 4090, 24GB)1枚で、7B〜13BクラスのLLMを自社データにファインチューニングできるようになります
  • 研究の民主化: 大規模モデルの実験が、数千万円規模のクラスタではなく、1台のワークステーションで可能になります
  • コスト削減: クラウドGPUの利用時間が1/10以下になることもあり、企業のAI導入コストを大幅に下げられます

本記事の内容

  • フルファインチューニングのメモリ問題
  • LoRAの基本的なアイデアの復習
  • QLoRAの3つの革新(NF4量子化、Double Quantization、Paged Optimizers)
  • QLoRAの計算フローとメモリ使用量の分析
  • QLoRAの性能比較とGuanacoモデル
  • PyTorchでの実装(教育目的のNF4実装からHugging Face PEFTまで)

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

画像なし
【実践】LoRA/PEFTで効率的にLLMをファインチューニングする
LoRAの数学的原理と実装を詳しく解説しています。QLoRAはLoRAの上に構築される手法です。
画像なし
LLMの量子化(INT8/INT4)を理論から実装まで解説
量子化の数学的基礎(スケール、ゼロポイント、対称/非対称量子化)を解説しています。
画像なし
ファインチューニングと転移学習をわかりやすく解説
ファインチューニングの基本概念と全体ファインチューニングの課題を解説しています。

フルファインチューニングのメモリ問題

QLoRAがなぜ必要なのかを実感するために、まずはフルファインチューニングがどれほどメモリを消費するのかを定量的に確認しましょう。

モデルの重みだけでも巨大

LLMのパラメータは通常、FP32(32bit = 4バイト)またはFP16/BF16(16bit = 2バイト)で格納されます。65Bパラメータのモデルの場合、重みだけで以下のメモリが必要です。

$$ \text{重みメモリ} = 65 \times 10^9 \times 4 \text{ bytes} = 260 \text{ GB(FP32)} $$

FP16で半分にしても130GBです。A100 80GBを2枚使ってもギリギリという水準です。

Adamオプティマイザの追加コスト

ファインチューニングでは最も広く使われるAdamオプティマイザは、各パラメータに対して 1次モーメント(平均)2次モーメント(分散) の2つの状態を保持します。つまり、パラメータと同じ精度で保存すると、重みの 2倍 の追加メモリが必要です。FP32パラメータ自体も合わせると、合計で 3倍 になります。

$$ \text{Adamメモリ} = \underbrace{P \times 4}_{\text{重み(FP32)}} + \underbrace{P \times 4}_{\text{1次モーメント}} + \underbrace{P \times 4}_{\text{2次モーメント}} = 12P \text{ bytes} $$

ここで $P$ はパラメータ数です。65Bモデルでは $12 \times 65 \times 10^9 = 780$ GBです。

勾配のメモリ

逆伝播で計算される勾配は、パラメータと同じ形状・同じ精度で保持されます。FP32なら追加で260GBです。

メモリ使用量のまとめ

以下の表に、代表的なモデルサイズごとのメモリ要件をまとめます。

項目 7B 13B 65B
重み(FP16) 14 GB 26 GB 130 GB
Adam状態(FP32) 56 GB 104 GB 520 GB
勾配(FP16) 14 GB 26 GB 130 GB
合計 84 GB 156 GB 780 GB

7Bモデルですら、フルファインチューニングにはA100 80GBが最低1枚必要です。65Bとなると、A100を10枚近く並べる計算になります。これでは、大多数の研究者や開発者にとって手が出ません。

この「メモリの壁」をどう乗り越えるか。まず、LoRAが一つの解を提示しました。次のセクションでは、QLoRAの土台であるLoRAの基本的なアイデアを簡潔に復習します。

LoRAの復習

LoRA(Low-Rank Adaptation)は、「ファインチューニングによる重みの変化量 $\Delta \bm{W}$ は本質的に低ランクである」という洞察に基づいた手法です。

基本アイデア

事前学習済みの重み行列 $\bm{W}_0 \in \mathbb{R}^{d \times k}$ をファインチューニングで更新する際、変化量 $\Delta \bm{W}$ を2つの低ランク行列の積で近似します。

$$ \bm{W}’ = \bm{W}_0 + \Delta \bm{W} = \bm{W}_0 + \bm{B}\bm{A} $$

ここで $\bm{B} \in \mathbb{R}^{d \times r}$、$\bm{A} \in \mathbb{R}^{r \times k}$、$r \ll \min(d, k)$ です。

イメージとしては、$d \times k$ の巨大な行列を直接動かす代わりに、ランク $r$ の「薄い行列ペア」だけを学習するということです。$d = k = 4096$、$r = 8$ の場合、学習パラメータ数は $r(d + k) = 65{,}536$ となり、元の $d \times k = 16{,}777{,}216$ の約 0.4% に圧縮されます。

LoRAの順伝播

LoRA適用後の線形層の出力は次のようになります。

$$ \bm{h} = \bm{W}_0 \bm{x} + \frac{\alpha}{r} \bm{B}\bm{A}\bm{x} $$

ここで $\alpha$ はスケーリング係数で、学習率を調整する役割を果たします。重要なのは、$\bm{W}_0$ は凍結されたまま(勾配を計算しない)で、$\bm{A}$ と $\bm{B}$ のみが学習対象となる点です。

LoRAの限界

LoRAはパラメータ効率の面では優れていますが、ベースモデルの重み $\bm{W}_0$ は依然としてGPUメモリ上にFP16/BF16で保持されます。つまり、65Bモデルの場合、凍結された重みだけで130GB(FP16)のメモリを占有します。

ここで自然な疑問が生まれます。ベースモデルの重みをもっと圧縮して、例えば4bitにしてしまえば、さらにメモリを節約できるのではないか? しかし、量子化による精度低下はファインチューニングの性能を著しく損なわないのか? QLoRAは、この問いに明確な答えを出します。

QLoRAの3つの革新

QLoRAは、LoRAの枠組みに3つの革新的な技術を組み合わせることで、メモリ効率とファインチューニング性能を両立させました。

  1. 4-bit NormalFloat(NF4)量子化 — 情報理論的に最適な4bitデータ型
  2. Double Quantization — 量子化定数自体をさらに量子化
  3. Paged Optimizers — GPU⇔CPU間のページスワップでOOM回避

順番に見ていきましょう。

5-1. 4-bit NormalFloat(NF4)量子化

なぜ均等量子化では不十分なのか

一般的な4bit整数量子化(INT4)では、値の範囲を16個のレベルに 均等に 分割します。しかし、ニューラルネットワークの重みは通常 正規分布に従う ことが経験的に知られています。正規分布では、ゼロ付近に値が集中し、裾は薄くなります。

均等量子化をすると、値が密集しているゼロ付近では量子化レベルの「密度」が足りず、めったに出現しない裾の値に対しては無駄にレベルを割り当ててしまいます。これは、図書館で蔵書数に関係なく全棚を同じ幅にするようなものです。人気のある分野の棚はぎゅうぎゅう、マイナーな分野の棚はスカスカ — 明らかに非効率です。

NF4の発想: 正規分布に合わせた不均等量子化

NF4(4-bit NormalFloat)は、重みが正規分布 $\mathcal{N}(0, \sigma^2)$ に従うと仮定し、各量子化レベルに 等しい確率質量 を割り当てるデータ型です。

直感的には、「ゼロ付近にレベルを密に置き、裾にはまばらに置く」ことで、最も頻繁に出現する値を高精度に表現できます。情報理論的には、入力分布が既知の場合に量子化誤差を最小化する 最適量子化 に対応します。

数学的な構成

まず、標準正規分布 $\mathcal{N}(0, 1)$ の累積分布関数 $\Phi$ を使って、確率空間 $[0, 1]$ を $2^b + 1$ 個の等間隔点に分割します($b = 4$ の場合、$2^4 + 1 = 17$ 個)。

$$ q_i = \Phi^{-1}\left(\frac{i}{2^b + 1}\right), \quad i = 0, 1, \dots, 2^b $$

$\Phi^{-1}$ は標準正規分布の逆累積分布関数(分位関数)です。この操作は、「正規分布の確率密度が等しくなるような区切り点を求める」ことに対応します。

次に、隣り合う区切り点の中点を量子化レベルとします。

$$ c_i = \frac{q_i + q_{i+1}}{2}, \quad i = 0, 1, \dots, 2^b – 1 $$

こうして得られた $2^b = 16$ 個の量子化レベル $\{c_0, c_1, \dots, c_{15}\}$ がNF4のコードブックです。

ゼロを正確に表現するために、コードブックの一つのエントリを $0$ に固定し、残り15個で正負を非対称に割り当てます。具体的には、負側に8レベル、正側に7レベル(+ ゼロ)とします。

量子化と逆量子化のプロセス

実際の重みをNF4で量子化する手順は以下のとおりです。

ステップ1: ブロック単位のスケーリング

重みテンソルをブロックサイズ $B$(典型的には64)ごとに分割し、各ブロック内の絶対最大値 $\text{absmax}$ でスケーリングします。

$$ \bm{w}_{\text{norm}} = \frac{\bm{w}}{\text{absmax}(\bm{w})} $$

これにより、正規化後の値は $[-1, 1]$ に収まります。

ステップ2: 最近傍のNF4レベルへのマッピング

正規化された各値 $w_{\text{norm},j}$ に対して、最も近いNF4レベル $c_i$ を見つけ、そのインデックス $i$ を4bitで格納します。

$$ i^* = \arg\min_i |w_{\text{norm},j} – c_i| $$

ステップ3: 逆量子化

推論や順伝播の際には、4bitインデックスからNF4レベルを復元し、スケールを掛け戻します。

$$ \hat{w}_j = c_{i^*} \times \text{absmax}(\bm{w}) $$

NF4は、均等量子化(INT4)と比較して、正規分布に従う入力に対する量子化誤差を最小化します。論文の実験では、NF4はINT4に対して一貫した性能向上を示しています。

均等量子化とNF4の違いが理解できたところで、次はQLoRAの2つ目の革新である「量子化定数自体の量子化」を見ていきましょう。

5-2. Double Quantization

量子化定数のメモリオーバーヘッド

NF4量子化では、ブロックサイズ $B = 64$ ごとに1つの量子化定数(absmax値)を保持する必要があります。この定数はFP32(32bit)で格納されるため、1パラメータあたりの追加メモリは次のようになります。

$$ \text{量子化定数のオーバーヘッド} = \frac{32}{B} = \frac{32}{64} = 0.5 \text{ bit/param} $$

4bitの量子化に対して0.5bitの追加は、相対的に12.5%のオーバーヘッドです。小さく見えるかもしれませんが、65Bモデルでは $65 \times 10^9 \times 0.5 / 8 \approx 4$ GBにもなります。

Double Quantizationのアイデア

Double Quantization(二重量子化)は、「量子化定数自体をさらに量子化する」というシンプルかつ強力なアイデアです。

具体的には、次のように二段階の量子化を行います。

  1. 第1段階: 重みをブロックサイズ $B_1 = 64$ でNF4量子化し、各ブロックのabsmax値(FP32)を記録
  2. 第2段階: absmax値を $B_2 = 256$ 個ずつまとめて、8bit量子化(FP8相当)する

第2段階の量子化定数(FP32)はさらに上のレベルですが、ブロックサイズが256と大きいため、追加メモリは無視できるほど小さくなります。

メモリ削減の計算

第1段階のみ(Single Quantization)の場合、1パラメータあたりの総メモリは次のとおりです。

$$ \text{Single Q} = 4 + \frac{32}{B_1} = 4 + \frac{32}{64} = 4.5 \text{ bit/param} $$

Double Quantizationを適用すると、第1段階の量子化定数が32bit → 8bitに圧縮されます。

$$ \text{Double Q} = 4 + \frac{8}{B_1} + \frac{32}{B_1 \times B_2} = 4 + \frac{8}{64} + \frac{32}{64 \times 256} $$

各項を計算すると、第2項は $0.125$ bit/param、第3項は約 $0.002$ bit/param です。

$$ \text{Double Q} \approx 4.127 \text{ bit/param} $$

つまり、Double Quantizationにより 1パラメータあたり約0.37bit のメモリを削減できます。65Bモデル全体で換算すると、これは約3GBの節約に相当します。48GBのGPU上では無視できない量です。

量子化による圧縮は理解できました。しかし、訓練中にGPUメモリがオプティマイザ状態で埋まり、突然OOM(Out of Memory)エラーが発生することがあります。QLoRAの3つ目の革新はこの問題を扱います。

5-3. Paged Optimizers

訓練中のメモリスパイク

ファインチューニング中、ミニバッチのサイズが変動したり、長いシーケンスが入力されたりすると、一時的にGPUメモリ使用量が跳ね上がることがあります。通常、このスパイクがGPUメモリ容量を超えるとOOM(Out of Memory)エラーが発生し、訓練が中断します。

NVIDIA Unified Memoryの活用

Paged Optimizersは、NVIDIAのUnified Memory機能を利用して、GPUメモリが不足した際にオプティマイザ状態をCPU RAMに自動的にページアウトする仕組みです。

動作の流れは以下のとおりです。

  1. 通常時: オプティマイザ状態(Adamの1次・2次モーメント)はGPU上に保持
  2. メモリ不足時: GPUメモリが逼迫すると、しばらく使われていないオプティマイザ状態をCPUメモリに退避(ページアウト)
  3. 再利用時: パラメータ更新の際に必要になったら、CPUからGPUに自動的に戻す(ページイン)

これは、OSのメモリ管理における仮想メモリ(スワップ)と同じ発想です。RAMが足りなくなったらディスクに一時退避するのと同様に、GPUメモリが足りなくなったらCPU RAMに退避します。

性能への影響

ページスワップはGPU-CPU間のPCIeバスを経由するため、頻繁に発生するとボトルネックになります。しかし実際には、メモリスパイクは一時的であることが多く、Paged Optimizersのオーバーヘッドは最小限にとどまります。

論文の実験では、Paged Optimizersを有効にしてもスループットの低下はほとんど観測されませんでした。これは、メモリスパイクが発生するタイミングが訓練全体の中でごく一部に限られるためです。

3つの革新を個別に理解したところで、次はこれらがどのように組み合わさって一つの計算フローになるのかを見ていきましょう。

QLoRAの計算フロー

順伝播の全体像

QLoRAの順伝播は、凍結された4bit量子化重みと、FP16/BF16で保持されるLoRAアダプターの2つの経路を組み合わせます。

$$ \bm{Y} = \underbrace{\text{dequant}(\bm{W}_{\text{NF4}})}_{\text{4bit → FP16に復元}} \bm{X} + \underbrace{\bm{B}\bm{A}\bm{X}}_{\text{LoRA(FP16)}} $$

ステップを詳しく追うと、次のようになります。

ステップ1: NF4重みの逆量子化

凍結された重み $\bm{W}_{\text{NF4}}$(4bit)を、NF4のコードブックとスケール値を使ってFP16に復元します。

$$ \hat{\bm{W}} = \text{dequant}(\bm{W}_{\text{NF4}}) \in \mathbb{R}^{d \times k} $$

この逆量子化はブロック単位で行われ、計算コストは小さいです。重要なのは、この復元された $\hat{\bm{W}}$ はメモリ上に一時的にしか存在しない点です。行列積の計算が終わったらすぐに破棄されるため、FP16の重み全体を常時保持するわけではありません。

ステップ2: メイン経路の計算

$$ \bm{Y}_{\text{main}} = \hat{\bm{W}} \bm{X} $$

ステップ3: LoRA経路の計算

$$ \bm{Y}_{\text{lora}} = \frac{\alpha}{r} \bm{B}(\bm{A}\bm{X}) $$

LoRA経路では、まず $\bm{A}\bm{X}$($r \times n$ 行列、$n$ はバッチ内のトークン数)を計算し、次に $\bm{B}$ を掛けます。中間テンソルのサイズが $r$ で決まるため、メモリ効率が良好です。

ステップ4: 合算

$$ \bm{Y} = \bm{Y}_{\text{main}} + \bm{Y}_{\text{lora}} $$

逆伝播と勾配の流れ

逆伝播では、損失関数 $\mathcal{L}$ からの勾配がLoRA部分($\bm{A}$, $\bm{B}$)にのみ伝播します。

$\bm{B}$ に対する勾配を求めるために、連鎖律を適用すると次のようになります。

$$ \frac{\partial \mathcal{L}}{\partial \bm{B}} = \frac{\alpha}{r} \frac{\partial \mathcal{L}}{\partial \bm{Y}} (\bm{A}\bm{X})^T $$

同様に、$\bm{A}$ に対する勾配は次のとおりです。

$$ \frac{\partial \mathcal{L}}{\partial \bm{A}} = \frac{\alpha}{r} \bm{B}^T \frac{\partial \mathcal{L}}{\partial \bm{Y}} \bm{X}^T $$

凍結された $\bm{W}_{\text{NF4}}$ には勾配を計算しません。そのため、$\bm{W}$ に対するオプティマイザ状態(Adamのモーメント)も不要です。これが、QLoRAのメモリ効率の鍵です。

逆量子化の際の計算グラフとしては、$\hat{\bm{W}}$ は定数として扱われ、逆伝播の対象にはなりません。4bit量子化の「段差」(量子化誤差)は順伝播にのみ影響し、LoRAの低ランク行列がこの誤差を吸収・補償する役割を担います。

メモリ使用量の詳細比較

QLoRAの各構成要素がどれだけメモリを節約するのか、代表的なモデルサイズで計算してみましょう。

モデル重みのメモリ

モデル FP16 NF4 (Single Q) NF4 (Double Q)
7B 14.0 GB 3.9 GB 3.6 GB
13B 26.0 GB 7.3 GB 6.7 GB
65B 130.0 GB 36.6 GB 33.5 GB

NF4 (Single Q) のメモリは $P \times 4.5 / 8$ bytes、NF4 (Double Q) は $P \times 4.127 / 8$ bytes で計算しています。

LoRAの学習パラメータのメモリ(ランク $r = 64$)

Transformerの各層にあるAttention($\bm{W}_Q$, $\bm{W}_K$, $\bm{W}_V$, $\bm{W}_O$)の4つの射影行列にLoRAを適用する場合、1層あたりのLoRAパラメータ数は次のようになります。

$$ \text{1層あたり} = 4 \times 2 \times d_{\text{model}} \times r $$

7Bモデル($d_{\text{model}} = 4096$, 32層)では、LoRAパラメータは約 $4 \times 2 \times 4096 \times 64 \times 32 \approx 67M$ で、FP16で約134MBです。

Adamオプティマイザ状態のメモリ

QLoRAでは、LoRAパラメータに対してのみAdamの状態を保持します。67MパラメータのFP32 Adam状態は $67M \times 12 \approx 804$ MBです。

QLoRAの総メモリ使用量

モデル 重み (NF4 DQ) LoRA + Adam 合計 (概算)
7B 3.6 GB ~1.0 GB ~5 GB
13B 6.7 GB ~1.5 GB ~9 GB
65B 33.5 GB ~5.0 GB ~39 GB

65Bモデルでも約39GBに収まり、48GB GPU(A6000やA100 40GB+α)での訓練が可能になります。フルファインチューニングの780GBと比較すると、約20分の1 への圧縮です。

具体的なメモリ節約量を確認したところで、次の疑問は「これほどの圧縮をして、性能は大丈夫なのか?」ということでしょう。実験結果を見ていきましょう。

性能比較

QLoRA vs フルファインチューニング vs 16bit LoRA

QLoRAの最も驚くべき結果は、フルファインチューニングとほぼ同等の性能を達成したという点です。

論文では、さまざまなモデルサイズ(7B, 13B, 33B, 65B)とベンチマークで広範な実験を行いました。その結果は以下のとおりです。

MMLU(Massive Multitask Language Understanding)ベンチマーク

手法 LLaMA 7B LLaMA 13B LLaMA 65B
フル FT(16bit) 基準 基準 基準
LoRA(16bit) -0.2% -0.1% -0.1%
QLoRA(NF4) -0.3% -0.1% ±0.0%
QLoRA(FP4) -1.0% -0.5% -0.3%
QLoRA(INT4) -1.2% -0.7% -0.4%

この表から読み取れる重要なポイントがいくつかあります。

  1. QLoRA(NF4)はフルファインチューニングとの差がほぼゼロです。65Bモデルでは統計的に有意な差がありません
  2. NF4はFP4やINT4より一貫して高性能です。正規分布に最適化されたデータ型の優位性がはっきりと表れています
  3. モデルサイズが大きいほど量子化の影響が小さいです。これは、パラメータ数が多いほどLoRAの補償能力が高まるためと考えられます

Guanaco: QLoRAで訓練されたChatGPT対抗モデル

QLoRAの実用性を示す象徴的な成果が Guanaco モデルです。GuanacoはLLaMA 65Bに対して、OASST1(Open Assistant)データセットでQLoRAファインチューニングを施したものです。

訓練条件は以下のとおりです。

  • ベースモデル: LLaMA 65B
  • 量子化: NF4 + Double Quantization
  • LoRA: ランク64、全Attention層に適用
  • データ: OASST1の約10,000サンプル
  • 訓練時間: 単一48GB GPU(A100 40GBまたはA6000)で約24時間
  • メモリ使用量: 約41GB

Chatbot Arenaでの評価

Guanaco 65Bは、GPT-4を審判としたペアワイズ比較(Elo評価方式)で以下の結果を達成しました。

モデル Eloレーティング
GPT-4 1348
Guanaco 65B 1022
ChatGPT (GPT-3.5) 916
Guanaco 33B 992

Guanaco 65Bは ChatGPTを上回るスコア を記録しました。単一GPUで24時間、たった10,000サンプルの訓練でこの性能を達成したことは、QLoRAのファインチューニング能力の高さを証明しています。

小型モデルでの結果

さらに注目すべきは、QLoRAが小型モデルでも有効であるという点です。

モデル GPU 訓練時間 Chatbot Arena
Guanaco 7B RTX 4090 (24GB) ~6時間 GPT-3.5の約90%
Guanaco 13B RTX 4090 (24GB) ~12時間 GPT-3.5と同等
Guanaco 33B A100 40GB ~18時間 GPT-3.5超え

消費者向けGPU(RTX 4090)でも、7B〜13Bモデルのファインチューニングが実用的な時間で完了することがわかります。

性能面で安心できたところで、いよいよ実装に移りましょう。まずはNF4量子化の仕組みを教育目的でPythonで実装し、次にbitsandbytesとHugging Face PEFTを使った実践的なQLoRA設定を見ていきます。

PyTorchでの実装

NF4量子化の仕組みの実装(教育目的)

まず、NF4量子化がどのように機能するのかを理解するために、量子化レベルの構成からPythonで実装してみましょう。

NF4のコードブックを構成する処理は、標準正規分布の分位関数を使って等確率の区切り点を求めるものです。以下のコードでは、この過程をステップごとに可視化します。

import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm

# NF4コードブックの構成
# 4bit = 16レベル、うち1つをゼロに割り当て
# 負側: 8レベル、正側: 7レベル + ゼロ

def create_nf4_codebook():
    """NF4量子化コードブックを構成する"""
    # 負側: [-1, 0] の範囲で8レベル
    # 等確率になるように分位点を計算
    # offset = 0.5 * (1/(2*8) + 1/(2*8)) ... 等確率分割
    negative_levels = norm.ppf(np.linspace(1/(2*9), 0.5, 8, endpoint=False))

    # 正側: [0, 1] の範囲で7レベル
    positive_levels = norm.ppf(np.linspace(0.5 + 1/(2*8), 1 - 1/(2*8), 7))

    # ゼロを追加
    codebook = np.concatenate([negative_levels, [0.0], positive_levels])

    # [-1, 1] に正規化
    codebook = codebook / np.max(np.abs(codebook))

    return np.sort(codebook)

# コードブックの生成
nf4_codebook = create_nf4_codebook()
print("NF4 コードブック(16レベル):")
for i, val in enumerate(nf4_codebook):
    print(f"  レベル {i:2d}: {val:+.6f}")

このコードを実行すると、16個の量子化レベルが表示されます。ゼロ付近にレベルが密集し、$\pm 1$ 付近ではまばらになっていることが確認できるはずです。これは正規分布の確率密度に合わせた不均等な配置であり、頻繁に出現するゼロ近傍の値を高精度に表現できる設計です。

次に、このNF4コードブックと均等量子化(INT4)を視覚的に比較してみましょう。

import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm

def create_nf4_codebook():
    """NF4量子化コードブックを構成する"""
    negative_levels = norm.ppf(np.linspace(1/(2*9), 0.5, 8, endpoint=False))
    positive_levels = norm.ppf(np.linspace(0.5 + 1/(2*8), 1 - 1/(2*8), 7))
    codebook = np.concatenate([negative_levels, [0.0], positive_levels])
    codebook = codebook / np.max(np.abs(codebook))
    return np.sort(codebook)

nf4_codebook = create_nf4_codebook()

# INT4(均等量子化)のレベル: [-1, 1] を16等分
int4_levels = np.linspace(-1, 1, 16)

# 正規分布の確率密度関数
x = np.linspace(-3.5, 3.5, 1000)
pdf = norm.pdf(x)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# 左: INT4(均等量子化)
ax = axes[0]
ax.fill_between(x, pdf, alpha=0.3, color='steelblue', label='Normal PDF')
ax.plot(x, pdf, color='steelblue', linewidth=1.5)
for level in int4_levels:
    ax.axvline(level * 2.5, color='red', alpha=0.6, linewidth=1.5, linestyle='--')
ax.scatter(int4_levels * 2.5, np.zeros(16), color='red', s=80, zorder=5, label='INT4 levels')
ax.set_title('INT4 (Uniform Quantization)', fontsize=14)
ax.set_xlabel('Value', fontsize=12)
ax.set_ylabel('Density', fontsize=12)
ax.legend(fontsize=10)
ax.set_xlim(-3.5, 3.5)

# 右: NF4
ax = axes[1]
ax.fill_between(x, pdf, alpha=0.3, color='steelblue', label='Normal PDF')
ax.plot(x, pdf, color='steelblue', linewidth=1.5)
for level in nf4_codebook:
    ax.axvline(level * 2.5, color='green', alpha=0.6, linewidth=1.5, linestyle='--')
ax.scatter(nf4_codebook * 2.5, np.zeros(16), color='green', s=80, zorder=5, label='NF4 levels')
ax.set_title('NF4 (Normal Float Quantization)', fontsize=14)
ax.set_xlabel('Value', fontsize=12)
ax.set_ylabel('Density', fontsize=12)
ax.legend(fontsize=10)
ax.set_xlim(-3.5, 3.5)

plt.tight_layout()
plt.savefig('int4_vs_nf4.png', dpi=150, bbox_inches='tight')
plt.show()

左のINT4グラフでは、量子化レベルが値域全体に等間隔で配置されています。正規分布の密度が高いゼロ付近でも裾でも同じ間隔です。一方、右のNF4グラフでは、ゼロ付近にレベルが集中し、裾にはまばらに配置されています。正規分布の確率密度が高い領域をきめ細かく量子化し、めったに出現しない大きな値には粗い量子化で済ませていることが一目でわかります。これが、NF4が情報理論的に最適であるという直感的な根拠です。

NF4による量子化と量子化誤差の比較

次に、実際にランダムな正規分布データを量子化して、INT4とNF4の量子化誤差を数値的に比較してみましょう。

import numpy as np
from scipy.stats import norm

def create_nf4_codebook():
    """NF4量子化コードブックを構成する"""
    negative_levels = norm.ppf(np.linspace(1/(2*9), 0.5, 8, endpoint=False))
    positive_levels = norm.ppf(np.linspace(0.5 + 1/(2*8), 1 - 1/(2*8), 7))
    codebook = np.concatenate([negative_levels, [0.0], positive_levels])
    codebook = codebook / np.max(np.abs(codebook))
    return np.sort(codebook)

def quantize_nf4(weights, codebook, block_size=64):
    """NF4量子化を実行する"""
    n = len(weights)
    # ブロックサイズに合わせてパディング
    pad_size = (block_size - n % block_size) % block_size
    weights_padded = np.concatenate([weights, np.zeros(pad_size)])

    n_blocks = len(weights_padded) // block_size
    quantized = np.zeros_like(weights_padded)
    scales = np.zeros(n_blocks)

    for b in range(n_blocks):
        start = b * block_size
        end = start + block_size
        block = weights_padded[start:end]

        # ブロック内の絶対最大値でスケーリング
        absmax = np.max(np.abs(block))
        if absmax == 0:
            scales[b] = 0
            continue
        scales[b] = absmax
        normalized = block / absmax

        # 最近傍のNF4レベルにマッピング
        for j in range(block_size):
            idx = np.argmin(np.abs(normalized[j] - codebook))
            quantized[start + j] = codebook[idx] * absmax

    return quantized[:n], scales

def quantize_int4(weights, block_size=64):
    """INT4均等量子化を実行する"""
    levels = np.linspace(-1, 1, 16)
    n = len(weights)
    pad_size = (block_size - n % block_size) % block_size
    weights_padded = np.concatenate([weights, np.zeros(pad_size)])

    n_blocks = len(weights_padded) // block_size
    quantized = np.zeros_like(weights_padded)

    for b in range(n_blocks):
        start = b * block_size
        end = start + block_size
        block = weights_padded[start:end]

        absmax = np.max(np.abs(block))
        if absmax == 0:
            continue
        normalized = block / absmax

        for j in range(block_size):
            idx = np.argmin(np.abs(normalized[j] - levels))
            quantized[start + j] = levels[idx] * absmax

    return quantized[:n]

# 正規分布に従うダミー重みの生成
np.random.seed(42)
weights = np.random.randn(10000).astype(np.float32)

# 各量子化手法の適用
nf4_codebook = create_nf4_codebook()
weights_nf4, _ = quantize_nf4(weights, nf4_codebook)
weights_int4 = quantize_int4(weights)

# 量子化誤差の計算
mse_nf4 = np.mean((weights - weights_nf4) ** 2)
mse_int4 = np.mean((weights - weights_int4) ** 2)

print(f"INT4 均等量子化  MSE: {mse_int4:.6f}")
print(f"NF4 量子化       MSE: {mse_nf4:.6f}")
print(f"NF4による改善率: {(1 - mse_nf4/mse_int4)*100:.1f}%")

この実験の結果、NF4の量子化誤差(MSE)はINT4と比べて有意に小さくなります。正規分布に従うデータに対してNF4が理論どおりに優れた量子化精度を発揮していることが数値で確認できます。改善率は概ね10〜30%程度になることが多く、これは4bitという極めて限られたビット数でのファインチューニングにおいて、蓄積すると無視できない差になります。

bitsandbytesライブラリの使い方

教育目的の実装で原理を理解したところで、実際のQLoRAでは bitsandbytes ライブラリが4bit量子化を担当します。bitsandbytesは、CUDAカーネルレベルでNF4量子化を効率的に実装しており、NVIDIA GPUに最適化されています。

以下のコードは、bitsandbytesを使ってモデルを4bit量子化する設定方法を示します。

# bitsandbytesとtransformersを使ったQLoRA設定の例
# 注意: このコードはNVIDIA GPU環境で実行する必要があります

from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
import torch

# 4bit量子化の設定
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,                    # 4bit量子化を有効化
    bnb_4bit_quant_type="nf4",            # NF4データ型を使用
    bnb_4bit_use_double_quant=True,       # Double Quantizationを有効化
    bnb_4bit_compute_dtype=torch.bfloat16 # 計算時のデータ型
)

# モデルの読み込み(4bit量子化が自動適用される)
model_name = "meta-llama/Llama-2-7b-hf"
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto"   # GPU/CPUに自動配置
)
tokenizer = AutoTokenizer.from_pretrained(model_name)

# モデルのメモリ使用量を確認
total_params = sum(p.numel() for p in model.parameters())
memory_bytes = model.get_memory_footprint()
print(f"パラメータ数: {total_params / 1e9:.1f}B")
print(f"メモリ使用量: {memory_bytes / 1e9:.2f} GB")

BitsAndBytesConfig の各パラメータの意味は次のとおりです。load_in_4bit=True はモデルの全線形層の重みを4bitに量子化して読み込むことを指示します。bnb_4bit_quant_type="nf4" は量子化データ型にNF4を指定しています("fp4" も選択可能ですが、NF4のほうが高性能です)。bnb_4bit_use_double_quant=True はDouble Quantizationを有効にし、追加のメモリ節約を行います。bnb_4bit_compute_dtype=torch.bfloat16 は、逆量子化後の行列演算をBF16精度で行うことを指定しています。

Hugging Face PEFTでのQLoRA設定

bitsandbytesで量子化されたモデルに対して、PEFTライブラリでLoRAを適用することで、QLoRAの全体構成が完成します。

# PEFTライブラリを使ったLoRAの設定
# 注意: このコードはNVIDIA GPU環境で実行する必要があります

from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

# 4bit量子化モデルの勾配チェックポイントを有効化
model.gradient_checkpointing_enable()
model = prepare_model_for_kbit_training(model)

# LoRAの設定
lora_config = LoraConfig(
    r=64,                           # LoRAのランク
    lora_alpha=16,                  # スケーリング係数
    target_modules=[                # LoRAを適用する層
        "q_proj",                   # Query射影
        "k_proj",                   # Key射影
        "v_proj",                   # Value射影
        "o_proj",                   # Output射影
        "gate_proj",                # FFNのゲート射影
        "up_proj",                  # FFNのアップ射影
        "down_proj",                # FFNのダウン射影
    ],
    lora_dropout=0.05,              # ドロップアウト率
    bias="none",                    # バイアスは学習しない
    task_type="CAUSAL_LM"           # タスク種別
)

# LoRAアダプターの適用
model = get_peft_model(model, lora_config)

# 学習パラメータ数の確認
model.print_trainable_parameters()
# 出力例: trainable params: 67,108,864 || all params: 6,738,415,616 || trainable%: 0.9961

LoraConfig の主要パラメータを解説します。r=64 はLoRAのランクで、値が大きいほど表現力は高まりますが、メモリと計算コストが増えます。論文では $r = 64$ が良好な性能を示しています。lora_alpha=16 はスケーリング係数で、出力に $\alpha / r$ の重みがかかります。target_modules はLoRAを適用する対象モジュール名のリストで、Attentionの4つの射影行列に加えて、FFN(Feed-Forward Network)の射影行列にも適用するのが現在の一般的な構成です。

prepare_model_for_kbit_training は、4bit量子化モデルでLoRA訓練を行うために必要な前処理を行います。具体的には、LayerNormの重みをFP32にキャストし、勾配チェックポイントの設定を調整します。

訓練の実行

QLoRA設定が完了したモデルを訓練する際は、Hugging Faceの SFTTrainer(Supervised Fine-Tuning Trainer)を使うのが最も簡便です。

# QLoRAモデルの訓練設定例
# 注意: このコードはNVIDIA GPU環境で実行する必要があります

from transformers import TrainingArguments
from trl import SFTTrainer
from datasets import load_dataset

# データセットの読み込み
dataset = load_dataset("timdettmers/openassistant-guanaco", split="train")

# 訓練設定
training_args = TrainingArguments(
    output_dir="./qlora-output",
    num_train_epochs=1,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,    # 実効バッチサイズ: 4 * 4 = 16
    learning_rate=2e-4,
    weight_decay=0.001,
    optim="paged_adamw_32bit",        # Paged Optimizerを使用
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    fp16=False,
    bf16=True,                        # BF16で訓練
    logging_steps=25,
    save_strategy="epoch",
    max_grad_norm=0.3,
    group_by_length=True,             # 同じ長さの入力をグループ化
)

# SFTTrainerで訓練
trainer = SFTTrainer(
    model=model,
    train_dataset=dataset,
    tokenizer=tokenizer,
    args=training_args,
    max_seq_length=1024,
)

# 訓練の実行
trainer.train()

TrainingArguments で特に重要なのは optim="paged_adamw_32bit" です。これはQLoRAの3つ目の革新であるPaged Optimizersを有効にし、GPUメモリ不足時にオプティマイザ状態をCPUに退避できるようにします。gradient_accumulation_steps=4 は、メモリに収まる小さなバッチを4回蓄積してから勾配を更新することで、実効的なバッチサイズを大きくするテクニックです。

メモリ使用量の比較可視化

最後に、フルファインチューニング、16bit LoRA、QLoRAのメモリ使用量を視覚的に比較しましょう。

import numpy as np
import matplotlib.pyplot as plt

# モデルサイズ(パラメータ数, 単位: B)
model_sizes = [7, 13, 33, 65]

# メモリ使用量の計算(GB)
# フルFT: 重み(FP16) + Adam状態(FP32, 2倍) + 勾配(FP16)
full_ft_memory = [p * 2 + p * 4 * 2 + p * 2 for p in model_sizes]  # 12 * P bytes → GB

# 16bit LoRA: 重み(FP16) + LoRA Adam(小) + LoRA勾配(小)
lora_16bit_memory = [p * 2 + 0.5 + 0.2 for p in model_sizes]  # 重み + LoRA分

# QLoRA: 重み(NF4 DQ) + LoRA Adam(小) + LoRA勾配(小)
qlora_memory = [p * 4.127 / 8 + 0.5 + 0.2 for p in model_sizes]

# 可視化
fig, ax = plt.subplots(figsize=(10, 6))
x = np.arange(len(model_sizes))
width = 0.25

bars1 = ax.bar(x - width, full_ft_memory, width, label='Full Fine-tuning (FP16)',
               color='#e74c3c', alpha=0.85, edgecolor='white', linewidth=0.5)
bars2 = ax.bar(x, lora_16bit_memory, width, label='LoRA (16-bit)',
               color='#3498db', alpha=0.85, edgecolor='white', linewidth=0.5)
bars3 = ax.bar(x + width, qlora_memory, width, label='QLoRA (NF4 + DQ)',
               color='#2ecc71', alpha=0.85, edgecolor='white', linewidth=0.5)

# GPU容量の参照線
ax.axhline(y=24, color='orange', linestyle='--', alpha=0.7, linewidth=1.5, label='RTX 4090 (24 GB)')
ax.axhline(y=48, color='purple', linestyle='--', alpha=0.7, linewidth=1.5, label='A6000 (48 GB)')
ax.axhline(y=80, color='brown', linestyle='--', alpha=0.7, linewidth=1.5, label='A100 (80 GB)')

# バーの上にメモリ量を表示
for bars in [bars1, bars2, bars3]:
    for bar in bars:
        height = bar.get_height()
        if height > 5:
            ax.text(bar.get_x() + bar.get_width()/2., height + 2,
                    f'{height:.0f}', ha='center', va='bottom', fontsize=9, fontweight='bold')

ax.set_xlabel('Model Size (Billion Parameters)', fontsize=13)
ax.set_ylabel('Memory Usage (GB)', fontsize=13)
ax.set_title('Memory Requirements: Full FT vs LoRA vs QLoRA', fontsize=15, fontweight='bold')
ax.set_xticks(x)
ax.set_xticklabels([f'{s}B' for s in model_sizes], fontsize=12)
ax.legend(fontsize=10, loc='upper left')
ax.set_ylim(0, max(full_ft_memory) * 1.15)
ax.grid(axis='y', alpha=0.3)

plt.tight_layout()
plt.savefig('memory_comparison.png', dpi=150, bbox_inches='tight')
plt.show()

このグラフからは、3つの手法のメモリ使用量のスケーリング特性が一目瞭然です。

  1. フルファインチューニング(赤) はモデルサイズに対して線形に増大し、65Bモデルでは780GBにも達します。A100 80GBを10枚必要とする水準です
  2. 16bit LoRA(青) は学習パラメータこそ少ないものの、ベースモデルの重みをFP16で保持するため、依然として130GB(65B)のメモリが必要です。これでもA100が2枚必要です
  3. QLoRA(緑) はベースモデルの重みを4bit(NF4 + DQ)に圧縮するため、65Bモデルでも約39GBに収まります。単一の48GB GPU(A6000やA100 40GB)で動作可能です

特に注目すべきは、QLoRAの棒グラフがGPU容量のラインを下回っている点です。7Bモデルは24GBのRTX 4090に余裕で収まり、65Bモデルですら48GBのA6000に収まります。これがQLoRAの革新性を最も端的に表しています。

まとめ

本記事では、QLoRA(Quantized Low-Rank Adaptation)について、理論から実装まで解説しました。

  • フルファインチューニングのメモリ問題: 65Bモデルの訓練には780GBものメモリが必要であり、大規模クラスタが不可欠です
  • QLoRAの3つの革新: NF4量子化(情報理論的に最適な4bitデータ型)、Double Quantization(量子化定数の二重量子化)、Paged Optimizers(GPU-CPU間のメモリスワップ)を組み合わせることで、メモリを約20分の1に削減します
  • 性能の維持: QLoRAはフルファインチューニングとほぼ同等の性能を達成し、Guanaco 65BはChatGPTを超えるスコアを記録しました
  • 実用性: 7BモデルはRTX 4090(24GB)で約6時間、65BモデルはA6000(48GB)で約24時間と、実用的な環境・時間でファインチューニングが可能です
  • 実装の容易さ: bitsandbytesとHugging Face PEFTを使えば、数十行のコードでQLoRAを適用できます

QLoRAは、大規模言語モデルのファインチューニングを「一部の恵まれた研究機関だけの特権」から「個人の研究者や小規模チームにも手が届く技術」へと転換させた、まさに民主化の立役者と言えるでしょう。

次のステップとして、以下の記事も参考にしてください。

画像なし
【実践】LoRA/PEFTで効率的にLLMをファインチューニングする
LoRAの数学的原理をより詳しく学べます。低ランク分解の理論からPyTorchでのスクラッチ実装まで。
画像なし
LLMの量子化(INT8/INT4)を理論から実装まで解説
INT8/INT4量子化の基礎、GPTQ、AWQなど、QLoRAの土台となる量子化技術を網羅的に解説しています。
画像なし
ファインチューニングと転移学習をわかりやすく解説
ファインチューニングの全体像と転移学習の基本を解説しています。