| 模型 | test loss |
|---|---|
| fp32 | 0.826 |
| PTQ int4 | 1.071 |
| QAT int4 | 0.920 |
M06 概覽 | QAT 的訓練流程:讓訓練時的圖等於 convert 之後的圖
M05 用 PTQ 量化一個訓練好的模型,並學會怎麼查精度問題。位元數再壓低時,PTQ 可能真的不夠,這時可以讓模型在訓練中就看到量化誤差、自己去適應它,這就是 QAT(quantization-aware training)。 QAT 的圖長什麼樣在 M04-2 看過;這一章只管訓練。後面六本 notebook(M06-1~M06-6,M06-5、M06-6 是選讀)逐一動手驗證。
這一章回答的問題
| 問題 | 一句話的答案 | 動手驗證 |
|---|---|---|
| prepared 的 QAT 圖能直接訓練嗎? | 可以,它是普通的 GraphModule;round 的梯度靠 STE | M06-1 |
| 評估前要做什麼? | BN 切 eval、observer 關掉,否則結果不可重現 | M06-2 |
| 訓練時的圖等於 convert 之後的圖嗎? | 不一定:XNNPACK 預設的 weight fake quant 就不相等 | M06-3 |
| 低位元時,QAT 真的比 PTQ 好嗎? | int4 下明顯較好;同樣配方再訓練 fp32 後做 PTQ 則沒有改善,所以效益來自量化適應 | M06-4 |
選讀的兩本:M06-5 把 scale 也交給梯度學(range learning);M06-6 處理 weight 由別的參數算出來(weight_norm)時的 QAT。見後面的〈選讀〉兩節。
先看一個例子:int4 下 QAT 追回 PTQ 掉的精度
模型和 M05 相同(訓練好的 TinyConvNet)。int8 下 PTQ 已經貼著 fp32,沒有東西可救(M06-1),所以這裡用 int4:activation [-8, 7]、weight [-7, 7]。 比較 PTQ int4 與 QAT int4(訓練 5 個 epoch)的 test loss,並檢查 QAT 訓練完的圖和它 convert 之後的圖輸出是否一致:
QAT 追回了 PTQ int4 比 fp32 多出的 test loss 的 62%
QAT 訓練完的 fake quant 圖 vs convert 之後的圖:SQNR 141.1837615966797 dB,最大差 0.00 個輸出 LSB
- PTQ int4 的 test loss 比 fp32 高了一截;QAT 只訓練 5 個 epoch 就追回一部分(比例印在上面)。這只是一個 seed,5 個 seed 與對照組的比較在 M06-4。
- 最後一行是這一章最重要的檢查:訓練時模型看到的圖(fake quant 圖),和 convert 之後的 Q/DQ 圖,輸出一致:SQNR 是 inf,或最大差在 1 個輸出 LSB 內(判準見後面〈全章的原則〉)。這件事不是自動成立的,M06-3 與 M06-5 各抓到一個讓它不成立的地方。
這裡用的 qat_quantizer、train_qat、freeze_for_eval 都是本專案的工具(common/qat.py),不是 torchao 的 API。M06-1 會把訓練迴圈逐行寫出來,之後才換成這些工具。
定義
fake quant
QAT 在圖上插的不是 observer,而是 fake quant:forward 時先量化再立刻反量化,輸出仍是 fp32,但值已經落在量化的格點上。scale \(s\)、zero point \(z\)、整數範圍 \([q_{min}, q_{max}]\):
\[ \hat{x} = \big(\operatorname{clamp}(\operatorname{round}(x/s) + z,\ q_{min},\ q_{max}) - z\big)\cdot s \tag{1} \]
STE(straight-through estimator)
round 幾乎處處梯度為 0,直接微分就訓練不動。STE 在 backward 時把 round 當成恆等函數,只保留 clamp 的效果:
\[ \frac{\partial \hat{x}}{\partial x} = \begin{cases} 1 & q_{min} \le \operatorname{round}(x/s) + z \le q_{max} \\ 0 & \text{otherwise} \end{cases} \tag{2} \]
用一個 int4 的 FakeQuantize 和式 (1)、(2) 比對:
fq = FakeQuantize(observer=MovingAverageMinMaxObserver, dtype=torch.int8, qscheme=torch.per_tensor_affine,
quant_min=-8, quant_max=7, eps=2**-12)
with torch.no_grad():
fq(torch.linspace(-1, 1, 100)) # 校準 range
fq.apply(disable_observer)
s, z = (float(v) for v in fq.calculate_qparams())
x = torch.linspace(-2, 2, 41, requires_grad=True)
y = fq(x)
y.sum().backward()
q = torch.round(x.detach() / s) + z
by_eq1 = (q.clamp(-8, 7) - z) * s # 式 (1)
by_eq2 = ((q >= -8) & (q <= 7)).float() # 式 (2)
assert x.grad is not None
print(f"s = {s:.4f}, z = {z:.0f};式 (1) 相符:{torch.allclose(y, by_eq1)};式 (2) 相符:{torch.equal(x.grad, by_eq2)}")
assert torch.allclose(y, by_eq1) and torch.equal(x.grad, by_eq2)s = 0.1333, z = -1;式 (1) 相符:True;式 (2) 相符:True
range 外的值梯度是 0,這一步得不到更新。STE 的推導見部落格〈搞懂 Quantization Aware Training 中的 Fake Quantization〉;STE 的改良(EWGS、MAD 等)整理在部落格〈量化技術路線〉的 QAT 一節。
指標:test loss,比較對象是「同樣配方再訓練的 fp32」
M05 用「對 fp32 輸出的 SQNR」。QAT 會改 weight,這個 SQNR 量到的是「離原模型多遠」,不是「量化後好不好」,所以這一章改用 test loss(cross-entropy,連續、而且是 QAT 直接最佳化的量)。 QAT 多做了「訓練」這件事,要分開訓練本身與量化的影響,基準是用同樣配方再訓練、不量化的 fp32(M06-1 C);int4 時再加上「再訓練後做 PTQ」這組對照(M06-4)。seed 只改變資料的順序,test loss 就會不同,所以 M06-4 每組跑 5 個 seed。
流程
export(train=True) BN 要是 train 版的 op
→ prepare_qat_pt2e 插 fake quant、建 conv + BN 的 QAT 子圖(M04-2)
→ 訓練 每個 epoch 切 train/eval(M06-1)
→ freeze_for_eval BN 用 running stats、observer 關掉(M06-2)
→ convert_pt2e 得到 Q/DQ 圖
全章的原則:訓練時的圖 = convert 之後的圖
QAT 的前提是「訓練時模擬的量化誤差 = 部署時的量化誤差」。檢查方法固定:同一批資料餵 fake quant 圖(freeze 之後)和 convert 圖,比較輸出。 這一章抓到兩個讓等號不成立的地方:
| 出處 | 等號為什麼不成立 | 解法 |
|---|---|---|
| M06-3 | weight 的 fused fake quant 用自己的公式算 scale | weight 改用非 fused 的 FakeQuantize |
| M06-5 | learnable 的 scale 被梯度推成負數,convert 時被夾成 eps | 每步把 scale 夾回正數 |
判準是:輸出 SQNR 為 inf,或最大差在 1 個輸出 LSB 內(輸出 dequantize 的 scale)。 後者是因為 activation 的 fused fake quant,scale 偶爾和 convert 用的值差 1 ULP,只是浮點捨入(M06-3 C)。
這個檢查只保證「訓練圖 = Q/DQ 圖」;Q/DQ 圖到後端整數 kernel 的那一段,M07 起再看。
選讀一:把 scale 也交給梯度學(M06-5)
到 M06-4 為止,scale 都由 observer 的 min/max 統計決定,訓練只改 weight。LearnableFakeQuantize 把 scale 與 zero point 做成 nn.Parameter,讓 loss 的梯度去調整量化範圍(LSQ 的做法)。 接進 PT2E 只要把 spec 的 fake quant 類別換掉,但有兩件事要自己處理:
- scale 是第一次 forward 才建立的參數,optimizer 要在那之後才建。
- torchao 只在 convert 時把 scale 夾成正數;訓練中梯度可能把它推成負數,訓練圖就不等於 convert 圖了,所以要每步夾回正數。
這個模型在 int4 上,range learning 3 個 seed 都優於 observer 版;代價是多了 grad scaling、qparams 的 lr 這組超參數,調錯會發散。 梯度的推導見部落格〈Learning Zero-Point and Scale in Quantization Parameters〉。
選讀二:weight 由別的參數算出來時(M06-6)
M05 概覽的〈選讀〉介紹過 torch.nn.utils.parametrize:註冊之後,參數變成「由別的參數算出來」,每次存取都重新計算(PyTorch 教學 Parametrizations)。 例子是 weight_norm,每個輸出 channel 各自算
\[ W = g \cdot \frac{v}{\lVert v \rVert} \tag{3} \]
export 之後,式 (3) 變成圖上的 aten._weight_norm,conv 的 weight 是它的輸出、不是 get_attr。PTQ 不訓練,在 prepare 之前把它換成常數就好;QAT 不一樣,訓練的正是 \(g\)、\(v\)。
fake quant 插在哪裡、梯度怎麼走
和 PTQ 的 observer 一樣,weight 的 fake quant 接在 _weight_norm 的輸出上,量化的是算出來的 \(W\)。 backward 時,STE 的梯度穿過 fake quant,再經 _weight_norm 傳回 \(g\)、\(v\)。所以 QAT 期間 weight_norm 照常有效,被訓練的是 \(g\) 與 \(v\)。
有 BN 時,哪一步報錯
讀常數的 torchao 內部函式只接受 get_attr,所以「要把 conv 的 weight 當常數讀出來」的那一步會報錯:
ValueError: Expected get_attr node, got call_function
PTQ 和 QAT 在不同的步驟碰到它:
| 流程 | 什麼時候折 BN | 結果 |
|---|---|---|
| PTQ | prepare_pt2e 一開始(M04-1) |
prepare 就報錯 |
| QAT | prepare_qat_pt2e 只建 QAT 子圖、不折(M04-2);convert_pt2e 才折 |
prepare 能過、訓練照常,訓練全部做完、convert 時才報錯 |
怎麼解
把 \(W\) 算好、存成一般的參數,BN 就能照常折。關鍵是什麼時候做:
| 時機 | 做法 | 結果 |
|---|---|---|
| export 之前 | remove_parametrizations(..., leave_parametrized=True) |
等於放棄訓練 \(g\)、\(v\) |
| 訓練之後、convert 之前 | strip_weight_norm(gm):把圖上的 _weight_norm 換成常數 |
訓練了 \(g\)、\(v\),convert 也能折 BN |
strip_weight_norm 是本專案的工具(common/weight_norm.py),不是 torch 或 torchao 的 API,M05-5 已經介紹過。讀者只要知道它做的事;換完之後輸出逐位元不變、convert 圖和 fake quant 圖一致,都在 M06-6 C 段驗證。
有 BN 又要保留 weight_norm 的 QAT 流程因此是:export(train=True) → prepare_qat_pt2e → 訓練 → freeze_for_eval → strip_weight_norm → convert_pt2e。
小結與本章地圖
- QAT 的圖可以直接訓練,round 的梯度靠 STE;評估與 convert 前要 BN 切 eval、observer 關掉。
- 全章的原則是「訓練時的圖 = convert 之後的圖」,檢查方法是比較兩張圖的輸出。
- int4 下 QAT 明顯優於 PTQ;同樣配方再訓練 fp32 後做 PTQ 沒有改善,所以效益來自訓練時模擬量化,不是多訓練。
- (選讀)range learning 有效但多了要調的超參數;weight_norm 要保留到訓練結束,convert 前才拔。
| notebook | 回答的問題 |
|---|---|
| M06-1 最小 QAT | STE 的梯度;最小訓練迴圈;convert 後和 PTQ 同形 |
| M06-2 評估前的兩個開關 | BN 與 observer 不切會怎樣;allow_exported_model_train_eval |
| M06-3 fused fake quant 的陷阱 | 為什麼 XNNPACK 預設設定的訓練圖 ≠ convert 圖;scale 公式 |
| M06-4 int4 的對照實驗 | 低位元時 QAT 是否比 PTQ 好;效益來自量化適應還是多訓練 |
| M06-5(選讀)range learning | LearnableFakeQuantize 怎麼接、要注意什麼、效果 |
| M06-6(選讀)weight_norm | 保留 \(g\)、\(v\) 訓練;有 BN 時 convert 報錯與解法 |
這一章刻意跳過的:Q/DQ 圖之後會被改寫成什麼、和後端的整數運算差多少(M07 起)。
參考資料
- PyTorch 官方教學:PyTorch 2 Export Quantization-Aware Training
- torch v2.14.0 原始碼:
QuantUtils.h、fused_obs_fake_quant.cpp(fused fake quant 的 scale 公式,M06-3) - torchao 0.18 原始碼:
fake_quantize.py(FakeQuantize、LearnableFakeQuantize) - R. Krishnamoorthi, Quantizing deep convolutional networks for efficient inference: A whitepaper, 2018:per-channel、QAT 的實驗
- 部落格:搞懂 Quantization Aware Training 中的 Fake Quantization、Learning Zero-Point and Scale in Quantization Parameters、Convolution 與 BatchNorm 的融合大法
- PyTorch 教學:Parametrizations;文件:
weight_norm(選讀 M06-6 用) - Weight Normalization 的筆記:weight_norm 為什麼把 weight 拆成 \(g\) 與 \(v\)、兩者的梯度(選讀 M06-6 用)