跳至主要内容

M03 概覽 | observer 的位置由誰決定:標籤與 Quantizer

Open In Colab

M02 手插 observer 時,位置是直接照 XNNPACKQuantizer 抄的。真實的後端不能這樣:它要能對任何模型自動決定哪裡量化、用什麼規格。 做這件事的就是 Quantizer。這一章打開它,最後寫出自己的 Quantizer。 後面七本 notebook(M03-1~M03-7)逐一動手驗證。

為什麼需要 Quantizer

同一個 conv,後面接 relu 時要不要在中間量化?add 的兩個輸入要不要用同一個 scale?bias 要不要量化成 int32? 這些都是後端的決定:硬體能融合哪些 op、整數加法怎麼做,每個後端不同。PT2E 把這些決定集中在 Quantizer 裡,prepare_pt2e 和 convert_pt2e 本身不需要知道任何後端。

這一章回答四個問題:

問題 一句話的答案 動手驗證
Quantizer 怎麼告訴 prepare_pt2e 位置? 在 node 上貼標籤;prepare_pt2e 照標籤插 observer M03-1、M03-2
Quantizer 怎麼在任何模型上找到該標的 op? 用規則(node.target、pattern)找,不點名 M03-3、M03-6
不同位置的 qparams 怎麼連動? 共用同一個 observer,或由別的位置推導、或寫死 M03-4、M03-5
使用者怎麼指定哪一段用什麼規格? 後端各自的設定 API,背後就是規則 M03-6、M03-7

先看一個例子:XNNPACKQuantizer 在 conv → relu 上做了什麼

第一個階段是 Quantizer 標完的圖,每個 node 底下寫著它的標籤:每條輸入邊一行「邊 上游:規格」,輸出一行「輸出:規格」。 第二個階段是 prepare_pt2e 之後的圖,綠框是新長出來的 observer。請看兩件事:

  • 每一張標籤都變成一個 observer,位置一一對應。bias 的規格是「不量化」,所以沒有 observer。
  • conv2d 的輸出沒有標籤,所以 conv 與 relu 之間沒有 observer:XNNPACKQuantizer 把 conv + relu 當成一個 pattern,後端可以融成一個 kernel。

定義

三個角色

角色 是什麼 做什麼
標籤 QuantizationAnnotation 存在 node.meta["quantization_annotation"] 的資料 記錄這個 node 的哪些輸入邊、或它的輸出要量化,用什麼規格
Quantizer 後端作者寫的類別 在圖上找 node、貼標籤、檢查圖能不能處理
prepare_pt2e torchao 的函式 呼叫 Quantizer,再照標籤插 observer;沒有標籤的地方什麼都不做

標籤:兩種位置

QuantizationAnnotation 用兩個欄位描述位置:

  • input_qspec_map:{上游 node: spec},貼在 consumer 上,表示「上游 → 這個 node」這條邊要量化。只影響這一個 consumer。
  • output_qspec:這個 node 的輸出要量化。影響輸出的所有 consumer。

另外兩個欄位:_annotated(已經處理過,其他 Quantizer 不要再動,M03-3、M03-6)與 allow_implicit_sharing(M03-4)。

規格:QuantizationSpec

每個位置的規格是一份 QuantizationSpec,回答「量化成什麼樣子、用哪種 observer 收集統計」。 上面例子裡 XNNPACKQuantizer 的 activation 與 weight 規格:

cfg = xnnpack.global_config
assert cfg is not None and cfg.input_activation is not None and cfg.weight is not None
rows = []
for field in ("dtype", "quant_min", "quant_max", "qscheme", "ch_axis", "observer_or_fake_quant_ctr"):
    values = []
    for spec in (cfg.input_activation, cfg.weight):
        v = getattr(spec, field)
        v = getattr(getattr(v, "p", None), "func", v)  # with_args 包過的 observer,剝開取類別
        values.append(getattr(v, "__name__", str(v)))
    rows.append([field, *values])
table(rows, ["欄位", "activation", "weight"])
欄位 activation weight
dtype torch.int8 torch.int8
quant_min -128 -127
quant_max 127 127
qscheme torch.per_tensor_affine torch.per_channel_symmetric
ch_axis None 0
observer_or_fake_quant_ctr HistogramObserver PerChannelMinMaxObserver

activation 是 int8 per-tensor affine,weight 是 int8 per-channel symmetric、沿 output channel(ch_axis=0)各一個 scale。 用哪種 observer 是這份 XNNPACKQuantizer 預設設定的選擇(M02-1 比較過各種 observer)。

除了一般的 QuantizationSpec,還有三種 spec,差在 qparams 從哪來:

spec qparams 從哪來 例子 notebook
QuantizationSpec 自己的 observer 看資料算 大部分的 activation、weight M03-1
SharedQuantizationSpec(位置) 和另一個位置共用同一個 observer add、cat 的輸入與輸出用同一個 scale M03-4
DerivedQuantizationSpec 由其他位置的 observer 推導 int32 bias:scale = activation scale × weight scale M03-5
FixedQParamsQuantizationSpec 寫死在 spec 裡 sigmoid 的輸出(值域已知);交給定點 DSP 的模型輸出(格式已規定) M03-5

