第 2 章 量化对称量化非对称量化

第 2 章 从第一性原理构建量化

第 2 章 从第一性原理构建量化

原书仓库的 ch2/ 目录提供了一整本 Jupyter Notebook(Building Quantization from First Principles.ipynb)和对应的脚本 build_quantization_from_first_principles.py。这一章我们用最少的代码,把量化从"概念"变成"能跑的 Python 类"。

2.1 两个量化器:对称与非对称

第 1 章说量化的本质是"把浮点数映射到整数的均匀格子上"。实现它需要两个参数:

  • 缩放因子 scale(S):决定格子间距。real_range / int_range。
  • 零点 zero-point(Z):决定格子的偏移,让浮点 0 能精确映射到某个整数。

围绕这两个参数,诞生了两种基本量化器。

2.1.1 对称量化器(Symmetric Quantizer):用于权重

权重有一个好习惯:它的分布通常以 0 为中心近似对称(钟形曲线)。既然对称,就不需要偏移——把零点钉死在 Z = 0,用绝对值最大值来确定范围即可。

# 来自 ch2 notebook 的 SymmetricQuantizer(整理)
class SymmetricQuantizer:
    def __init__(self, bits=8):
        self.bits = bits
        self.q_max = (1 << (bits - 1)) - 1  # 127
        self.q_min = -self.q_max            # -127,避开 -128 以保持完美对称
        self.scale = 1.0
        self.zero_point = 0                 # 强制 Z = 0

    def calibrate(self, tensor):
        abs_max = tensor.abs().max()
        if abs_max == 0:
            self.scale = 1.0
        else:
            self.scale = abs_max / self.q_max  # S = r_max_abs / q_max

    def quantize(self, tensor):
        scaled = tensor / self.scale
        rounded = torch.round(scaled)
        clamped = torch.clamp(rounded, self.q_min, self.q_max)
        return clamped.to(torch.int8)

    def dequantize(self, q_tensor):
        return q_tensor.float() * self.scale

注意一个细节:q_min = -127 而不是 -128。INT8 的完整范围是 [-128, 127],但为了对称性(-127 和 127 关于 0 对称),对称量化主动放弃了 -128 这一格。这是精度上的一点小牺牲,换来的是推理时少一次 Z 的修正运算。

2.1.2 非对称量化器(Affine Quantizer):用于激活

激活(activation)则完全不同。经过 ReLU 之后,激活值严格非负,分布是"一堆 0 + 一条正方向的长尾"。如果还用对称量化,负半轴的 128 个格子全部浪费,正半轴只有 128 格来覆盖全部数据,分辨率直接减半。

非对称量化的解法是引入 zero-point Z,让格子整体平移,把范围精确地贴在 [r_min, r_max] 上:

# 来自 ch2 notebook 的 AffineQuantizer(整理)
class AffineQuantizer:
    def __init__(self, bits=8):
        self.bits = bits
        self.q_min = 0
        self.q_max = (1 << bits) - 1        # 255(无符号)
        self.scale = 1.0
        self.zero_point = 0

    def calibrate(self, tensor):
        r_min = tensor.min().item()
        r_max = tensor.max().item()

        # 关键:必须把 0.0 包含进范围("Zero-Point Nudge")
        r_min = min(r_min, 0.0)
        r_max = max(r_max, 0.0)

        real_range = r_max - r_min
        int_range = self.q_max - self.q_min

        if real_range == 0:
            self.scale = 1.0
            self.zero_point = 0
        else:
            self.scale = real_range / int_range
            initial_z = self.q_min - (r_min / self.scale)
            self.zero_point = int(round(initial_z))
            self.zero_point = max(self.q_min, min(self.q_max, self.zero_point))

    def quantize(self, tensor):
        scaled = (tensor / self.scale) + self.zero_point
        rounded = torch.round(scaled)
        clamped = torch.clamp(rounded, self.q_min, self.q_max)
        return clamped.to(torch.uint8)      # 激活用无符号

    def dequantize(self, q_tensor):
        return self.scale * (q_tensor.float() - self.zero_point)

Zero-Point Nudge(零点微调):校准范围时,即使数据的 min/max 都不包含 0,也要强制把 0.0 纳入范围。原因很实际:推理管线里 0 有特殊含义(padding、ReLU 截断、稀疏),如果 0 不能精确表示为某个整数,反量化回去的 0 会变成非 0,引入系统性误差。Z = round(q_min - r_min / S) 保证浮点 0 落在整数格上。

量化/反量化的公式可以统一写成:

  • 量化:q = clip(round(r / S + Z))
  • 反量化:r = S * (q - Z)

对称量化就是 Z = 0 的特例。

2.2 把它们装进一个 Linear 层:整数运算管线

有了两个量化器,就可以构造第 2 章的核心实验对象:Linear8bit——一个把输入和权重分别量化、再模拟整型矩阵乘的线性层。

