INT8 量化为什么不准:五组实测拆解误差的四个来源
引子:量化只有一行代码
// 量化int8_tq=round(r/scale)+zero_point;// 反量化floatrhat=scale*(q-zero_point);就这两行。但一个 INT8 模型落地,最常遇到的不是"怎么量化",而是:
“量化完掉了 3 个点,我该调哪里?”
绝大多数人的第一反应是去折腾校准策略(改百分位、换 KL 散度、换 MSE)。但我把误差拆开实测了一遍,结论跟直觉相反:
校准策略是收益最小的那个旋钮,量化粒度才是最大的那个。
下面五组实验全部可复现(bench/int8_quant_bench.cpp,无第三方依赖,g++ -O2直接跑)。统一用SQNR(信号量化噪声比,dB,越大越好)衡量:
SQNR = 10 · log10( 信号能量 / 量化噪声能量 )每 +6 dB ≈ 噪声能量减半再减半(即降到 1/4)。这是量化领域的通用尺子。
一、先把量化接回课本:它就是"定点数"
INT8 量化不是什么新东西,它就是《计算机组成原理》数据的机器表示那一章里的定点数。
| 概念 | 课本里的说法 | 量化里的说法 |
|---|---|---|
| 定点格式 Qm.n | m 位整数 + n 位小数 | INT8 权重通常是Q7(1 符号位 + 7 小数位) |
| 定标(scaling) | 真值 = 整数 × 2⁻ⁿ | r = q × scale |
| 表示范围 | 受总位数限制 | INT8 有符号补码:−128 ~ 127 |
| 精度 | 相邻两个可表示值的间隔 | scale,也叫量化步长 |
| 溢出 | 超出范围就回绕/饱和 | 校准(calibration)要解决的 |
| 舍入模式 | IEEE 754 的四种舍入 | round/ 截断 / 就近偶数 |
一句话:所谓量化,就是给一组浮点数挑一个"小数点位置"(scale),然后用 8 bit 存下它。
于是误差只能从四个地方来:
- 小数点挑得不够细→ 量化粒度问题(per-tensor vs per-channel)
- 小数点挑得不够准→ 动态范围/截断问题(校准策略)
- 算的过程中爆了→ 累加器溢出
- 取整的方式有偏→ 舍入方式
逐个实测。
二、误差来源①:量化粒度 —— 最大的那块,+10.35 dB
实验设计:模拟一个 depthwise 卷积的权重,64 个通道 × 3×3。关键是让不同通道的"量级"差 100 倍(0.1 ~ 10,对数均匀)——这在 depthwise 卷积里非常常见,因为每个通道是独立学习的。
// 通道 c 的权重 ~ N(0, 1) * ch_scale[c],ch_scale 跨 0.1 ~ 10ch_scale[c]=std::pow(10.f,-1.f+2.f*(c/(float)(C-1)));对比两种方案:
- per-tensor:整层共享一个
scale,由全层最大值决定 - per-channel:每个通道一个
scale
实测结果
通道量级跨度 : 0.100 ~ 10.000 (100 倍) per-tensor scale : 0.182082 (由最大通道决定) 方案 SQNR(dB) 相对L2误差 被压死通道数 per-tensor 36.89 1.43% 3/64 per-channel 47.24 0.43% 0/64 最惨通道 #0:本征 scale=0.1000,per-tensor 下相对误差 100.0% → 该通道的权重被量化成 2 个可用电平(满量程 255)怎么读这组数字
per-tensor 的 scale 被最大的那个通道绑架了。
scale 取 0.182 是为了装下量级 10 的通道。可量级只有 0.1 的通道 #0,它的权重分布在 ±0.3 左右,除以 0.182 之后整个通道只剩 2~3 个可用电平——不是"精度差点",是这条通道的特征被整个抹平了。实测它相对误差 100%,也就是量化后的权重跟"全零"差不多。
64 个通道里有3 个被压死(相对误差 > 50%)。
per-channel 之后:每个通道各取各的 scale,全部分到 255 个电平 →SQNR 36.89 → 47.24 dB,+10.35 dB。
+10.35 dB 是什么概念?噪声能量降到原来的 1/10.8。这是本文所有手段里收益最大的一个,而且成本几乎为零——只是多存 64 个 float 而已。
为什么 depthwise 卷积是重灾区
普通卷积的权重是[out_c, in_c, kh, kw],每个输出通道要跨所有输入通道做加权求和,通道之间的量级差异在求和时被平均掉了。
depthwise 卷积每个输出通道只跟一个输入通道卷——没有跨通道求和,通道间的量级差异原封不动地暴露出来。
所以业界的共识是:depthwise 卷积必须做 per-channel 量化。MobileNet、EfficientNet 这类大量使用 depthwise 的轻量网络,per-tensor 量化基本是废的。
三、误差来源②:校准与截断 —— 收益最小,风险最大
实验设计:模拟一个带长尾的激活分布:99.8% 服从 N(0,1),0.2% 是 |x| ∈ [15, 25] 的离群点。这是真实激活的典型形态——主体很窄,但有极少数"跑得很远"的值。
max 校准(用真实最大值定 scale)会怎样?scale 被撑到 24.98/127 = 0.197,主体 ±1 的部分只分到 ±5 个电平。
于是有了经典优化:百分位裁剪——把阈值定在某个分位数上,超出部分直接截断(clamp)。
实验 2:扫描不同的百分位
分布:99.8% ~ N(0,1) + 0.2% 离群点 (|x| in [15,25]) 真实动态范围 : ±24.98 策略 阈值 SQNR(dB) 截断损失 舍入损失 ------------------------------------------------------------- max 校准 24.978 27.68 0.0000% 0.1708% 99.99% 分位 24.558 27.81 0.0002% 0.1652% 99.9% 分位 20.446 23.17 0.3687% 0.1138% 99.5% 分位 2.966 4.60 34.6682% 0.0024% 99.0% 分位 2.644 4.44 36.0127% 0.0019% 95.0% 分位 1.976 4.05 39.3107% 0.0010%看"截断损失"和"舍入损失"这两列的拉锯:阈值降得越低,舍入误差越小(从 0.17% 降到 0.001%),但截断损失爆炸式上升(从 0% 涨到 39%)。
最优在 99.99%,27.81 dB,比 max 校准只好了 0.13 dB。而一旦百分位选过头(99.9% → 99.5%),直接从 23 dB 崩到4.6 dB。
实验 2b:换个角度再验证一次
上面可能只是这一组分布的巧合。换个变量再扫一遍——固定主体 N(0,1)、离群点 |x| ~ U(15,25),扫离群点占比:
离群占比 离群能量 max校准 99.99% 99.9% 99.0% 裁剪收益 ------------------------------------------------------------------- 0.01% 2.2% 25.05 18.57 18.14 17.38 +0.00 dB 0.10% 29.0% 26.43 26.77 16.37 6.54 +0.33 dB 0.50% 67.0% 29.70 29.76 28.66 3.04 +0.07 dB 2.00% 89.1% 34.49 34.50 34.59 20.30 +0.10 dB 5.00% 95.5% 38.15 38.15 38.22 31.42 +0.07 dB 裁剪收益 = 最好百分位 − max校准五组里,裁剪的最高收益只有 +0.33 dB,最低 +0.00 dB。而选错百分位最多能崩 −18 dB。
那为什么"百分位裁剪能涨点"的说法这么流行?
这不是我的实验做错了,而是两个尺度的错位:
1. 看"离群能量"那一列。离群点个数很少(0.01% ~ 5%),但它们扛着 2% ~ 95% 的能量(因为误差按平方算,|x|=25 的一个点顶 625 个 |x|=1 的点)。
砍掉它们省下的舍入误差,抵不过砍掉的那截信号。SQNR 是全局能量指标,它对"砍掉高能量样本"极其敏感。
2. 真实场景里,裁剪确实有用,但不是靠"选百分位"。
工业界真正有效的是:
- MSE / KL 散度最小化搜索:在候选阈值上算出量化后分布与原始分布的 KL 散度,选最小的那个(TensorRT 的 entropy calibration 就是这么干的)——它是搜出来的,不是拍一个 99.9%
- 离群点分解(outlier decomposition):LLM 量化里的 SmoothQuant / AWQ 思路——把离群点所在的维度单独用 FP16 算,主体正常量化。这是拆开处理,不是砍掉
实操建议:如果你只有精力动一个旋钮,动量化粒度,别动校准百分位。真要动校准,用搜索(MSE/KL),别用拍脑袋的百分位。
四、误差来源③:累加器溢出 —— 最容易被忽略的那个
这一条严格说不是"量化误差",而是量化之后算术运算引入的误差。它最隐蔽,因为它不表现为"精度差一点",而是表现为"结果彻底错"。
INT8 矩阵乘的本质是:K 个 int8 × int8 的乘积累加起来。单个乘积最大 128 × 127 =16,256。
int16_t能表示 ±32,767。也就是说——理论上 K ≥ 3 就可能溢出。
实验 3:实测溢出频率
随机 int8 × int8,累加 K 次,跑 20000 次统计:
K int16 溢出次数 int32 峰值 峰值/INT16_MAX 峰值/INT32_MAX ------------------------------------------------------------------ 64 9022/20000 174234 5.32x 0.008113% 128 11861/20000 274653 8.38x 0.012790% 256 14302/20000 383242 11.70x 0.017846% 512 15837/20000 483745 14.76x 0.022526% 1024 16944/20000 712104 21.73x 0.033160% 2048 17910/20000 1039549 31.73x 0.048408%K=512 时,20000 次里有 15837 次(79%)真实值超出了 int16 的表示范围。而这还只是随机数据——真实激活是有相关性的,同号相乘的概率更高,只会更糟。
理论最坏情况对照:
最坏 |acc| = K × 128 × 127 K=2048 最坏 |acc| = 33,292,288 int16 溢出 / int32 安全int32能撑到 K = 2,147,483,647 / 16,256 ≈132,104。常规卷积展开后的 K(in_c × kh × kw)一般在几千量级,int32 绰绰有余。
这就是为什么
所有 INT8 GEMM kernel 都用 int32 累加器,只在最后一步做反量化和再量化(requantize)。你在 TFLite、QNNPACK、oneDNN 的 INT8 kernel 里看到的vpmaddubsw(乘加得到 16 位)后面必接vpmaddwd(再拓宽到 32 位累加),就是这个道理。
踩坑提示:如果你为了省带宽/省寄存器,把累加器改成 int16,溢出是静默回绕的——不会崩,不会报错,只是某些像素的值突然翻符号。这种 bug 极难定位。
补充一个结构上的点:int8 乘法溢出时要不要饱和(saturate)而不是回绕?ARM 的VQDMULH/ x86 的VPDPBUSD这类指令自带饱和语义;手写 C 代码时如果没有饱和处理,int16_t溢出就是 UB 或回绕。这是课本里"定点运算的溢出处理"在真实 kernel 里的样子。
五、误差来源④:舍入方式 —— 白捡的 6 dB
定点乘法之后要移回原来的小数点位置:Q7 × Q7 = Q14,需要右移 7 位。
int32_tp=a*b;// Q14int32_ttr=p>>7;// 算术右移 = 向负无穷取整int32_trd=(p+(1<<6))>>7;// 加半个 LSB 再移 = 四舍五入两行差一个常数。差多少?实验 4 跑 200 万次随机乘法:
方式 平均误差(LSB) 均方误差(LSB²) 有误差比例 ------------------------------------------------------------ 算术右移 -0.48273 0.31804 96.5% 四舍五入 +0.01410 0.08333 96.5% 噪声能量比:0.2620 ≈ 理论值 1/4,即相差 5.82 dB两个结论
1. 算术右移有 −0.5 LSB 的系统性偏差。这是偏差(bias),不是噪声(noise)——噪声会互相抵消,偏差不会。
第一层偏 −0.5 LSB,第二层在这个基础上再偏 −0.5……几十层累加下来,整个激活分布会被系统性地推向一个方向。对检测、分割这类依赖绝对数值的任务尤其致命。
(注意 C++ 里(int)float_value是向零截断,正负两边都往 0 缩,不是向负无穷;>>对负数是向负无穷。两者的偏差模式不一样,但都有偏。)
2. 噪声能量正好差 4 倍。
理论很好推:
- 截断:误差在 (−1, 0] 上均匀分布 → 均值 −0.5,方差 1/12 → MSE = 1/4 + 1/12 =1/3
- 舍入:误差在 [−0.5, +0.5] 上均匀分布 → 均值 0,方差 1/12 → MSE =1/12
比值1/4,即10·log₁₀(4) = 6.02 dB。实测 0.2620(略高于 1/4,因为 3.5% 的样本恰好整除、误差为 0),对应 5.82 dB——理论和实践对上了。
这是本文最"白捡"的一条:改一个常数,白得 6 dB,零成本零风险。
顺带回课本:这就是 IEEE 754 的舍入模式那一节。默认"就近取偶"(round-half-to-even)是有讲究的——它无偏。而 C 语言默认的整型转换走"向零截断",是有偏的。当年背的四种舍入模式,在这里直接换算成 6 dB。
六、收益排行榜:先调哪个旋钮
把四组实验的收益排个序:
| 手段 | 实测收益 | 风险 | 优先级 |
|---|---|---|---|
| per-tensor → per-channel | +10.35 dB | 几乎无(多存 C 个 scale) | 🥇 最高 |
| 算术右移 → 四舍五入 | +5.82 dB(理论 6.02) | 无,纯赚 | 🥈 |
| int16 → int32 累加器 | 消除 45%~90% 的溢出 | 无(int32 是标配) | 🥉 已是默认 |
| max → 最优百分位裁剪 | +0.33 dB | 选错百分位−23.6 dB | ⚠️ 最后再碰 |
反直觉的地方在于:大家花时间最多的校准策略,收益最小、风险最大;而收益最大的量化粒度和舍入方式,往往是框架已经帮你做好的(前提是你知道要去打开它)。
排查顺序建议:
精度掉了 ↓ ① per-channel 开了吗?(尤其有 depthwise 时) ← 10 dB 级 ↓ ② 卷积核用的是 round 还是 >>? ← 6 dB 级 ↓ ③ 累加器是 int32 吗?有没有中间饱和? ← 不崩则已,一崩全错 ↓ ④ 跑逐层敏感度分析,挑 top-k 敏感层回退 FP16 ← 定点优化 ↓ ⑤ 最后才动校准:用 MSE/KL 搜索,别拍百分位 ← 0.3 dB 级七、哪些层不能量化
有了上面的分析,"哪些层不能量化"就不再是背名单,而是有判断依据的。
判断原则(三条)
- 通道间量级差异大的层 → 必须 per-channel,否则不能量化(来源①)
- 误差会直接暴露到输出的层 → 不能量化(没有后续层帮你"平均掉")
- 量化了却不省算力的层 → 不该量化(只掉精度不换速度,纯亏)
常见名单
| 层 | 为什么敏感 | 通常怎么处理 |
|---|---|---|
| Depthwise 卷积 | 通道间 scale 差异可达百倍,per-tensor 直接压死通道(见实验 1) | 强制 per-channel |
| 网络第一层 | 输入是原始像素 [0,255],动态范围大、分布与后续层完全不同;且只有 3 个通道,误差无处稀释 | 保留 FP32,或用非对称量化 + 单独校准 |
| 网络最后一层(分类/回归头) | 直接产出 logits / 坐标,误差没有下游可以弥补 | 保留 FP32 / FP16 |
| Softmax / LayerNorm / Sigmoid | 逐元素非线性,输出动态范围极大;且不是 GEMM,量化省不到算力 | 保留 FP16,或接在量化层后面单独算 |
| 残差相加(Add) | 两条支路的 scale 不同,定点域没法直接加,要先 rescale——rescale 本身又引入一轮误差 | 强制两支路共享 scale,或回退 FP16 |
| bbox 回归 / 关键点坐标 | 输出是绝对坐标值,对数值精度敏感,量化后框会漂 | 检测头保留 FP16 |
更靠谱的办法:逐层敏感度分析
与其背名单,不如让数据告诉你。流程很简单:
# 伪代码baseline=evaluate(full_fp32_model)sens={}forlayerinmodel.layers:layer.force_fp16()# 只把这一层回退sens[layer]=baseline-evaluate(model)# 掉了多少layer.restore_int8()# 掉得最多的 top-k 层,永久保留 FP16forlayerintopk(sens,k=8):layer.force_fp16()这个流程花的只是几轮推理的时间,但给出的答案比任何经验名单都准——因为你的模型、你的数据,跟论文里的不是一回事。
(注意:敏感度分析要逐层做,不能一次回退一大片。层与层之间的误差会互相掩盖,也会互相放大。)
八、可复用的检查清单
量化前
- 统计了每层权重/激活的通道间 scale 差异吗?(差异 > 10 倍就必须 per-channel)
- 有 depthwise 卷积吗?有的话 per-channel 是硬性的,不是可选项
- 第一层和最后一层的量化策略单独定了吗?
- 校准集有多少张图?(少于 100 张,激活分布统计不可靠)
量化中
- 卷积 kernel 用的是四舍五入还是算术右移?(前者白得 6 dB)
- 累加器是int32吗?
- 中间有没有
int16的临时变量? - 溢出走的是饱和还是回绕?(你想要的通常是饱和)
量化后
- 跑过逐层敏感度分析吗?还是只看端到端指标?
- 精度掉了之后,是先换粒度还是先调校准?(别搞反顺序,前者 10 dB 后者 0.3 dB)
- 端到端精度是在完整验证集上测的,还是只跑了几张图?
九、小结
| 问题 | 答案 |
|---|---|
| 量化本质是啥? | 给一组浮点数挑小数点位置,用 8 bit 存——就是课本里的定点数 |
| 误差从哪来? | 粒度、校准、累加溢出、舍入方式,四个来源 |
| 最大的一块? | 量化粒度:per-tensor → per-channel 值+10.35 dB |
| 最白捡的一块? | 舍入方式:右移改四舍五入,噪声能量降到 1/4,+6 dB |
| 最容易翻车的? | 百分位裁剪:收益仅 +0.33 dB,选错百分位−18 dB |
| 哪些层不能量化? | depthwise(除非 per-channel)、首尾层、softmax/LayerNorm、残差 Add、回归头 |
| 最靠谱的手段? | 逐层敏感度分析,别背名单 |
一句话:量化掉点的时候,先去查粒度和舍入这两个 10 dB / 6 dB 级别的旋钮,别一上来就去拧那个只值 0.3 dB 却可能崩 18 dB 的百分位。
回望课本:INT8 量化把《组成原理》数据的机器表示那一章几乎用满了——补码的表示范围、定点数的定标、溢出与饱和、IEEE 754 的舍入模式。当年觉得这些是"为了考试才存在的细节",现在每一个都对应着模型落地上几个百分点的精度。
彩蛋:SQNR 不是精度的全部
本文全程用 SQNR 衡量,但它有个前提假设:量化误差是随机的、与信号无关的。真实情况往往不是。
- 量化误差与输入相关(同样的输入总是产生同样的偏差)
- 误差会逐层累积,而且是非线性的
- 网络的某些层对误差有放大作用
所以:SQNR 适合用来比较"同一层的不同量化方案",不适合用来预测端到端精度。一个 SQNR 高 10 dB 的方案,端到端有可能更好,也有可能(在误差被放大的层上)反而更差。
SQNR 告诉你"哪个方案更值得试",最终判断权永远在端到端指标手里。
互动:你量化时踩过最深的坑是哪一个?是 depthwise,还是校准,还是那个"从来没想过"的累加器?
附录:完整可运行代码(全文内联,无需下载资源)
下面这一个文件整段复制即可跑(macOS / Linux 通用,无第三方依赖)。文中全部数字都由它在本机产生(Apple clang 16 / arm64 验证通过,与正文一致)。
编译运行
g++-O2-std=c++11 int8_quant_bench.cpp-oiq&&./iqint8_quant_bench.cpp(量化误差四来源 + SQNR 排行,单文件全程可复现)
// INT8 量化误差实测 demo// g++ -O2 -std=c++11 int8_quant_bench.cpp -o iq && ./iq// 实验 1: per-tensor vs per-channel(模拟 depthwise conv 权重)// 实验 2: 截断策略扫描(带长尾的激活分布)// 实验 3: 累加器位宽(int16 vs int32 溢出实测)// 实验 4: Q7 定点乘法 —— 截断 vs 舍入的系统性偏差#include<cstdio>#include<cstdint>#include<cmath>#include<vector>#include<random>#include<algorithm>// ---------------- 基础工具 ----------------structQParam{floatscale;intzero_point;// 非对称量化用};// 对称量化:[-128,127],zero_point 固定 0staticQParamsym_param(constfloat*p,size_t n){floatabsmax=0.f;for(size_t i=0;i<n;++i)absmax=std::max(absmax,std::fabs(p[i]));QParam q;q.scale=absmax/127.f;if(q.scale<1e-12f)q.scale=1e-12f;q.zero_point=0;returnq;}// 非对称量