跳至主要内容

M07-1 | fp32 的數值底線:同一個 fp32 模型,換個 kernel,輸出就不同

Open In Colab

這一章和之後的 M08、M09 會比較好幾種「同一個量化模型的不同實作」:Q/DQ 圖、reference representation、lowering、torch.compile、後端的 int8 kernel、ONNX Runtime。 比較之前要先知道:完全不量化的 fp32 模型,只換個 kernel 會差多少?這個量級就是「合理差異」的底線,小於它的差,不是量化或 lowering 造成的。

M06-2 看過一個現象:QAT 圖在 eval、observer 關掉之後,同一筆樣本「單獨跑」和「與 15 筆同批跑」偶爾差恰好一格。這本 notebook 會追到它的源頭。

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)

數字和平台有關: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_output
batch 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 看到的現象。