模型量化基础:浮点与定点、量化原理、对称量和非对称量化
量化这词听起来唬人,说白了就是把模型里那些精度很高的浮点数,换成占位更少的整数。我刚开始接触时也觉得不就是降精度吗,后来才发现里面的门道挺深。咱们一个个看。
浮点与定点:精度与效率的取舍
先聊最基础的东西——浮点 vs 定点。浮点数大家熟,FP32 占 4 字节,能表示的范围大、精度高。但推理时算起来也重,尤其 GPU 显存带宽扛不住。定点数呢?说白了就是固定的小数点位置,比如用 Q7.8 格式,7 位整数 + 8 位小数,总共 16 位,能省一半空间。
为什么量化能加速?两点:一是模型体积小了,内存搬移快;二是整数运算硬件比浮点快得多。现在很多 CPU/GPU 都内置 INT8 算子,效率翻倍。我记得有一次给一个 7B 参数的模型做量化,FP32 占 26GB,量化到 INT8 直接降到 7GB,推理延迟从 300ms 压到 80ms。虽然精度掉了 0.5%,但用户感知不到。
这里有个误区:并不是所有层都适合定点数。像 BatchNorm 的 gamma/beta 参数量化后敏感度极高。我建议先做敏感度分析,再决定哪些层量化,哪些保留 FP16。
量化原理:映射与缩放
量化本质是数学映射。把一个浮点范围 [min, max] 映射到整数范围 [0, 255](UINT8)或 [-128, 127](INT8)。公式很简单:
q = round((x - min) / scale)
scale = (max - min) / (qmax - qmin)
反向解码时:x = q * scale + min。实际工程里常用 零点(zero-point) 来对齐浮点零和整数零,避免量化后非零偏移。
工程小贴士:我习惯把缩放因子 scale 存成 FP32,推理平台上用定点乘加指令(如 Intel VNNI、ARM SDOT)来加速。零点一般存成 INT8 或 INT32,避免类型转换开销。
举个具体例子。有一层 activation 范围 [-3.0, 3.0],量化到 INT8:
max = 3.0, min = -3.0
qmax = 127, qmin = -128
scale = (3.0 - (-3.0)) / (127 - (-128)) = 6.0 / 255 ≈ 0.023529
zero_point = round(- (-3.0) / 0.023529) = round(127.5) = 127
# 验证:浮点 0.0 => q = round(0.0/0.023529 + 127) = 127,对齐整数零
对称量化 vs 非对称量化
这俩是量化最核心的分支。
| 维度 | 对称量化 | 非对称量化 |
|---|---|---|
| 整数范围 | 对称有符号,如 [-128, 127] | 无符号或有符号,常用 [0, 255] 或 [-128, 127] |
| 零点 | 固定为 0 | 需要计算零点(zero-point) |
| 映射公式 | q = round(x / scale) | q = round(x / scale + zero_point) |
| 适用场景 | 权重(权重分布接近对称) | 激活值(ReLU 后非负分布不均匀) |
| 硬件效率 | 更高(不需要处理零点偏移) | 稍低(需额外减零点操作) |
| 精度损失 | 对非对称分布损失略大 | 能更好拟合非对称分布 |
我个人偏好:权重几乎一律用对称量化。激活值呢?如果 ReLU 后值域全正,非对称量化能节省一个 bit(范围从 8bit 变成 8bit 但有效表示更密)。但是非对称量化零点处理要小心,我踩过坑——某次把 zero_point 存成 INT8,计算时截断溢出,结果精控偏差直接崩了。
量化参数的计算
量化参数就是 scale 和 zero_point。常见方法有:
- MinMax:直接用训后最小/最大值算,速度快但对异常值敏感。
- Percentile:取 99.99% 分位数,砍掉极端离群点,我常用 99.9% 或 99.99%。
- MSE 最佳切分:遍历不同截断边界,选量化后与原始分布 MSE 最小的 scale。这个效果好但计算成本高。
注意:训后量化(PTQ)计算参数时,一定要用校准数据集(几百到几千张图/文本样本),而不是整个训练集。我见过有人用整个训练集算 scale,结果量化后过拟合到训练分布,推理时崩得一塌糊涂。
代码里怎么算?以 PyTorch 观察期和量化器为例:
import torch
from torch.quantization import PerChannelMinMaxObserver
obs = PerChannelMinMaxObserver(dtype=torch.quint8)
obs(x) # 观察一定量的激活值
scale, zero_point = obs.calculate_qparams()
# scale: tensor([0.0235]), zero_point: tensor([127], dtype=torch.uint8)
实际部署时,这些参数要冻结到模型文件或单独配置文件里,推理库直接读取。
避坑指南
我整理几个实操中容易翻车的地方:
- 零点类型:非对称量化 zero_point 建议与量化类型相同(比如 quint8 就存 uint8),否则推理时需要类型转换,增加时延。
- 跨层优化:某些框架(如 TFLite)会自动将相邻层的 scale/zero_point 合并,减少重量化次数。但前提是你的量化参数要写入图结构里。
- 分组量化:当通道间数值差异大时(如 depthwise 卷积),用 per-channel 量化比 per-tensor 好很多。代价是参数存储翻 channel 倍。
- 硬件限制:有些 NPU 只支持对称量化,或者只支持 per-tensor。提前查硬件手册,别做无用功。
上图画了对称和非对称两条路。可以看到,对称量化解码简单,但若激活值全是正值(比如 ReLU 后 [0, 6.5]),对称量化会浪费半个负区间,精度损失反而大。非对称通过零点把映射起点拖到 0,每条 bit 都用上了。
一句话总结:权重用对称,激活看分布。分布偏正且范围小 → 非对称;分布对称或范围大 → 对称。实际以校准集上的精度调试验证为准,别盲从理论。
还有一个点:量化并不是只能训后做。如果条件允许,量化感知训练(QAT) 在训练过程中模拟量化误差,精度比 PTQ 高不少。代价是需要重训或 fine-tune。我的经验是:当 PTQ 精度掉超过 2% 时,果断上 QAT,尤其小模型 sensitive 层多。
好了,这部分先聊到这儿。量化是轻量推理最入门也最关键的一步,理解清楚浮点/定点、映射原理以及对称/非对称的区别,后面学训练后量化、量化感知训练、混合精度、整数内核优化时才能站稳脚。