Skip to content

量子化(int8 / int4)

実装: llm/quant/ / 実行: go test ./llm/quant/

重みを整数に写せばメモリは 1/4 から 1/8 になる。仕組みは値の範囲を格子に合わせる係数を決めて丸めるだけで、難しさはその係数をどの単位で配るかに全部出る。LLM の活性にはごく一部だけ桁違いに大きい成分があり、全体で1つにするとそこに合わせて格子が粗くなり、大多数が潰れる。区切りを細かくする、桁違いのものを抜いて別に持つ、効きの大きい列に厚く配る、の3つで避ける。

この章で作るもの

重みが公開されているモデルは手元で動かせる。ただし Llama 3 70B を fp32 で動かすには 280GB のメモリが要る。コンシューマ GPU の VRAM は 24GB 程度で、そのままでは載らない。量子化はこの壁を崩す。各重みを 32bit の浮動小数から 8bit や 4bit の整数に写せば、メモリは 1/4〜1/8 になり、4bit 量子化した 70B は 35GB 前後まで縮んで現実的な範囲に入る。

原理は素朴だ。連続値である重みを、整数の格子点に丸める。格子の間隔(scale)を決めれば、実数と整数コードは相互に変換できる。容量を得るために精度を手放す取り替えで、ディスクとページでバイト単位のレイアウトを詰めたのと同じ種類の判断になる。

実数の重み        -0.8      -0.3   0   0.25        0.8
                   │         │    │    │           │
int8 の格子 ───────┼────┼────┼────┼────┼────┼──────┼──  scale = 0.8/127
(-127〜127)      -127      -48    0   40         127

復元: code × scale。丸めで最大 scale/2 の誤差
メモリ: fp32 4byte → int8 1byte(1/4)
対称量子化。max|x| を整数の最大コードに合わせて格子間隔 scale を決め、各値を最寄りの格子点に丸める。復元は code×scale で、丸めた分だけ誤差が出る

丸めるところまでは短く書ける。難しさは、その scale をどの単位で配るかに全部出る。

順に見ていく。

  1. scale を決めて丸めるだけ: 誤差は格子間隔の半分以内に収まる。ビットを増やすと格子が倍になる
  2. 外れ値が scale を潰す: LLM にはごく一部だけ桁違いの値があり、そこに合わせると大多数が潰れる
  3. scale は積の外に括り出せる: だから整数のまま積が取れる。メモリだけでなく計算も速くなる

① scale を決めて丸めるだけ

まず対称量子化。max|x| を整数コードの最大値に合わせて scale を決め、各値を丸める:

go

// Quantized は量子化した 1 次元データ。Codes は整数コード、Scale は復元係数、
// Zero は非対称量子化のゼロ点(対称なら 0)。
type Quantized struct {
	Codes []int
	Scale float64
	Zero  int
}

// QuantizeSymmetric は対称量子化。max|x| を整数の最大コードに合わせ、
// 0 を厳密にコード 0 へ写す。重み(0 対称に分布しがち)に向く。
func QuantizeSymmetric(x []float64, bits int) *Quantized {
	qmax := (1 << (bits - 1)) - 1 // 例: 8bit → 127
	maxAbs := 0.0
	for _, v := range x {
		if a := math.Abs(v); a > maxAbs {
			maxAbs = a
		}
	}
	scale := maxAbs / float64(qmax)
	if scale == 0 {
		scale = 1 // 全ゼロ入力のフォールバック(0除算回避)
	}
	codes := make([]int, len(x))
	for i, v := range x {
		codes[i] = clampInt(int(math.Round(v/scale)), -qmax, qmax)
	}
	return &Quantized{Codes: codes, Scale: scale, Zero: 0}
}

テストで固定したのは2点だ。まず復元誤差が格子間隔 scale の半分を超えないこと。最近傍に丸めるので、どの値も最寄りの格子点までの距離は半間隔以内に収まる。次に 0 が厳密にコード 0 に写ること。対称量子化は 0 を保存するので、バイアスやゼロ埋めが量子化でずれない。

