2. 量化类型:对称量化 vs 非对称量化,per-tensor vs per-channel量化

好,咱们接着聊量化的具体类型。说实话,这块内容在项目里踩坑最多。我刚开始接触量化时,觉得不就是把 float 转成 int 嘛,能有多复杂?结果第一次把模型量化完,精度直接掉了 5 个点,老板脸都绿了。后来才发现,选错量化类型,后果真的很严重。

2.1 对称量化 vs 非对称量化

先说说最基础的概念。量化说白了,就是找一个映射关系,把浮点数映射到整数上。这个映射关系怎么找?两种思路。

对称量化

对称量化,名字就告诉你了——它是对称的。什么意思?就是浮点数的 0 和整数的 0 严格对齐。比如你的权重范围是 [-1.0, 1.0],量化到 int8 [-128, 127],那 -1.0 对应 -128,1.0 对应 127,0 对应 0。完美对称。

公式长这样:

scale = max(|x|) / 127
x_int = round(x / scale)

注意,这里没有 zero_point。因为零点就是 0,不需要额外存储。

我个人习惯:对称量化用在权重上特别多。为什么?因为权重的分布通常比较对称,正负值都有,而且 0 就是 0,不会出现偏差。我在项目中做过统计,大部分卷积层的权重分布都是近似高斯分布,中心在 0 附近。这时候用对称量化,信息损失最小。

非对称量化

非对称量化就灵活多了。它允许浮点数的 0 和整数的 0 不对齐。什么意思?比如你的激活值范围是 [0.0, 6.0],全是正数。如果硬用对称量化,那 [-128, 0] 这半边就浪费了,分辨率直接砍半。

非对称量化的公式:

scale = (max - min) / 255
zero_point = round(-min / scale)
x_int = round(x / scale) + zero_point

这里多了个 zero_point,就是用来做偏移的。说白了,就是把浮点数的范围「平移」到整数范围上。

避坑指南:我曾经在量化 ReLU 后的激活值时,图省事用了对称量化。结果精度掉了 2 个点,查了半天才发现问题。ReLU 输出全是非负的,用对称量化等于浪费了一半的表示范围。换成非对称量化后,精度立马回来了。所以记住:激活值用非对称,权重用对称,这是行业惯例。

对比一下

特性 对称量化 非对称量化
零点对齐
存储开销 低(无 zero_point) 高(需存储 zero_point)
适用场景 权重、分布对称的数据 激活值、分布偏斜的数据
计算复杂度 稍高(需处理偏移)

2.2 Per-tensor vs Per-channel 量化

好,接下来聊另一个维度。刚才说的对称/非对称,是「怎么量化」。现在要说的 per-tensor 和 per-channel,是「量化粒度」——也就是一个 scale 管多大范围。

Per-tensor 量化

Per-tensor 量化,就是整个张量(比如一个卷积层的所有权重)共用一个 scale 和 zero_point。简单粗暴,计算量小。

举个例子:一个卷积层有 64 个输出通道,每个通道的权重范围可能不一样。但 per-tensor 量化不管这些,它取所有通道的最大最小值,算一个全局的 scale。

# 伪代码:per-tensor 量化
tensor_min = min(weight_tensor)
tensor_max = max(weight_tensor)
scale = (tensor_max - tensor_min) / 255
# 所有通道共用这一个 scale

这样做的好处是啥?实现简单,硬件友好。很多老一点的推理框架只支持 per-tensor。但问题也很明显——如果某个通道的数值范围特别小,它会被「淹没」在全局范围里,精度损失很大。

我曾经在量化一个深度可分离卷积时,用了 per-tensor 量化。结果 depthwise 层的精度崩了。为什么?因为 depthwise 每个通道独立卷积,通道间的数值差异特别大。per-tensor 量化把大通道和小通道混在一起算 scale,小通道的信息几乎全丢了。后来换成 per-channel,问题解决。

Per-channel 量化

Per-channel 量化,就是每个输出通道独立计算自己的 scale 和 zero_point。说白了,每个通道都有自己的「量化尺子」。

# 伪代码:per-channel 量化
for ch in range(num_channels):
    ch_min = min(weight_tensor[ch])
    ch_max = max(weight_tensor[ch])
    scale[ch] = (ch_max - ch_min) / 255
    zero_point[ch] = round(-ch_min / scale[ch])
    # 每个通道独立量化

这样做的好处很明显——精度更高。每个通道都能用满自己的表示范围,不会互相干扰。但代价是啥?需要存储更多的 scale 和 zero_point。比如一个 64 通道的卷积层,per-tensor 只需要存 1 个 scale,per-channel 要存 64 个。

怎么选?

我个人的经验是这样的:

  • 权重用 per-channel:尤其是卷积层,通道间差异大,per-channel 能保住精度。现在主流的量化工具(比如 TensorRT、ONNX Runtime)都默认对权重用 per-channel。
  • 激活值用 per-tensor:激活值的分布通常比较稳定,通道间差异不大。而且激活值是动态计算的,per-channel 会增加运行时开销。所以一般用 per-tensor 就够了。
  • 特殊情况特殊处理:比如前面说的 depthwise 卷积,或者分组卷积,通道间独立性很强,这时候激活值也建议用 per-channel。

一个小技巧:如果你不确定该用哪种,可以先跑一遍 per-tensor 量化,看看精度。如果精度掉得不多,那就用 per-tensor,省事。如果精度掉得厉害,换成 per-channel 试试。我一般会写个脚本自动对比两种方案的精度差异,省得手动试。

2.3 组合起来看

好了,现在我们把两个维度组合起来。实际应用中,常见的组合方式有四种:

组合 权重量化 激活值量化 典型场景
对称 + per-tensor 对称,全局 scale 对称,全局 scale 早期框架,简单模型
对称 + per-channel 对称,每通道 scale 对称,全局 scale 权重敏感,激活值稳定
非对称 + per-tensor 非对称,全局 scale 非对称,全局 scale 激活值非负,权重分布偏斜
非对称 + per-channel 非对称,每通道 scale 非对称,全局 scale 高精度要求,资源充足

你想想看,YOLO 这种目标检测模型,对精度要求高,对延迟也敏感。我一般推荐用「对称 per-channel 量化权重 + 非对称 per-tensor 量化激活值」。这个组合在精度和效率之间取得了不错的平衡。

嗯,这里要注意一点:不是所有硬件都支持 per-channel 量化。比如一些老旧的 NPU 或者 DSP,只支持 per-tensor。所以选方案之前,先看看你的目标硬件支持什么。我在一个嵌入式项目里就吃过这个亏——模型量化好了,结果芯片不支持 per-channel,只能重新量化,白白浪费了两天时间。

好了,量化类型这块就聊到这儿。记住一句话:权重用对称 per-channel,激活值用非对称 per-tensor。这是行业里经过大量实践验证的黄金组合。当然,具体项目具体分析,但至少这个组合能给你一个不错的起点。