跳至主要内容

M02-3 | 手動重做 convert_pt2e:observer → quantize/dequantize

Open In Colab

M02-2 手做了 prepare_pt2e。這本 notebook 手做 convert_pt2e,最後和它的結果逐項比對。

先看 convert_pt2e 依序做了什麼(torchao 0.18 quantize_pt2e.py):

順序 步驟 做什麼 本 notebook
1 observer → q/dq 每個 observer 換成一對 quantize/dequantize,scale 與 zero point 寫進圖裡 B 段
2 _fold_conv_bn_qat 折掉 QAT 訓練時留著的 conv + bn 子圖 略過(PTQ 的 BN 在 prepare 就折掉了,M04)
3 DuplicateDQPass 一個 dequantize 有多個使用者時,替每個使用者複製一份 C 段
4 PortNodeMetaForQDQ 把標註等 metadata 搬到 q/dq node 上 略過(不影響圖的結構與數值)
5 constant_fold weight 的 quantize 輸入是常數,預先算好,只存 int8 D 段

開始之前,A 段先確認 quantize/dequantize 這兩個 op 的數學。

import copy

import torch
from torch.fx import Graph
from torch.fx.passes.infra.pass_manager import PassManager
from torch.fx.passes.utils.matcher_utils import SubgraphMatcher

from common.data import calibration_batches
from common.graph_utils import diff_ops, observer_nodes, op_counts
from common.models import example_inputs, get_model
from common.pt2e import DuplicateDQPass, convert_pt2e, export_module, get_xnnpack_quantizer, prepare_pt2e, quiet
from common.viz import draw_stages, table

quiet()
_ = torch.manual_seed(0)

QD = torch.ops.quantized_decomposed  # convert_pt2e 產生的 q/dq op 都在這個 namespace

A. quantize/dequantize 的數學

兩個 op 就是 M01 的公式:\(q = \text{clamp}(\text{round}(x/s) + z,\ q_{\min}, q_{\max})\),\(\hat{x} = (q - z)\cdot s\)。 選一個偏小的 scale,讓部分元素超出範圍、被 clamp:

x = torch.randn(8) * 2
scale, zp, qmin, qmax = 0.02, 3, -128, 127

q_op = QD.quantize_per_tensor(x, scale, zp, qmin, qmax, torch.int8)
dq_op = QD.dequantize_per_tensor(q_op, scale, zp, qmin, qmax, torch.int8)
q_hand = (torch.round(x / scale) + zp).clamp(qmin, qmax).to(torch.int8)
dq_hand = (q_hand.to(torch.float32) - zp) * scale
clamped = ((torch.round(x / scale) + zp < qmin) | (torch.round(x / scale) + zp > qmax))

table([["x", *[f"{v:.3f}" for v in x.tolist()]],
       ["q(op)", *q_op.tolist()],
       ["q(手寫)", *q_hand.tolist()],
       ["dq(op)", *[f"{v:.3f}" for v in dq_op.tolist()]],
       ["被 clamp", *["✱" if c else "" for c in clamped.tolist()]]])
x 3.082 -0.587 -4.358 1.137 -2.169 -2.797 0.807 1.676
q(op) 127 -26 -128 60 -105 -128 43 87
q(手寫) 127 -26 -128 60 -105 -128 43 87
dq(op) 2.480 -0.580 -2.620 1.140 -2.160 -2.620 0.800 1.680
被 clamp ✱ ✱ ✱
assert torch.equal(q_op, q_hand) and torch.equal(dq_op, dq_hand) and clamped.any()

op 與手寫公式逐位元相同。被 clamp 的元素還原後停在範圍邊界(\((q_{\max} - z)\cdot s\) 或 \((q_{\min} - z)\cdot s\)),這就是 clipping 誤差。

準備:一張校準好、還沒 convert 的圖

TinyConvNet(conv-bn-relu、residual block、linear),用 XNNPACKQuantizer(per-channel weight)prepare 並校準。 official 是 convert_pt2e 的結果,後面每一步都和它比。

model = get_model("tiny").eval()
quantizer, _ = get_xnnpack_quantizer(is_per_channel=True)
prepared = prepare_pt2e(export_module(model, example_inputs(8)), quantizer)
with torch.no_grad():
    for batch in calibration_batches(n=128, batch_size=16):
        prepared(*batch)

official = convert_pt2e(copy.deepcopy(prepared))  # convert_pt2e 會原地修改,對複本呼叫
x_test = torch.randn(8, 3, 16, 16)


