- 量子化の計算が「目盛りの幅を決める」と「一番近い目盛りに丸める」の 2 つだけでできていること
q = round(x / scale) + zero_pointと復元の式が実際の数値でどう動くか(16 個の重みを電卓で追える形で)- 対称量子化と非対称量子化の違い。誤差が大きいほうが重みで主流になっている理由
- 外れ値 1 個がなぜ「その 1 個の誤差」で済まないのか。ブロック単位に切ると何が起きるのか
- 「4bit 量子化」のファイルが 4bit/重み にならない理由と、
W4A16という表記の意味
やることは 2 つだけ — 幅を決めて、近い目盛りに丸める
第 1 章で、量子化とは「重みを元より少ないビット数で表せる値の集合へ写し取る操作」だと決めました。この章はその「写し取る」の中身を開けます。結論から言うと、やっていることは 2 つだけです。
- 目盛りの幅を決める(これを scale と呼びます)
- それぞれの重みを一番近い目盛りに丸める(round)
ここでいったん身近な道具にたとえます。1 mm 刻みのものさしで測ると 37.4 mm のものは「37 mm」と読むしかなく、1 cm 刻みに持ち替えれば「40 mm」になって捨てる量が増えます。量子化はこれと同じで、刻みの粗さを自分で選び、その粗さを受け入れる代わりにビット数を節約する操作です。
比喩はここで終わりにして、実際の仕組みに戻します。ものさしと違うのは 2 点です。第一に、目盛りの本数がビット数で決まります。4 ビットなら 24 = 16 本、3 ビットなら 8 本しかありません。第二に、目盛りの幅は決まっていません。測る対象(そのテンソルの重み)を見てから決めます。16 本の目盛りを、重みが実際に散らばっている範囲にちょうど張るように配置する。この「幅をいくつにしたか」を覚えておく値が scale です。
第 2 章で見た FP16 のような浮動小数点形式は、値が小さいところでは目盛りが細かく、大きいところでは粗くなるように刻みが自動的に伸縮する形式でした。量子化された整数はその逆で、刻みを固定して、その刻み幅を別の場所に 1 個だけ書いておく形式です。だから量子化されたテンソルは「整数の列」だけでは復元できず、必ず scale がセットで付いてきます。この「セットで付いてくる値」が、後で見るビット数の話にそのまま効いてきます。
式を 3 点セットで — 量子化と復元
まず言葉で書きます。量子化は「重みを目盛りの幅で割り、一番近い整数に丸め、格子の原点をずらしている分を足す」操作です。復元(逆量子化 / dequantize)はその逆をたどるだけで、「整数から原点のずれを引き、目盛りの幅を掛ける」操作です。
式にすると次の 2 本になります。
q = round(x / scale) + zero_point
x' = scale × (q - zero_point)
記号の意味を 1 つずつ定義します。
x— 元の重み(FP16 などの実数)。x'は復元した値で、xとは一致しませんq— 保存される整数。4 ビット符号なしなら 0〜15 の 16 通りscale— 目盛り 1 個ぶんの実数の幅。整数ではなく実数で、FP16 などの高い精度でそのまま保存しますzero_point— 実数の 0.0 に対応する整数値。「ゼロ点」とも書きます。整数です
scale と zero_point は次のように決めます(Hugging Face Transformers 公式の量子化コンセプトガイドの記法)。val_min / val_max は量子化する重みの実際の最小値・最大値、q_min / q_max は整数側の下限・上限です。
scale = (val_max - val_min) / (q_max - q_min)
zero_point = q_min - round(val_min / scale)
zero_point がわざわざ用意されているのは、実数の 0.0 をぴったり表現できるようにするためです。HF Optimum のコンセプトガイドはこう書いています。「0 という値を正確に表現できることは重要である。機械学習モデルの至るところで使われるからだ」。NVIDIA のホワイトペーパーも「z は整数に丸められるので、実数のゼロが正確に表現できる。これは表現可能な範囲をわずかに調整する結果になる」と明記しています。次の節の手計算で、この「わずかなずれ」が実際に見えます。
数値を 1 個入れてみます。scale = 0.062、zero_point = 7(この値の出どころは次の節で計算します)のとき、x = 0.21 という重みは次のように処理されます。
q = round(0.21 / 0.062) + 7 = round(3.387) + 7 = 3 + 7 = 10
x' = 0.062 × (10 - 7) = 0.186
0.21 が 0.186 になりました。差の −0.024 が量子化誤差です。保存されるのは 10 という整数 1 個(4 ビット)だけで、0.21 という値は二度と戻ってきません。
もう 1 つ、実装では必ず付いている処理があります。クリッピングです。計算した q が整数の上限・下限を超えたら、そこで打ち止めにします。
q = clip(round(x / scale) + zero_point, q_min, q_max)
これがないと、想定外に大きな重みが 16 に飛び出して 4 ビットに入らなくなります。クリッピングは単なる安全弁ではなく、あとで見るとおり意図的に使う設計の道具でもあります。
手を動かす — 16 個の重みを 4bit にして戻す
式を追うだけでは腹に落ちないので、実際に計算します。あるテンソルから連続する 16 個の重みを取り出したとします(値は説明用に選んだものです)。
-0.42, -0.31, -0.28, -0.19, -0.11, -0.08, -0.03, 0.00, 0.02, 0.06, 0.13, 0.21, 0.24, 0.33, 0.40, 0.51
これを 4 ビット符号なし(q_min = 0、q_max = 15)に量子化します。まずパラメータを決めます。計算例です。
scale = (0.51 - (-0.42)) / (15 - 0) = 0.93 / 15 = 0.062
zero_point = 0 - round(-0.42 / 0.062) = 0 - round(-6.774) = 0 - (-7) = 7
目盛りの幅が 0.062、実数の 0.0 が整数の 7 に対応する、という格子が決まりました。あとは 16 個を順に流すだけです。
| 元の値 x | x ÷ 0.062 | q = 丸め + 7 | 復元値 x' | 誤差 x' − x |
|---|---|---|---|---|
| -0.42 | -6.774 | -7 + 7 = 0 | -0.434 | -0.014 |
| -0.31 | -5.000 | -5 + 7 = 2 | -0.310 | 0.000 |
| -0.28 | -4.516 | -5 + 7 = 2 | -0.310 | -0.030 |
| -0.19 | -3.065 | -3 + 7 = 4 | -0.186 | +0.004 |
| -0.11 | -1.774 | -2 + 7 = 5 | -0.124 | -0.014 |
| -0.08 | -1.290 | -1 + 7 = 6 | -0.062 | +0.018 |
| -0.03 | -0.484 | 0 + 7 = 7 | 0.000 | +0.030 |
| 0.00 | 0.000 | 0 + 7 = 7 | 0.000 | 0.000 |
| 0.02 | 0.323 | 0 + 7 = 7 | 0.000 | -0.020 |
| 0.06 | 0.968 | 1 + 7 = 8 | 0.062 | +0.002 |
| 0.13 | 2.097 | 2 + 7 = 9 | 0.124 | -0.006 |
| 0.21 | 3.387 | 3 + 7 = 10 | 0.186 | -0.024 |
| 0.24 | 3.871 | 4 + 7 = 11 | 0.248 | +0.008 |
| 0.33 | 5.323 | 5 + 7 = 12 | 0.310 | -0.020 |
| 0.40 | 6.452 | 6 + 7 = 13 | 0.372 | -0.028 |
| 0.51 | 8.226 | 8 + 7 = 15 | 0.496 | -0.014 |
この表から読み取れることが 5 つあります。
1. 誤差には天井がある。一番近い目盛りに丸めるのだから、ずれは目盛り幅の半分を超えません。理論上の上限は 0.062 ÷ 2 = 0.031 で、表の最大値は 0.030(−0.28 の行)。量子化誤差の大きさは scale だけで決まる、というのがここで押さえるべき一般則です。
2. 平均するとかなり小さい。16 個の誤差の絶対値を平均すると 0.0145(計算例)で、値域 0.93 の 1.6% です。16 ビットで 65,536 通りのビット列を書ける形式から 16 通りに落としたのに、1 個あたりのずれはこの程度で収まります。
3. 0.0 はぴったり保たれる。x = 0.00 の行の誤差は 0.000 です。zero_point を持たせた効果がここに出ています。
4. 一番大きい値は、なぜかぴったりにならない。x = 0.51 は 0.496 に戻り、0.014 ずれました。これは zero_point を整数の 7 に丸めた副作用です。格子が表現できる範囲は 0.062 × (0 − 7) = -0.434 から 0.062 × (15 − 7) = 0.496 なので、元の [-0.42, 0.51] からわずかに左へずれています。前節で引いた NVIDIA の注記がそのまま観測できたということです。
5. 16 段あるのに、使われたのは 13 段だけ。q の列に 1 / 3 / 14 は一度も現れず、逆に q = 2 と q = 7 には複数の重みが集まっています。目盛りは等間隔に置くしかないのに、重みは等間隔に散らばっていないからです。この「段の使い残し」が量子化の効率を決める要素で、次の節の外れ値の話に直結します。
対称と非対称 — zero_point を持つか、捨てるか
ここまで計算してきたのは 非対称量子化(asymmetric、affine 量子化とも呼ぶ)です。scale と zero_point の 2 つを持ち、重みの実際の [最小値, 最大値] をそのまま整数の全域へ写します。
もう一方が 対称量子化(symmetric、NVIDIA の用語では scale 量子化)です。zero_point を 0 に固定してしまうやり方で、原点をまたいだ対称な範囲 [-a, a] を整数の対称な範囲へ写します。パラメータは scale 1 個だけになります。
q = clip(round(x / scale), -(2^(b-1) - 1), 2^(b-1) - 1)
scale = max(|x|) / (2^(b-1) - 1)
b はビット数です。4 ビットなら 2^3 - 1 = 7 なので、q は −7〜7 の 15 通りになります。この「重みの絶対値の最大で割る」やり方は absmax 量子化と呼ばれ、LLM.int8() の論文が「絶対値最大による量子化は最もよく使われている手法である」と書いているとおり、LLM の実装でいちばん目にする形です。
さっきの 16 個を対称量子化でやり直すと、scale = 0.51 / 7 = 0.0729 になり、平均絶対誤差は 0.0145 から 0.0186 へ約 28% 悪化します(計算例)。値域が 0 を中心に完全対称ではない(左に −0.42、右に 0.51)ぶん、片側の目盛りが余るからです。
ではなぜ、誤差が大きいほうが重みでは主流なのでしょうか。答えは推論の速さです。NVIDIA のホワイトペーパーは、zero_point を持つと行列積が 3 つの項に分解されることを示しています。うち 2 項は事前に計算して畳み込めますが、残る 1 項は量子化された入力行列を含むので事前計算ができません。論文の結論はこうです。「この追加計算は、実装によってはかなりのオーバーヘッドを持ち込み、整数演算パイプラインが持つスループット上の優位を減らす、あるいは消し去ってしまう。したがって推論性能を最大化するには、重みには scale 量子化を使うことを推奨する」。
もう 1 つの理由は、そもそも重みの分布がほぼ 0 対称だからです。QLoRA 論文は「学習済みニューラルネットワークの重みは通常、標準偏差 σ のゼロ中心の正規分布を持つ」と書いています。左右がだいたい同じなら、非対称にしても得は少ない。この分布の話は第 4 章で扱います。
4bit 特有の落とし穴。符号付き 4 ビットは −8〜7 の 16 通りありますが、対称にするために −8 を捨てて −7〜7 の 15 通りだけ使う実装があります。NVIDIA はこれについて「8 ビット量子化では 256 個のうち 1 個を失うだけなので無視できるが、より低いビット幅では、表現可能な値の個数と対称性のトレードオフを再評価すべきである」と注意しています。8 ビットなら損失は 0.4% ですが、4 ビットでは 16 段のうち 1 段 = 6% を捨てることになります。論文や実装によって分母が 2^(b-1) か 2^(b-1) - 1 で 1 違うのはこの選択の差です。
scale の決め方 — 外れ値 1 個が全員の目盛りを粗くする
scale を決めるには、まず「どの範囲を格子で覆うか」を決める必要があります。量子化サーベイはこう定義しています。「この切り取り範囲(clipping range)を選ぶ過程を、しばしば キャリブレーション(calibration) と呼ぶ」。
重みのキャリブレーションで基本になるのは、これまで使ってきた min-max と absmax です。HF Optimum も「min-max: 観測された最小値と最大値を範囲とする。これは重みに対してうまく働く」と書いています。重みは推論中に変化しないので、実際の最小値・最大値を見て決めるだけで済みます。
ところがこの素直な方法には決定的な弱点があります。飛び抜けて大きい値が 1 個混じっていると、その 1 個に合わせて格子全体が引き伸ばされるのです。
数値で確かめます。さっきの 16 個のうち、最後の 0.51 だけを 5.10 に置き換えます。残り 15 個は 1 つも動かしません。
scale = (5.10 - (-0.42)) / 15 = 5.52 / 15 = 0.368
zero_point = 0 - round(-0.42 / 0.368) = 0 - (-1) = 1
目盛りの幅が 0.062 から 0.368 へ、約 6 倍粗くなりました。この格子に 16 個を流すと何が起きるか。-0.42 〜 -0.19 の 4 個はすべて q = 0、-0.11 〜 0.13 の 7 個はすべて q = 1、0.21 〜 0.40 の 4 個はすべて q = 2、そして 5.10 だけが q = 15 になります。
16 段のうち、実際に使われたのは 4 段だけです。平均絶対誤差は 0.0145 から 0.0759 へ、5.2 倍に悪化します(計算例)。しかも悪化しているのは外れ値ではなく、何も悪いことをしていない残り 15 個のほうです。LLM.int8() 論文がこの問題を一文で言い切っています。「テンソルごとに単一のスケール定数を使う量子化手法の主な課題は、1 個の外れ値が他のすべての値の量子化精度を下げてしまうことである」。
クリッピング — 目盛りの外に出た値をどう扱うか
外れ値に格子を引き伸ばされるのが嫌なら、選択肢は 1 つです。外れ値を範囲の外に置いて、切り捨てる。これがクリッピングを設計の道具として使うということです。式は前に出したものと同じで、[val_min, val_max] を実際の最小・最大より狭く取るだけです。
キャリブレーションの手法は、この「どこで切るか」の決め方の違いとして整理できます。NVIDIA のホワイトペーパーが挙げているのは次の 3 つです。
- Max — 観測した絶対値の最大をそのまま使う。何も切らない
- Percentile — 絶対値の分布のパーセンタイルで決める。「たとえば 99% キャリブレーションでは、絶対値の大きい 1% を切り捨てる」
- Entropy — 量子化前後の情報の損失を KL ダイバージェンス(= 2 つの分布のずれを測る尺度)で最小化する。「これは TensorRT が使う既定の手法である」
トレードオフの本質は、同じ論文がはっきり言葉にしています。「分布を切り取ることは、少数の外れ値に大きな切り取り誤差を負わせる代わりに、大多数の値の丸め誤差を小さくする取引である」。
さっきの例で 5.10 を切って上限を 0.40 にすれば、残り 15 個の誤差は元の水準に戻りますが、5.10 は上限の 0.40 付近へ潰されるので、その 1 個だけで約 4.7 という巨大な誤差を負います(計算例)。どちらの誤差が最終的な出力に効くかは、その値が何に使われているかで決まります。この判断こそが第 5 章で見る各手法の分かれ目です。
粒度 — scale を何個持つか
外れ値の害を抑える、もう 1 つの、そしてローカル LLM でいちばん重要な手が粒度(granularity)です。scale を 1 個で済ませず、テンソルを細かく区切ってそれぞれに専用の scale を持たせるのです。粒度には段階があります。
- per-tensor — テンソル 1 枚に scale 1 個。いちばん粗く、いちばん実装が速い
- per-channel — 出力チャネル(重み行列の列)ごとに scale 1 個。サーベイは「各チャネルに専用のスケール係数を割り当てる。これはより良い量子化解像度を保証し、しばしば高い精度をもたらす」と書いている
- per-group / per-block — チャネルの中をさらに区切り、連続する N 要素ごとに scale 1 個。N は 32 / 64 / 128 あたりが一般的
なぜ好きなだけ細かくできないのか。NVIDIA のホワイトペーパーが行列積の式から導いているのですが、整数の行列積を使うにはスケールを総和の外へ出す必要があり、そのためにはスケールが総和のインデックスに依存してはいけないのです。結論は「量子化の粒度が、活性化については per-row か per-tensor、重みについては per-column か per-tensor である限り、整数の行列積は可能である」。つまり整数演算にこだわると、重みは per-channel より細かくできません。
ところがローカル LLM で使う 4bit は整数演算をしていません(次の節で詳しく見ます)。重みは読み出したあと 16 ビットに戻してから掛け算されるので、この制約が外れます。だから GGUF は 32 要素ごとという細かいブロックを平然と使えるのです。そして低ビットではそれが必須になります。NVIDIA の Model Optimizer ドキュメントは「低ビット量子化(たとえば 4 ビット)では、精度を保つために通常 per-block 量子化が必要になる」と書いています。
QLoRA 論文はブロック分割の目的をこう述べています。「外れ値の問題を防ぐため、よく取られる手法は、入力テンソルをブロックに分割してそれぞれ独立に量子化し、各ブロックに専用の量子化定数 c を持たせることである」。同じ段落が挙げている症状 — 外れ値のせいで「いくつかのビンにはほとんど、あるいは全く数値が量子化されない」 — は、図 3-2 の白い四角そのものです。
効果を数値で確かめます。外れ値入りの 16 個を、前半 8 個と後半 8 個の 2 ブロックに分けて、それぞれ独立に量子化します(計算例)。
- ブロック A(
-0.42 〜 0.00)—scale = 0.42 / 15 = 0.028。外れ値が入っていないので目盛りは細かいまま。平均絶対誤差 0.002 - ブロック B(
0.02 〜 5.10)—scale = 5.08 / 15 = 0.339。ここは粗い。平均絶対誤差 0.066 - 16 個全体 — 平均絶対誤差 0.0759 から 0.0340 へ半減
重要なのは全体の数字より、ブロック A の誤差が 0.002 まで下がったことです。外れ値と同じテンソルにいるだけで巻き込まれていた 8 個が、無関係になりました。これが「ブロック単位」という発明の中身です。害は消えていません。ブロック B の中に閉じ込められただけです。
メタデータの代償 — 4bit 量子化が 4bit で終わらない理由
細かくすれば誤差が減るなら、なぜ全部を 8 要素ごとや 4 要素ごとにしないのか。scale そのものがビットを食うからです。使う式は第 2 章で bpw(bits per weight)を定義したときのものがそのまま通用します。
重み 1 個あたりの追加ビット = scale のビット数 ÷ ブロックサイズ
scale は復元の基準になる値なので、高い精度で持つのが基本です。llama.cpp の素朴な 4bit 型なら FP16(16 ビット)。ブロックサイズが 32 なら 16 ÷ 32 = 0.5 ビット。重み 1 個につき 0.5 ビットが上乗せされるということです。4 ビット量子化のはずが、実際には 4.5 ビット使っています。QLoRA 論文も「W に対して 32 ビットの定数とブロックサイズ 64 を使うと、量子化定数は平均して 1 パラメータあたり 32/64 = 0.5 ビットを追加する」と書いており、分子と分母が違っても割り算の答えは同じ 0.5 です。
なお、この 0.5 ビットが惜しければ scale 自身を量子化するという手もあります。QLoRA は量子化定数を 8 ビットに落として 1 パラメータあたり 0.5 ビットを 0.127 ビットまで削り(double quantization。第 5 章で開けます)、llama.cpp の Q4_K はサブブロックのスケール(と最小値)を 6 ビットに縮めています(第 6 章で開けます)。
llama.cpp の実装で具体的に見ると次のようになります。同リポジトリの ggml/src/ggml-common.h が定義しているブロック構造体から、そのまま計算できる値です。
| 構成 | ブロック 1 個(32 要素)の内訳 | bits/weight |
|---|---|---|
| 4bit の量子値だけ(理論値) | 32 × 4 = 128 bit | 4.0 |
+ FP16 の scale 1 個(対称 / Q4_0) | 128 + 16 = 144 bit(18 バイト) | 4.5 |
+ FP16 の scale と最小値(非対称 / Q4_1) | 128 + 32 = 160 bit(20 バイト) | 5.0 |
ここで対称と非対称の選択がもう一度効いてきます。非対称は誤差が小さいのに、ブロックごとに 2 個のメタデータを持つのでさらに 0.5 bpw 高くつく。同じ 5.0 bpw を払うなら、対称のまま 5 ビット量子化にしたほうが良いかもしれない — こういう比較が成立してしまうのです。
だから「4bit 量子化」と書かれたファイルは、決して パラメータ数 ÷ 2 バイトになりません。llama.cpp 公式リポジトリの実測では、Llama-3.1-8B の Q4_K_M は 4.8944 bits/weight(4.58 GiB)です。0.5 ビットぶんのスケールに加えて、テンソルごとに違うビット幅を混ぜているぶんが乗っています。この内訳をビット単位で開けるのは第 6 章です。
量子化は保存形式であって計算形式ではない — W4A16 の読み方
最後に、初心者がいちばん誤解しやすい点を固めます。4 ビットに量子化したモデルは、4 ビットで計算していません。
量子化サーベイは、量子化モデルの動かし方を 2 つに分類しています。1 つは integer-only quantization(すべてを整数演算で行う)で、もう 1 つがこちらです。
simulated quantization(別名 fake quantization)では、量子化されたモデルパラメータは低精度で保存されるが、演算(行列積や畳み込みなど)は浮動小数点演算で実行される。したがって量子化されたパラメータは、浮動小数点演算の前に逆量子化される必要がある。
ローカル LLM の 4bit は、ほぼすべてこの simulated quantization のほうです。QLoRA 論文はこの動作を実装の言葉で書いています。「QLoRA の重みテンソルが使われるたびに、テンソルを BFloat16 へ逆量子化し、それから 16 ビットで行列積を実行する」。
これが「4 ビットにしたのに、なぜ精度がそこまで落ちないのか」への直接の答えです。4 ビットの丸め誤差が入るのは重みの値そのものだけで、何千個もの積を足し込んでいく計算は 16 ビットで行われます(足し込みの途中は、精度が落ちないようさらに広い FP32 の累算器を使うのが普通です)。つまり量子化に由来する誤差がそこで新たに増えることはありません。
この「重みは何ビットか / 層の間を流れる数値は何ビットか」を 1 語で書くのが W<n>A<m> という表記です。W は weight(重み)のビット数、A は activation(活性化 = 層の間を流れる数値)のビット数を表します。
| 表記 | 重み | 活性化 |
|---|---|---|
W16A16 | 16bit | 16bit(量子化していない状態) |
W8A16 | 8bit | 16bit |
W4A16 | 4bit | 16bit(GGUF の Q4 系・GPTQ・AWQ・NF4 はここ) |
W8A8 | 8bit | 8bit(整数演算を使う。SmoothQuant 等) |
重みだけを量子化する方式(weight-only 量子化)とは、A が 16 のままの構成のことです。NVIDIA の Model Optimizer ドキュメントは使い分けをこう整理しています。「weight-only 量子化は帯域が制約された場面で有効であり、重みと活性化の両方を量子化すると帯域と計算コストの両方を削減できる」。ローカルの 1 人利用はまさに帯域が効く側です。
整数演算を使わないなら得はないのでは、という疑問への答えは第 2 章で見ました。4 ビットを 2 個ずつ 1 バイトに詰め、そのバイト列をメモリから読む段で帯域を稼ぐ — 図 3-4 で 2 個ずつ並べていた四角が、まさにその 1 バイトです。
この章で足しておきたいのは、その得を取りこぼさないための実装上の条件です。読み出し・unpack・逆量子化・積和は、まとめて 1 つの GPU カーネルで処理されることが多い。HF が「データ転送が速くなることによる高速化が、その場で unpack と逆量子化を行う計算オーバーヘッドを上回りうる」と書いているのはそういう意味です。逆量子化した 16 ビットの重みをいちいちメモリに書き戻していたら、節約したはずの帯域を自分で使い潰してしまいます。
つまずきポイント
「量子化は桁を落とすだけ」だと思っていた。量子化された整数だけでは元の値を復元できません。必ず scale(非対称なら zero_point も)がセットで保存され、しかも高精度で持つ必要があるので、ビット数の節約を確実に食います。「4bit 量子化なのにファイルが理論値より 2 割大きい」の主犯はこれです。
「4bit に量子化した = 4bit で計算している」だと思っていた。ローカル LLM の 4bit はほぼすべて weight-only 量子化(W4A16)で、保存が 4 ビット、計算は 16 ビットです。行列積の途中の桁は守られています。だからこそ精度が思ったより落ちません。重みも活性化も 8 ビットにする W8A8 は、原理から違う別物です。
外れ値 1 個は「その 1 個の誤差」では済まない。scale は範囲の両端から決まるので、外れ値 1 個が同じスケールを共有する全員の目盛りを粗くします。この章の計算例では 1 個を 10 倍にしただけで、無関係な 15 個の誤差が 5 倍以上になりました。だからブロック単位に切るという対策が要るのです。
zero_point の符号規約は資料によって逆になる。この章は HF 公式の記法(x' = scale × (q - zero_point))に統一しました。なお HF は丸めの内側に入れて q = round(x / scale + zero_point) と書きますが、zero_point は整数なので丸めの外へ出しても答えは同じです。本章は手で追いやすい外側の形にしました。量子化サーベイは Q(r) = Int(r/S) - Z、r̃ = S(Q(r) + Z) と書き、Z の符号が逆です。同じ affine 変換の別表記なので、式を写すときは1 つの出典に統一すること。混ぜると符号を間違えます。
理解度チェック
Q1. scale = 0.05、zero_point = 8 の 4bit 格子(0〜15)で、重み x = -0.17 はどの整数になり、復元値と誤差はいくらになりますか。
q = round(-0.17 / 0.05) + 8 = round(-3.4) + 8 = -3 + 8 = 5。復元値は x' = 0.05 × (5 - 8) = -0.15 で、誤差は +0.02 です。誤差が scale の半分(0.025)以内に収まっていることも確認してください。
Q2. 32 要素ごとに FP16 の scale を 1 個持つとき、重み 1 個あたり何ビット余計にかかりますか。ブロックを 16 要素に半分にしたらどうなりますか。
16 ÷ 32 = 0.5 ビット。ブロックを 16 要素にすると 16 ÷ 16 = 1.0 ビットで、追加分が倍になります。4bit 量子化の実効値が 4.5 bpw から 5.0 bpw になるということで、これは 5bit 量子化の領域に近づきます。「細かくすれば精度が上がる」がどこかで採算割れするのはこのためです。
Q3. 同じデータで対称量子化のほうが誤差が大きいのに、重みでは対称量子化が主流なのはなぜですか。
zero_point を持つと行列積が 3 項に分解され、そのうち 1 項は量子化された入力行列を含むため事前計算できません。NVIDIA はこの追加計算が整数演算の速度優位を消しかねないとして、重みには scale 量子化(対称)を推奨しています。加えて重みの分布はほぼ 0 対称なので、非対称にしても得が小さいという事情もあります。
Q4. あるテンソルに外れ値が 1 個混じっているとき、per-tensor と per-block(32 要素)では結果はどう違いますか。
per-tensor では scale が 1 個しかないので、そのテンソルの全要素の目盛りが外れ値に合わせて粗くなります。per-block では外れ値を含む 32 要素のブロックだけが粗くなり、他のブロックは自前の scale を持つので影響を受けません。害が消えるのではなく、1 ブロックの中に閉じ込められるのが要点です。
Q5. W4A16 と W8A8 は何が違いますか。ローカルで使う GGUF の Q4 系はどちらですか。
W は重み、A は活性化のビット数です。W4A16 は重みだけを 4 ビットにして計算は 16 ビットで行う構成(weight-only 量子化)、W8A8 は重みも活性化も 8 ビットにして整数演算を使う構成です。GGUF の Q4 系は W4A16 で、GPTQ・AWQ・NF4 も同じ側です。
まとめ
- 量子化の計算は「目盛りの幅(scale)を決める」と「一番近い目盛りに丸める」の 2 つだけ。式は
q = round(x / scale) + zero_pointとx' = scale × (q - zero_point) - 量子化誤差の大きさは scale だけで決まる。丸めのずれは必ず
scale ÷ 2以内で、計算例では上限 0.031 に対し実測最大 0.030、平均 0.0145 だった - 非対称は誤差が小さく、対称は速い。重みで対称(absmax)が主流なのは、分布がほぼ 0 対称であることと、zero_point が行列積に事前計算できない項を持ち込むため
- 外れ値 1 個が、同じ scale を共有する全員の目盛りを粗くする。1 個を 10 倍にした計算例では平均誤差が 5.2 倍になり、16 段のうち 4 段しか使われなくなった
- 対策が粒度。per-block まで細かくすれば外れ値の害はブロック内に閉じ込められる。代償は
追加ビット = scale のビット数 ÷ ブロックサイズで、FP16 の scale を 32 要素ごとに持てば +0.5 bpw - 量子化は保存形式であって計算形式ではない。
W4A16は「重み 4bit・活性化 16bit」で、重みは読み出したあと 16 ビットに戻して掛け算される
ここまでで「どうやって圧縮しているか」は完全に分かりました。残っている大きな疑問は、それでなぜ文章が書けるのかです。65,536 通りを 16 通りに削り、1 個あたり 1.6% の誤差を全部の重みに入れて、なぜモデルは壊れないのか。次の第 4 章で、重みの分布・外れ値・層ごとの感度差という 3 つの角度から答えます。
参考リンク
- Hugging Face Transformers — Quantization concepts(scale / zero-point の式、symmetric と asymmetric、weight packing)
- Hugging Face Optimum — Quantization(concept guide)(clip 込みの完全形、accumulation データ型、キャリブレーションの 3 分類)
- NVIDIA TensorRT Model Optimizer — Basic Concepts(precision format / scaling factor / block format、低ビットでの per-block の必要性)
- Wu et al., Integer Quantization for Deep Learning Inference: Principles and Empirical Evaluation(affine と scale 量子化、粒度の数学的制約、キャリブレーション手法)
- Gholami et al., A Survey of Quantization Methods for Efficient Neural Network Inference(キャリブレーションの定義、粒度の 4 分類、simulated と integer-only)
- Dettmers et al., QLoRA: Efficient Finetuning of Quantized LLMs(ブロック単位量子化の定式化、量子化定数のビット数、保存 4bit / 計算 16bit)
- Dettmers et al., LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale(absmax 量子化、外れ値 1 個が全体の精度を下げる問題)
- llama.cpp — ggml/src/ggml-common.h(
block_q4_0/block_q4_1のバイト数定義) - llama.cpp — tools/quantize/README.md(Llama-3.1-8B の bits/weight 実測表)