import torch
from common.graph_utils import sqnr
from common.pt2e import (
HistogramObserver,
MinMaxObserver,
MovingAverageMinMaxObserver,
PerChannelMinMaxObserver,
PlaceholderObserver,
get_xnnpack_quantizer,
quiet,
)
from common.viz import table
quiet()
_ = torch.manual_seed(0)
QMIN, QMAX = -128, 127
def minmax(symmetric=False):
"""int8 的 MinMaxObserver;symmetric=False 是 affine。"""
qscheme = torch.per_tensor_symmetric if symmetric else torch.per_tensor_affine
return MinMaxObserver(dtype=torch.int8, qscheme=qscheme, quant_min=QMIN, quant_max=QMAX)
def qdq(x, scale, zp, qmin=QMIN, qmax=QMAX):
"""quantize → dequantize:量化後再還原成浮點,看損失了多少。"""
q = (x / scale).round() + zp
return (q.clamp(qmin, qmax) - zp) * scaleM02-1 | Observer 動物園:同一份資料,不同 observer 算出什麼
observer 只做兩件事:
forward(x):記下x的數值範圍(min/max 或 histogram),然後把x原樣傳出去。calculate_qparams():依目前記下的範圍算出 scale 與 zero point。
affine 量化的公式(範圍先擴張到包含 0,再均分成 256 格)在 M01 概覽 已經推過,這裡不重講。 這本 notebook 比較的是「範圍怎麼選」:
- A. symmetric 與 affine:同一份資料,scale 與 zero point 差在哪。
- B. 三種資料分布 × 三種 observer(MinMax 的 affine/symmetric、Histogram)。
- C. MinMax 與 MovingAverage:校準途中出現一個異常的 batch。
- D. per-channel:scale 變成向量,
ch_axis要選對。 - E. 實際的 quantizer 用哪種 observer:PTQ、QAT、dynamic 各不同;dynamic 用的
PlaceholderObserver為什麼什麼都不記。
A. symmetric 與 affine
symmetric 規定 zero point 固定是 0,範圍取正負兩邊絕對值較大的那一邊:
\[ s = \frac{\max(|x_{\min}|,\ |x_{\max}|)}{(q_{\max} - q_{\min})/2},\qquad z = 0 \tag{1} \]
分母是 127.5:int8 的 256 格切成兩半,正負半軸各一半。affine 則把 256 格全部用在 \([x_{\min}, x_{\max}]\)(擴張到含 0)上。
x = torch.randn(20000)
aff, sym = minmax(), minmax(symmetric=True)
aff(x)
sym(x)
s_aff, z_aff = aff.calculate_qparams()
s_sym, z_sym = sym.calculate_qparams()
mn, mx = aff.min_val, aff.max_val
s_sym_hand = torch.maximum(-mn, mx) / ((QMAX - QMIN) / 2) # 公式 (1)
table([["affine", f"{float(s_aff):.6f}", int(z_aff), "M01 已驗證"],
["symmetric", f"{float(s_sym):.6f}", int(z_sym), f"公式 (1):{float(s_sym_hand):.6f}"]],
["方案", "scale", "zero point", "手算"])| 方案 | scale | zero point | 手算 |
|---|---|---|---|
| affine | 0.033117 | 3 | M01 已驗證 |
| symmetric | 0.034065 | 0 | 公式 (1):0.034065 |
assert int(z_sym) == 0 and torch.allclose(s_sym, s_sym_hand)
assert s_sym > s_aff # 同一份資料,symmetric 的格子比較粗這份資料大致對稱,所以兩者只差一點:symmetric 的 scale 稍大(格子稍粗),因為較短的那半軸有一段沒用到。 資料越不對稱,差距越大,B 段的 relu 就是極端的例子。
B. 資料分布 × observer
比較三種 observer:MinMax 的 affine 與 symmetric,以及 HistogramObserver(E 段會看到,本專案用的 XNNPACKQuantizer 預設設定在 PTQ 時對 activation 用它)。 後者記錄數值分布的 histogram,再搜尋一個範圍讓量化誤差變小,不一定直接用 min/max(torchao 的 docstring 說這是「L2 誤差最小化的近似」)。 範圍縮小為什麼可能讓誤差變小:誤差分成 discretization(格子的粗細)與 clipping(範圍外的值被截斷)兩項,範圍越大前者越大、後者越小。推導與實驗見部落格〈Quantization Error (Case with Clipping)〉;上集〈Case without Clipping〉推導 discretization 誤差。
三種資料:
- gaussian:大致對稱。
- relu(gaussian):relu 之後的 activation,全部 ≥ 0。
- heavy tail:Student-t(自由度 3),大部分值很小,偶爾冒出很大的值。真實的 activation 常常長這樣。
「可表示範圍」是 int8 的 \([q_{\min}, q_{\max}]\) 還原回浮點後的範圍;SQNR 是 qdq 還原後對原值的訊號雜訊比,越高越好。
data = {
"gaussian": torch.randn(100_000),
"relu(gaussian)": torch.relu(torch.randn(100_000)),
"heavy tail t(3)": torch.distributions.StudentT(3.0).sample((100_000,)),
}
observers = {
"MinMax affine": lambda: minmax(),
"MinMax symmetric": lambda: minmax(symmetric=True),
"Histogram affine": lambda: HistogramObserver(dtype=torch.int8, qscheme=torch.per_tensor_affine,
quant_min=QMIN, quant_max=QMAX),
}
result, hi_bound = {}, {}
rows = []
for dname, d in data.items():
for oname, make in observers.items():
obs = make()
obs(d)
s, z = (float(v) for v in obs.calculate_qparams())
result[dname, oname] = sqnr(d, qdq(d, s, z))
hi_bound[dname, oname] = (QMAX - z) * s
rows.append([dname, oname, f"{s:.4f}", int(z),
f"[{(QMIN - z) * s:.2f}, {(QMAX - z) * s:.2f}]", f"{result[dname, oname]:.2f}"])
table(rows, ["資料", "observer", "scale", "zp", "可表示範圍", "SQNR (dB)"])| 資料 | observer | scale | zp | 可表示範圍 | SQNR (dB) |
|---|---|---|---|---|---|
| gaussian | MinMax affine | 0.0346 | -5 | [-4.25, 4.57] | 40.01 |
| gaussian | MinMax symmetric | 0.0358 | 0 | [-4.58, 4.54] | 39.69 |
| gaussian | Histogram affine | 0.0332 | 0 | [-4.25, 4.22] | 40.30 |
| relu(gaussian) | MinMax affine | 0.0183 | -128 | [0.00, 4.66] | 45.56 |
| relu(gaussian) | MinMax symmetric | 0.0365 | 0 | [-4.68, 4.64] | 39.54 |
| relu(gaussian) | Histogram affine | 0.0150 | -128 | [0.00, 3.83] | 44.39 |
| heavy tail t(3) | MinMax affine | 0.3740 | -8 | [-44.88, 50.49] | 23.83 |
| heavy tail t(3) | MinMax symmetric | 0.3973 | 0 | [-50.85, 50.46] | 23.29 |
| heavy tail t(3) | Histogram affine | 0.3189 | 12 | [-44.64, 36.67] | 24.32 |
relu_gap = result["relu(gaussian)", "MinMax affine"] - result["relu(gaussian)", "MinMax symmetric"]
print(f"relu(gaussian):affine 比 symmetric 高 {relu_gap:.2f} dB")
assert 5 < relu_gap < 7relu(gaussian):affine 比 symmetric 高 6.02 dB
- relu(gaussian):affine 的 zero point 是 −128,整個 int8 範圍都用在 \([0, x_{\max}]\); symmetric 的負半軸完全浪費,等於少了 1 bit。每少 1 bit,SQNR 約少 6 dB,和上面量到的差距一致。 這是 activation 常用 affine 的原因之一。weight 則常用 symmetric,因為 zero point = 0 讓整數運算少一個交叉項(M10)。
- heavy tail:MinMax 完全不裁,scale 被少數極端值撐大,大部分的值擠在少數幾格裡。
Histogram 和 MinMax affine 比,範圍與 SQNR 各差多少:
shrunk = all(hi_bound[d, "Histogram affine"] < hi_bound[d, "MinMax affine"] for d in data)
better = [d for d in data if result[d, "Histogram affine"] > result[d, "MinMax affine"]]
print(f"三種資料的上界,Histogram 都比 MinMax 小? {shrunk}")
print(f"SQNR 比 MinMax affine 高的資料:{better}")
assert shrunk and 0 < len(better) < len(data)三種資料的上界,Histogram 都比 MinMax 小? True
SQNR 比 MinMax affine 高的資料:['gaussian', 'heavy tail t(3)']
Histogram 會把範圍往內收一點,裁掉少量極端值,但 SQNR 不一定比 MinMax 高,有的資料反而較低。 它用 histogram 近似量化誤差再搜尋範圍,是近似解,不保證比 MinMax 好。
C. MinMax 與 MovingAverage:校準途中的 spike
連續餵 20 個 batch,第 3 個 batch 的幅度是其他的 10 倍。 MovingAverageMinMaxObserver 第一個 batch 直接用該 batch 的 min/max,之後每個 batch 這樣更新(\(c\) 是 averaging_constant):
\[ x_{\min} \leftarrow x_{\min} + c\,(x_{\min}^{\text{batch}} - x_{\min}) \tag{2} \]
\(x_{\max}\) 也一樣。MinMax 則是一路取最小/最大。
mm = minmax()
c = 0.1
ma = MovingAverageMinMaxObserver(averaging_constant=c, dtype=torch.int8, qscheme=torch.per_tensor_affine,
quant_min=QMIN, quant_max=QMAX)
hand_min = None
rows, mm_hist = [], []
for i in range(20):
amp = 10.0 if i == 3 else 1.0
batch = torch.randn(1000) * amp
mm(batch)
ma(batch)
b_min = batch.min()
hand_min = b_min if hand_min is None else hand_min + c * (b_min - hand_min) # 公式 (2)
mm_hist.append(float(mm.min_val))
if i in (0, 2, 3, 4, 10, 19):
rows.append([i, f"{amp:.0f}x", f"[{float(mm.min_val):.2f}, {float(mm.max_val):.2f}]",
f"[{float(ma.min_val):.2f}, {float(ma.max_val):.2f}]"])
table(rows, ["batch", "本批幅度", "MinMax 的範圍", "MovingAverage 的範圍"])| batch | 本批幅度 | MinMax 的範圍 | MovingAverage 的範圍 |
|---|---|---|---|
| 0 | 1x | [-3.00, 3.22] | [-3.00, 3.22] |
| 2 | 1x | [-3.25, 3.41] | [-3.02, 3.21] |
| 3 | 10x | [-29.90, 35.58] | [-5.70, 6.45] |
| 4 | 1x | [-29.90, 35.58] | [-5.45, 6.10] |
| 10 | 1x | [-29.90, 35.58] | [-4.47, 4.73] |
| 19 | 1x | [-29.90, 35.58] | [-3.77, 3.79] |
assert hand_min is not None and torch.allclose(ma.min_val, hand_min) # 公式 (2) 與 observer 相同
assert mm_hist[3:] == [mm_hist[3]] * 17 # MinMax 在 spike 之後就不再變- MinMax 的範圍只會變大、不會變小:一個 spike 就永久撐大。
- MovingAverage 依公式 (2) 慢慢往新的 batch 靠,spike 會被之後的 batch 沖淡。代價是範圍依賴 batch 的順序,越後面的 batch 影響越大。
XNNPACK 的 QAT 設定,activation 用的就是 moving average(E 段的表)。 推論(torchao 原始碼沒有寫原因):訓練時 weight 一直在變,activation 的範圍也跟著變,需要一個「跟得上」的估計。
D. per-channel:scale 是向量
conv 的 weight 形狀是 (out_ch, in_ch, kh, kw)。per-channel 量化讓每個 channel 有自己的 scale,ch_axis 指定沿哪一維切。 把第 0 個輸出 channel 放大 5 倍,模擬各 channel 範圍差很多的情況:
conv_w = torch.randn(4, 3, 3, 3)
conv_w[0] *= 5.0
rows, per_axis = [], {}
for axis in (0, 1):
obs = PerChannelMinMaxObserver(ch_axis=axis, dtype=torch.int8, qscheme=torch.per_channel_symmetric,
quant_min=-127, quant_max=127)
obs(conv_w)
s, z = obs.calculate_qparams()
shape = [1] * conv_w.dim()
shape[axis] = -1 # 讓 scale 沿著 axis 廣播
per_axis[axis] = sqnr(conv_w, qdq(conv_w, s.view(shape), z.view(shape), -127, 127))
rows.append([axis, "輸出 channel" if axis == 0 else "輸入 channel", tuple(s.shape), f"{per_axis[axis]:.2f}"])
table(rows, ["ch_axis", "沿著", "scale 的形狀", "SQNR (dB)"])| ch_axis | 沿著 | scale 的形狀 | SQNR (dB) |
|---|---|---|---|
| 0 | 輸出 channel | (4,) | 46.16 |
| 1 | 輸入 channel | (3,) | 42.11 |
assert per_axis[0] > per_axis[1]放大的是輸出 channel,所以 ch_axis=0 才能讓那個 channel 單獨用大的 scale,其他 channel 不受拖累。 PyTorch 的 conv 與 linear weight,per-channel 都是指輸出 channel(ch_axis=0)。給錯軸不會報錯,只會默默變差。
E. quantizer 實際用哪種 observer:PTQ、QAT、dynamic 各不同
A~D 比較的都是「校準時記錄範圍」的 observer。實際的 quantizer 會依量化方式選不同的類別。 下表是 XNNPACKQuantizer(torchao 測試版)三種設定下,activation 用的類別,物件直接由 quantizer 設定裡的 constructor 建出來:
rows = []
for label, kw in [("PTQ(預設)", {}), ("QAT", {"is_qat": True}), ("dynamic", {"is_dynamic": True})]:
quantizer, _ = get_xnnpack_quantizer(is_per_channel=True, **kw)
cfg = quantizer.global_config
assert cfg is not None and cfg.input_activation is not None
act = cfg.input_activation.observer_or_fake_quant_ctr()
inner = getattr(act, "activation_post_process", None) # FakeQuantize 類別裡面包著一個 observer
rows.append([label, type(act).__name__, type(inner).__name__ if inner is not None else "—"])
table(rows, ["設定", "activation 用的類別", "裡面包的 observer"])| 設定 | activation 用的類別 | 裡面包的 observer |
|---|---|---|
| PTQ(預設) | HistogramObserver | — |
| QAT | FusedMovingAvgObsFakeQuantize | MovingAverageMinMaxObserver |
| dynamic | PlaceholderObserver | — |
assert [r[1] for r in rows] == ["HistogramObserver", "FusedMovingAvgObsFakeQuantize", "PlaceholderObserver"]
assert rows[1][2] == "MovingAverageMinMaxObserver"- PTQ 用
HistogramObserver(B 段比較過)。 - QAT 用的是 fake quant 類別,裡面包著 MovingAverage observer(C 段;fake quant 是什麼,見 M02-2)。
- dynamic 用的
PlaceholderObserver是新面孔,下面說明。
這張表是這份 quantizer 設定的選擇,不是 PTQ 的規定。observer 的種類寫在每個 QuantizationSpec 的 observer_or_fake_quant_ctr 裡,由寫 quantizer 的人決定,同一個後端也可以換掉。在 torchao 0.18、executorch 1.5.0 上查過,executorch 版 XNNPACKQuantizer 與 X86InductorQuantizer 的預設設定,PTQ 的 activation 也是 HistogramObserver;M10 的自訂 VPU quantizer 則改用把 scale 取成 2 的冪次的 MinMax observer。
dynamic 量化為什麼用 PlaceholderObserver
dynamic 量化的 activation scale 不在校準時決定,而是每次執行時依當下的輸入現算(M01-3 看過 convert 後的 choose_qparams)。 所以這條邊不需要記錄任何範圍,只需要一個標記:「這裡要量化成某個 dtype」。PlaceholderObserver 就是這個標記。 和 MinMaxObserver 比一比:
y = torch.randn(1000)
mm_obs = minmax()
ph = PlaceholderObserver(dtype=torch.float32)
def qparams_or_error(obs):
try:
s, z = obs.calculate_qparams()
return f"scale={float(s):.4f}, zp={int(z)}"
except Exception as e:
return f"報錯:{e}"
rows = []
for name, obs in [("MinMaxObserver", mm_obs), ("PlaceholderObserver", ph)]:
out = obs(y)
rows.append([name, torch.equal(out, y), ", ".join(b for b, _ in obs.named_buffers()) or "(沒有)",
qparams_or_error(obs)])
table(rows, ["observer", "forward 輸出 == 輸入", "記錄用的 buffer", "calculate_qparams()"])| observer | forward 輸出 == 輸入 | 記錄用的 buffer | calculate_qparams() |
|---|---|---|---|
| MinMaxObserver | True | eps, min_val, max_val | scale=0.0245, zp=6 |
| PlaceholderObserver | True | (沒有) | 報錯:calculate_qparams should not be called for PlaceholderObserver |
assert all(r[1] for r in rows) # 兩者的 forward 都原樣傳出
assert rows[1][2] == "(沒有)" and rows[1][3].startswith("報錯") # Placeholder 什麼都不記、算不出 scale兩者的 forward 都把輸入原樣傳出,所以插了 observer 的圖輸出不變。差別在記錄:MinMaxObserver 用 min_val、max_val 記下範圍,算得出 scale; PlaceholderObserver 什麼都不記,呼叫 calculate_qparams() 會直接報錯。convert 時它變成執行時才算 scale 的 choose_qparams,而不是寫死的常數。
小結
| 問題 | 答案 |
|---|---|
| symmetric 和 affine 差在哪 | symmetric 的 zero point 固定是 0,正負半軸各分一半的格子;資料偏一邊(relu 之後)時會浪費約 1 bit |
| Histogram 和 MinMax 差在哪 | Histogram 會把範圍往內收、裁掉少量極端值,但 SQNR 不一定比 MinMax 高 |
| MinMax vs MovingAverage | MinMax 一個 spike 就永久撐大;MovingAverage 會慢慢沖淡,但依賴 batch 順序 |
| per-channel | 每個輸出 channel 一組 scale(ch_axis=0),軸給錯不會報錯 |
| observer 會改數值嗎 | 不會,forward 原樣傳出。會改數值的是 fake quant(M02-2) |
dynamic 為什麼用 PlaceholderObserver |
scale 在執行時才算,校準時不需要記錄範圍;它只標記這條邊要量化 |