def max_diff(a, b):
    with torch.no_grad():
        return float((a(x_test) - b(x_test)).abs().max())


print(f"prepared 圖上的 observer 數量 = {len(observer_nodes(prepared))}")
prepared 圖上的 observer 數量 = 16

B. 步驟 1:observer → q/dq

對每個 observer:用 calculate_qparams() 取出 scale、zero point,在它前面插 quantize、dequantize,讓下游改吃 dequantize,再刪掉 observer。 改圖的動作就是 M00-4 的 Step 4~5(用 call_function 取代 call_module、把常數放進圖裡)。scale 與 zero point 放的位置依量化方式而定:

  • per-tensor:scale、zero point 是 Python 數字,直接寫在 node 的參數裡。
  • per-channel:scale、zero point 是 tensor,先註冊成 buffer,再用 get_attr node 讀出來。
def replace_observers_with_qdq(gm):
    """把圖上每個 observer(call_module)換成 quantize → dequantize 兩個 call_function。"""
    graph = gm.graph
    for k, (node, obs) in enumerate(observer_nodes(gm)):
        scale, zp = obs.calculate_qparams()
        qmin, qmax, dtype = obs.quant_min, obs.quant_max, obs.dtype
        src = node.args[0]
        with graph.inserting_before(node):
            if scale.numel() > 1:  # per-channel:tensor 註冊成 buffer,用 get_attr 取
                gm.register_buffer(f"_scale_{k}", scale.detach().clone())
                gm.register_buffer(f"_zero_point_{k}", zp.detach().clone())
                s_node, z_node = graph.get_attr(f"_scale_{k}"), graph.get_attr(f"_zero_point_{k}")
                axis = int(obs.ch_axis)
                q = graph.call_function(QD.quantize_per_channel.default, (src, s_node, z_node, axis, qmin, qmax, dtype))
                dq = graph.call_function(QD.dequantize_per_channel.default, (q, s_node, z_node, axis, qmin, qmax, dtype))
            else:                  # per-tensor:Python 數字直接寫在參數裡
                s, z = float(scale), int(zp)
                q = graph.call_function(QD.quantize_per_tensor.default, (src, s, z, qmin, qmax, dtype))
                dq = graph.call_function(QD.dequantize_per_tensor.default, (q, s, z, qmin, qmax, dtype))
        node.replace_all_uses_with(dq)
        graph.erase_node(node)
        gm.delete_submodule(node.target)  # observer 模組用完就丟
    graph.lint()
    gm.recompile()
    return gm


manual = replace_observers_with_qdq(copy.deepcopy(prepared))
draw_stages([(prepared, "校準後的 prepared"), (manual, "步驟 1 之後")], focus=["conv2d"], hops=2,
            title="stem 的 conv2d:observer 換成 q/dq")

左邊的三個 observer(虛線,下一步消失)在右邊各變成一對 q/dq(綠框)。 weight 那對是 per-channel,參數裡的 _scale_0 就是剛註冊的 buffer;activation 那對是 per-tensor,scale 直接寫在參數裡。

輸出和 convert_pt2e 比:

print(f"步驟 1 之後,與 convert_pt2e 的輸出最大差 = {max_diff(official, manual):.3e}")
assert max_diff(official, manual) == 0
步驟 1 之後,與 convert_pt2e 的輸出最大差 = 0.000e+00

數值已經完全一樣。但圖的 op 組成還有兩處不同(official = convert_pt2e,manual = 只做了步驟 1):

table(diff_ops(official, manual, "official", "manual", tablefmt="html"))
op official manual delta
dequantize_per_tensor 12 11 -1
quantize_per_channel 0 5 5
adaptive_avg_pool2d 1 1 0
add 1 1 0
conv2d 4 4 0
dequantize_per_channel 5 5 0
flatten 1 1 0
linear 1 1 0
max_pool2d 2 2 0
quantize_per_tensor 11 11 0
relu 4 4 0
co, cm = op_counts(official), op_counts(manual)
assert co["quantize_per_channel"] == 0 and cm["quantize_per_channel"] == 5
assert co["dequantize_per_tensor"] == cm["dequantize_per_tensor"] + 1
  1. quantize_per_channel:official 沒有,因為 weight 的 quantize 被預先算掉了(步驟 5,D 段)。
  2. dequantize_per_tensor:official 多一個(步驟 3,C 段)。

