3. 量化参数进阶:对称量化 vs 非对称量化、量化尺度(Scale)与零点(Zero Point)计算

聊完基础概念,咱们得进入真正的硬核环节了。量化参数怎么选,直接决定模型精度掉几个点。我见过不少团队,模型结构一样,就因为量化参数没调对,推理结果直接崩了。

说白了,量化就是把 float32 的权重和激活值,映射到 int8 或者 int4 的整数空间。这个映射怎么做的?核心就两个参数:Scale(尺度)和Zero Point(零点)。今天咱们就把这两个东西彻底讲透。

3.1 对称量化 vs 非对称量化:两种映射哲学

先看一张图,帮你快速建立直觉。

对称量化 vs 非对称量化 核心对比 对称量化 浮点范围: [-max, max] 整数范围: [-128, 127] Zero Point = 0 映射: 浮点0 → 整数0 非对称量化 浮点范围: [min, max] 整数范围: [0, 255] Zero Point ≠ 0 映射: 浮点0 → 整数ZP 适用场景对比 对称量化:权重分布对称(如经过 BN 后的权重)| 非对称量化:激活值分布偏斜(如 ReLU 后全为正) 对称量化公式 q = round(x / scale) scale = max(|x|) / 127 非对称量化公式 q = round(x / scale) + ZP scale = (max-min) / 255

3.2 对称量化:简单粗暴,但有限制

对称量化,顾名思义,浮点数的正负范围是对称的。映射到 int8 时,整数范围是 [-128, 127]。Zero Point 固定为 0,不需要额外存储。

公式很简单:

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

举个例子,假设权重范围是 [-3.0, 2.8],对称量化会取 max(|x|) = 3.0,scale = 3.0 / 127 ≈ 0.0236。然后每个浮点数除以 scale 再四舍五入。

核心优势:计算快,硬件友好。很多 NPU 和 DSP 原生支持对称量化,因为不需要处理 Zero Point 的偏移。

但有个坑。如果数据分布不对称,比如全是正数 [0.1, 3.0],对称量化会把负半轴的量化范围浪费掉。我做过一个实验,ReLU 后的激活值用对称量化,精度直接掉了 2%。

3.3 非对称量化:灵活,但多一个参数

非对称量化就聪明多了。它用浮点数的实际最小值和最大值来定范围,整数范围用 [0, 255](uint8)。

公式:

q = round(x / scale) + ZP
scale = (max - min) / 255
ZP = round(-min / scale)

还是刚才的例子,激活值范围 [0.1, 3.0],非对称量化会算:

  • scale = (3.0 - 0.1) / 255 ≈ 0.0114
  • ZP = round(-0.1 / 0.0114) ≈ -9

你看,Zero Point 不是 0 了。反量化的时候要记得减回去:

x = (q - ZP) * scale

我的经验:激活值量化,我基本都用非对称。尤其是 MobileNet、EfficientNet 这类轻量模型,激活值分布经常偏斜,非对称能多保住 0.5-1% 的精度。

3.4 Scale 和 Zero Point 的计算细节

纸上谈兵够了,咱们直接上代码。这是我在项目中实际用过的计算函数:

import numpy as np

def calc_symmetric_scale(x):
    """对称量化:只算 scale,ZP=0"""
    max_abs = np.max(np.abs(x))
    if max_abs == 0:
        return 1.0
    scale = max_abs / 127.0
    return scale

def calc_asymmetric_params(x):
    """非对称量化:算 scale 和 ZP"""
    x_min = np.min(x)
    x_max = np.max(x)
    if x_min == x_max:
        return 1.0, 0
    scale = (x_max - x_min) / 255.0
    zp = round(-x_min / scale)
    # 钳位到 [0, 255]
    zp = max(0, min(255, zp))
    return scale, zp

# 测试
weights = np.array([-2.5, -1.0, 0.0, 1.5, 3.0])
act = np.array([0.1, 0.5, 1.2, 2.8, 3.0])

s_sym = calc_symmetric_scale(weights)
print(f"对称 scale: {s_sym:.6f}")  # 3.0/127 ≈ 0.023622

s_asym, zp = calc_asymmetric_params(act)
print(f"非对称 scale: {s_asym:.6f}, ZP: {zp}")
# (3.0-0.1)/255 ≈ 0.011373, ZP ≈ -9

我曾经踩过的坑:计算 ZP 时直接用了 round(-min/scale),没做钳位。结果有个极端情况,ZP 算出来是 -12,反量化后数据全偏了。记住,uint8 的 ZP 必须在 [0, 255] 范围内。

3.5 两种方案的对比与选型

维度 对称量化 非对称量化
整数范围 [-128, 127] [0, 255]
Zero Point 固定 0 需计算,非 0
存储开销 少存一个参数 多存 ZP
计算复杂度 低,硬件友好 略高,需处理偏移
精度表现 数据对称时好 数据偏斜时更好
推荐场景 权重、BN 后的值 激活值、Embedding

我个人习惯:权重用对称,激活用非对称。权重经过训练后分布通常比较对称,用对称量化省一个参数,计算也快。激活值嘛,ReLU 系列出来全是非负的,非对称量化能充分利用 256 个整数级别。

3.6 实战中的避坑指南

最后分享几个我实际项目中遇到的坑:

  • 离群点处理:如果数据里有一个特别大的值,比如 100,其他都在 [-1, 1],对称量化的 scale 会被拉得很大,导致大部分数据精度丢失。我一般会做离群点裁剪,或者用百分位法取范围。
  • Scale 的精度:Scale 本身是 float32,但有些硬件要求 scale 是 float16 或者定点数。转换时精度损失可能累积,建议用高精度算完再转。
  • ZP 的整数溢出:反量化时 (q - ZP) 可能超出 int16 范围,尤其是 q 和 ZP 都接近边界时。用 int32 做中间计算,最后再截断。
  • 校准数据集:算 scale 和 ZP 时,用多少数据?太少不够统计,太多又慢。我一般用 500-1000 张图片,覆盖各种情况就够了。

一句话总结:对称量化省参数、跑得快,适合权重;非对称量化精度高、灵活,适合激活值。Scale 和 ZP 的计算看似简单,但离群点、精度转换、溢出这些细节,才是真正决定量化成败的地方。