ビット数と誤差の関係も測れる。ビットを1増やすと格子点が倍になり間隔が半分になるので、誤差はビット数に対して指数的に下がる。メモリはビット数に線形なので、ここに「どこで刻むか」の判断が生まれる。

重みは 0 対称に分布しがちだが、活性値(層の途中を流れる値。特に RMSNormとSwiGLU の後や ReLU 系の出力)は片側に偏る。0.2〜1.0 のような範囲に対称量子化を使うと、負側のコード域が丸ごと無駄になる。非対称量子化は [min, max] を整数域全体に写してこれを避ける:

go

// QuantizeAsymmetric は非対称量子化。[min, max] を [0, 2^bits-1] に写す。
// 片側に偏った分布(活性値など、0 以上に固まる)で全コード域を使え、
// 対称より誤差が小さくなる。
func QuantizeAsymmetric(x []float64, bits int) *Quantized {
	qmax := (1 << bits) - 1 // 例: 8bit → 255
	lo, hi := x[0], x[0]
	for _, v := range x {
		if v < lo {
			lo = v
		}
		if v > hi {
			hi = v
		}
	}
	scale := (hi - lo) / float64(qmax)
	if scale == 0 {
		scale = 1
	}
	zero := int(math.Round(-lo / scale)) // 実数 0 に対応するコード
	codes := make([]int, len(x))
	for i, v := range x {
		codes[i] = clampInt(int(math.Round(v/scale))+zero, 0, qmax)
	}
	return &Quantized{Codes: codes, Scale: scale, Zero: zero}
}

// Dequantize は整数コードを実数へ戻す。x' = (code - zero) · scale。
func (q *Quantized) Dequantize() []float64 {
	out := make([]float64, len(q.Codes))
	for i, c := range q.Codes {
		out[i] = float64(c-q.Zero) * q.Scale
	}
	return out
}

ゼロ点(zero)が加わるのがポイントで、実数の 0 がどのコードに対応するかを覚えておく。復元は (code − zero) × scale になる。テストでは偏った分布 [0.2, 1.0] で、非対称が対称より誤差が小さいことを固定した。全コード域を使う分だけ格子が細かくなる。

一般に重みは対称、活性は非対称という使い分けになる。重みは静的に一度量子化すればよい(PTQ、学習後量子化)が、活性は入力ごとに範囲が変わるので実行時に scale を決めるか、代表データで事前に統計を取る(キャリブレーション)必要がある。

動かす

「格子と誤差」は重みの分布を int8 / int4 / int2 の格子に丸め、ビット数を変えると格子が粗くなって誤差が増える様子を見る。「メモリ」はモデルサイズとビット数から必要メモリを計算し、fp32 では載らないモデルが 4bit なら手元の GPU に収まる境界を確かめられる。

デモ量子化(格子とメモリ)int8
格子と誤差メモリint8int4int2
元の重み → 復元値(誤差)256 格子点
-0.82±0.003
-0.61±0.003
-0.44±0.001
-0.30±0.001
-0.12±0.000
0.00±0.000
0.09±0.000
0.25±0.003
0.41±0.001
0.58±0.003
0.77±0.000
0.95±0.000

8bit = 256 個の格子点。max|誤差| = 0.0035。ビットを減らすと格子が粗くなり、丸め誤差が増える。int8 はほぼ無損失、int4 で実用、int2 は品質が急落する

量子化は連続値の重みを整数の格子に丸めるだけ。ビットを減らすほどメモリは線形に減り、 格子は粗くなって誤差は指数的に増える。int8 はほぼ無損失、4bit が実用の主戦場。 fp32 では GPU に載らないモデルが、4bit なら手元で動く。

② 外れ値が scale を潰す

ここからがこの章の中心になる。

