import contextlib
import torch
import torch.nn.functional as F
from common.data import calibration_batches, trained_tiny_net
from common.env import cpu_profile, env_summary
from common.pt2e import quiet
from common.viz import table
quiet()
_ = torch.manual_seed(0)M07-1 | fp32 的數值底線:同一個 fp32 模型,換個 kernel,輸出就不同
這一章和之後的 M08、M09 會比較好幾種「同一個量化模型的不同實作」:Q/DQ 圖、reference representation、lowering、torch.compile、後端的 int8 kernel、ONNX Runtime。 比較之前要先知道:完全不量化的 fp32 模型,只換個 kernel 會差多少?這個量級就是「合理差異」的底線,小於它的差,不是量化或 lowering 造成的。
M06-2 看過一個現象:QAT 圖在 eval、observer 關掉之後,同一筆樣本「單獨跑」和「與 15 筆同批跑」偶爾差恰好一格。這本 notebook 會追到它的源頭。
- A. 單一 conv2d:固定第 1 筆樣本,batch 1~32 逐一比;並以 fp64 當參考,量各自的誤差。
- B. 用 profiler 看各 batch size 實際走哪個 ATen kernel。
- C. 手動切 kernel:用
torch.backends的開關關掉 NNPACK/mkldnn,同一個 batch 換實作再比。 - D. 整個
TinyConvNet:單獨跑 vs 同批跑,以及關掉 conv 的快速路徑之後還剩多少。
數字和平台有關:CPU 挑哪個 conv 實作、在哪個 batch size 切換,取決於 CPU 架構、PyTorch 的 build(有沒有 mkldnn)、執行緒數與輸入大小。 下面存的輸出是在這個環境跑的;在你的機器上執行時,表格下的說明會依你的結果印出。 env_summary() 是本專案的工具(common/env.py),印出一行環境摘要:
print(env_summary())Darwin arm64|torch 2.14.0|threads=4|mkldnn=False|nnpack=True
A. 單一 conv2d:第 1 筆樣本在不同 batch size 下的輸出
同一組 weight、同一筆樣本,只改變「同批還有幾筆」。另外用 fp64 算一份參考答案,看每個實作離它多遠:
(x,) = next(calibration_batches(32, batch_size=32, seed=5))
w = torch.randn(16, 3, 3, 3)
BATCHES = (1, 2, 8, 15, 16, 32)
with torch.no_grad():
ref64 = F.conv2d(x.double(), w.double(), padding=1)
y1 = F.conv2d(x[:1], w, padding=1)
diffs, errs = {}, {}
for n in BATCHES:
yn = F.conv2d(x[:n], w, padding=1)
diffs[n] = float((yn[:1] - y1).abs().max())
errs[n] = float((yn.double() - ref64[:n]).abs().max())
table([[n, f"{diffs[n]:.1e}", f"{errs[n]:.1e}"] for n in BATCHES], ["batch", "第 1 筆 vs batch 1", "對 fp64 的最大誤差"])| batch | 第 1 筆 vs batch 1 | 對 fp64 的最大誤差 |
|---|---|---|
| 1 | 0.0e+00 | 2.5e-06 |
| 2 | 0.0e+00 | 2.6e-06 |
| 8 | 0.0e+00 | 4.1e-06 |
| 15 | 0.0e+00 | 4.1e-06 |
| 16 | 7.4e-05 | 1.2e-04 |
| 32 | 7.4e-05 | 1.2e-04 |
switch = next((n for n in BATCHES if diffs[n] > 0), None)
if switch:
print(f"第 1 筆的輸出到 batch {BATCHES[BATCHES.index(switch) - 1]} 都逐位元相同,batch {switch} 開始差 {diffs[switch]:.1e}。")
else:
print(f"batch {BATCHES[0]}~{BATCHES[-1]} 的第 1 筆輸出都逐位元相同:這台機器的 conv 沒有依 batch 換實作(C 段改用手動切換)。")
assert max(diffs.values()) < 1e-3 # 差異是 fp32 捨入的量級第 1 筆的輸出到 batch 15 都逐位元相同,batch 16 開始差 7.4e-05。
同一筆樣本、同一組 weight,只是同批的樣本變多,結果就可能不同。下一段看是不是換了 kernel。
B. 各 batch size 實際走的 kernel
用 profiler 記錄一次 F.conv2d,列出實際呼叫的 ATen kernel(去掉外層的 conv2d/convolution 包裝)。 cpu_profile() 是本專案的工具(common/env.py):就是 torch.profiler.profile(activities=[CPU]),只是把 profiler 在 C 層印的雜訊訊息擋掉。
WRAPPERS = ("aten::conv2d", "aten::convolution", "aten::_convolution", "aten::_nnpack_available")
def conv_kernels(x, w):
with cpu_profile() as prof:
F.conv2d(x, w, padding=1)
return ", ".join(e.name for e in prof.events() or [] if e.name.startswith("aten::") and e.name not in WRAPPERS and "conv" in e.name)
kernels = {n: conv_kernels(x[:n], w) for n in BATCHES}
table([[n, kernels[n]] for n in BATCHES], ["batch", "ATen kernel"])| batch | ATen kernel |
|---|---|
| 1 | aten::thnn_conv2d, aten::_slow_conv2d_forward |
| 2 | aten::thnn_conv2d, aten::_slow_conv2d_forward |
| 8 | aten::thnn_conv2d, aten::_slow_conv2d_forward |
| 15 | aten::thnn_conv2d, aten::_slow_conv2d_forward |
| 16 | aten::_nnpack_spatial_convolution |
| 32 | aten::_nnpack_spatial_convolution |
groups = {}
for n in BATCHES:
groups.setdefault(kernels[n], []).append(n)
for k, ns in groups.items():
print(f"batch {', '.join(map(str, ns))} → {k}")
same_kernel_same_output = all(diffs[n] == 0 for n in BATCHES if kernels[n] == kernels[1])
print("和 batch 1 走同一個 kernel 的,第 1 筆輸出都逐位元相同:", same_kernel_same_output)
assert same_kernel_same_outputbatch 1, 2, 8, 15 → aten::thnn_conv2d, aten::_slow_conv2d_forward
batch 16, 32 → aten::_nnpack_spatial_convolution
和 batch 1 走同一個 kernel 的,第 1 筆輸出都逐位元相同: True
CPU 的 conv 依 shape 挑實作:同一個實作內逐位元相同,跨實作才有差(對照 A 段)。切換門檻與 CPU、PyTorch build、執行緒數、輸入大小有關。
C. 手動切 kernel:同一個 batch 換實作
torch.backends.nnpack.flags(enabled=False)、torch.backends.mkldnn.flags(enabled=False) 是 PyTorch 公開的開關,關掉後 conv 退回通用實作。這樣在任何機器上都能看到「只換 kernel」的差:
CONFIGS = (("預設", False, False), ("關 NNPACK", True, False), ("關 mkldnn", False, True), ("兩者都關", True, True))
def fast_paths(nnpack_off=False, mkldnn_off=False):
"""暫時關掉 CPU conv 的快速路徑,離開 with 就恢復。"""
stack = contextlib.ExitStack()
if nnpack_off:
stack.enter_context(torch.backends.nnpack.flags(enabled=False))
if mkldnn_off:
stack.enter_context(torch.backends.mkldnn.flags(enabled=False))
return stack
outs, used = {}, {}
with torch.no_grad():
for name, nn_off, mk_off in CONFIGS:
with fast_paths(nn_off, mk_off):
outs[name] = F.conv2d(x, w, padding=1)
used[name] = conv_kernels(x, w)
table([[name, used[name], f"{float((outs[name] - outs['預設']).abs().max()):.1e}",
f"{float((outs[name].double() - ref64).abs().max()):.1e}"] for name, _, _ in CONFIGS],
["設定", "ATen kernel", "vs 預設", "對 fp64"])| 設定 | ATen kernel | vs 預設 | 對 fp64 |
|---|---|---|---|
| 預設 | aten::_nnpack_spatial_convolution | 0.0e+00 | 1.2e-04 |
| 關 NNPACK | aten::thnn_conv2d, aten::_slow_conv2d_forward | 1.2e-04 | 4.1e-06 |
| 關 mkldnn | aten::_nnpack_spatial_convolution | 0.0e+00 | 1.2e-04 |
| 兩者都關 | aten::thnn_conv2d, aten::_slow_conv2d_forward | 1.2e-04 | 4.1e-06 |
c_max = max(float((o - outs["預設"]).abs().max()) for o in outs.values())
print(f"換實作後最大差 {c_max:.1e}" if c_max > 0 else "這台機器沒有快速路徑可關,四種設定都是同一個實作。")
assert all(torch.equal(outs[a], outs[b]) for a in used for b in used if used[a] == used[b]) # 同一個 kernel → 逐位元相同
assert c_max < 1e-3換實作後最大差 1.2e-04
- 同一份輸入、同一組 weight,只換 kernel 輸出就不同;走同一個 kernel 的設定則逐位元相同。
- 快速實作對 fp64 的誤差可能比較大。NNPACK 比通用實作離 fp64 更遠,推論是它用了 Winograd/FFT 一類的快速演算法(這裡沒有讀原始碼確認)。
D. 整個 fp32 模型:單獨跑 vs 同批跑
用 M05 的 TinyConvNet(trained_tiny_net()),16 筆樣本逐筆單獨跑 vs 一次同批跑。分別在預設與「兩者都關」下量,另外單獨量最後的 linear:
model, x16 = trained_tiny_net(), x[:16]
d = {}
with torch.no_grad():
for name, nn_off, mk_off in (CONFIGS[0], CONFIGS[-1]):
with fast_paths(nn_off, mk_off):
alone = torch.cat([model(x16[i : i + 1]) for i in range(16)])
d[f"整個模型,{name}"] = float((alone - model(x16)).abs().max())
h = torch.randn(16, model.fc.in_features) # 只看最後的 linear:同樣 16 個 row 的輸入
d["只有 fc(linear)"] = float((torch.cat([model.fc(h[i : i + 1]) for i in range(16)]) - model.fc(h)).abs().max())
table([[k, f"{v:.1e}"] for k, v in d.items()], ["", "16 筆單獨跑 vs 同批跑的最大差"])| 16 筆單獨跑 vs 同批跑的最大差 | |
|---|---|
| 整個模型,預設 | 2.4e-05 |
| 整個模型,兩者都關 | 2.4e-07 |
| 只有 fc(linear) | 4.8e-07 |
d_default, d_off, d_fc = d.values()
if d_default > 0 and d_off == 0:
print("關掉 conv 的快速路徑後差異消失:「單獨 vs 同批」的差完全來自 conv 換實作。")
elif d_off > 0 and d_fc > 0:
print(f"關掉 conv 的快速路徑後剩 {d_off:.1e};linear 本身在 batch 1 與 16 就差 {d_fc:.1e}。")
elif d_default > 0:
print("關掉 conv 的快速路徑後仍有差:這台機器上還有其他依 shape 換實作的 op(這裡沒有追查)。")
else:
print("單獨跑與同批跑完全相同(沒有依 batch 換實作);底線請看 C 段。")
assert max(d.values()) < 1e-3關掉 conv 的快速路徑後剩 2.4e-07;linear 本身在 batch 1 與 16 就差 4.8e-07。
在上面存的輸出裡,關掉 conv 的快速路徑後還剩一點差,來自 linear:矩陣乘法函式庫對「一個 row」與「多個 row」的輸入可能走不同的 routine(推論,這裡沒有讀原始碼確認)。
小結
- fp32 本身就有底線:同一個 conv 換實作、整個模型單獨跑 vs 同批跑,都差在 1e-5~1e-4 的量級(上面印出的數字)。
- 比較任何兩個實作(Q/DQ vs reference、eager vs lowered、PyTorch vs ONNX Runtime)時,這個量級的差是 fp32 kernel 的底線,不是量化或 lowering 的錯。
- 量化圖裡,這個小差偶爾會把落在 round 邊界的值推到隔壁格,變成「恰好差 1 格」,也就是 M06-2 看到的現象。