跳至主要内容

M08 概覽 | XNNPACK/ExecuTorch:把 Q/DQ 圖變成 .pte

Open In Colab

M07 的三種處理都還在 PyTorch 裡:Q/DQ 圖經過 reference representation、x86 lowering、torch.compile,conv 仍是 fp32(在沒有 mkldnn 的環境)。 這一章走 M07 概覽的路線 A:把 Q/DQ 圖交給 ExecuTorch 的 XNNPACK backend,匯出時就把 int8 kernel 定下來,存成部署檔 .pte。

ExecuTorch、XNNPACK 各在哪一層(推論框架、硬體擴充單位、kernel 函式庫),M07 概覽的前半講過,這裡不重講。這一章回答四個問題:

問題 一句話的答案 notebook
用哪一份 XNNPACKQuantizer? 有兩份同名的;匯出 .pte 用 executorch 版 M08-1
Q/DQ 圖怎麼變成 .pte? to_edge_transform_and_lower 切出 delegate,to_executorch 序列化 M08-2
XNNPACK 不支援的 op 怎麼辦? 留在 delegate 外由 ExecuTorch 執行,圖被切開 M08-3
真的變快了嗎? 換 runtime 就快很多;int8 要和同一個 runtime 的 fp32 比 M08-4

這一章需要 executorch(uv sync --group executorch)。

先看一個例子:conv + relu 走完整條路

最小的 conv + relu,從 PyTorch 模型一路走到 ExecuTorch runtime 執行。下面只用 PyTorch、torchao、ExecuTorch 的原始 API,分四步。

第 1 步:PT2E,得到 Q/DQ 圖。 和 M01 起的流程相同,只有兩處不同:quantizer 用 ExecuTorch 的 XNNPACK backend 附的那一份(M08-1);torch.export.export 不給 dynamic_shapes,shape 依 x 寫死成 batch 1(原因在 M08-2 C),所以校準資料一筆一筆餵。

model = get_model("conv_relu").eval()
x = example_inputs(1)                                           # batch 1

gm = torch.export.export(model, x).module()                     # static shape
quantizer = XNNPACKQuantizer().set_global(get_symmetric_quantization_config(is_per_channel=True))
prepared = prepare_pt2e(gm, quantizer)
with torch.no_grad():
    for (batch,) in calibration_batches(32):
        for i in range(len(batch)):
            prepared(batch[i : i + 1])
qdq = convert_pt2e(prepared)                                    # Q/DQ 圖

這就是 M07 概覽開頭的「dq → op → q」單元:int8 進、int8 出,中間的 conv、relu 用 fp32 算。

第 2 步:lowering。 再 export 一次(to_edge_transform_and_lower 吃 ExportedProgram,convert_pt2e 給的是 GraphModule),然後讓 XnnpackPartitioner 切出 XNNPACK 吃得下的子圖:

edge = to_edge_transform_and_lower(torch.export.export(qdq, x), partitioner=[XnnpackPartitioner()])
lowered = edge.exported_program().graph_module

conv、relu、q/dq 全都不見了,換成一顆 executorch_call_delegate。它呼叫的 lowered_module_0 是 XNNPACK 的 delegate,裡面保留著被收進去的原始子圖:

delegate = getattr(lowered, "lowered_module_0")
inner = delegate.original_module.graph_module                   # 被收進 delegate 的子圖
print("backend:", delegate.backend_id)
backend: XnnpackBackend

整個單元,連同 weight 的 dequantize_per_channel,都在 delegate 裡。名稱也變了:conv2d 在 Edge dialect 已經 decompose 成 convolution。

第 3 步:序列化成 .pte。 to_executorch() 為 delegate 以外的圖做 memory planning,.buffer 是 .pte 檔的 bytes:

pte = edge.to_executorch().buffer
print(f".pte:{len(pte)} bytes")
.pte:3872 bytes

部署時交出去的就是這些 bytes(寫成 model.pte 檔)。

第 4 步:用 ExecuTorch runtime 執行。 第一次載入 XNNPACK delegate 時,ExecuTorch 的 C++ 層會在 stderr 印幾行偵測 CPU 的 log,和結果無關,用本專案的 capture_stderr()(common/env.py,不是 executorch 的 API)收起來:

with capture_stderr():
    program = Runtime.get().load_program(pte)                   # 也可以傳 .pte 檔的路徑
    forward = program.load_method("forward")
assert forward is not None
y = forward.execute([x[0]])[0]
print("輸出 shape:", tuple(y.shape))
輸出 shape: (1, 8, 16, 16)

program 在執行期間要一直留著:只留 forward、讓 program 被回收的話,forward 執行時會讀到已釋放的記憶體。

