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つに閉じていました。
違うのは「何を捨ててよいか」の根拠です。JPEG は「人の目は高周波に鈍い」という生理を根拠に捨てる場所を選べた。LLM にそれはなく、どこを守り、どこを粗く丸めるかの設計そのものが技術の中身になります。
スケールとゼロ点
(FP32・BF16・FP8といった「型」そのものの内部構造は 数の表し方 — FP32からFP8・INT4まで にあります。本記事は型を前提に、実際に落とす手順を扱います。)
丸める前に2つの数を決めます。実数の範囲を整数の範囲へ対応させるスケール と、実数の0がどの整数に乗るかを決めるゼロ点 です。 ビットに落とすとして、
、 は量子化したい値の最大・最小です。この式が言っているのは要するに、値の広がりを、使える整数の個数で割って目盛り幅にしたということ。8ビットでも整数は256個しかなく、広がりが大きいほど目盛りは粗くなります。
量子化と逆量子化はこうです。
つまり、目盛りの幅で割り、いちばん近い整数に丸め、はみ出したぶんは表せる範囲の端に押し戻す。読み出すときは目盛りの幅を掛け直すだけです。 が保存される整数、 が復元された実数です。両者は一致せず、差は最大で目盛りの半分 。情報を捨てているのはこの 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) # 丸めで落ちた端数は戻らない
コメント
コメントにはログインが必要です