FP32 tensor 怎么量化成 INT8?
scale 和 zero point 怎么算?

英文问法Implement quantize_fp32_to_int8(x, scale). · Add a zero_point for asymmetric quantization. · When is symmetric quantization not enough? · What happens to values outside [−128, 127], or with outliers?

考过面经:NVIDIA 一亩三分地题库的 hot 题("FP32 Tensor to Int8 Quantization",phone screen,60 分钟,有 5 个 follow-up,汇总帖 1190285)· NVIDIA 2025 MLE 店面(FP32→INT8,仅摘要)· NVIDIA Senior AI Engineer 2023(numerical precision 和 quantization 的 corner case,仅摘要)

先弄清楚:每个概念是什么

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 个格子

symmetric:s = 3/127 = 0.0236,z = 0x(float)q(int8)−3−1−42000.5213127−128这些格子没用上q = −128 … −43asymmetric:s = 4/255 = 0.0157,z = −64x(float)q(int8)−1−1280−64 = z0.5−323127
图 1:同一组 x = [−1, 0, 0.5, 3],上面是实数,下面是 int8 的 256 个格子,竖箭头是 round(x/s) + z。symmetric 左边一大段格子没用上,asymmetric 正好铺满。
scale = 0.1、z = 0:x = 20 → x/s = 200,超出 [−128, 127]x(float)q(int8)int8 装不下012.720−1280127clamp 到 127,还原成 12.7−56直接 astype(np.int8):200 绕回成 −56,符号都反了
图 2:scale = 0.1 时 x = 20 算出来是 200。先 clamp(橙色)还原成 12.7,误差就是超出的那部分。直接转 int8(蓝色)会绕回成 −56。

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.

关键公式

\[ q = \mathrm{clamp}\big(\mathrm{round}(x/s) + z,\ -128,\ 127\big) \] \[ \hat x = s\,(q - z) \]

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\)最大误差
symmetric3/127 = 0.02360−42, 0, 21, 127−0.992, 0, 0.496, 30.0079
asymmetric4/255 = 0.0157−64−128, −64, −32, 127−1.004, 0, 0.502, 2.9960.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。

必答点

追问

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