体系 › 推論・高速化
·無料·10分で読めます

LLM量子化を1から解説 — なぜ精度を落としても動くのか

16ビットの重みを4ビットに切り詰めても、モデルは文章を書き続けます。なぜ壊れないのか。スケールとゼロ点という2つの数から始め、重みは落とせるのに活性化が落としにくい理由(外れ値)、PTQとQATの分かれ道、INT8/INT4で実際に起きること、そして「perplexityだけ見て通す」という最も多い評価事故まで。

要点

  • 量子化とは、連続的に散らばる値を有限個の格子点に写し、1つの値を表すビット数を減らすことです。
  • スケール s とゼロ点 z を決めて丸める操作で、情報を捨てるのは round の1箇所だけ、誤差は最大で s/2 です。
  • 重みは静的で落とせるが、活性化は動的で、外れ値が1つあるとスケールが引き伸ばされ落としにくいです。
  • weight-only 量子化が decode を速くするのはメモリ帯域律速だからで、バッチを上げて計算律速になると利得は薄れます。
  • 「4bit量子化」はスケールの保管コストが乗り、group size 128・fp16 なら実効 4.25 ビットです。

量子化とは、目盛りに丸めること

体重計に乗ると「63.5 kg」と出ます。本当の体重は 63.4827… kg かもしれませんが、表示は 0.1 kg 刻みに丸められている。目盛りを粗くすれば記録に要る桁数は減り、実際の値との差は開きます。

量子化とは、これだけのことです。 連続的に散らばる値を有限個の格子点に写し、1つの値を表すのに必要なビット数を減らす。

言語モデルの重み1個は、ふつう fp16 や bf16、つまり2バイトで持たれています。これを INT4(0.5バイト)で持てるなら、モデルが占めるメモリは4分の1です。効くのはメモリ量だけではありません。KVキャッシュの記事のとおり decode フェーズはメモリ帯域律速——毎ステップ重みを丸ごと読み出す時間が支配的なので、読むバイト数が4分の1になること自体がそのまま速度になります。

「fp16 は連続で INT4 は離散」ではありません。浮動小数点数も有限個の値しか表せないので、あれもすでに量子化です。違うのは格子の並び方だけ——浮動小数点は0の近くで細かく遠ざかるほど粗い指数的な格子、整数量子化は等間隔の一様な格子です。

同じ操作を、あなたはもう見ている

この「目盛りで割って丸める」という操作、まったく同じものが画像圧縮の中心にあります。JPEG は8×8ブロックを周波数成分に書き換えたあと、各成分を量子化テーブルの値で割って四捨五入する。低周波は細かい目盛りで、高周波は粗い目盛りで。JPEG が非可逆になる理由は、その round ただ1つに閉じていました。

FIG 1画質スライダーが動かしているのは目盛りの粗さそのもの。LLMの重み量子化で起きているのは、これと数学的に同じ操作です(詳しくは記事「JPEGはなぜ劣化するのか」/ja/a/jpeg-explained/ へ)

違うのは「何を捨ててよいか」の根拠です。JPEG は「人の目は高周波に鈍い」という生理を根拠に捨てる場所を選べた。LLM にそれはなく、どこを守り、どこを粗く丸めるかの設計そのものが技術の中身になります。

スケールとゼロ点

(FP32・BF16・FP8といった「型」そのものの内部構造は 数の表し方 — FP32からFP8・INT4まで にあります。本記事は型を前提に、実際に落とす手順を扱います。)

丸める前に2つの数を決めます。実数の範囲を整数の範囲へ対応させるスケール ss と、実数の0がどの整数に乗るかを決めるゼロ点 zz です。bb ビットに落とすとして、

s=xmax⁡−xmin⁡2b−1,z=round ⁣(−xmin⁡s)s = \frac{x_{\max} - x_{\min}}{2^b - 1}, \qquad z = \mathrm{round}\!\left(-\frac{x_{\min}}{s}\right)
(1)

xmax⁡x_{\max}、xmin⁡x_{\min} は量子化したい値の最大・最小です。この式が言っているのは要するに、値の広がりを、使える整数の個数で割って目盛り幅にしたということ。8ビットでも整数は256個しかなく、広がりが大きいほど目盛りは粗くなります。