FixedQParamsQuantizationSpec 的兩種用途,共通點是 qparams 事先就知道,不必從資料估。 第二種的例子:speech enhancement 模型輸出的 spectrogram 要直接交給用定點數運算的 DSP,雙方約定格式是 Q3.12(int16、scale \(2^{-12}\)), scale 就得照約定寫死,不能交給校準決定。

Quantizer 的三個方法與 prepare_pt2e 的順序

方法 必須實作? 做什麼
transform_for_annotation(model) 否(基底預設不改圖) 標註前改寫圖,例如把純量常數變成 tensor 屬性
annotate(model) 是 找 node、貼標籤
validate(model) 是 標完之後檢查這張圖能不能處理,不行就報錯

prepare_pt2e 依序(torchao 0.18):把 BN 折進 conv → transform_for_annotation → annotate → validate → 照標籤插 observer。 所以 Quantizer 看到的是 BN 已經折掉的圖(M03-1 B 段驗證過呼叫順序,M03-2 用這件事拿到標完的圖)。

QAT 用的 prepare_qat_pt2e 順序不同:transform_for_annotation → annotate → validate 之後才處理 conv + BN(_fuse_conv_bn_qat),而且 BN 不折掉,留在圖上繼續訓練。 所以 QAT 時 Quantizer 看到的是 BN 沒有折掉的圖(QAT 圖上的 BN 長什麼樣,見 M04-2)。

找 node 的兩種方式

  • 點名:用 node 名稱,例如「relu 的輸出」。換了模型就失效,只適合實驗(M03-1、M03-4 B 段)。
  • 規則:用 node.target 比對 op,必要時看前後文(例如 conv 的唯一使用者是 relu)。模型換了照樣適用,真實的 Quantizer 都這樣做(M03-3)。

torchao 測試版 XNNPACKQuantizer 有些 annotator 依賴 node.meta["source_fn_stack"] 找 pattern,torch 2.14 的 export 不再填這個欄位,cat 因此靜默沒被標(M03-2 C 段)。

用程式驗證:十行的規則式 Quantizer

只標所有 relu 的輸出。annotate_output_qspec 是 torchao 提供的公開輔助函式,會在需要時建立標籤:

class ReluOutputQuantizer(Quantizer):
    def annotate(self, model):
        for node in model.graph.nodes:
            if node.op == "call_function" and node.target == torch.ops.aten.relu.default:
                annotate_output_qspec(node, act_spec())
                node.meta[ANNOTATION_KEY]._annotated = True
        return model

    def validate(self, model):
        pass


for name in ("conv_relu", "residual", "tiny"):
    p = prepare_pt2e(export_module(get_model(name).eval(), example_inputs(4)), ReluOutputQuantizer())
    n_relu = sum(n.target == torch.ops.aten.relu.default for n in p.graph.nodes)
    print(f"{name:<10} relu {n_relu} 個 → observer {len(observer_nodes(p))} 個")
    assert len(observer_nodes(p)) == n_relu
conv_relu  relu 1 個 → observer 1 個
residual   relu 2 個 → observer 2 個
tiny       relu 4 個 → observer 4 個

同一個 Quantizer 不需要知道模型長什麼樣,三個模型上 observer 數都等於 relu 數。

小結與本章地圖

  • prepare_pt2e 自己不知道哪裡該量化,它只照標籤插 observer;位置與規格全部由 Quantizer 決定。
  • 標籤有兩種位置(輸入邊、輸出);規格有四種 spec,差在 qparams 從哪來。
  • Quantizer 用規則找 node;fusion、共用、局部量化,都只是「標籤怎麼貼」的不同寫法。
notebook 回答的問題
M03-1 標籤與 prepare_pt2e 標籤長什麼樣、三個角色怎麼分工、邊標籤與輸出標籤
M03-2 讀懂 XNNPACKQuantizer 的標籤 真實的 Quantizer 貼了什麼、fusion 在標籤上的樣子、哪些 op 沒被標到
M03-3 規則式 Quantizer 用 node.target 找 op、_annotated、validate
M03-4 共用 qparams SharedQuantizationSpec、隱式共用何時合併、何時串接
M03-5 Derived 與 FixedQParams int32 bias 的 scale 怎麼推導;值域已知、或輸出格式已規定(例如 Q3.12)時用固定 qparams
M03-6 局部量化與組合 只量化一部分、ComposableQuantizer、標註順序
M03-7 後端的設定 API X86InductorQuantizer 的優先順序、XNNPACKQuantizer 默默不生效的設定

這一章刻意跳過的:BN 怎麼折進 conv、QAT 圖上的 fusion(M04);哪些層該留在 fp32(M05);為一個虛構的硬體從頭寫完整的 Quantizer(M10)。

參考資料