Ch10: 量化入门——用精度换速度的艺术
Part 2: 移动端推理基础(从零开始) 前置章节:Ch09: 模型格式与转换 后续章节:Ch11: ncnn——零依赖的手工匠人(Part 3 开篇)
从高清照片到 JPEG
你用手机拍了一张照片,RAW 格式有 50MB。你要发微信给朋友——50MB 太大了,手机流量扛不住。于是你把它压缩成 JPEG,只有 3MB。朋友收到后放在手机屏幕上看,画质和原图几乎没有区别——日常拍摄的场景下,JPEG 压缩造成的画质损失人眼根本分辨不出来。
但如果你拍的是一张夜空星轨长曝光照片,JPEG 压缩后可能会出现明显的色阶断裂(banding)——在深色区域的渐变处出现突兀的色块。这是因为 JPEG 把色彩信息从”连续的”压缩成”离散的”,在大部分场景下这个离散化足够精细看不出差别,但在需要极其细腻的渐变过渡的场景下,精度损失就暴露了。
神经网络的量化和 JPEG 压缩本质上做的是同一件事——把高精度的连续数值映射到低精度的离散数值。fp32(32位浮点)就像 RAW 格式,范围大精度高但体积大;int8(8位整数)就像 JPEG,只有 256 个可能的值但体积只有原来的四分之一。大多数推理场景下,int8 的精度损失对最终结果的影响微乎其微——就像普通照片的 JPEG 压缩不影响观看。但在某些困难任务(如细粒度分类、语义分割的边界区域)上,精度损失可能变得明显——就像暗部的色阶断裂。
为什么量化有效
量化能有效工作,根本原因是神经网络对微小的数值扰动非常鲁棒。
训练好的神经网络的权重值,如果你给每个权重加上一点点随机噪声(比如万分之一的扰动),模型的输出几乎不变。这是因为神经网络本质上是一个高度冗余的系统——几百万个参数共同决定输出,单个参数的微小偏差被其他参数”稀释”了。
量化做的就是给每个数值加上一个小的”舍入误差”——把 0.3782 舍入成 0.375,把 1.2456 舍入成 1.25。只要这个舍入误差足够小(相对于数值本身),网络的行为就基本不变。
三种精度的对比
| 精度 | 位宽 | 可表示的值 | 数值范围 | 体积 (相对) | 计算速度 (相对) |
|---|---|---|---|---|---|
| fp32 | 32 bit | ~40亿种 | +/-3.4x10^38 | 1x (基准) | 1x (基准) |
| fp16 | 16 bit | ~65000种 | +/-65504 | 0.5x | ~2x |
| int8 | 8 bit | 256种 | -128 ~ +127 | 0.25x | ~2-4x |
fp32(单精度浮点)是训练的标准精度,也是推理的”安全选择”。每个数占 4 字节,可以精确表示约 40 亿个不同的值。对推理来说,这个精度通常远超实际需要。
fp16(半精度浮点)用 2 字节表示一个数,范围和精度都比 fp32 小。但对绝大多数推理任务来说够用了——模型权重和激活值很少超过 fp16 的范围。fp16 的计算速度在支持 fp16 指令的硬件上(ARM v8.2+ 的 fp16 NEON、移动 GPU)通常是 fp32 的 2 倍,因为 SIMD 寄存器可以装下双倍的数据(Ch06 讲过:128-bit 寄存器装 4 个 fp32 或 8 个 fp16)。
int8(8位整数)用 1 字节表示一个数,只有 256 个可能的值。这是量化的主要目标精度。int8 的计算速度可以达到 fp32 的 2-4 倍(NEON 的 vdotq_s32 指令每次处理 16 个 int8 乘加),模型体积缩小到原来的 25%。代价是精度损失——但在大多数场景下可以接受。
量化带来的三重收益
量化不仅仅是”用更少的位数表示数字”。它在三个维度上都带来了收益。
第一,模型体积缩小。一个 25.6M 参数的 ResNet-50 模型,fp32 时约 102MB,int8 时只有约 25.6MB。对手机来说,安装包小 75% 意味着用户下载速度更快、占用存储更少。
第二,内存占用降低。不仅权重占内存少了,中间激活值也可以用 int8 存储。Ch08 讲过,内存是移动端最紧迫的约束——量化直接缓解了这个压力。
第三,计算速度提升。这一点需要硬件支持。ARM NEON 的 int8 并行度是 fp32 的 4 倍(128-bit 寄存器装 16 个 int8 vs 4 个 fp32),计算吞吐量直接翻 4 倍。加上 ARMv8.2 的 vdotq_s32(4路 int8 点积)指令,int8 卷积的理论速度可以达到 fp32 的 4 倍以上。实际加速比通常在 2-4 倍之间,取决于具体模型和硬件。
量化的数学原理
量化的数学核心是一个线性映射——把浮点数的连续范围映射到整数的离散范围。
基本公式
浮点值 = scale x (整数值 - zero_point)
或者反过来:
整数值 = round(浮点值 / scale) + zero_point
其中 scale(缩放因子)和 zero_point(零点偏移)是量化参数,它们决定了浮点和整数之间的映射关系。确定了这两个参数,就确定了量化方案。
用一个具体的例子来理解。假设我们要把范围 [-1.0, 1.0] 内的浮点数量化到 int8(范围 [-128, 127]):
scale = (1.0 - (-1.0)) / (127 - (-128)) = 2.0 / 255 = 0.00784
zero_point = 0(对称量化,后面会解释)
量化过程:
浮点值 0.5 -> round(0.5 / 0.00784) + 0 = round(63.8) = 64
浮点值 -0.3 -> round(-0.3 / 0.00784) + 0 = round(-38.3) = -38
浮点值 0.0 -> round(0.0 / 0.00784) + 0 = 0
反量化过程:
整数值 64 -> 0.00784 x (64 - 0) = 0.50176 (误差 0.00176)
整数值 -38 -> 0.00784 x (-38 - 0) = -0.29792 (误差 0.00208)
可以看到,量化再反量化后的值和原始值有微小的差别——这就是量化误差。误差的大小取决于 scale 的值:scale 越小,精度越高,但可表示的范围也越小。
对称量化 vs 非对称量化
对称量化:zero_point = 0。浮点值的零被精确映射到整数的零。这意味着量化范围关于零对称:[-max, +max] 映射到 [-128, +127]。好处是计算更简单(不需要处理 zero_point),坏处是如果数据分布不对称(比如 ReLU 的输出全是非负数),会浪费一半的表示范围。
对称量化: [-max_val, +max_val] -> [-128, +127]
scale = max_val / 127
zero_point = 0
非对称量化:zero_point 不为零。可以更精确地匹配数据的实际分布范围。比如 ReLU 输出的范围是 [0, max_val],非对称量化会把 0 映射到 -128,max_val 映射到 127——充分利用了 int8 的全部 256 个值。
非对称量化: [min_val, max_val] -> [-128, +127]
scale = (max_val - min_val) / 255
zero_point = round(-128 - min_val / scale)
实际中,权重通常使用对称量化(权重分布通常接近零对称),激活值(特别是 ReLU 后)通常使用非对称量化。
量化计算的实际过程
量化后的卷积运算完全在整数域进行——这就是速度提升的来源。简化流程如下:
输入激活 (fp32) -> 量化 -> int8 激活
|
权重 (fp32) -> 预量化 -> int8 权重
|
int8 激活 x int8 权重 = int32 累加结果 <-- 整数乘法,硬件快
|
int32 结果 x (scale_input x scale_weight) -> fp32 输出
|
下一层的输入
关键点:乘法和累加都在整数域完成(int8 x int8 = int16,累加到 int32),只在最后一步用一次浮点乘法把结果缩放回浮点范围。这一次浮点运算的开销和整个卷积的几百万次乘加相比微不足道。
两种量化方式
确定 scale 和 zero_point 有两种截然不同的方式,对应两种量化流程。
PTQ(Post-Training Quantization,训练后量化)
PTQ 不需要重新训练模型——在训练好的 fp32 模型上直接进行。流程是:
- 准备一个小的”校准数据集”(calibration dataset),通常几十到几百张代表性图片就够
- 用 fp32 模型对校准数据集做前向传播,收集每一层激活值的统计分布(最大值、最小值、直方图)
- 根据统计分布,为每一层确定最优的 scale 和 zero_point
- 把 fp32 权重用确定好的 scale 量化为 int8 权重
- 导出量化后的模型
PTQ 的最大优势是简单快速——不需要训练环境,不需要训练数据(校准数据集可以很小),不需要 GPU,几分钟就能完成。所有六个推理引擎都支持 PTQ。
PTQ 的劣势是对量化敏感的模型(如非常小的模型、低比特量化)可能有明显的精度下降。原因是 PTQ 完全基于统计信息来确定量化范围——如果校准数据不够代表性,确定的范围可能不是最优的。
QAT(Quantization-Aware Training,量化感知训练)
QAT 在训练过程中”模拟”量化的效果——在前向传播时插入模拟量化节点(fake quantization),让网络在训练时就”适应”量化带来的噪声。反向传播时用直通估计器(Straight-Through Estimator, STE)绕过量化节点的不可导问题。
普通训练: input -> conv -> relu -> output
| 反向传播
QAT训练: input -> [量化->反量化] -> conv -> [量化->反量化] -> relu -> output
| 反向传播(STE 绕过量化节点)
QAT 的优势是精度更高——网络在训练时已经学会了”在量化噪声下也能给出好的输出”。对于精度敏感的任务(如人脸识别、医学图像),QAT 通常比 PTQ 好 1-3 个百分点的精度。
QAT 的劣势是需要完整的训练环境和训练数据,需要几个小时甚至几天的额外训练。对于”拿到一个开源模型想快速部署”的场景,QAT 的门槛太高。
| 方式 | 需要训练? | 需要训练数据? | 耗时 | 精度 |
|---|---|---|---|---|
| PTQ | 否 | 只需小量校准数据 | 分钟级 | 够用(大多数场景) |
| QAT | 是 | 需要完整训练集 | 小时-天 | 最优 |
实际建议:先试 PTQ,如果精度不满足要求再上 QAT。大多数情况下 PTQ 的精度已经够用。
校准(Calibration)过程
PTQ 的核心步骤是校准——确定每一层的 scale 和 zero_point。校准的关键是准确估计每一层激活值的分布范围。
三种校准方法
MinMax 校准:直接取校准数据集上观察到的最小值和最大值作为量化范围。
激活值: [0.1, 0.3, 0.8, 2.1, 0.5, 1.2, 0.9, 3.7]
min = 0.1, max = 3.7
scale = (3.7 - 0.1) / 255 = 0.0141
优点:简单。缺点:容易受异常值(outlier)影响——如果有一个激活值是 100.0(异常大),整个量化范围就被拉到 [0.1, 100.0],大部分正常值(0-4 之间)只能用很少的整数值表示,精度严重下降。
KL 散度校准(Entropy Calibration):通过计算 fp32 分布和 int8 分布之间的 KL 散度(Kullback-Leibler divergence)来找到最优的截断阈值。基本思路是:允许截掉一小部分极端值(少量溢出),换取中间大部分值的更高精度。
激活值直方图:
|
| ####
| ########
|############
|################
+--------------------> 值
0 1 2 3 4 100 (100是异常值)
KL校准: 截断到 [0, 4],丢弃100这个异常值
好处: 0-4之间的值获得更高精度
代价: 超过4的值被截断到4
NVIDIA 的 TensorRT 率先使用了 KL 散度校准,效果通常优于 MinMax。ncnn 的 ncnn2table 工具也支持这种方法。
Percentile 校准:取分布的某个百分位数(如 99.99%)作为截断阈值——忽略最极端的 0.01% 的值。比 MinMax 鲁棒,比 KL 散度简单。
ncnn 的校准工具:ncnn2table
ncnn 提供了 ncnn2table 工具来执行 PTQ 校准。使用流程如下:
# Step 1: 准备校准图像列表
find calibration_images/ -name "*.jpg" > image_list.txt
# Step 2: 运行校准
ncnn2table \
model.param model.bin \
image_list.txt \
model.table \
mean=[104,117,123] \
norm=[1,1,1] \
shape=[224,224,3] \
thread=4
# Step 3: 使用量化表进行推理(C++ 代码中)
# ncnn::Net net;
# net.opt.use_int8_inference = true;
# net.load_param("model.param");
# net.load_model("model.bin");
# // 加载量化校准表即可启用 INT8 推理
ncnn2table 的输出是一个 .table 文件,包含每一层的 scale 值。推理时 ncnn 读取这个表,在运行时动态完成量化和反量化。
混合精度量化
并非模型中的每一层都同样适合量化。某些层对量化非常敏感——量化后精度显著下降;另一些层则很鲁棒——量化后几乎无影响。
混合精度量化(Mixed Precision Quantization)的思路是:敏感层保留高精度(fp32 或 fp16),不敏感层使用低精度(int8)。
模型的各层量化敏感度:
Conv1: 低 -> int8 (体积大、计算量大、不敏感 -> 最大收益)
Conv2: 低 -> int8
Conv3: 中 -> int8 (还能接受)
Conv4: 高 -> fp16 (量化后精度掉太多 -> 保留高精度)
FC: 低 -> int8
混合精度: 大部分层 int8 获得速度收益,少数敏感层 fp16 保住精度
怎么判断一层是否敏感?最直接的方法是逐层量化测试:每次只量化一层(其他层保持 fp32),然后在验证集上测精度。精度下降最多的那些层就是敏感层。
TNN 的 NC8HW8 布局是专门为 fp16 推理设计的——把 8 个 channel 打包在一起(而非 NC4HW4 的 4 个),恰好匹配 fp16 在 128-bit NEON 寄存器中的容量(128 / 16 = 8)。这种设计让 TNN 在混合精度推理中可以为 fp16 层使用最优的数据布局。
量化在六个引擎中的支持
六个引擎对量化的支持范围和深度各不相同。
| 引擎 | PTQ | QAT | fp16 | INT8 | INT4 | 特色 |
|---|---|---|---|---|---|---|
| ncnn | 支持 | - | 支持 | 支持 | - | ncnn2table 校准工具,elempack 自适应 |
| MNN | 支持 | 支持 | 支持 | 支持 | 支持 | 端侧训练 + QAT,LLM INT4 推理 |
| ORT | 支持 | 支持 | 支持 | 支持 | 支持 | 丰富的量化工具链,onnxruntime-quantization |
| TNN | 支持 | - | 支持 (NC8HW8) | 支持 | - | fp16 专用 NC8HW8 布局 |
| Paddle-Lite | 支持 | 支持 | 支持 | 支持 | - | PaddleSlim 量化训练框架 |
| MACE | 支持 | - | 支持 | 支持 | - | Hexagon DSP INT8 整图下推 |
几个值得注意的细节:
ncnn 的量化流程是最清晰简洁的。ncnn2table 校准 -> .table 文件 -> 推理时加载。没有复杂的配置选项——符合 ncnn “极简”的设计哲学。
MNN 的量化能力最全面。除了标准 PTQ,MNN 还支持 QAT(通过端侧训练功能实现)和 INT4 量化(主要用于 LLM 推理)。MNN 的量化工具集成在 MNNConvert 中,可以在模型转换的同时完成量化。
TNN 的 NC8HW8是一个有趣的设计——这是专门为 fp16 推理设计的数据布局。128-bit 的 NEON 寄存器可以装 8 个 fp16 值(而非 4 个 fp32),所以 TNN 把 channel 按 8 个一组打包(NC8HW8),让 fp16 SIMD 利用率达到 100%。这和 ncnn 的 elempack 思路一致——根据数据精度调整打包数——但 TNN 是固定选择而非动态调整。
MACE 的 Hexagon 量化走的是完全不同的路线。MACE 把整个量化后的模型下推到 Hexagon DSP 执行——DSP 的 HVX 指令集原生支持 INT8 运算,每条指令可以同时处理 128 个 int8 值(1024-bit 向量宽度)。这种”整图下推”模式让量化推理的效率比逐层在 CPU 上做 INT8 更高。
Paddle-Lite 的 PaddleSlim 提供了完整的 QAT 工具链——在 PaddlePaddle 训练框架中做量化感知训练,然后通过 opt 工具导出量化后的 .nb 模型。整个流程在百度的生态内闭环完成。
实际效果:YOLOv8n fp32 vs INT8
用具体数字来感受量化的实际收益。以下数据综合各引擎公开 benchmark 的典型值(实际表现因设备和实现而异)。
| 指标 | fp32 | fp16 | INT8 (PTQ) | INT8 (QAT) |
|---|---|---|---|---|
| 模型体积 | ~12.8 MB | ~6.4 MB | ~3.2 MB | ~3.2 MB |
| 推理延迟 (ARM CPU) | ~80 ms | ~45 ms | ~25 ms | ~25 ms |
| mAP (COCO val) | 37.3% | ~37.2% | ~36.5% | ~36.9% |
| 峰值内存 | ~30 MB | ~18 MB | ~10 MB | ~10 MB |
几个关键观察:
fp16 几乎无损。mAP 从 37.3% 降到 ~37.2%——0.1% 的差异在测试误差范围内,可以认为没有精度损失。推理速度提升约 1.8 倍,模型体积减半。如果你的硬件支持 fp16(大多数现代 ARM 都支持),fp16 是一个”没有理由不用”的优化。
INT8 PTQ 有可接受的精度损失。mAP 从 37.3% 降到 ~36.5%——0.8% 的下降在大多数应用场景中可以接受。速度提升约 3.2 倍,体积和内存占用都降到原来的四分之一。
QAT 收回了大部分精度。mAP 从 36.5%(PTQ)提升到 ~36.9%——通过额外的训练,精度损失从 0.8% 收窄到 0.4%。代价是需要重新训练。
LLM 场景更极端。大语言模型的量化通常更激进——INT4 甚至 INT3 量化。LLaMA-7B 从 fp16(14GB)量化到 INT4(3.5GB)后,才能在手机上运行。INT4 的精度损失比 INT8 大,但对于对话类应用,用户通常不太敏感。
何时该用/不该用量化
量化不是万能药。以下是一些实际的决策建议。
推荐使用量化的场景
部署到移动端/嵌入式设备时——这是量化最主要的应用场景。体积和内存的缩减对移动端意义重大。几乎所有移动端部署都应该至少使用 fp16。
模型需要实时响应时——比如实时目标检测(要求 >30fps)、相机场景识别、AR 特效。INT8 量化的 2-4 倍加速可能意味着从”勉强可用”到”流畅体验”的质变。
边缘部署资源受限时——IoT 设备、无人机、车载系统等场景,设备算力和内存都很有限,量化是必备技术。
不推荐或谨慎使用的场景
精度极度敏感的任务——医疗影像诊断、金融风控等,1% 的精度下降可能带来严重后果。这类场景建议先用 fp16(精度损失极小),INT8 需要充分验证。
模型已经很小时——如果模型权重只有 1-2MB,量化到 INT8 省的那几百 KB 意义不大,反而引入了量化/反量化的额外开销和精度损失。
训练数据难以获取校准集时——PTQ 需要几百张代表性输入图片做校准。如果你的应用场景特殊(比如工业缺陷检测),难以收集足够多样的校准数据,量化效果可能不理想。
推荐的量化决策路径
1. 首先尝试 fp16 推理 -> 几乎无精度损失,简单有效
2. 如果 fp16 速度不够 -> 尝试 PTQ INT8
3. 评估 INT8 精度 -> 如果可接受就完成
4. 如果 INT8 精度不够 -> 尝试混合精度(敏感层保留 fp16)
5. 如果混合精度仍不够 -> 尝试 QAT(需要训练资源)
6. 如果以上都不满足需求 -> 考虑换更小的模型架构(如 MobileNet 系列)
量化的未来方向
量化技术还在快速演进。几个值得关注的方向:
更低比特量化(INT4/INT2):LLM 的端侧部署推动了 INT4 甚至更低比特量化的发展。GPTQ、AWQ 等方法在 INT4 精度下仍能保持可接受的对话质量。MNN 已经支持 INT4 量化推理。
动态量化:不是对所有输入使用固定的 scale,而是根据每次推理的实际输入动态计算 scale。好处是对不同输入分布都能保持较高精度,代价是需要运行时计算 scale(额外开销)。
硬件原生支持:越来越多的移动 SoC 在硬件层面支持 int8/int4 运算。ARMv9 的 SME(Scalable Matrix Extension)、高通的 Hexagon V73+ 都在向更低比特的原生支持演进。硬件进步会让量化的”速度收益/精度代价”比值越来越好。
本章小结
量化是移动端推理的核心优化技术,本质是用更少的比特位来表示数据,换取更小的体积、更快的速度和更低的内存占用。其数学基础是线性映射:float_value = scale x (int_value - zero_point)。
两种主要量化方式:PTQ(训练后量化)简单快速但精度有一定损失,适合大多数场景;QAT(量化感知训练)在训练时模拟量化效果,精度更高但需要训练资源。校准过程是 PTQ 的关键——通过分析激活值分布来确定最优的 scale 和 zero_point。
fp16 推理是”免费午餐”——几乎不损失精度但速度翻倍,应该是移动端推理的默认选择。INT8 量化收益显著(体积减 75%、速度提升 2-4 倍)但有 1-3% 的精度损失,需要根据场景评估。混合精度量化是精度和速度之间的折中方案。
六个引擎都支持 INT8 量化,MNN 额外支持 QAT 和 INT4,TNN 有专门为 fp16 设计的 NC8HW8 布局。ncnn 的 ncnn2table 工具提供了最简洁的 PTQ 工作流程。
Part 2 的七章基础概念到此结束。你现在已经理解了推理的本质(Ch04)、移动端约束(Ch05)、CPU SIMD 优化(Ch06)、GPU 计算(Ch07)、内存管理(Ch08)、模型格式(Ch09)和量化技术(Ch10)。这些基础知识是阅读 Part 3(六大框架精讲)和 Part 4(核心抽象对比)的前提——后续章节的很多设计决策都是在回应本 Part 讲述的这些约束和优化方向。
导读目录:README.md 上一章:Ch09: 模型格式与转换——从 PyTorch 到手机的桥梁 下一章:Ch11: ncnn——零依赖的手工匠人