量子化と逆量子化はこうです。

q=clip(round(x/s)+z, 0, 2b−1),x^=s (q−z)q = \mathrm{clip}\big(\mathrm{round}(x/s) + z,\ 0,\ 2^b-1\big), \qquad \hat{x} = s\,(q - z)
(2)

つまり、目盛りの幅で割り、いちばん近い整数に丸め、はみ出したぶんは表せる範囲の端に押し戻す。読み出すときは目盛りの幅を掛け直すだけです。qq が保存される整数、x^\hat{x} が復元された実数です。両者は一致せず、差は最大で目盛りの半分 s/2s/2。情報を捨てているのはこの round ただ1箇所——JPEG とまったく同じ構図です。

import numpy as np

def quantize(x, bits=8):
    qmax = 2 ** bits - 1
    s = (x.max() - x.min()) / qmax          # 目盛りの幅
    z = round(-x.min() / s)                 # 実数の0が乗る格子点
    q = np.clip(np.round(x / s) + z, 0, qmax).astype(np.uint8)
    return q, s, z

def dequantize(q, s, z):
    return s * (q.astype(np.float32) - z)   # 丸めで落ちた端数は戻らない

対称と非対称、そして粒度

zz を0に固定する流儀が対称量子化です(s=max⁡∣x∣/(2b−1−1)s = \max|x| / (2^{b-1}-1))。計算が軽く、0を中心にほぼ対称な重みと相性がいい。逆に ReLU 後の活性化のように片側にしか値がないものを対称で扱うと整数の半分が無駄になるため、zz を使う非対称量子化が要ります。

もう1つ効くのが粒度です。テンソル全体で1組の s,zs, z を共有する(per-tensor)のが最も軽いが、広がりの大きい行が1つあるだけで全体の目盛りが粗くなる。そこで出力チャネルごと(per-channel)、重み128個ずつの塊ごと(group-wise)と細かくします。ただし s,zs,z の保管コストが乗り、4ビット・group size 128・s,zs,z が fp16 なら重み1個あたり0.25ビット増えて実効 4.25 ビット。「4bit量子化」が正確に4ビットだったことはありません。

なぜ重みは落とせて、活性化は落としにくいのか

ここが核心です。同じ round のはずが、重みは4ビットまで落ちるのに活性化は8ビットでも難しい。理由は3つあります。

第一に、重みは静的です。 学習が終われば値は確定しており、全部を眺めて xmax⁡x_{\max} と xmin⁡x_{\min} を決められる。活性化は入力ごとに変わるため代表的な入力から統計を取る(キャリブレーション)しかなく、本番の入力が想定と違えば範囲を外します。

第二に、分布の形が違います。 重みは0中心の釣鐘型に近く、一様な格子と相性がいい。活性化は層や位置で形が変わります。

第三が決定的で、活性化には外れ値がいます。 大きな Transformer では、特定の特徴次元に他より桁違いに大きな活性値が系統的に現れることが報告されています(LLM.int8() の論文, arXiv:2208.07339)。ss は最大値で決まることを思い出してください。1つでも巨大な値が混じれば目盛りは粗くなり、大多数の小さな値が同じ格子点に潰れる。 外れ値そのものではなく、外れ値が目盛りを引き伸ばすことが害なのです。

対処は2方向。外れ値の出る次元だけ高精度で計算する混合精度の分解と、外れ値をスケーリングで重み側へ移す方法(SmoothQuant)です。実務でまず普及したのが重みだけを量子化し計算は16ビットで行う W4A16 なのは、こうした事情からでした。

PTQ と QAT

量子化をいつやるかで手法は分かれます。

PTQ(Post-Training Quantization)は学習済みモデルに後から適用します。少量のキャリブレーションデータで統計を取り、丸めるだけ。学習しないので大きなモデルでも現実的な時間で終わる。ただ素朴に丸めると4ビットでは壊れやすく、賢く丸める工夫が加わりました。GPTQ は重みを列ごとに量子化し、丸め誤差を残りの重みに埋め合わせさせる。AWQ は活性化の大きい入力チャネルに掛かる重みを事前にスケールして守る。どちらも「全部を等しく丸めない」発想です。