# 来自 ch2 notebook 的 Linear8bit(结构整理)
class Linear8bit(nn.Module):
    def __init__(self, in_features, out_features, bias=True):
        super().__init__()
        self.in_features = in_features
        self.out_features = out_features
        self.weight = nn.Parameter(torch.randn(out_features, in_features))
        if bias:
            self.bias = nn.Parameter(torch.randn(out_features))
        else:
            self.register_parameter('bias', None)
        # 混合量化:权重用对称,输入用非对称
        self.weight_quantizer = SymmetricQuantizer(bits=8)
        self.input_quantizer = AffineQuantizer(bits=8)

    def forward(self, x, verbose=False):
        # STEP 1: 校准并量化输入(运行时)
        self.input_quantizer.calibrate(x)
        x_q = self.input_quantizer.quantize(x)   # UINT8

        # STEP 2: 校准并量化权重(离线/初始化)
        self.weight_quantizer.calibrate(self.weight)
        w_q = self.weight_quantizer.quantize(self.weight)  # INT8

        # STEP 3: 整数点积(核心运算)
        # 用 int32 累加器避免求和溢出
        term1_dot_product = torch.mm(x_q.float(), w_q.float().t())

        # STEP 4: 交叉项修正(混合量化的关键)
        # y = S_w * S_x * (Term1 - Z_x * Sum(q_w))  (Z_w = 0 所以只需修正 Z_x)
        # ...

这里值得停下来仔细看 STEP 4 的交叉项修正。真正的整数推理引擎里,输出是这样算的:

y = S_w * S_x * (Sum(q_w * q_x) - Z_x * Sum(q_w)) + bias
  • Sum(q_w * q_x) 是整数点积,用 int32 累加防止溢出。
  • Z_w = 0(权重对称),所以少了一项。
  • Sum(q_w) 对每个输出通道是常数,可以预先算好,推理时零额外开销。

如果忽略 - Z_x * Sum(q_w) 这一项会怎样?Notebook 里专门做了一个对照实验:把缺了修正项的输出(y_wrong)和正确输出(y_8bit)分别与 FP32 基准比较误差。

# 来自 ch2 notebook:交叉项修正的重要性
y_int_uncorrected = torch.mm(x_q.float(), w_q.float().t())
y_wrong = y_int_uncorrected * scale_w * scale_x + layer_8bit.bias

error_wrong = y_wrong - output_fp32
mae_wrong = torch.mean(torch.abs(error_wrong)).item()
mae = torch.mean(torch.abs(output_8bit - output_fp32)).item()

print(f"MAE without Z_x correction: {mae_wrong:.4f}")
print(f"MAE WITH Z_x correction:    {mae:.4f}")
print(f"The affine correction term reduced error by factor of {mae_wrong/mae:.1f}x")

运行结果通常显示:忽略交叉项会让误差放大几十倍。这正是"为什么激活必须用非对称量化"在算术层面的体现——激活的 zero-point 不是摆设,丢掉它等于丢掉了一整个修正项。

2.3 实验 1:ReLU Trap——为什么激活不能用对称量化

Notebook 的第 22-23 格做了这个经典实验:生成一组 ReLU 输出(严格非负、带尖峰),分别用对称和非对称量化,观察哪一方浪费了格子。

# 实验数据:ReLU 输出 = 严格非负 + 大量 0 + 一些尖峰
inputs_fp32 = torch.randn(128, 64)
inputs_fp32 = torch.relu(inputs_fp32) * 2.5   # 偏移、正数、带尖峰

你会看到:对称量化器把 [-max, max] 范围铺开,但数据全部挤在 [0, max],负半轴的 128 个格子完全空置——等于把 INT8 白白降级成 7bit 精度。非对称量化器把范围收紧到 [min, max],同样的 256 格全部用在了数据真正分布的地方。

经验法则:权重 → 对称(分布天然以 0 为中心);激活 → 非对称(ReLU 后非负,需要 zero-point 修正)。这套组合在第 2.2 节的 Linear8bit 里被称为 Hybrid(混合)量化。

2.4 实验 2:长尾分布——误差到底从哪来

ErrorAnalyzer 类用来量化"量化误差"本身。Notebook 里最值得玩味的实验是 "The Long Tail" Problem:构造一组"LLM 式"的激活数据——大量小值 + 极少数巨大的异常值。

这个实验的结论直接预告了第 7 章的核心矛盾:

  • absmax 范围被异常值绑架:一个 100.0 的异常值会把 S 撑得很大,让 [0, 10] 区间内占 99% 的正常值全部被压到同一个格子,分辨率归零。
  • 两种误差的对立:量化误差 = granular error(粒度误差) + overload error(过载/截断误差)。格子铺得太宽,小值的粒度误差变大;格子铺得太窄,大值被截断的过载误差变大。校准方法的本质,就是在这两种误差之间找平衡(第 4 章展开)。

Notebook 用 plot_output_fidelity 画了一张 FP32 输出 vs INT8 输出的散点图,点越贴近对角线 y=x 说明量化损失越小。你可以直观看到:正常区间内点密集成线,被截断的异常值则在对角线外形成"飞出"的点。

2.5 关键收获

这一章用不到 200 行代码,把量化的核心骨架全部搭了起来:

  1. 两个参数定天下:S(缩放)和 Z(零点)决定了整数格子的位置和间距。
  2. 对称 vs 非对称:权重用对称(Z=0),激活用非对称(需要 Z)。ReLU Trap 实验证明了为什么。
  3. 整数运算管线:y = S_w·S_x·(Σ(q_w·q_x) − Z_x·Σ(q_w)) + bias,交叉项修正必须做,Σ(q_w) 可预计算。
  4. 误差是两种误差的权衡:粒度误差 vs 过载误差,校准方法就是在它们之间找平衡。

这套骨架是整本书的地基。下一章,我们把"格子"从整张张量细化到每个通道、每个分组——看看粒度选择如何让量化误差进一步下降。


作者: itech001 来源: 公众号:AI人工智能时代(the-ai-era) 网站: https://www.theaiera.top/ 关注每日最新AI新闻和技术博客,主页有更多的文章的AI 技术参考:https://www.theaiera.top

本文首发于 AI人工智能时代,转载请注明出处。