22. 大きなAIモデルを小さくする魔法!量子化とダブル量子化を徹底解説

スポンサーリンク

22. 大きなAIモデルを小さくする魔法!量子化とダブル量子化を徹底解説

前回の記事で、AIモデルのデータ型について学びましたよね。FP32やBF16、INT4といった形式がどう違うか、そしてメモリにどう影響するかという話でした。

でも、ここで実践的な疑問が浮かびます。「すでにFP32で訓練されたモデルがある場合、それを本当に4ビットに変換できるの?」

答えは「はい!」です。それを実現する技術が量子化(Quantization)なんです。

想像してみてください。70億パラメータのモデルがFP32で28GBものメモリを必要とするところ、4ビット量子化を使えば4GB未満に圧縮できるんです。これは「強力なクラウドマシンが必要」と「自分のノートPCのGPUで動く」の違いなんですよ!

今回は、量子化がどう機能するのか、そしてさらに効率を上げる「ダブル量子化」という技術まで、わかりやすく解説していきます。最後まで読めば、大規模なAIモデルをどうやって小さくするのか、その仕組みが理解できるようになりますよ!

なぜ量子化が重要なの?

最初にニューラルネットワークを訓練したとき、おそらく各数値が何ビット使うかなんて深く考えなかったと思います。FP32がデフォルトで、単純に「動く」からそれで良かったんですね。

でも、モデルがどんどん大きくなるにつれて、その前提は崩れ始めました。

今日では、大規模モデルのパラメータをすべて保存するだけで、訓練を始める前にほとんどのGPUのメモリを使い果たしてしまうことがあるんです。

量子化は、根本的でありながらシンプルなアイデアでこの問題を解決します。それは「重みを表現するのに常に32ビット精度が必要というわけではない」という発想です。

代わりに、モデルの動作を意味のある形で変更することなく、INT8やINT4のようなより小さな整数形式に圧縮できます。

これが量子化の核心です。限られたハードウェアで大規模モデルを扱えるようにする、数学的なショートカットなんですね!

核心的なアイデア:浮動小数点を整数にマッピングする

直感から始めましょう。

モデルの重みが-2.5から+3.0の間の連続した数値だと想像してください。量子化は、その滑らかな範囲を少数の離散的なビン(階段のステップのようなもの)にマッピングします。数値を最も近いステップに丸めるような感じですね。

最もシンプルなレベルでは、そのマッピングは2つの量で決まります:

  1. スケール(Scale):各ビンの幅
  2. ゼロポイント(Zero Point):実際のゼロに対応する整数

これらが一緒になって、浮動小数点値が整数になる方法と、それを後で元に戻す方法を定義します。

短い例で見てみましょう!

シンプルな量子化の例

次のようなモデルの重みの小さなセットがあるとします:

weights = [0.7, -1.4, 2.5, -0.8, 1.9, -1.0, 0.3, 2.1, -0.5, 0.0]

これらを8ビット整数に量子化します。つまり、各数値は-128から+127の範囲内に収める必要があります。

ステップ1:範囲を見つける

まず、値の範囲を知る必要があります:

min_val = -1.4
max_val = 2.5

量子化後、-1.4から2.5の範囲は-128から127の範囲にマッピングされます。

ステップ2:スケールファクターとゼロポイントを計算

次に、スケールとゼロポイントを計算します。

scale = (max_val - min_val) / (127 - (-128))
scale = (2.5 - (-1.4)) / 255
scale = 3.9 / 255
scale ≈ 0.0153

スケールファクター0.0153は、量子化スケールでの1単位のステップ(例えば-128から-127)が元のスケールでの0.0153のステップに対応することを意味します。

ゼロポイントは、量子化スケール内のどの数値が元のデータの実際の値ゼロに対応するかを決定します:

zero_point = round(-128 - (min_val / scale))
zero_point = round(-128 - (-1.4 / 0.0153))
zero_point ≈ -36

ステップ3:各重みを量子化

次に、次の式を使用して各浮動小数点を整数に変換します:

quantized_value = round(weight / scale + zero_point)

いくつか量子化してみましょう:

# 重み: 0.7
quantized = round(0.7 / 0.0153 + (-36)) = 10

# 重み: -1.4 (最小値)
quantized = round(-1.4 / 0.0153 + (-36)) = -128

# 重み: 2.5 (最大値)
quantized = round(2.5 / 0.0153 + (-36)) = 127

# 重み: 0.0
quantized = round(0.0 / 0.0153 + (-36)) = -36

10個の重みすべてを量子化すると:

元の値:   [0.7, -1.4, 2.5, -0.8, 1.9, -1.0, 0.3, 2.1, -0.5, 0.0]
量子化値: [10, -128, 127, -88, 88, -101, -16, 101, -69, -36]

ステップ4:品質チェックのための逆量子化

推論中にこれらの重みを使用するには、それらを浮動小数点に変換し直します:

dequantized_value = (quantized - zero_point) × scale

最初の重みを逆量子化してみましょう:

# 量子化値: 10
dequantized = (10 - (-36)) × 0.0153
dequantized = 46 × 0.0153
dequantized ≈ 0.70

元の値が0.7で、逆量子化値が0.70。誤差はほぼゼロです!素晴らしいですね。

達成したこと

メモリ節約を見てみましょう:

元の値(FP32):
– 10個の重み × 4バイト = 40バイト

量子化後(INT8):
– 10個の重み × 1バイト = 10バイト
– 1つのスケールファクター(FP32)= 4バイト
– 1つのゼロポイント(FP32)= 4バイト
– 合計:18バイト

メモリ削減:40バイト → 18バイト = 2.2倍小さい!

この例には10個の重みしかないため、スケールとゼロポイントが大きなオーバーヘッドになっています。でも、100万個の重みを持つ実際のモデル層を想像してください:

  • 1,000,000個の重み × 1バイト = 1MB
  • スケール + ゼロポイント = 8バイト(無視できる!)
  • 合計:約1MB

メモリ削減:4MB → 1MB = 4倍小さい!

さらに、4ビット量子化(INT4)を使えば、8倍の圧縮が得られます。4MBをわずか0.5MBに削減できるんです。これが、普通のハードウェアで70億パラメータのモデルをファインチューニングできるレベルの圧縮なんですよ!

外れ値問題:1つのスケールではすべてに合わない

ここまでは順調です。でも、落とし穴があります。

テンソル全体に1つのグローバルスケールとゼロポイントを使用すると、外れ値が重大な精度損失を引き起こす可能性があるんです。

ほとんどが小さな重みの行列で、1つだけ大きなスパイクがある場合を想像してください:

ブロック1: [-0.05, 0.03, -0.02, 0.01, ...]
ブロック2: [-0.08, 0.04, 8.5, -0.06, ...]

その単一の8.5という値が範囲を劇的に広げてしまいます。すると、すべての小さな値(学習のほとんどが起こる場所)がいくつかのビンに押し込まれ、精度を失ってしまうんです。

解決策は?すべてに1つのスケールを使用しないことです。

ブロックワイズ量子化:ローカルコンテキストにはローカルスケール

1つのスケールでテンソル全体を量子化する代わりに、より小さなチャンク(通常、ブロックあたり64または128値)に分割します。

各ブロックは独自のスケールとゼロポイントを持ち、そのローカル範囲に適応します。

視覚的には、大きな行列をタイルにスライスすることを想像できます。各タイルには独自のミニチュア量子化スキームがあるんです。

これにより、モデルの一部の外れ値が残りの部分に影響を与えないため、精度が劇的に向上します。

bitsandbytesを含むほとんどの最新ライブラリは、デフォルトでブロックワイズ量子化を使用しています。

隠れたコスト:メタデータのオーバーヘッド

ブロックワイズ量子化は精度問題を解決しますが、新しい問題を導入します。

各ブロックには独自のスケールとゼロポイントが必要で、これらの数値もメモリを消費します。小さいですが、数百万個もあるんです。

70億パラメータのモデルの数値を計算してみましょう:

70億パラメータ
ブロックサイズ = 64
→ 約1億900万ブロック

各ブロックのスケールとゼロポイントは32ビット浮動小数点として保存されます:

  • 1億900万個のスケールファクター × 4バイト = 436MB
  • 1億900万個のゼロポイント × 4バイト = 436MB
  • 合計:872MB

量子化定数を保存するためだけに約0.9GBのオーバーヘッドがあります!

「これらも圧縮できないの?」と思いますよね。実は、できるんです!

ダブル量子化:圧縮器を圧縮する!

賢い洞察はこうです:これらのスケールとゼロポイントの値も単なる数値です。なぜそれらも量子化しないのでしょうか?

それがまさにダブル量子化(Double Quantization)が行うことです。

2回目の量子化パスを適用し、これらのスケールファクターとゼロポイントを32ビットから8ビット精度に削減し、総メモリでさらに10~15%節約します。

ステップバイステップ:2層の量子化

  1. 第1層:モデルの重みを量子化(例:4ビット)
    → ブロックごとにスケールとゼロポイントを生成
  2. 第2層:すべてのスケールとゼロポイントの値を収集し、再び量子化(通常8ビット)
    → 保存コストを劇的に削減

圧縮を重ねた圧縮です!

簡単に図示するとこうなります:

単一量子化 → 4ビット重み + (32ビットスケール、32ビットゼロ) ブロックごと
ダブル量子化 → 4ビット重み + (8ビットスケール、8ビットゼロ) ブロックごと
             + (32ビットメタスケール、32ビットメタゼロ) ブロックグループごと

70億パラメータモデルのメモリ節約

実際の数字で見てみましょう:

セットアップ:
– ブロックサイズ:ブロックあたり64パラメータ
– ブロック数:7B ÷ 64 = 約1億900万ブロック

元のモデル(BF16):

重み:7B × 2バイト(BF16)= 14GB

単一量子化(4ビット):

重み:7B × 0.5バイト(4ビット)= 3.5GB
スケールファクター:1億900万 × 4バイト(FP32)= 436MB
ゼロポイント:1億900万 × 4バイト(FP32)= 436MB
────────────────────────────────────────
合計:約4.37GB

