JA EN
体系推論・高速化
·★ 会員·10分で読めます

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

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


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

体重計に乗ると「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=xmaxxmin2b1,z=round ⁣(xmins)s = \frac{x_{\max} - x_{\min}}{2^b - 1}, \qquad z = \mathrm{round}\!\left(-\frac{x_{\min}}{s}\right)
(1)

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

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

q=clip(round(x/s)+z, 0, 2b1),x^=s(qz)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)   # 丸めで落ちた端数は戻らない

を0に固定する流儀が対称量子化です()。計算が軽く、0を中心にほぼ対称な重みと相性がいい。逆に ReLU 後の活性化のように片側にしか値がないものを対称で扱うと整数の半分が無駄になるため、 を使う非対称量子化が要ります。

この先にあるもの

§

ここから先は会員限定です

解説記事371本・教科書26章・学生モード48単元・論文精読6本が、月額¥490ですべて読み放題になります。新しい解説は毎日3本ずつ増えます。いつでも解約でき、解約後も期間の終わりまで読めます。

会員の方はログインすると続きが表示されます

コメント

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