前言:为什么你的模型在云端跑得飞快,到了端侧就"卡壳"了?
作为一名算法工程师,你可能遇到过这样的场景:在实验室的 GPU 服务器上,你的 YOLOv5 模型以 60 FPS 的速度流畅运行,检测精度 mAP 达到了 0.85。但当这个模型被部署到 IPC 摄像头或嵌入式设备上时,帧率突然掉到了 5 FPS,甚至根本跑不起来。
这不是你的模型有问题,而是因为量化这个看似简单但暗藏玄机的技术,在端侧 AI 落地中扮演着关键角色。
在这篇文章中,我将深入剖析量化的本质,带你理解从 FP32 到 INT8 的转换过程中到底发生了什么,以及为什么这个过程对端侧 AI 至关重要。
一、量化的本质:从"游标卡尺"到"塑料尺"的转变
1.1 FP32 vs INT8:精度与效率的权衡
想象一下,你在进行精密测量:
- **FP32(32 位浮点数)**就像一把高精度的游标卡尺,可以测量到 0.001 毫米的精度,但使用起来需要仔细校准,计算复杂。
- **INT8(8 位整数)**就像一把普通的塑料尺,只能精确到 1 毫米,但使用简单快捷。
在计算世界中,这种差异更加明显:
| 特性 | FP32 | INT8 |
|---|---|---|
| 内存占用 | 4 字节 | 1 字节 |
| 计算速度 | 慢(需要浮点运算单元) | 快(整数运算单元) |
| 功耗 | 高 | 低 |
| 精度 | 高(约 7 位有效数字) | 低(256 个离散值) |
量化的核心目标就是:在保持模型精度的前提下,将模型从 FP32 转换为 INT8,从而实现:
- 内存占用减少 75%:从 4 字节降到 1 字节
- 推理速度提升 4–8 倍:整数运算比浮点运算快得多
- 功耗大幅降低:对电池供电设备至关重要
1.2 量化的数学原理:连续值到离散值的映射
量化的本质是将连续的浮点数值映射到有限的整数空间。以对称量化为例,核心公式如下:
整数 = round(浮点数 / scale) 浮点数 ≈ 整数 × scale其中,scale是量化尺子的刻度密度,决定了数值的映射范围。
举个具体例子:
假设某个卷积层的权重值范围是[-2.54, 2.54],我们使用 INT8 对称量化(范围[-128, 127]):
计算 scale:
scale = max(|权重|) / 127 = 2.54 / 127 = 0.02量化过程:
- 权重值 1.27 →
round(1.27 / 0.02)=round(63.5)=64 - 权重值 -0.51 →
round(-0.51 / 0.02)=round(-25.5)=-26
- 权重值 1.27 →
反量化过程:
- 整数 64 →
64 × 0.02=1.28(与原始值 1.27 相差 0.01) - 整数 -26 →
-26 × 0.02=-0.52(与原始值 -0.51 相差 0.01)
- 整数 64 →
这个例子展示了量化的两个关键点:
- 舍入误差:连续值到离散值的转换必然产生精度损失
- 截断风险:如果数值超出
[-128, 127]范围,会被强制截断
二、量化对象:我们到底在"量"什么?
在深度学习模型中,需要量化的对象主要有两大类,它们占据了量化误差来源的 90%。
2.1 权重(Weight):静态的"印好的书"
权重是神经网络中可学习的参数,对于卷积层和全连接层来说,权重在训练完成后就固定不变。
特点:
- 固定不变:训练完成后,权重值不再变化
- 可提前量化:可以在模型部署前就完成量化
- 分布相对稳定:权重分布通常比较规律
量化时机:离线提前量化
实际例子:
在 YOLOv5 的 Backbone 网络中,第一个卷积层可能有3×3×3×64 = 1728个权重参数。这些参数在训练完成后就固定了,我们可以一次性计算它们的scale,然后量化为 INT8,存储到模型文件中。
# 权重量化伪代码defquantize_weight(weight_fp32):max_abs=np.max(np.abs(weight_fp32))scale=max_abs/127# 对称量化weight_int8=np.round(weight_fp32/scale).astype(np.int8)returnweight_int8,scale2.2 激活值(Activation):动态的"读书时的想法"
激活值是神经网络中每一层的中间输出,它们随着输入数据的变化而变化。
特点:
- 动态变化:每张输入图片产生的激活值都不同
- 无法提前预知:必须在实际推理时才能确定
- 分布不稳定:可能存在极端值(outlier)
量化时机:在线量化或校准时量化
实际例子:
当你在 IPC 摄像头中检测摔倒行为时:
- 白天场景:激活值分布可能集中在
[0, 10]范围 - 夜晚场景:激活值分布可能集中在
[0, 5]范围 - 逆光场景:可能出现极端值,激活值达到 50 甚至更高
这种动态性使得激活值的量化变得复杂,需要特殊处理。
2.3 其他受影响的算子
除了权重和激活值,以下算子也会受到量化的影响:
Add / Residual / Concat
这些算子涉及多个输入的合并,需要对齐输入输出的scale,否则数值会溢出。
例子:在 ResNet 的残差连接中
# 主分支输出main_branch=conv1(x)# scale_main = 0.05# 残差分支输出residual_branch=conv2(x)# scale_residual = 0.08# 相加前需要对齐 scalealigned_main=main_branch*(scale_main/scale_residual)output=aligned_main+residual_branchBias
偏置通常不单独量化,而是提升到 INT32 参与累加,防止溢出。
原因:Bias 的值通常比权重小,直接量化到 INT8 会损失太多精度。
Softmax / Sigmoid
这些激活函数有时保留 FP16 以避免精度崩塌,特别是在分类任务中。
实际案例:在人脸识别模型中,Softmax 层的输出直接影响识别准确率,很多工程师会选择保留 FP16 精度。
BatchNorm
BatchNorm 在训练后会被融合进 Conv,不参与独立量化。
融合公式:
W_fused = W * gamma / sqrt(var + eps) b_fused = (beta - mean) * gamma / sqrt(var + eps) + bias三、Scale:量化尺子的诞生过程
scale是量化过程中最重要的参数,它决定了数值的映射范围和精度。
3.1 Scale 的本质
scale的作用是将浮点数值映射到整数范围。对于对称量化,scale的计算公式为:
scale = max(|x|) / 127这个公式的含义是:找到数据的最大绝对值,将其映射到 INT8 的最大值 127。
3.2 Observer(观察员):谁来统计 Scale?
在 PyTorch 等框架中,Observer 是专门用于统计数据分布的模块。它的唯一职责是:盯着数据流,记录最大绝对值。
Observer 的工作流程:
- 在模型前向传播过程中插入 Observer
- Observer 记录每个张量的最大值和最小值
- 根据统计结果计算
scale
3.3 权重 Scale:一次统计,终身使用
权重的scale计算非常简单:
max_abs=max(abs(weight))scale_weight=max_abs/127特点:
- 权重固定不变,量化后可永久存储为 INT8
- 类似"量一次全班最高身高,以后校服都按这个做"
实际例子:
假设 YOLOv5 模型的第一个卷积层权重范围是[-1.27, 1.27],那么:
scale_weight = 1.27 / 127 = 0.01这个scale一旦确定,就可以永久使用,不需要重新计算。
3.4 激活 Scale:必须动态统计(关键难点)
激活值的scale统计是量化中最复杂的部分,因为激活值随输入图像变化,无法提前预知。
方式 A:校准集统计(PTQ / 工具常用)
这是最常见的激活值scale统计方法:
- 准备校准集:收集 100–1000 张代表性图片
- 运行模型:用这些图片跑一遍模型
- 统计最大值:记录每层激活的最大绝对值
- 计算 scale:取这批数据的 max 作为最终
scale
配置示例:
{"calibration_dataset_dir":"/path/to/calibration_images","num_calibration_images":500}实际案例:
在摔倒检测项目中,我们收集了 500 张包含不同场景(白天、夜晚、室内、室外)的图片作为校准集。通过统计这些图片的激活值分布,我们得到了稳定的scale。
方式 B:滑动平均(QAT 训练时用)
在 QAT(量化感知训练)过程中,使用滑动平均来统计激活值的scale:
running_max = 0.9 × old_max + 0.1 × current_max优势:
- 避免单张异常图片(过亮/过暗)影响
scale - 让
scale更稳定 - 这是 QAT 效果好的原因之一
实际例子:
假设在训练过程中:
- 第 1 张图片的激活最大值是 10
- 第 2 张图片的激活最大值是 50(异常值)
- 第 3 张图片的激活最大值是 12
使用滑动平均:
running_max_1 = 0.9 × 0 + 0.1 × 10 = 1 running_max_2 = 0.9 × 1 + 0.1 × 50 = 5.9 running_max_3 = 0.9 × 5.9 + 0.1 × 12 = 6.51可以看到,异常值 50 被平滑了,最终的running_max更接近正常值。
四、量化误差从哪来?
理解量化误差的来源,对于优化量化精度至关重要。
4.1 舍入误差
舍入误差是由于连续值到离散值的转换产生的。
例子:
- 真实值:1.27
- 量化后:
round(1.27 / 0.01)=127 - 反量化:
127 × 0.01=1.27(完美!)
但如果真实值是 1.275:
- 量化后:
round(1.275 / 0.01)=128(超出 INT8 范围!) - 反量化:
127 × 0.01=1.27(丢失了 0.005)
4.2 截断误差(更严重)
截断误差是由于数值超出量化范围而被强制截断产生的。
例子:
假设某个激活值是 5.0,但scale是 0.02:
- 理论量化值:
round(5.0 / 0.02)=250 - 但 INT8 范围是
[-128, 127],所以被截断为127 - 反量化:
127 × 0.02=2.54 - 误差高达 2.46!
截断误差的后果:
- 权重分布太宽 → 截断严重
- 激活值有极端 outlier → 截断严重
- 小目标特征响应弱 → 量化后容易被当成噪声抹除
4.3 小目标/弱特征最吃亏
在目标检测任务中,小目标的特征响应通常很弱。量化后,这些弱信号很容易被量化为 0,导致检测失败。
实际案例:
在摔倒检测中:
- 人体目标:特征响应强度约 10–20
- 小目标(如远处的行人):特征响应强度约 2–3
- 如果
scale设置不当,小目标的 2–3 可能被量化为 0
量化前后对比:
原始激活值:[15, 12, 3, 2, 1] scale = 0.05 量化后:[300, 240, 60, 40, 20] → 截断为 [127, 127, 60, 40, 20] 反量化:[6.35, 6.35, 3.0, 2.0, 1.0]可以看到,大目标的特征从 15 降到 6.35,小目标的特征从 3 降到 3.0(保持不变)。但如果scale更大,小目标的特征可能直接变成 0。
4.4 核心洞察:奇异值撑大 Scale
这是量化中最关键的洞察:奇异值会撑大scale,导致绝大多数正常值被"挤在一起"。
例子:
假设激活值分布如下:
正常值:95% 的数据在 [0, 10] 范围内 奇异值:5% 的数据在 [50, 100] 范围内如果使用 max 统计scale:
scale = 100 / 127 = 0.787量化后:
- 正常值 10 →
round(10 / 0.787)=13 - 正常值 1 →
round(1 / 0.787)=1 - 奇异值 100 →
round(100 / 0.787)=127
问题:正常值[1, 10]被压缩到[1, 13],量化分辨率急剧下降!
结论:scale不合理只是"症状",奇异值才是"病因"。
五、PTQ vs QAT:为什么 PTQ 不够?
5.1 PTQ(训练后量化):简单但不够
PTQ 的流程很简单:
- 正常训练 FP32 模型
- 训练完直接用校准集统计
scale并量化 - 部署量化模型
类比:一个只会用游标卡尺的工程师,突然让他用塑料尺干活,必然出错。
PTQ 的问题:
- 模型从未"学过"如何适应量化
- 权重和激活分布可能不适合量化
- 对奇异值敏感,容易产生截断误差
实际案例:
在摔倒检测项目中,我们尝试使用 PTQ:
- FP32 模型 mAP:0.85
- PTQ 后 mAP:0.72(下降 13%!)
精度下降的主要原因是小目标检测失败,因为小目标的弱特征被量化噪声淹没。
5.2 QAT(量化感知训练):让模型适应量化
QAT 的核心思想是在训练的前向传播中插入 FakeQuant(伪量化):
x → round(x / scale) × scale → 继续计算QAT 的优势:
- 模型在训练时就"知道"自己将来会被量化
- 反向传播时通过 STE(直通估计器)更新权重
- 主动抑制奇异值,让数值分布天生适合量化
类比:提前让工程师练习用塑料尺,他学会了如何在这个限制下保持精度。
实际案例:
同样的摔倒检测项目,使用 QAT:
- FP32 模型 mAP:0.85
- QAT 后 mAP:0.83(仅下降 2%!)
精度大幅提升的原因是:
- 权重分布被压缩,集中在
[-120, +120]内 - 激活值的奇异值被抑制
- 小目标的弱特征得到保留和增强
六、QAT 如何降低量化误差?
6.1 压缩权重分布
QAT 训练后,权重分布会发生显著变化。
训练阶段对比:
| 阶段 | 权重分布特点 |
|---|---|
| FP32 预训练 | 宽、尖峰、长尾,易出奇异值 |
| QAT 后 | 集中在[-120, +120]内,分布紧凑 |
实际例子:
YOLOv5 第一个卷积层的权重分布:
FP32 预训练: 范围:[-2.5, 2.5] 99% 的权重在 [-1.5, 1.5] 奇异值:2.3, -2.1(占总数 0.1%) QAT 后: 范围:[-1.8, 1.8] 99% 的权重在 [-1.2, 1.2] 奇异值:基本消失效果:工具量化时无需剧烈截断,精度损失更小。
6.2 规整激活分布
QAT 对激活值分布的影响更加显著。
主要变化:
- 抑制极端 outlier:通过梯度更新,主动减少极端激活值
- 放大弱信号:对小目标检测至关重要
- 对称化分布:让激活分布更接近对称分布
实际案例:
摔倒检测模型中,某个检测头的激活分布:
FP32 预训练: 范围:[0, 50] 小目标激活:2–5 大目标激活:10–30 奇异值:45–50(0.5%) QAT 后: 范围:[0, 25] 小目标激活:3–8(增强!) 大目标激活:12–20 奇异值:基本消失关键效果:小目标的激活值从 2–5 提升到 3–8,量化后不易被淹没。
6.3 对齐量化配置
QAT 的配置必须与硬件支持的量化方案严格对齐。
AT5050 芯片要求:
{"feature":["symm",8],"weight":["symm","per_tensor",8]}对应的 QAT 配置:
activation_quant=quantizer.FakeQuantize.with_args(observer=quantizer.MovingAverageMinMaxObserver,dtype=torch.qint8,qscheme=torch.per_tensor_symmetric,# ✅ 对称,对齐硬件quant_min=-128,quant_max=127,)weight_quant=quantizer.FakeQuantize.with_args(observer=quantizer.MovingAverageMinMaxObserver,dtype=torch.qint8,qscheme=torch.per_tensor_symmetric,# ✅ per-tensor,对齐硬件quant_min=-128,quant_max=127,)如果配置不对齐:
- 训练:模型学习的是"假分布"
- 部署:硬件执行的是"真量化"
- 结果:精度崩塌
七、scales.json 的作用与局限
7.1 它是什么?
scales.json是 QAT 训练结束后,Observer 统计到的每层scale记录:
{"model.0.conv.weight":{"scale":0.0123,"zero_point":0},"model.2.act":{"scale":0.0456,"zero_point":0}}7.2 它的作用
理想情况:工具直接加载scales.json,完全对齐 QAT 的量化参数,精度最佳。
辅助作用:作为工具校准的初始值或约束。
非必需品:多数自研工具会重新统计scale。
7.3 为什么你的工具不加载也有效?
很多工程师困惑:为什么我的工具不加载scales.json,量化效果也很好?
原因:
- 工具通过校准集重新统计激活
scale - QAT 已经把数值分布"塑形"好了
- 即使
scale略有差异,误差也很小
关键认知:QAT 的价值在于改变数值分布,而非仅仅传递scale。
实际案例:
在摔倒检测项目中:
- 使用
scales.json:mAP = 0.83 - 不使用
scales.json:mAP = 0.825 - 差异仅 0.005,几乎可以忽略
这说明 QAT 塑形后的数值分布本身就适合量化,scale的精确值不是最关键的。
八、量化转换过程:从 FP32 到 INT8 模型
8.1 输入准备
量化转换需要三个输入:
- FP32 权重:训练好的模型权重(
best.pt) - 校准数据:几十~上千张代表性图片
- 量化配置:对称、per-tensor、int8
8.2 核心步骤
步骤 1:统计 Scale
权重scale:
scale_w=max(|W_fp32|)/127激活scale:
# 跑校准集,统计每层激活的最大值forimageincalibration_dataset:activation=model(image)max_abs=max(max_abs,max(abs(activation)))scale_act=max_abs/127步骤 2:量化权重
W_int8=round(W_fp32/scale_w)步骤 3:生成部署模型
- 包含 INT8 权重
- 包含 INT32 Bias
- 包含 Scale 表(或查表)
此时模型已从"数学公式"变为"整数查表运算"。
8.3 数据格式变化
FP32 Weights (4 bytes per weight) ↓ 统计 scale ↓ 量化 INT8 Weights (1 byte per weight) + Scale Table → 部署模型(INT8 + Scale Table)内存节省示例:
YOLOv5s 模型:
- FP32 权重:14 MB
- INT8 权重:3.5 MB
- 节省:75%
九、部署时推理过程:芯片上的数据流
9.1 输入与预处理
输入:摄像头uint8 [0, 255]图片
预处理(CPU/ISP):
- Resize 到模型输入尺寸(如
640×640) - Normalize(归一化到
[0, 1]) - BGR→RGB(如果需要)
- HWC→CHW(维度转换)
格式变化:uint8→FP32(部分高级芯片支持 INT8 输入,可省略此步)
9.2 激活量化(首次量化)
用校准得到的scale_act_input将 FP32 输入特征图量化为 INT8:
x_int8=round(x_fp32/scale_act_input)从此以后,数据一路都是 INT8。
9.3 INT8 卷积(核心计算)
芯片最擅长的部分,本质是INT8 × INT8 → INT32的乘加(MAC)运算:
y_int32=x_int8 ⊗ W_int8其中⊗代表卷积运算。
为什么快?
- 整数运算比浮点运算简单
- 硬件可以并行执行多个 MAC 操作
- 功耗低
9.4 Scale 矫正(关键,查表实现)
由于y_int32的实际物理意义是(x / scale_x) × (W / scale_w),需要将其还原到正确的数值量级:
y_real ≈ y_int32 ×(scale_x × scale_w)工具配置:
{"weight_op_process_scale":"table"}含义:scale_x * scale_w这个乘法因子被预先算好,存放在查找表中,芯片直接查表获取结果,无需实时计算。
9.5 Bias 加法与激活函数
Bias:
y_int32=y_int32+bias_int32使用 INT32 防止溢出。
激活函数:
如 ReLUmax(0, y),仍在 INT32 域完成。
9.6 重新量化(回到 INT8)
为了下一层 Conv 的输入仍是 INT8,需要将 INT32 结果重新压缩:
y_int8=round(y_int32/scale_act_next)实现 INT8-in, INT8-out 的流水级联。
9.7 循环与输出
重复步骤 9.3–9.6,直到 Detect Head。最后一层通常会转回 FP32,以便后续 NMS 和后处理(CPU/ARM 完成)。
9.8 推理全过程数据流总览
uint8 图片 [0, 255] ↓ FP32(预处理) ↓ INT8(激活量化) ↓ INT8 Conv ( × INT8 权重) ↓ INT32(累加) ↓ Scale 矫正(查表) ↓ INT8(重新量化) ↓ ...(重复) ↓ FP32(输出层) ↓ CPU 后处理(NMS)十、实战经验:量化精度优化的关键技巧
10.1 摔倒检测必做 QAT
小目标对量化极度敏感,PTQ 往往无法满足精度要求。
实际数据:
- FP32:mAP = 0.85
- PTQ:mAP = 0.72(下降 13%)
- QAT:mAP = 0.83(下降 2%)
10.2 学习率要小
QAT 阶段的学习率通常为预训练的1/10 ~ 1/20。
原因:
- 量化引入了非线性,大学习率会导致训练不稳定
- 需要精细调整权重分布
推荐配置:
# 预训练阶段lr=0.01# QAT 阶段lr=0.001# 降为 1/1010.3 关闭 EMA
QAT 期间禁用 ModelEMA(指数移动平均)。
原因:
- EMA 会平滑权重分布,与 QAT 的目标冲突
- QAT 需要直接优化当前权重
10.4 校准集质量决定上限
校准集必须覆盖典型场景:
- 不同光照条件(白天、夜晚、逆光)
- 不同尺度(近景、远景)
- 不同遮挡情况(无遮挡、部分遮挡)
实际案例:
在摔倒检测项目中:
- 使用 100 张随机图片:mAP = 0.78
- 使用 500 张精心挑选的图片:mAP = 0.83
10.5 精度验收标准
QAT + PTQ 的 mAP 下降应控制在 1% 以内。
如果下降超过 1%:
- 检查 QAT 配置是否与硬件对齐
- 检查校准集质量
- 检查是否有奇异值未被抑制
10.6 关注奇异值
QAT 的核心收益之一就是抑制权重和激活中的极端值。
检查方法:
# 统计权重分布weight_max=np.max(np.abs(weight))weight_99th=np.percentile(np.abs(weight),99)ratio=weight_max/weight_99thifratio>2.0:print("警告:存在奇异值!")10.7 善用 error_analysis
通过layer_error_args定位量化引起的精度热点。
示例:
error_analysis=analyze_quantization_error(model_fp32,model_int8,calibration_dataset,layer_error_args=["model.0.conv","model.2.conv"])十一、常见误区澄清
| 误区 | 正确理解 |
|---|---|
| QAT 必须导出 INT8 模型 | ❌ 只需导出 float 权重 |
| scales.json 必须被工具加载 | ❌ 它是辅助,非必需 |
| 激活值可以提前量化 | ❌ 必须在线/校准统计 |
| QAT 只是为了传 scale | ❌ 核心是改变数值分布 |
| PTQ 加校准就够了 | ❌ 小目标检测 QAT 必选 |
| 训练时没 fuse 就没融合 | ❌ 融合发生在部署工具 |
| QAT 配置随便选 | ❌ 必须严格对齐硬件物理约束 |
十二、总结
量化不是简单的"数据类型转换",而是一个涉及数学原理、硬件约束、工程实践的复杂过程。
核心要点:
- 量化本质:连续值到离散值的映射,必然产生精度损失
- 量化对象:权重和激活值是两大核心,激活值量化更复杂
- Scale 生成:权重
scale固定,激活scale需动态统计 - 误差来源:舍入误差和截断误差,奇异值是主要病因
- PTQ vs QAT:QAT 让模型适应量化,效果远好于 PTQ
- 部署流程:从 FP32 到 INT8 的转换涉及多个步骤
- 工程实践:校准集质量、学习率调整、奇异值控制是关键
下一步:
在下一篇文章中,我将深入探讨"为什么 QAT 有时也救不了精度",揭示层融合机制和硬件物理约束对量化精度的深层影响。