第 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)) + biasSum(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 行代码,把量化的核心骨架全部搭了起来:
- 两个参数定天下:
S(缩放)和Z(零点)决定了整数格子的位置和间距。 - 对称 vs 非对称:权重用对称(
Z=0),激活用非对称(需要Z)。ReLU Trap 实验证明了为什么。 - 整数运算管线:
y = S_w·S_x·(Σ(q_w·q_x) − Z_x·Σ(q_w)) + bias,交叉项修正必须做,Σ(q_w)可预计算。 - 误差是两种误差的权衡:粒度误差 vs 过载误差,校准方法就是在它们之间找平衡。
这套骨架是整本书的地基。下一章,我们把"格子"从整张张量细化到每个通道、每个分组——看看粒度选择如何让量化误差进一步下降。
作者: itech001 来源: 公众号:AI人工智能时代(the-ai-era) 网站: https://www.theaiera.top/ 关注每日最新AI新闻和技术博客,主页有更多的文章的AI 技术参考:https://www.theaiera.top
本文首发于 AI人工智能时代,转载请注明出处。