ダブル量子化(4ビット + 8ビットメタデータ):

重み:7B × 0.5バイト = 3.5GB
スケールファクター(8ビット):1億900万 × 1バイト = 109MB ⬇️
ゼロポイント(8ビット):1億900万 × 1バイト = 109MB ⬇️

第2レベルブロック:1億900万 ÷ 256 = 約42万6000ブロック
メタスケール:42万6000 × 4バイト = 1.7MB
メタゼロ:42万6000 × 4バイト = 1.7MB
────────────────────────────────────────
合計:約3.72GB

合計圧縮:
– 元の値(BF16):14GB
– 単一量子化:4.37GB(69%削減)
– ダブル量子化:3.72GB(73%削減)

節約されたメモリ:単一量子化からダブル量子化で650MB(15%の追加節約)

70億パラメータのモデルが、14GB(BF16)から3.72GB(4ビット + ダブル量子化)になり、73%も削減されるんです!

NF4:ニューラルネットワーク向けの4ビット最適化

量子化がどのように機能し、ダブル量子化がメモリを節約する方法を説明しました。でも、4ビット量子化をはるかに効果的にするもう1つの最適化があります。それがNF4(NormalFloat4)です。

前回のレッスンでNF4について学びましたよね。これは、ニューラルネットワークの重みの正規分布に最適化された不均一なビン間隔を持つ4ビット形式です。

標準のINT4は、その16個の値を均等に配置します(-8、-7、-6、…、6、7のように)。でも、ニューラルネットワークの重みはゼロ周辺に集中しているんです。

NF4は、ほとんどの重みが存在するゼロ付近により多くのビンを割り当て、末端には少なく割り当てます。

NF4のビン配置:

ビン: [-1.0, -0.70, -0.53, -0.39, -0.28, -0.18, -0.09, 0,
       0.08, 0.16, 0.25, 0.34, 0.44, 0.56, 0.72, 1.0]
→ ゼロ付近で密 → 重要な場所で解像度を最大化

標準INT4のビン配置:

ビン: [-8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7]
→ 等間隔 → まばらな領域で解像度を無駄に使う

ニューラルネットワークを4ビットに量子化する場合、標準のINT4の代わりにNF4を使用すると、通常、品質損失が3~5%から1~2%に減少します。メモリコストがゼロで大幅な改善なんです!

これが、QLoRAがベースモデルの重みにデフォルトでNF4を使用する理由です。ブロックワイズ量子化 + ダブル量子化 + NF4の組み合わせにより、48GBのVRAMで70億パラメータのモデルをファインチューニングでき、出力品質の損失を最小限に抑えられるんですよ。

実践での実装

これらすべての技術がコードでどのように組み合わさるか見てみましょう。

すべてを手作業で構築する必要はありません。bitsandbytesのようなライブラリは、モデルをロードするときに量子化を自動的に処理してくれます。

次のようになります:

from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,              # 4ビット量子化を有効化
    bnb_4bit_quant_type="nf4",      # NormalFloat4形式を使用
    bnb_4bit_use_double_quant=True, # ダブル量子化を有効化
    bnb_4bit_compute_dtype=torch.bfloat16  # 安定性のためにBF16で計算
)

model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    quantization_config=bnb_config,
    device_map="auto"
)

print("4ビットNF4量子化とダブル量子化を有効にしてモデルをロードしました。")

3つの重要な設定に注目してください:

  • load_in_4bit=True:ブロックワイズアプローチで4ビット量子化を有効化
  • bnb_4bit_quant_type=”nf4″:ニューラルネットワーク向けに最適化されたビン間隔を使用
  • bnb_4bit_use_double_quant=True:量子化メタデータを圧縮

この構成により、BF16で通常約14GBを必要とする70億パラメータのモデルが、品質損失を最小限に抑えて4GB未満のVRAMに快適に収まります。すごいですよね!

まとめと次のステップ

量子化はLLMエンジニアリングプロジェクトにおいて不可欠な技術です。これが大規模なLLMファインチューニングを可能にするんです。

これまでに、次のことを理解しました:

  • 量子化が連続した重みを整数にマッピングする方法
  • ブロックワイズ量子化が外れ値問題を解決する理由
  • ダブル量子化がメタデータを圧縮する方法
  • NF4がニューラル重みに最適な4ビット表現を提供する方法

これらの技術を組み合わせることで、モデルサイズを最大8倍削減し、それ以外では小さすぎるGPUで70億パラメータのモデルのファインチューニングを可能にします。

次回は、この効率性がLoRAとどのように組み合わさってQLoRAを作成するかを見ていきます。QLoRAは、アダプタベースの更新と量子化されたベース重みの両方を使用して大規模モデルをファインチューニングする画期的な技術です。

言い換えれば、大きなモデルを収める方法を学びました。次は、それらを効率的にファインチューニングする方法を学びます。楽しみにしていてくださいね!

コメント

タイトルとURLをコピーしました