跳至主要内容

M08-2 | 從 Q/DQ 圖到 .pte:lowering、檔案大小、runtime 執行

Open In Colab

M07 的三種處理都還在 PyTorch 裡,沒有產生 int8 kernel。這本 notebook 走路線 A(M07 概覽):把 Q/DQ 圖交給 ExecuTorch 的 XNNPACK backend,產生部署檔 .pte,再用 ExecuTorch runtime 執行。

export(static shape)→ PT2E                 Q/DQ 圖(executorch 版 XNNPACKQuantizer,M08-1)
  → to_edge_transform_and_lower             XnnpackPartitioner 切出子圖,編譯成 delegate
  → to_executorch                           memory planning,序列化成 .pte
  → ExecuTorch runtime                      載入 .pte、執行

每一步的名詞(Edge dialect、partitioner、delegate、memory planning)在 M08 概覽。這本 notebook 需要 executorch(uv sync --group executorch)。

import tempfile
from pathlib import Path

import torch

from common.pt2e import quiet  # 必須在 executorch 之前 import(executorch 會 import torchao)
from common.data import trained_tiny_net
from common.env import capture_stderr, env_summary
from common.executorch_utils import PteRunner, delegation_summary, lower_to_xnnpack, xnnpack_ptq
from common.graph_utils import op_counts, short
from common.models import example_inputs
from common.viz import draw, table

from executorch.backends.xnnpack.partition.xnnpack_partitioner import XnnpackPartitioner  # noqa: E402
from executorch.exir import to_edge_transform_and_lower  # noqa: E402

quiet()
_ = torch.manual_seed(0)
print(env_summary())
Darwin arm64|torch 2.14.0|threads=4|mkldnn=False|nnpack=True

起點:static shape 的 Q/DQ 圖

模型是 M05 的 TinyConvNet(訓練過)。xnnpack_ptq 是本專案的包裝(common/executorch_utils.py,不是 executorch 的 API),做的是 M01 起熟悉的 PTQ,只有兩處不同:

  • export_module(..., dynamic_batch=False):關掉動態 batch,圖只吃 example_inputs 的 batch 大小(這裡是 1)。原因在 C 段。
  • quantizer 用 executorch 版 XNNPACKQuantizer(per-channel weight)。

因為 batch 固定是 1,校準資料也切成一筆一筆餵。

model = trained_tiny_net().eval()
x = example_inputs(1)
qdq = xnnpack_ptq(model, x)  # export(static)→ prepare_pt2e → 校準 → convert_pt2e
counts = op_counts(qdq)
n_before = sum(counts.values())
n_qdq = sum(v for k, v in counts.items() if "quantize" in k)
print(f"Q/DQ 圖:{n_before} 個 call_function,其中 q/dq {n_qdq} 個")
Q/DQ 圖:42 個 call_function,其中 q/dq 28 個

A. lowering:Q/DQ 圖上的 op 去了哪裡

只用 executorch 的原始 API,分三步:

ep = torch.export.export(qdq, x)                                          # 1. 再 export 一次,得到 ExportedProgram
edge = to_edge_transform_and_lower(ep, partitioner=[XnnpackPartitioner()])  # 2. 轉成 Edge dialect,切 delegate
pte = edge.to_executorch().buffer                                         # 3. memory planning,序列化成 .pte 的 bytes
lowered = edge.exported_program().graph_module                            # 第 2 步之後的圖
print(f"Lowering 之後的 GraphModule:{len(lowered.graph.nodes)} 個 node;.pte {len(pte)} bytes")
Lowering 之後的 GraphModule:5 個 node;.pte 20880 bytes

第 1 步再 export 一次,是因為 to_edge_transform_and_lower 吃的是 ExportedProgram,而 convert_pt2e 回傳的是 GraphModule。這一步也把 shape 依 x 寫死。 本專案把這三步包成 lower_to_xnnpack(gm, example_inputs),回傳 (edge, pte),M08-3、M08-4 用它。

第 2 步之後的圖(lowering 之後的 GraphModule):

draw(lowered, notes={"executorch_call_delegate": "整張 Q/DQ 圖\n交給 XNNPACK",
                     "getitem": "從 delegate 的輸出 tuple 取值"},
     title="Lowering 之後的 GraphModule")

Q/DQ 圖上的 conv、relu、q/dq 都不見了,只剩一顆 executorch_call_delegate。‹lowered_module_0› 是它的 delegate:XNNPACK 編譯好的子圖。

哪些 op 進了 delegate?delegation_summary(本專案的包裝)呼叫 executorch 的 get_delegation_info(lowered),逐一數每個 op 是交給 XNNPACK、還是留在 ExecuTorch。op 名稱是 Edge dialect 的寫法:

n_sub, n_del, n_non, by_op = delegation_summary(edge)
table([[op, d, nd] for op, (d, nd) in sorted(by_op.items())], ["op(Edge dialect 名稱)", "交給 XNNPACK", "留在 ExecuTorch"])
op(Edge dialect 名稱) 交給 XNNPACK 留在 ExecuTorch
aten_add_tensor 1 0
aten_convolution_default 4 0
aten_linear_default 1 0
aten_max_pool2d_default 2 0
aten_mean_dim 1 0
aten_relu_default 4 0
aten_view_copy_default 1 0
getitem 0 1
quantized_decomposed_dequantize_per_channel_default 5 0
quantized_decomposed_dequantize_per_tensor_default 12 0
quantized_decomposed_quantize_per_tensor_default 11 0
left = sorted(op for op, (_, nd) in by_op.items() if nd)
print(f"delegate {n_sub} 個;交給 XNNPACK 的 node {n_del} 個(lowering 前有 {n_before} 個),留下的:{left}")
assert n_sub == 1 and n_del == n_before and left == ["getitem"]
delegate 1 個;交給 XNNPACK 的 node 42 個(lowering 前有 42 個),留下的:['getitem']
  • Lowering 前的 op 全部進了同一個 delegate,留下的只有 getitem:它是 lowering 時新增的,不是運算。執行時 ExecuTorch 只呼叫 XNNPACK 一次。
  • 名稱變了:conv2d 在 Edge dialect 已經 decompose 成 convolution,adaptive_avg_pool2d 變成 mean,flatten 變成 view_copy。
  • q/dq 也交給了 XNNPACK,但夾在兩個 op 中間的 q/dq 不會變成運算,只留下 int8 tensor 的 scale 與 zero point(M08 概覽〈lowering 時 q/dq 去哪了〉)。

