FP32 tensor 怎么量化成 INT8?
scale 和 zero point 怎么算?
先弄清楚:每个概念是什么
- Quantization
- 用 256 个整数格子近似一段实数区间,存 1 byte 而不是 4 byte,代价是最多差半格。例:scale = 0.1 时 0.33 → 3,还原成 0.3,误差 0.03。
- Scale \(s\)
- 一个格子代表多少实数 = 区间宽度 ÷ 格子数。例:区间 [−1, 3] 分成 255 段,\(s = 4/255 \approx 0.0157\)。
- Zero point \(z\)
- 实数 0 对应哪个整数。\(z\) 要 round 成整数,所以 0 能被精确表示,zero padding、ReLU 后的大量 0 都没有误差,代价是 [min, max] 两端会被稍微挪动。例:区间 [−1, 3] 时 \(z = -64\)。
- Clamp(saturation)
- 超出 [−128, 127] 的值钉在边界上。不能直接 cast,因为 int8 会 wraparound。例:200 直接转 int8 是 −56;clamp 后是 127。
- Per-tensor vs per-channel
- 整个 tensor 共用一个 scale,还是每个 output channel 各一个。weight 固定,可以离线 per-channel(weight-only 量化见 inference cost)。activation 每个输入都不同,只能 per-tensor 加 calibration(或 QAT)。例:一行 weight 约 ±0.03、另一行约 ±2:共用 scale 时第一行只剩 1、−1、2;per-channel 后是 42、−85、127,这一行的误差小约 70 倍。
看图:实数怎么放进 256 个格子
30 秒回答
Quantize with q = clamp(round(x / scale) + zero_point, −128, 127) and dequantize with x_hat = scale · (q − zero_point). Symmetric sets zero_point = 0 and scale = max|x| / 127; it keeps integer matmuls cheap, so weights use it. After ReLU the data isn't centered and symmetric wastes half the levels, so we use asymmetric: scale = (max − min) / 255 and zero_point = round(−min / scale) − 128, which makes 0 exact. Clamp before casting to avoid wraparound. In range the error is at most scale / 2; outliers inflate the scale, so use per-channel scales, percentile calibration, or keep outliers in higher precision.
关键公式
symmetric(只缩放,常用 [−127, 127]):
\[ s = \frac{\max|x|}{127},\qquad z = 0 \]asymmetric(affine,区间先扩到包含 0):
\[ s = \frac{\max - \min}{255},\qquad z = \mathrm{round}\!\Big(\!-\frac{\min}{s}\Big) - 128 \]误差:区间内 \(|x - \hat x| \le s/2\)(rounding error);区间外是 clipping error。
手算例子
题库自检:quantize(np.array([-1., 0., 1.]), scale=0.1) → [−10, 0, 10]。再看偏向正数的 \(x = [-1,\ 0,\ 0.5,\ 3]\):
| \(s\) | \(z\) | \(q\) | \(\hat x\) | 最大误差 | |
|---|---|---|---|---|---|
| symmetric | 3/127 = 0.0236 | 0 | −42, 0, 21, 127 | −0.992, 0, 0.496, 3 | 0.0079 |
| asymmetric | 4/255 = 0.0157 | −64 | −128, −64, −32, 127 | −1.004, 0, 0.502, 2.996 | 0.0039 |
symmetric 的 −128 到 −43 没用上,格子宽 1.5 倍,误差上限 \(s/2\) 也大 1.5 倍。这 4 个点上的最大误差正好差一倍。
手撕代码(NumPy)
import numpy as np
def quantize(x, scale, zero_point=0, qmin=-128, qmax=127):
q = np.round(x / scale) + zero_point
return np.clip(q, qmin, qmax).astype(np.int8) # 先 clamp 再 cast
def dequantize(q, scale, zero_point=0):
return scale * (q.astype(np.float32) - zero_point)
def qparams(x, symmetric=True):
if symmetric: # z = 0,配 qmin=-127
return max(np.abs(x).max(), 1e-8) / 127, 0
lo, hi = min(x.min(), 0.0), max(x.max(), 0.0) # 包含 0
scale = max(hi - lo, 1e-8) / 255
return scale, int(np.round(-lo / scale)) - 128
per-channel 只改 scale 的形状:np.abs(W).max(axis=1, keepdims=True) / 127,靠 broadcasting 复用同一个 quantize。1e-8 防全 0 tensor 时除以 0。
必答点
- \(q = \mathrm{clamp}(\mathrm{round}(x/s) + z)\),\(\hat x = s(q - z)\)
- symmetric:\(z = 0\)、\(s = \max|x|/127\);asymmetric:\(s = (\max-\min)/255\),\(z\) 让 0 精确
- 先 clamp 再 cast,不能 wraparound
- 误差 ≤ \(s/2\);outlier 撑大 \(s\) → per-channel、percentile calibration、outlier 单独处理
- weight:per-channel symmetric;activation:per-tensor,要 calibration
追问
symmetric 什么时候不够用?
分布偏向一边时(ReLU 后、softmax 输出),一半格子浪费,误差翻倍。但 asymmetric 的 weight 会让 matmul 多出一项不能离线算的 zero-point 项,所以 weight 一般用 symmetric,asymmetric 留给 activation。
输入 skewed 或有 outlier,error 会怎样?
用 max 定 range 时,一个 outlier 就把 \(s\) 撑大:[0.1, 0.2, 0.3, 100] 量化成 [0, 0, 0, 127],小值全变 0。办法:percentile / entropy calibration 剪掉极少数 outlier。LLM.int8() 把幅度 ≥ 6 的少数 feature 维度(约 0.1%)留在 FP16。SmoothQuant 把 activation 的 outlier 等价地挪到 weight 上。
来源Jacob et al. 2018 §2.1 eq. 1(pp.2–3)· Wu et al. 2020, Integer Quantization for DL Inference §3.1 eq. 1–8(pp.3–4)、§3.2–3.3(pp.4–5)、§3.4(p.6)、§6(p.12)· Dettmers et al. 2022, LLM.int8() §1(p.2)、§3.2(p.5)· Xiao et al. 2023, SmoothQuant abstract