C. 步驟 3:DuplicateDQPass

找出目前被多個使用者共用的 dequantize:

def shared_dqs(gm):
    return [n.name for n in gm.graph.nodes
            if n.op == "call_function" and n.target == QD.dequantize_per_tensor.default and len(n.users) > 1]


before = copy.deepcopy(manual)
shared = shared_dqs(manual)
manual = PassManager([DuplicateDQPass()])(manual).graph_module
print(f"套用前,被多個使用者共用的 dequantize:{shared}")
print(f"套用後:{shared_dqs(manual)}")
assert len(shared) == 1 and shared_dqs(manual) == []
套用前,被多個使用者共用的 dequantize:['dequantize_per_tensor_default_2']
套用後:[]
draw_stages([(before, "套用前"), (manual, "DuplicateDQPass 之後")], focus=shared,
            title="residual block 的入口:一個 dq 餵兩個 op")

這是 residual block 的入口:同一個 dequantize 同時餵給 conv2d_1 和 add。套用之後,兩個使用者各有一份自己的 dequantize。

共用一個 dq 會有什麼問題?M01 概覽提過,後端要讓 int8 真的變快,是在 Q/DQ 圖上找「dq → op → q」這個 pattern, 把整段換成一個 int8 kernel。這個「把 Q/DQ 圖換成後端 kernel」的動作叫 lowering(M07 起實際做)。

換掉的時候,pattern 裡的 dq 會跟著消失。但上圖左邊那個 dq 還要餵給 add: 如果把「dq → conv2d_1」換成一個 int8 conv,add 就少了輸入。 所以比對 pattern 時,只要 pattern 內部的 node 還有外面的使用者,這一段就不能算配對成功。 torch.fx 的 SubgraphMatcher 就是這樣規定的(原始碼的 _is_contained:配到的 node 不能有 pattern 以外的使用者,pattern 的輸入與輸出除外)。

實際用 SubgraphMatcher 在兩張圖上找「dq → conv2d」(只比對 pattern 的前半段,參數的數值不比對),看哪些 conv 配得到:

def dq_conv_matcher(gm):
    """「dequantize → conv2d」的 pattern;參數個數照圖上的 conv2d,數值不比對(ignore_literals)。"""
    conv = next(n for n in gm.graph.nodes if n.target == torch.ops.aten.conv2d.default)
    dq = conv.args[0]
    pattern = Graph()
    xq, w, b = pattern.placeholder("xq"), pattern.placeholder("w"), pattern.placeholder("b")
    x = pattern.call_function(dq.target, (xq, *dq.args[1:]))
    pattern.output(pattern.call_function(conv.target, (x, w, b, *conv.args[3:])))
    return SubgraphMatcher(pattern, ignore_literals=True)


matcher = dq_conv_matcher(manual)


def matched_convs(gm):
    return sorted(n.name for m in matcher.match(gm.graph) for n in m.nodes_map.values()
                  if n.target == torch.ops.aten.conv2d.default)


table([["套用前(dq 共用)", ", ".join(matched_convs(before))],
       ["DuplicateDQPass 之後", ", ".join(matched_convs(manual))]],
      ["圖", "配得到「dq → conv2d」的 conv"])
圖 配得到「dq → conv2d」的 conv
套用前(dq 共用) conv2d, conv2d_2, conv2d_3
DuplicateDQPass 之後 conv2d, conv2d_1, conv2d_2, conv2d_3
assert "conv2d_1" not in matched_convs(before) and "conv2d_1" in matched_convs(manual)

套用前,conv2d_1 配不到:它前面的 dq 還要餵 add。套用之後,conv2d_1 和 add 各有自己的 dq, 「dq → conv2d_1」自成一段,後端可以把它換成 int8 conv,add 用自己那份 dq,不受影響。 這就是 DuplicateDQPass 的用途,它的 docstring 也這樣寫:共用一個 dequantize 的使用者無法各自被標註與 lowering。

複製只是多一個參數相同的 node,數值完全等價:

print(f"套用後與 convert_pt2e 的輸出最大差 = {max_diff(official, manual):.3e}")
assert max_diff(official, manual) == 0
套用後與 convert_pt2e 的輸出最大差 = 0.000e+00

D. 步驟 5:constant folding

weight 的 quantize 輸入是常數(get_attr 讀的參數),結果不必等到執行時才算。 預先用 op 算出 int8 weight,註冊成 buffer,圖上改用 get_attr 讀它,原本的 quantize node 就沒人用了。