scale は max|x| から決まる。つまりいちばん大きい1つの値が、その範囲すべての格子の細かさを決めている。値がだいたい同じ桁に収まっていれば問題は起きない。だが LLM ではそうならない。

単位を細かくする

まず素直な手として、scale を持つ単位を細かくしていく。行列全体で1つ持つか、行ごとに持つか:

go

// QuantizedMatrix は行ごとに独立の scale を持つ量子化行列(per-channel)。
type QuantizedMatrix struct {
	rows       [][]int
	perChannel []float64 // 行ごとの scale(per-tensor なら全行同一)
}

// QuantizeMatrixPerTensor は行列全体で 1 つの scale を使う(per-tensor)。
// スケールがまちまちな行があると、大きい行に引っ張られて小さい行の分解能が潰れる。
func QuantizeMatrixPerTensor(rows [][]float64, bits int) *QuantizedMatrix {
	qmax := (1 << (bits - 1)) - 1
	maxAbs := 0.0
	for _, r := range rows {
		for _, v := range r {
			if a := math.Abs(v); a > maxAbs {
				maxAbs = a
			}
		}
	}
	scale := maxAbs / float64(qmax)
	if scale == 0 {
		scale = 1
	}
	m := &QuantizedMatrix{}
	for _, r := range rows {
		codes := make([]int, len(r))
		for j, v := range r {
			codes[j] = clampInt(int(math.Round(v/scale)), -qmax, qmax)
		}
		m.rows = append(m.rows, codes)
		m.perChannel = append(m.perChannel, scale)
	}
	return m
}

// QuantizeMatrixPerChannel は行ごとに scale を決める(per-channel)。
// 各行が自分のレンジをフルに使えるので、行間でスケールが違っても精度が保たれる。
// 実物の重み量子化はほぼこの単位(出力チャネルごと)で行う。
func QuantizeMatrixPerChannel(rows [][]float64, bits int) *QuantizedMatrix {
	qmax := (1 << (bits - 1)) - 1
	m := &QuantizedMatrix{}
	for _, r := range rows {
		maxAbs := 0.0
		for _, v := range r {
			if a := math.Abs(v); a > maxAbs {
				maxAbs = a
			}
		}
		scale := maxAbs / float64(qmax)
		if scale == 0 {
			scale = 1
		}
		codes := make([]int, len(r))
		for j, v := range r {
			codes[j] = clampInt(int(math.Round(v/scale)), -qmax, qmax)
		}
		m.rows = append(m.rows, codes)
		m.perChannel = append(m.perChannel, scale)
	}
	return m
}

// DequantizeRow は指定行を実数へ戻す。
func (m *QuantizedMatrix) DequantizeRow(r int) []float64 {
	out := make([]float64, len(m.rows[r]))
	for j, c := range m.rows[r] {
		out[j] = float64(c) * m.perChannel[r]
	}
	return out
}

per-tensor は行列全体で1つの scale を使う。あるチャネルの重みが極端に大きいと、その値に合わせて scale が粗くなり、小さいレンジの行の分解能が潰れる。per-channel は行ごとに scale を持つので、各行が自分のレンジをフルに使える。テストでは、微小レンジの行と巨大レンジの行を混ぜた行列で、per-channel が小さい行の誤差を守ることを固定した。

さらに刻める。1行を 64〜128 要素のグループに分け、グループごとに scale を持つ:

go

// Grouped は Size 要素ごとに独立の scale を持つ量子化データ。
type Grouped struct {
	Codes  []int
	Scales []float64
	Size   int
	bits   int
}

