import copy
from typing import Any, cast
import torch
from common.graph_utils import observer_nodes
from common.models import example_inputs, get_model
from common.pt2e import (
DerivedQuantizationSpec,
FixedQParamsQuantizationSpec,
Quantizer,
annotate_input_qspec_map,
annotate_output_qspec,
convert_pt2e,
export_module,
prepare_pt2e,
quiet,
)
from common.qspecs import act_spec, weight_spec
from common.viz import draw_stages, table
quiet()
_ = torch.manual_seed(0)
CONV2D = torch.ops.aten.conv2d.default
SIGMOID = torch.ops.aten.sigmoid.defaultM03-5 | 不靠校準的 qparams:Derived 與 FixedQParams
到目前為止,每個位置的 scale 與 zero point 都由 observer 看資料算出來。但有些 tensor 的 qparams 不該靠校準決定:
| spec | qparams 從哪來 | 例子 |
|---|---|---|
DerivedQuantizationSpec |
由其他位置的 observer 算出來 | conv 的 bias:scale = activation scale × weight scale |
FixedQParamsQuantizationSpec |
寫死在 spec 裡,與資料無關 | sigmoid 的輸出:值域固定在 (0, 1);模型輸出要符合下游的定點格式(例如 Q3.12) |
- A. Derived:bias 量化成 int32,scale 由 activation 與 weight 推導。
- B. FixedQParams:sigmoid 的輸出用固定的 scale,不受校準資料影響;以及模型輸出直接交給整數運算的 DSP 時,怎麼用它對齊格式。
A. Derived:bias 的 scale 由 activation 與 weight 推導
整數 conv 代入 \(x = s_x(q_x - z_x)\)、\(w = s_w(q_w - z_w)\),\(z_x\)、\(z_w\) 是各自的 zero point。 一個 output channel 的乘加展開後,公因子是 \(s_x s_w\)(per-channel weight 時,每個 output channel 一個 \(s_w\)):
\[ \sum x w = s_x s_w \Big( \sum q_x q_w - z_w \sum q_x - z_x \sum q_w + N z_x z_w \Big) \]
\(N\) 是這個 channel 加總的項數。bias 用同一個 scale 量化成 int32,zero point 取 0,才能把 \(q_b\) 直接加進括號:
\[ y \approx s_x s_w \Big( \sum q_x q_w - z_w \sum q_x - z_x \sum q_w + N z_x z_w + q_b \Big), \qquad q_b = \operatorname{round}\!\Big(\frac{b}{s_x s_w}\Big) \tag{1} \]
\(z_x\)、\(z_w\) 只在括號的整數項裡,不會算出一個 bias zero point。 常數項 \(N z_x z_w\)、\(z_x \sum q_w\) 可以另折進 \(q_b\);\(z_w \sum q_x\) 依賴當下的輸入,所以 weight 通常取 symmetric(\(z_w = 0\))。 下面的 weight_spec() 就是這個設定。式 (1) 的 \(q_b\) 只量化 bias 本身,還沒有折進那些常數項。
這是整數推論常見的做法,實際要求依後端而定(M10 的 VPU 範例就要求 int32 bias)。 既然 bias 的 scale 由別人決定,它就不能自己校準,只能推導:DerivedQuantizationSpec 的 derived_from 列出依賴的位置, derive_qparams_fn 收到那些位置的 observer(順序與 derived_from 相同),回傳 (scale, zero point)。
def derive_bias_qparams(obs_or_fqs):
act_scale, _ = obs_or_fqs[0].calculate_qparams() # shape [1]
weight_scale, _ = obs_or_fqs[1].calculate_qparams() # shape [out_channels]
bias_scale = act_scale * weight_scale
return bias_scale, torch.zeros_like(bias_scale, dtype=torch.int32)
class ConvBiasQuantizer(Quantizer):
def annotate(self, model):
for node in model.graph.nodes:
if node.op != "call_function" or node.target != CONV2D:
continue
activation, weight, bias = node.args[0], node.args[1], node.args[2]
annotate_input_qspec_map(node, activation, act_spec())
annotate_input_qspec_map(node, weight, weight_spec())
annotate_input_qspec_map(node, bias, DerivedQuantizationSpec(
derived_from=[(activation, node), (weight, node)], # 依賴這兩條邊上的 observer
derive_qparams_fn=derive_bias_qparams,
dtype=torch.int32,
quant_min=-(2**31),
quant_max=2**31 - 1,
qscheme=torch.per_channel_symmetric,
ch_axis=0,
))
annotate_output_qspec(node, act_spec())
return model
def validate(self, model):
passprepare、校準,再 convert。第一個 row 是校準後的 prepare 圖,第二個 row 是 convert 之後:
model = get_model("conv_relu").eval()
prepared = prepare_pt2e(export_module(model, example_inputs(4)), ConvBiasQuantizer())
with torch.no_grad():
for _ in range(8):
prepared(torch.randn(4, 3, 16, 16))
converted = convert_pt2e(copy.deepcopy(prepared))
conv = next(n for n in prepared.graph.nodes if n.target == CONV2D)
draw_stages([(prepared, "prepare_pt2e(校準後)"), (converted, "convert_pt2e")], focus="conv2d", per_row=1,
notes=[{conv.args[2].name: "Derived:不看資料"}, {}], title="conv 的 bias:Derived observer → int32 常數")請看 bias 這條路:
- prepare 之後,bias 前面也有一個 observer,類別是
DerivedObserverOrFakeQuantize。它不記錄任何數值,calculate_qparams()被呼叫時才去問另外兩個 observer。 - convert 之後,bias 變成一個 int32 常數(右邊
dequantize_per_channel標籤裡的int32),經過 dequantize 再進 conv。weight 同樣變成 int8 常數。
驗證式 (1):bias observer 給出的 scale 等於 activation scale × weight scale:
# get_submodule 的回傳型別是 nn.Module;calculate_qparams 是 observer 的方法,用 Any 表達
act_obs, weight_obs, bias_obs = (cast(Any, prepared.get_submodule(a.target)) for a in conv.args[:3])
act_scale = act_obs.calculate_qparams()[0]
weight_scale = weight_obs.calculate_qparams()[0]
bias_scale = bias_obs.calculate_qparams()[0]
print("bias observer 的類別:", type(bias_obs).__name__)
print(f"scale 個數:activation {act_scale.numel()}、weight {weight_scale.numel()}、bias {bias_scale.numel()}")
print("bias scale 與 activation × weight 的最大差:", float((bias_scale - act_scale * weight_scale).abs().max()))
assert torch.equal(bias_scale, act_scale * weight_scale)bias observer 的類別: DerivedObserverOrFakeQuantize
scale 個數:activation 1、weight 8、bias 8
bias scale 與 activation × weight 的最大差: 0.0
再從 convert 之後的圖取出 int32 bias,乘回 scale,和原本的 fp32 bias 比:
bias_int32 = next(b for name, b in converted.named_buffers() if name.startswith("_frozen_param") and b.dtype == torch.int32)
restored = bias_int32.to(torch.float32) * bias_scale
error = (restored - model.conv.bias.detach()).abs()
print(f"int32 bias 的 shape:{tuple(bias_int32.shape)}")
print(f"還原後與原 bias 的最大誤差:{float(error.max()):.2e}(半個 scale 的最大值:{float(bias_scale.max() / 2):.2e})")
assert (error <= bias_scale / 2 + 1e-12).all()int32 bias 的 shape:(8,)
還原後與原 bias 的最大誤差:2.23e-05(半個 scale 的最大值:2.40e-05)
誤差不超過半個 scale,就是式 (1) 的捨入誤差。\(s_x s_w\) 很小,所以 int32 bias 幾乎沒有精度損失。
B. FixedQParams:值域已知或格式已規定時,不需要校準
sigmoid 的輸出一定落在 (0, 1)。用 uint8、scale \(= 1/256\)、zero point \(= 0\) 就能涵蓋 \([0, 255/256]\),不必看資料:
class SigmoidNet(torch.nn.Module):
def forward(self, x):
return torch.sigmoid(x)
class SigmoidQuantizer(Quantizer):
"""只量化 sigmoid 的輸出(輸入保持 fp32,誤差只來自輸出的量化)。"""
def __init__(self, output_spec):
self.output_spec = output_spec
def annotate(self, model):
for node in model.graph.nodes:
if node.op == "call_function" and node.target == SIGMOID:
annotate_output_qspec(node, self.output_spec)
return model
def validate(self, model):
pass
fixed = FixedQParamsQuantizationSpec(
dtype=torch.uint8, scale=1 / 256, zero_point=0, quant_min=0, quant_max=255, qscheme=torch.per_tensor_affine,
)和照常校準的 act_spec()(MinMax)比。故意讓校準資料範圍很小(randn × 0.1),測試資料範圍大得多(randn × 3),看兩種 qparams 在測試資料上的誤差:
gm = export_module(SigmoidNet(), (torch.randn(4, 8),))
calib = [torch.randn(4, 8) * 0.1 for _ in range(8)]
test_x = torch.randn(4, 8) * 3
rows = []
for title, spec in [("校準(MinMaxObserver)", act_spec()), ("固定 qparams", fixed)]:
p = prepare_pt2e(copy.deepcopy(gm), SigmoidQuantizer(spec))
with torch.no_grad():
for x in calib:
p(x)
obs = observer_nodes(p)[0][1]
scale, zp = obs.calculate_qparams()
with torch.no_grad():
err = float((convert_pt2e(p)(test_x) - torch.sigmoid(test_x)).abs().max())
rows.append([title, type(obs).__name__, f"{float(scale):.6f}", int(zp), f"{err:.4f}"])
table(rows, ["輸出的 qparams", "observer 類別", "scale", "zero point", "測試資料的最大誤差"])| 輸出的 qparams | observer 類別 | scale | zero point | 測試資料的最大誤差 |
|---|---|---|---|---|
| 校準(MinMaxObserver) | MinMaxObserver | 0.002261 | -128 | 0.4094 |
| 固定 qparams | FixedQParamsObserver | 0.003906 | 0 | 0.0019 |
calibrated_err, fixed_err = float(rows[0][4]), float(rows[1][4])
assert fixed_err <= 1 / 512 + 1e-6 < calibrated_err- 校準資料的範圍小,sigmoid 的輸出只落在 0.5 附近一小段。MinMax 的範圍擴張到含 0 之後,上限也只比 0.5 大一點;測試資料一大,超過上限的輸出全被截斷,誤差很大。
- 固定 qparams 完全不看資料,誤差只剩捨入,最多半個 scale(\(1/512\))。
適用於輸出值域在數學上已知的 op:sigmoid (0, 1)、tanh (−1, 1)、softmax (0, 1)。 推論(這裡沒有驗證):整數硬體常以查表實作這類 op,固定 qparams 讓表的內容不必依賴校準資料。
另一種用法:輸出格式由下游規定
sigmoid 的 qparams 是由數學上的值域決定的。另一種情況是由下游的格式決定:模型的輸出要直接交給只做整數運算的程式。 例如 speech enhancement 模型輸出的 spectrogram(或 mask),交給後面用定點數運算的 DSP,雙方事先約定輸出是 Q3.12:
- Q3.12 是 16-bit 有號定點數:1 個 sign bit、3 個整數 bit、12 個小數 bit,整數 \(q\) 代表 \(q / 2^{12}\)。
- 換成 affine 量化的參數:int16、scale \(= 2^{-12}\)、zero point \(= 0\)、整數範圍 \([-32768, 32767]\)。可表示 \([-8,\ 8 - 2^{-12}]\),每格 \(2^{-12} \approx 0.000244\)。
這組 qparams 是介面規格,不能交給校準決定:校準算出的 scale 會隨資料改變,DSP 那邊卻寫死了 \(2^{-12}\)。 所以在模型最後一個 op 的輸出貼 FixedQParamsQuantizationSpec,參數照上面填。這組 int16 的 spec 在 torchao 0.18 上 prepare、convert 都正常。要注意三件事:
- 超出 \([-8, 8)\) 的值會被 clamp。 固定 qparams 不會因為資料變大而放寬範圍,要先確認模型輸出真的落在範圍內;不夠的話要和 DSP 改約定(例如 Q4.11:範圍加倍、每格變粗一倍)。校準時的 observer 可以拿來檢查範圍,但不決定 qparams。
- convert 之後的 Q/DQ 圖,最後一個 node 仍是 dequantize。 圖的輸出是 fp32,只是每個值都剛好落在 \(2^{-12}\) 的格子上。要交出真正的 int16,得由後端 lowering 時去掉最後的 dq,或在交給 DSP 前乘上 \(2^{12}\) 轉成整數,數值完全一致。
- QAT 時,同一份 spec 會變成
FixedQParamsFakeQuantize,訓練中模型就看得到 Q3.12 的捨入與 clamp,可以學著把輸出放進範圍內。
小結
DerivedQuantizationSpec:qparams 由derive_qparams_fn從其他位置的 observer 算出;典型用途是 int32 bias,scale \(= s_x s_w\)。FixedQParamsQuantizationSpec:qparams 寫死在 spec 裡,不受校準資料影響。適合值域已知的 op,也適合輸出格式由下游規定的情況(例如交給定點 DSP 的 Q3.12)。- 兩者的位置上仍會插 observer(
DerivedObserverOrFakeQuantize、FixedQParamsObserver),只是 qparams 不由它看到的資料決定。