def get_attr_value(gm, target):
    obj = gm
    for part in target.split("."):
        obj = getattr(obj, part)
    return obj


def fold_weight_quantize(gm):
    """quantize_per_channel(get_attr weight) 的結果是常數:預先算好,存成 int8 buffer。"""
    graph = gm.graph
    folded = 0
    for node in list(graph.nodes):
        if node.op != "call_function" or node.target != QD.quantize_per_channel.default:
            continue
        if node.args[0].op != "get_attr":  # 只折 weight;activation 的輸入要到執行時才知道
            continue
        w, scale, zp = (get_attr_value(gm, a.target) for a in node.args[:3])
        w_int8 = QD.quantize_per_channel(w, scale, zp, *node.args[3:])  # 直接呼叫 op,算出 int8 weight
        name = f"_frozen_param{folded}"
        gm.register_buffer(name, w_int8)
        with graph.inserting_before(node):
            new = graph.get_attr(name)
        node.replace_all_uses_with(new)
        graph.erase_node(node)
        folded += 1
    graph.eliminate_dead_code()  # fp32 weight 的 get_attr 沒人用了,一併清掉
    graph.lint()
    gm.recompile()
    return folded


before = copy.deepcopy(manual)
n_fold = fold_weight_quantize(manual)
draw_stages([(before, "folding 前"), (manual, "folding 後")],
            focus=["quantize_per_channel_default", "dequantize_per_channel_default", "conv2d"], hops=0,
            title="stem 的 weight:quantize 預先算好")

左邊的 quantize_per_channel 吃 fp32 的 stem_conv_weight;右邊它消失了,dequantize 直接讀 int8 的 _frozen_param0。 圖上實際使用的 weight(get_attr 讀的、≥ 2 維的 tensor,bias 是 1 維不算)變小了:

def graph_weight_bytes(gm):
    total = 0
    for n in gm.graph.nodes:
        if n.op == "get_attr":
            t = get_attr_value(gm, n.target)
            if isinstance(t, torch.Tensor) and t.dim() >= 2:
                total += t.numel() * t.element_size()
    return total


table([["folding 前", f"{graph_weight_bytes(before):,}"],
       ["folding 後", f"{graph_weight_bytes(manual):,}"],
       ["convert_pt2e", f"{graph_weight_bytes(official):,}"]],
      ["圖", "weight 大小(bytes)"])
圖 weight 大小(bytes)
folding 前 39,104
folding 後 9,776
convert_pt2e 9,776
assert n_fold == 5
assert graph_weight_bytes(before) == 4 * graph_weight_bytes(manual) == 4 * graph_weight_bytes(official)

fp32 是 4 bytes、int8 是 1 byte,正好縮小 4 倍,與 convert_pt2e 相同。最後比一次 op 組成與輸出:

table(diff_ops(official, manual, "official", "manual", tablefmt="html"))
op official manual delta
adaptive_avg_pool2d 1 1 0
add 1 1 0
conv2d 4 4 0
dequantize_per_channel 5 5 0
dequantize_per_tensor 12 12 0
flatten 1 1 0
linear 1 1 0
max_pool2d 2 2 0
quantize_per_tensor 11 11 0
relu 4 4 0
print(f"與 convert_pt2e 的輸出最大差 = {max_diff(official, manual):.3e}")
assert op_counts(official) == op_counts(manual) and max_diff(official, manual) == 0
與 convert_pt2e 的輸出最大差 = 0.000e+00

三個步驟做完,手寫的圖與 convert_pt2e 的 op 組成、輸出都完全相同。被略過的兩步(_fold_conv_bn_qat、PortNodeMetaForQDQ)在這張 PTQ 圖上不影響結果。

小結

  • observer → q/dq 是把校準得到的 scale、zero point 寫死進圖:convert 之前它們存在 observer 模組裡,之後是圖上的常數或 buffer。
  • convert 只透過 calculate_qparams() 讀 qparams(torchao convert.py),FakeQuantize 也有這個方法,所以 QAT 的圖走同一條路。
  • weight 的 quantize 輸入是常數,可以預先算好、只存 int8;activation 的 q/dq 要等執行時才有輸入,必須留在圖上。
  • 結果仍是 Q/DQ 圖:dequantize 後面接的還是 fp32 的 conv2d,不會變快。換成整數 kernel 是後端的事(M07~M09)。