// QuantizeGroupwise は size 要素ごとに scale を決める。
//
// per-channel は行の端から端までで1つの scale なので、行の中に1つでも
// 大きい値があると、行ぜんぶの格子が粗くなる。区切りを細かくすれば、
// 大きい値の影響はその区切りの中だけで止まる。
//
// 実物の 4bit 量子化はほぼこの単位で、区切りは 64 か 128 要素が多い。
func QuantizeGroupwise(x []float64, bits, size int) *Grouped {
	qmax := (1 << (bits - 1)) - 1
	g := &Grouped{Codes: make([]int, len(x)), Size: size, bits: bits}
	for start := 0; start < len(x); start += size {
		end := start + size
		if end > len(x) {
			end = len(x)
		}
		maxAbs := 0.0
		for _, v := range x[start:end] {
			if a := math.Abs(v); a > maxAbs {
				maxAbs = a
			}
		}
		scale := maxAbs / float64(qmax)
		if scale == 0 {
			scale = 1
		}
		for i := start; i < end; i++ {
			g.Codes[i] = clampInt(int(math.Round(x[i]/scale)), -qmax, qmax)
		}
		g.Scales = append(g.Scales, scale)
	}
	return g
}

// Dequantize は整数コードを実数へ戻す。区切りごとの scale を掛ける。
func (g *Grouped) Dequantize() []float64 {
	out := make([]float64, len(g.Codes))
	for i, c := range g.Codes {
		out[i] = float64(c) * g.Scales[i/g.Size]
	}
	return out
}

// BitsPerValue は1要素あたりの実効ビット数。
//
// 区切りごとに scale を fp16 で持つので、その分が上乗せになる。
// 4bit・64要素なら 4 + 16/64 = 4.25 ビット。細かくするほど精度は上がるが、
// 縮めたはずのメモリを scale が食い返す。
func (g *Grouped) BitsPerValue() float64 {
	return float64(g.bits) + 16.0/float64(g.Size)
}

ただし、細かくすればするほど scale 自体の数が増える。scale を fp16 で持つなら、64 要素ごとで1要素あたり 4 + 16/64 = 4.25 ビット。16 要素まで刻むと 5 ビットになり、もはや int4 とは呼べない。外れ値を1つ混ぜた 256 要素で実測するとこうなった:

区切り平均誤差実効ビット
全体で1つ0.497354.000
128 要素0.263764.125
64 要素0.154344.250
32 要素0.097114.500
16 要素0.058865.000

配布されている 4bit 量子化モデルが「4.25 bpw」「4.5 bpw」と半端な数字で表記されるのは、この上乗せが乗っているからだ。

桁が違うと、刻んでも追いつかない

だが LLM の活性で起きているのは、単に「行ごとにレンジが違う」ではない。桁違いに大きい成分が、ごく一部だけ出る

512 要素のうち3個だけを 25 にして、残りは ±1 に収めた並びを int8 で測るとこうなる:

  • 全体で1つの scale: 大多数の平均誤差 0.050329
  • 64 要素ごとの scale: 0.020739(2.4倍の改善)
  • 外れ値を抜いて別に持つ: 0.002016(25.0倍の改善)

区切りを細かくしても 2.4 倍にしかならない。外れ値が入った区切りは、やはりその外れ値に合わせて粗くなるからだ。細かくするのは被害の範囲を狭めるだけで、被害そのものは消せない。

抜いてしまうと話が変わる。外れ値を配列から取り除き、元の精度のまま別に持つ:

go

// Mixed は外れ値だけを元の精度で残し、残りを整数にした形。
type Mixed struct {
	// Base は外れ値を抜いた残りの量子化結果。抜いた位置には 0 が入っている。
	Base *Quantized
	// Outlier は添字から元の値へ。ここだけ fp16 のまま持つ。
	Outlier map[int]float64
}

