INT8 量化为什么不准:五组实测拆解误差的四个来源
2026/9/15 7:47:34 网站建设 项目流程

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.nm 位整数 + n 位小数INT8 权重通常是Q7(1 符号位 + 7 小数位)
定标(scaling)真值 = 整数 × 2⁻ⁿr = q × scale
表示范围受总位数限制INT8 有符号补码:−128 ~ 127
精度相邻两个可表示值的间隔scale,也叫量化步长
溢出超出范围就回绕/饱和校准(calibration)要解决的
舍入模式IEEE 754 的四种舍入round/ 截断 / 就近偶数

一句话:所谓量化,就是给一组浮点数挑一个"小数点位置"(scale),然后用 8 bit 存下它。

于是误差只能从四个地方来:

  1. 小数点挑得不够细→ 量化粒度问题(per-tensor vs per-channel)
  2. 小数点挑得不够准→ 动态范围/截断问题(校准策略)
  3. 算的过程中爆了→ 累加器溢出
  4. 取整的方式有偏→ 舍入方式

逐个实测。


二、误差来源①:量化粒度 —— 最大的那块,+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 级

七、哪些层不能量化

有了上面的分析,"哪些层不能量化"就不再是背名单,而是有判断依据的。

判断原则(三条)

  1. 通道间量级差异大的层 → 必须 per-channel,否则不能量化(来源①)
  2. 误差会直接暴露到输出的层 → 不能量化(没有后续层帮你"平均掉")
  3. 量化了却不省算力的层 → 不该量化(只掉精度不换速度,纯亏)

常见名单

为什么敏感通常怎么处理
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&&./iq

int8_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;}// 非对称量

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询