後面的 notebook 用本專案的三個包裝(common/executorch_utils.py,都不是 executorch 的 API):

包裝 做的事
xnnpack_ptq(model, x) 第 1 步:static shape 的 export、executorch 版 quantizer、校準、convert
lower_to_xnnpack(gm, x) 第 2、3 步,回傳 (edge, pte)
PteRunner(pte) 第 4 步,並留住 program

名詞

名詞 是什麼
Edge dialect ExecuTorch 的中間表示:大多數 op 再 decompose 成 Core ATen op(例如 conv2d → convolution);partitioner 可以要求保留它認得的 op 不拆(例如 linear,讀 executorch 1.5.0 的 config_partitioner.py)
partitioner 在圖上找出某個 backend 吃得下的連通子圖;這裡是 XnnpackPartitioner
delegate 交給 backend 的一塊子圖,backend 在匯出時把它編譯成自己的格式;圖上留下一顆 executorch_call_delegate
memory planning to_executorch() 依匯出時的 shape,事先決定每個中間 tensor 放在哪塊記憶體,執行時不再配置
.pte ExecuTorch 的部署檔:圖、weight、delegate 的資料、memory plan 都在裡面
portable kernel ExecuTorch 自己的通用 C++ op 實作,跑沒有進 delegate 的 op

lowering 時 q/dq 去哪了

Q/DQ 圖上的 q/dq 都進了 delegate,但不是每一顆都變成運算。XNNPACK 把它們分成兩種(讀 executorch 1.5.0 的 backends/xnnpack/operators/op_quant_dequant.py、quant_params.py):

Q/DQ 圖上 到了 XNNPACK
夾在兩個 op 之間的 q、dq 不產生運算,只把 scale、zero point 記在 int8 tensor 上(QuantParams.from_q_dq_node)。一個單元就是一顆 int8 kernel
圖入口的 q、出口的 dq 變成 fp32 ↔︎ int8 的轉換(XNNPACK 的 Convert)
weight(int8 常數 + per-channel scale) 原樣存進 .pte
fp32 bias 量化成 int32,scale = \(s_x \cdot s_w\)(QuantParams.from_bias)
partitioner 不收的 op 留在 delegate 外,由 portable kernel 執行(M08-3)

所以 XNNPACK 用的 scale,就是 convert_pt2e 寫在 q/dq 上的那一套(observer 的 calculate_qparams())。QAT 的模型也一樣:部署用的是 convert 圖上的 scale,不是訓練時 fake quant 的 buffer(M06-3)。

和 Q/DQ 圖的數值差異

兩邊拿到相同的 scale、zero point,輸出仍不保證逐位元相同:Q/DQ 圖用 fp32 模擬量化,XNNPACK 用整數 kernel 算,捨入的位置不同。

  • 已知的一個原因是 bias:Q/DQ 圖上 bias 是 fp32,XNNPACK 在 lowering 時把它量化成 int32(上表)。
  • 其他可能的來源是 add、mean 的整數實作,以及 conv 的累加方式(推論,沒有讀 XNNPACK 的 kernel 原始碼)。

這些差來自整數捨入:在 executorch 1.5.0、macOS arm64 上,M08-2、M08-3 量到的最大差都是 1 個輸出 LSB。所以在 Q/DQ 圖上評估精度(M05、M06)可以代表部署的結果,只是別期待逐位元相同。比較的指標照 M07 概覽〈比較後端之前的規矩〉。

小結與本章地圖

  • 路線 A 在匯出時就決定 kernel:to_edge_transform_and_lower 讓 partitioner 切出 delegate,.pte 裡存的就是 XNNPACK 要執行的子圖。
  • 夾在 op 之間的 q/dq 只剩 int8 tensor 的 scale;只有圖的入口、出口真的轉換 fp32 ↔︎ int8。
  • .pte 依匯出時的 shape 做 memory planning,所以這一章一律用 static shape。
notebook 回答的問題
M08-1 兩份 XNNPACKQuantizer executorch 版與 torchao 測試版差在哪;static 與 dynamic 的規格;set_module_name
M08-2 從 Q/DQ 圖到 .pte lowering 前後的圖、.pte 的大小、runtime 執行、換 batch 會怎樣
M08-3 不支援的 op 圖怎麼被切開、邊界上傳什麼、和 Q/DQ 圖差多少
M08-4 latency eager、Q/DQ 圖、fp32 .pte、int8 .pte 誰快;收益分兩段

這一章刻意跳過的:動態 shape 的 .pte;XNNPACK 以外的 ExecuTorch backend(M07 概覽列出的那些);ONNX Runtime 的路線 B 在 M09。

參考資料