// QuantizeWithOutliers は絶対値が threshold を超える要素を分けてから量子化する。
//
// LLM の活性には、ごく一部だけ桁違いに大きい成分が出る。全体で1つの scale を
// 決めると、その数個に合わせて格子が粗くなり、残りの大多数の分解能が潰れる。
// 桁が違うので、per-channel や区切りを細かくするだけでは追いつかない。
//
// 外れ値を配列から抜いて元の精度のまま持ち、残りだけを量子化すると、
// 格子は残りのレンジに合う。LLM.int8() がしているのはこれになる。
func QuantizeWithOutliers(x []float64, bits int, threshold float64) *Mixed {
	rest := make([]float64, len(x))
	out := map[int]float64{}
	for i, v := range x {
		if math.Abs(v) > threshold {
			out[i] = v
			continue // 抜いた位置は 0 のまま。scale の決定に加わらない
		}
		rest[i] = v
	}
	return &Mixed{Base: QuantizeSymmetric(rest, bits), Outlier: out}
}

// Dequantize は整数の側を戻し、外れ値の位置を元の値で上書きする。
func (m *Mixed) Dequantize() []float64 {
	out := m.Base.Dequantize()
	for i, v := range m.Outlier {
		out[i] = v
	}
	return out
}

// OutlierRatio は外れ値の割合。ここが小さいから成り立つ手になる。
func (m *Mixed) OutlierRatio() float64 {
	if len(m.Base.Codes) == 0 {
		return 0
	}
	return float64(len(m.Outlier)) / float64(len(m.Base.Codes))
}

このときの誤差 0.002016 は、外れ値が最初から無い同じ並びの誤差と一致する。似た値になるのではなく、同じ値になる。外れ値を抜いた残りの最大値は元の並びの最大値と同じなので、scale がまったく同じに決まり、大多数の丸め先が1つも変わらないからだ。そして抜いた分は全体の 0.586% しかないので、そこを fp16 のまま持ってもメモリはほとんど増えない。LLM.int8() がしているのはこれだ。

  512 要素。3個だけ 25、残りは ±1 に収まっている

  全体で1つの scale   ┌────────────── 25 ──────────────┐
                      格子の間隔は 25/127 = 0.19685
                      ±1 の値がこの粗い格子に丸められる      誤差 0.050329

  64 要素ごと         ┌──┐┌──┐┌─ 25 ─┐┌──┐┌──┐┌──┐┌──┐┌──┐
                      外れ値が入った区切りだけ粗い            誤差 0.020739

  抜いて別に持つ      ┌─────────── 0.9988 ───────────┐
                      + {7:25, 200:25, 431:25} は fp16 のまま
                      格子の間隔は 0.9988/127 = 0.00786      誤差 0.002016

                      外れ値が最初から無い場合も 0.002016
                      → 抜けば、無かったのと完全に同じところへ戻る
いちばん大きい1つが格子の細かさを決める。区切りを細かくすると被害はその区切りで止まるが、消えはしない。抜いてしまえば残りは自分のレンジを使える

効きの大きい列に格子を厚く配る

もう1つ別の考え方がある。丸めの誤差はどの列でも同じだけ出るが、出力への効きは列ごとに違う。活性が大きい列の誤差は、そのぶん増幅されて出力に届く。

だったら、効きの大きい列だけ重みを引き伸ばしておけばよい:

go

// Scaled は列ごとの倍率で引き伸ばしてから量子化した重み。
type Scaled struct {
	Q *Quantized
	S []float64
}

// QuantizeScaled は列ごとの倍率 s で重みを引き伸ばしてから量子化する。
//
// 丸めの誤差は格子間隔の半分で、どの列でも同じだけ出る。だが出力への効きは
// 列ごとに違う。活性が大きい列の誤差は、そのぶん増幅されて出力に届く。
//
// そこで、効きの大きい列だけ重みを s 倍しておき、活性を 1/s 倍しておく。
// 掛けて割るので積は変わらないが、引き伸ばした列は格子の目盛りを s 倍
// 多く使うので、その列の丸め誤差は実質 1/s になる。AWQ の考え方になる。
func QuantizeScaled(w, s []float64, bits int) *Scaled {
	up := make([]float64, len(w))
	for i, v := range w {
		up[i] = v * s[i]
	}
	return &Scaled{Q: QuantizeSymmetric(up, bits), S: append([]float64(nil), s...)}
}

