把 fp16 权重压到 4 比特甚至 3 比特而不明显损失质量,是推理侧最直接的显存杠杆。量化不是简单"降低精度":不同格式、不同粒度、是否感知激活,得到的结果天差地别。这篇文章沿着复杂度阶梯完整实现------从两行的 absmax 量化到需要校准数据的 GPTQ------每一级都给出代码与误差来源分析。
一、两级量化:最朴素的基线
量化的数学本质是把连续值域映射到有限格点。最简单的两级方案用缩放因子把张量压进 int8/int4 的表示范围:
python
def quantize_absmax(w: torch.Tensor, bits=4):
qmax = 2 ** (bits - 1) - 1 # int4: 7
scale = w.abs().max() / qmax
q = torch.clamp(torch.round(w / scale), -qmax-1, qmax).to(torch.int8)
return q, scale
def dequantize_absmax(q, scale):
return q.float() * scale
def quant_error(w, bits=4):
q, s = quantize_absmax(w, bits)
return (w - dequantize_absmax(q, s)).pow(2).mean() / w.pow(2).mean()
它的误差来源一眼可见:缩放因子由单个最大值 决定,一个离群权重就把 scale 撑大,其余所有权重被迫挤在粗格点上。改进方向于是明确------用更细的粒度隔离离群值 :per-channel(每输出通道一个 scale)是基本要求,per-group(每 128 或 64 个连续权重一组 scale,即 GPTQ/AWQ 的 g128)进一步压缩误差。代价是缩放常数的存储开销:g128 的 int4 量化要额外存约 4% 的 fp16 缩放值,账要算清。比缩放粒度更进一步的格式改进是非均匀格点:权重归一化后近似正态分布,格点按正态分位数摆放(NF4)比均匀格点误差更小------这是格式层面的优化,与粒度层面的优化正交,可以叠加。
二、激活感知:权重量化的真正难题
朴素的权重量化(RTN,round-to-nearest)在 LLM 上有一个反直觉的失败:模型小的时候 RTN 掉点很少,参数量上来后突然崩坏 。原因被研究定位在激活分布:LLM 的激活有极少数维度数值极大(离群特征),与之对应的权重列哪怕误差很小,乘上大激活后误差被放大成不可忽略的输出扰动。这推出两条路线的分歧------naïve RTN 保护不了这些敏感列,必须让量化算法"知道"激活。
GPTQ 的做法是把逐列量化视为一个依次决策问题,用二阶信息(Hessian 的近似)补偿已量化列对未量化列的干扰,并且对敏感列分配更高精度:
python
def gptq_quantize(W, X, bits=4, groupsize=128, blocksize=128):
"""W: (out, in) 权重; X: (N, in) 校准激活样本"""
H = X.T @ X / X.shape[0] # Hessian 近似
dead = torch.diag(H) == 0
H[dead, dead] = 1 # 防奇异
damp = 0.01 * torch.mean(torch.diag(H))
H += torch.eye(H.shape[0], device=W.device) * damp
Hinv = torch.cholesky_inverse(torch.linalg.cholesky(H))
Hinv = torch.linalg.cholesky(Hinv.flip(0)).flip(0).T # Cholesky of inverse
qmax = 2 ** (bits - 1) - 1
W_q = torch.zeros_like(W)
scales = torch.zeros(W.shape[0], W.shape[1] // groupsize)
zeros = torch.zeros_like(scales)
for i1 in range(0, W.shape[1], blocksize): # 列块顺序处理
i2 = min(i1 + blocksize, W.shape[1])
for j1 in range(i1, i2, groupsize): # 组内求 scale/zero
j2 = min(j1 + groupsize, i2)
w = W[:, j1:j2]
s = w.abs().max(dim=1).values / qmax
z = torch.round(-w.min(dim=1).values / s) # 非对称量化
scales[:, j1//groupsize], zeros[:, j1//groupsize] = s, z
q = torch.clamp(torch.round(w / s.unsqueeze(1) + z.unsqueeze(1)),
0, 2*qmax+1)
W_q[:, j1:j2] = q
# 误差补偿: 本组量化误差反传到后续未量化列
err = (q - w / s.unsqueeze(1) - z.unsqueeze(1)) @ \
Hinv[j1:j2, j1:j2].T
if j2 < W.shape[1]:
W[:, j2:] -= err @ Hinv[j1:j2, j2:]
return W_q.to(torch.uint8), scales, zeros
这段代码里最值得理解的是误差反传那几行:量化第 j 组权重产生的误差并不就地丢弃,而是按 Hessian 逆的交叉项调整后续列的权重,让"已造成的输出误差"被"未量化列的微小挪移"抵消。GPTQ 论文的实证:在 3 比特下,RTN 让 175B 级模型困惑度爆表,GPTQ 只掉零点几个点------差距全部来自这个补偿与激活感知的列处理顺序。
三、AWQ 的洞察与在线 vs 离线的取舍
AWQ(Activation-aware Weight Quantization)给出另一个更轻量的视角:既然少数激活通道大,就保护与之对应的权重列------把敏感列乘上一个放大系数 s、其余列相应缩小,量化后整体数学不变,但敏感列落在粗格点上的相对误差被压小:
python
def awq_transform(w, act_scales, alpha=0.5):
"""w: (out, in); act_scales: (in,) 各输入通道的激活最大值"""
s = (act_scales ** alpha) / (w.norm(dim=0) ** (1 - alpha)) # 通道缩放
s = s / s.max()
return w * s.unsqueeze(0), 1.0 / s # 权重放大, 激活反向缩放
它只需要一小批校准数据统计激活尺度(不用 Hessian、不用反传),实现比 GPTQ 简单一个数量级,精度通常接近------所以实践中的默认选择是:能跑 GPTQ 跑 GPTQ,赶时间或工具链受限用 AWQ 思路的手工变换 。最后一个必须想清楚的取舍是离线量化 vs 在线反量化的成本:权重存成 4 比特,前向时反量化回 fp16 计算是常见实现,此时瓶颈常在反量化的访存而非计算------如果反量化开销吃掉了带宽节省,量化就是负优化;kernel 级的融合反量化(把 dequant 写进矩阵乘的加载路径)是量化库真正的技术含量。选型决策的诚实顺序:先测任务的精度容忍度(困惑度涨多少可接受、下游指标掉多少可接受),再选粒度与格式,最后才谈量化算法的先进性------精度预算没定,一切量化都是盲飞。