跳至主要内容

M02-1 | Observer 動物園:同一份資料,不同 observer 算出什麼

Open In Colab

observer 只做兩件事:

affine 量化的公式(範圍先擴張到包含 0,再均分成 256 格)在 M01 概覽 已經推過,這裡不重講。 這本 notebook 比較的是「範圍怎麼選」:

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) * scale

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 < 7
relu(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 在執行時才算,校準時不需要記錄範圍;它只標記這條邊要量化