// Dequantize は復元してから倍率で割り戻す。
func (s *Scaled) Dequantize() []float64 {
	out := s.Q.Dequantize()
	for i := range out {
		out[i] /= s.S[i]
	}
	return out
}

重みを s 倍し、活性を 1/s 倍する。掛けて割るので積は変わらない。だが引き伸ばした列は格子の目盛りを s 倍多く使うので、その列の丸め誤差は実質 1/s になる。テストで、活性が大きい3列を4倍に引き伸ばすと、その列の誤差が 0.05000 から 0.01454 に落ちること(4分の1に近い)を固定した。AWQ の考え方になる。

外れ値を抜くのが「潰す側を取り除く」なら、こちらは「守りたい側に多く配る」になる。どちらも、scale を一律に配るのをやめるという同じ判断から出ている。

動かす

下のデモは 512 要素の並びに外れ値を混ぜ、3つの持ち方で大多数の誤差を比べる。外れ値を 0 個にすると差が消えること、1個入れるだけで全体が崩れること、抜けば元に戻ることが見える。

デモ外れ値と scale の配り方int8 ・ 外れ値 3 / 512
外れ値 0外れ値 1外れ値 3外れ値 8int8int4

512 要素の並び。赤い棒が 25、それ以外は ±1 の中に収まっている

scale の持ち方格子の間隔大多数の平均誤差
全体で1つの scale0.196850.05033
64 要素ごとに scale0.00750〜0.196850.02074
外れ値を抜いて別に持つ0.007860.00202
外れ値は 0.6% しかないのに、全体で1つの scale にすると大多数の誤差が 25 倍になる。抜いて別に持つと 0.00202(外れ値が最初から無い場合)まで戻る

比べているのは外れ値そのものの誤差ではなく、外れ値でない大多数の誤差。全体で1つの scale を使うと、 いちばん大きい値に合わせて格子の間隔が決まるので、±1 に収まっている大多数がその粗い格子に丸められる。 64 要素ごとに区切ると被害はその区切りの中で止まり、抜いて別に持つと大多数は自分のレンジで格子を使える。

③ scale は積の外に括り出せる

ここまではメモリの話だった。量子化がもう1つ効くのは計算になる。

行列積の中身は内積の繰り返しで、内積は積の総和になる。量子化した値どうしの積を書き下すと:

Σ (a_i · sa)(b_i · sb) = sa · sb · Σ a_i b_i

scale が総和の外に出る。つまり中は整数の積和だけで済み、浮動小数の掛け算は最後の1回だけになる:

go

// DotCodes は整数コードのまま内積を取る。
//
//	Σ (a_i·sa)(b_i·sb) = sa·sb · Σ a_i b_i
//
// scale は総和の外に括り出せる。だから中は整数の積和だけで済む。
// 量子化がメモリだけでなく計算まで速くする理由がここにある。
//
// 桁あふれの心配も勘定できる。int8 どうしの積は最大 127×127 = 16129 で、
// 4096 項を足しても 6.6×10⁷。int32 の上限 2.1×10⁹ には遠い。
func DotCodes(a, b *Quantized) int {
	acc := 0
	for i := range a.Codes {
		acc += (a.Codes[i] - a.Zero) * (b.Codes[i] - b.Zero)
	}
	return acc
}

// Dot は括り出した scale を最後に1回だけ掛ける。
//
// 復元してから掛けると、要素ごとに浮動小数の積が要る。括り出すと、
// 浮動小数の積は最後の1回だけになる。
func Dot(a, b *Quantized) float64 {
	return float64(DotCodes(a, b)) * a.Scale * b.Scale
}

桁あふれの心配も勘定で片づく。int8 どうしの積は最大 127 × 127 = 16129。4096 項を足しても 6.6 × 10⁷ で、int32 の上限 2.1 × 10⁹ には遠い。13万項ほどまで余裕がある。だから貯め先は int32 でよい。