QAT(Quantization-Aware Training)は学習中に丸めを挟み込みます(fake quantization)。順伝播で丸めた値を使うので、モデルは低精度前提のまま重みを学べる。問題は round の微分が至るところ0であることで、逆伝播では丸めを無かったことにして勾配を通す straight-through estimator を使います。学習と同じ資源を要するため、極端に低いビット幅やエッジ向けで選ばれます。

INT8とINT4で、実際に何が起きるか

メモリの計算は素直です。70億パラメータなら fp16 で約14 GB、INT8 で約7 GB、INT4 で約3.5 GB+端数。1枚のGPUに載るかどうかが、この一段で変わります。

速度は込み入っています。重みだけを量子化すると、行列積の前に整数を実数へ戻す処理が挟まるので演算そのものは増えます。それでも decode が速くなるのはメモリ帯域律速だから——読むバイト数が減る利得が、逆量子化の追加演算を上回るのです。

裏を返すと、バッチサイズを上げて計算律速の領域に入ると利得は薄れます。 「メモリに載せるため」か「速くするため」かを区別しないと、結論を読み違えます。

評価の落とし穴

perplexity だけを見ない。 perplexity は次トークン予測の外れ具合の平均で、平均なので鈍い。量子化の劣化は薄く広がるのではなく特定の場所に集中して現れます——長い指示の追従、コード生成、桁の多い計算、学習データの少ない言語。自分のタスクに近い評価を1つ持つほうが、汎用ベンチマークを並べるより効きます。

キャリブレーションデータと評価データを分ける。 同じ分布で校正して同じ分布で測れば当然よく見えます。英語だけで校正した量子化が日本語で活性化の範囲を外すのは、十分ありえる話です。

変更は1つずつ入れる。 重みの量子化とKVキャッシュの量子化を同時に有効にして劣化が出たら、原因を切り分けられません。別の丸めなので劣化は積み上がります。出力分布そのものが変わるため、以前のプロンプトが同じように効く保証もありません。

現場ではこう使う

量子化に触るのは、限られたGPUやエッジ端末にモデルを載せる人と、推論コストを下げる責任を持つ基盤担当です。順序も決まっています。まず weight-only の INT8 か INT4 を試し、自分のタスクで評価し、足りなければ W8A8 や QAT へ。いきなり最も攻めた設定から入らないことです。

触るパラメータ名。 llama.cpp 系なら GGUF の量子化タイプ(Q4_K_M がビット幅と粒度を表す)。bitsandbytes なら load_in_4bit、bnb_4bit_quant_type(nf4 か fp4)、bnb_4bit_compute_dtype(取り違えると遅くなります)。GPTQ / AWQ なら group_size とキャリブレーションデータ。サービング側は vLLM の --quantization と --kv-cache-dtype。

落とし穴を3つ。 1つ目はキャリブレーションデータを本番と違う分布で用意すること——多言語で使うなら多言語で校正します。2つ目は量子化済みモデルをそのまま追加学習しようとすること。低ビットの重みは凍結し、学習は別に足した高精度パラメータ側で行うのが定石です。3つ目はサイズ見積もりで実効ビット数(4ではなく4.25)を忘れることです。

設計レビューでよく問われるのは「なぜ4ビットに落としても壊れないのか」です。筋道は2つ。1つは大規模モデルの重みには冗長性があり、値1つ1つの精度が出力を支配していないこと。もう1つが本題で、効いているのは丸め方の工夫のほう——group 単位でスケールを分け、重要なチャネルを守り、外れ値が目盛りを引き伸ばさないようにしている。「減らしても壊れない」のではなく「壊れないように減らす方法が見つかった」と答えられれば十分です。もう1問の定番「なぜ活性化はINT4にしにくいのか」には、動的であることと外れ値で。

まとめ

🎬 この内容の解説動画(Manim・字幕つき)

運営: TechAthletes · 編集方針 · 出典の付け方 · 訂正の記録

コメント

コメントにはログインが必要です