B. .pte 的檔案大小:fp32 與 int8

不量化,直接把 fp32 模型 lower,當作對照:

_, pte_fp32 = lower_to_xnnpack(model, x)
n_params = sum(p.numel() for p in model.parameters())
table([["fp32 .pte", len(pte_fp32), n_params * 4], ["int8 .pte", len(pte), n_params]],
      ["", ".pte bytes", "參數全部存成 fp32/int8 的大小"])
.pte bytes 參數全部存成 fp32/int8 的大小
fp32 .pte 45968 39760
int8 .pte 20880 9940
ratio = len(pte) / len(pte_fp32)
print(f"int8 .pte 是 fp32 .pte 的 {ratio:.0%}(模型共 {n_params} 個參數)")
assert len(pte) < len(pte_fp32)
int8 .pte 是 fp32 .pte 的 45%(模型共 9940 個參數)
  • .pte 是自包含的部署檔:圖、weight、delegate 的資料、memory plan 都在裡面。
  • int8 版的 weight 存成 int8,per-channel 的 scale 另外存;Q/DQ 圖上是 fp32 的 bias,lowering 時被量化成 int32 存進去(M08 概覽)。所以第三個 column 的 int8 估計值(每個參數 1 byte)略低於實際。
  • 其餘是圖結構、XNNPACK 子圖的描述、memory plan。這部分不隨參數量成比例變大,模型越大,int8 對 fp32 的比例越接近 1/4(推論,沒有量更大的模型)。

C. 用 ExecuTorch runtime 執行

部署時交出去的就是 .pte 檔。先寫到檔案,再從檔案載入。 PteRunner 是本專案的包裝:executorch.runtime.Runtime.get().load_program(...) 載入、load_method("forward") 取出 method、execute([...]) 執行(原始 API 在 M08 概覽)。它會一直留著 load_program 回傳的 Program:只留 Method、讓 Program 被回收的話,Method 執行時會讀到已釋放的記憶體。

with tempfile.TemporaryDirectory() as d:
    path = Path(d) / "tiny_int8.pte"
    path.write_bytes(pte)
    runner = PteRunner(path)
    y_pte = runner(x[0])
with torch.no_grad():
    y_qdq = qdq(x[0])
out_scale = [n for n in qdq.graph.nodes if short(n.target) == "quantize_per_tensor"][-1].args[1]
print(f"輸入 {tuple(x[0].shape)} → 輸出 {tuple(y_pte.shape)}")
print(f"與 Q/DQ 圖的最大差:{(y_pte - y_qdq).abs().max():.3g}({(y_pte - y_qdq).abs().max() / out_scale:.2f} 個輸出 LSB)")
輸入 (1, 3, 16, 16) → 輸出 (1, 4)
與 Q/DQ 圖的最大差:0.0643(1.00 個輸出 LSB)

輸出 LSB 是最後一個 quantize 的 scale:int8 的一格。.pte 用 XNNPACK 的整數 kernel 算,Q/DQ 圖用 fp32 模擬,兩者不保證逐位元相同(M08 概覽〈和 Q/DQ 圖的數值差異〉);M08-3 C 段用多筆輸入量差多少。

換一個 batch 大小

.pte 是用 batch 1 匯出的。餵 batch 4 試試。ExecuTorch runtime 的 C++ 層會把原因印到 stderr,用本專案的 capture_stderr()(common/env.py)收下來一起印:

with capture_stderr() as log:
    try:
        runner(torch.randn(4, 3, 16, 16))
        error = None
    except RuntimeError as e:
        error = str(e)
print("Python 端:", error)
print("C++ log:")
print(log.text.strip())
Python 端: method->set_inputs() for method 'forward' failed with error 0x10
C++ log:
[tensor_impl.cpp:140] Attempted to resize a static tensor. Expected shape (1, 3, 16, 16), but received (4, 3, 16, 16).
[method.cpp:1246] Error resizing tensor at input 0
assert error is not None and "static tensor" in log.text

to_executorch() 依匯出時的 shape 把每個中間 tensor 的記憶體事先規劃好(memory planning),執行時不再配置記憶體。輸入 tensor 的 shape 因此固定,餵不同的 batch 會在 set_inputs 這一步就失敗。

這就是 M00 起 export_module() 預設開動態 batch(校準時要餵不同 batch 大小)、到了 M08 卻要關掉的原因。ExecuTorch 也支援匯出時給上限的動態 shape(torch.export.Dim("batch", max=…)),本專案沒有用。

小結

  • to_edge_transform_and_lower 把 XNNPACK 吃得下的子圖整塊交出去;TinyConvNet 整張圖都進了同一個 delegate。
  • .pte 自帶圖、int8 weight 與 memory plan,用 ExecuTorch runtime 載入就能執行。
  • memory plan 依匯出時的 shape 決定,所以 static shape 匯出的 .pte 只吃那個 batch。
  • 不支援的 op 會怎樣在 M08-3;有沒有變快在 M08-4。