これが、量子化がメモリだけでなく計算まで速くする理由になる。整数の積和は浮動小数より1命令あたり多く処理でき、GPU の int8 演算器はそのために載っている。

ただし、実装によっては復元してから浮動小数で計算する。その場合の得はメモリと帯域の節約だけに限られる。「量子化したから速くなる」は自動ではなく、整数のまま計算する経路があって初めて成り立つ。

設計の観点

  • 難しさは scale の配り方に集まる: 丸めそのものは自明で、単位をどう切るかが精度を決める
  • 一律に配るのをやめる: 潰す側を抜く(外れ値分離)か、守る側に多く配る(引き伸ばし)か
  • 細かくする代償を数える: scale の数が増えると実効ビットが上がる。4bit と言いながら 4.5 ビットのことがある
  • 静的か動的かで手が変わる: 重みは静的なので一度量子化すればよい。活性は入力ごとに変わるのでキャリブレーションが要る
  • メモリと計算は別の得: 整数のまま積を取る経路が無ければ、速さは帯域の改善に限られる
  • どこまで刻めるか: int8 はほぼ無損失、int4 で実用、int2 は品質が急落する。モデルの冗長性が吸収できる下限がある
  • PTQ と QAT: 学習後にかける PTQ は手軽だが精度が落ちる。量子化を織り込んで学習する QAT は精度が高いが学習コストがかかる

対照と実例

手法ビットscale の配り方何を解いているか
fp16 / bf1616不要学習・推論の標準。fp32 比で半分
per-tensor int88行列に1ついちばん単純。外れ値に弱い
per-channel int88行に1つ行ごとのレンジ差を吸収
group-wise int44.25〜4.564〜128 要素に1つ被害の範囲を区切りの中で止める
LLM.int8()8(+外れ値fp16)行に1つ + 分離桁違いの成分を抜いて残りを守る
AWQ4区切り + 列の引き伸ばし効きの大きい列に格子を厚く配る
GPTQ3〜4区切り + 誤差補正丸めた誤差を残りの重みで打ち消す
QLoRA(NF4)4区切り + 分布に合わせた格子重みの分布そのものに格子を合わせる

裏どり:

  • LLM.int8()(2022): Dettmers et al.。ある規模を超えたあたりから活性に系統的な外れ値が現れることを示し、それだけ fp16 に残す混合精度で 8bit のほぼ無損失化を達成した。しきい値 6.0 は論文の値
  • GPTQ(2022): Frantar et al.。1つの重みを丸めたあと、その誤差を残りの重みに配って打ち消す。二次情報(ヘッセ行列)で配り方を決める
  • AWQ(2023): Lin et al.。重みではなく活性の大きさで重要な列を選ぶ。重み側だけ見ても分からないところが要点
  • QLoRA(2023): Dettmers et al.。4bit 量子化と LoRA を組み、単一 GPU での大規模モデル微調整を可能にした。NF4 は正規分布を仮定して格子点を等確率に置く
  • 外れ値の位置: 入力ごとにばらばらに出るのではなく、特定の次元に固まって現れる。だから事前に見つけて分けておける

簡略化したこと

  • 誤差補正なし: GPTQ のように丸めた誤差を残りの重みへ配る処理は入れていない。ここは二次情報が要る
  • NF4 なし: 格子点は等間隔。分布に合わせて不等間隔に置く話は表で触れるに留めた
  • 外れ値の見つけ方が固定しきい値: 絶対値だけで判定する。実物は次元ごとの統計を取る
  • 引き伸ばしの倍率が手動: AWQ は倍率を探索で決める。ここでは与えた倍率で効果だけを確かめている
  • 活性のキャリブレーションなし: 代表データから活性のレンジを推定する手順は入れていない
  • int8 の演算器を使っていない: 整数のまま積を取る形は書いたが、Go の int で回すだけ

参考資料