把一个大语言模型从FP16压到INT8甚至INT4,模型体积和显存占用能砍掉一半以上,推理速度也有可观提升。但实际动手的人很快会发现一个残酷的现实:权重量化通常问题不大,一旦对激活值做低比特量化,模型的困惑度立刻飙升,输出质量肉眼可见地变差。追根溯源,问题往往出在激活值里那少数几个异常大的数值上。本文就来拆解这个问题,并介绍异常值抑制和混合精度两条主流解决路线。

为什么异常值会毁掉量化效果
要理解异常值的破坏力,先要回到量化本身的数学形式。以对称量化为例,一组数值会被缩放到整数网格上:
import torch
def fake_quantize(x, num_bits=4):
qmax = 2 ** (num_bits - 1) - 1
scale = x.abs().max() / qmax # 用最大绝对值确定缩放因子
x_q = torch.round(x / scale).clamp(-qmax - 1, qmax)
return x_q * scale # 反量化,模拟量化误差关键就在scale的计算上。量化网格的分辨率完全由最大绝对值决定,比特数固定时,最大值越大,网格间距就越大。假设某个通道里存在一个数值为200的异常值,而其余99%的数值都分布在正负1之间,那么整个量化区间会被拉宽到200,导致每个整数刻度之间的间距变得很粗,那些正常的小数值几乎全部落在同一个刻度上,区分度彻底丧失。
大模型里的情况正是如此。研究发现,LLM的激活值中存在少量系统性出现的离群通道,它们的数值能达到普通值的几十甚至上百倍。这些通道在网络的浅层就开始出现,并且随着层数加深不断放大。更麻烦的是,这些异常值往往带有固定的模式,某些特定通道几乎在所有token位置上都会变大,这让简单的动态量化也难以招架。
还有一个容易被忽视的连带效应:异常值不仅影响激活值本身的量化,还会通过矩阵乘法把误差传导到权重侧的输出。一个量化误差为e的异常激活值,与权重相乘后误差同样按比例放大,等于在关键位置注入了强噪声,模型对细微语义差别的感知能力因此受损。
异常值抑制:SmoothQuant的迁移思路
异常值抑制的核心思想不是消灭异常值,而是把它们搬到更适合处理的地方去。SmoothQuant是这个方向最经典的方案,它利用了一个数学事实:线性层和逐元素缩放是可交换的。对激活值X除以一个平滑因子s,同时把权重W乘上同一个s,输出的数学结果完全不变:
# SmoothQuant 的核心变换
# X * W == (X / s) * (s * W),数学上严格等价
def smooth_weights(weight, activation_scale, alpha=0.5):
# alpha 控制迁移强度,0.5 是常用的平衡点
s = activation_scale.pow(alpha) / weight.abs().max(dim=0).values.pow(1 - alpha)
s = s.clamp(min=1e-5)
weight_smooth = weight * s.unsqueeze(0) # 权重吸收放大因子
return weight_smooth, s
# 前向时对激活值做同样缩放,量化后再进矩阵乘法
# x_smooth = x / s这个变换的精妙之处在于,激活值中被s放大的部分会数值变小、分布变平滑,而权重原本分布均匀,稍微放大一点并不会破坏它的可量化性。等于把量化难度从激活侧转移到了权重侧,而权重侧的承载能力天然更强。参数alpha控制迁移的比例,alpha越大,激活被平滑得越彻底,但权重侧压力也越大,工程上通常取0.5作为起点,再根据实际掉点情况微调。
需要注意的是,这种迁移只对线性层有效。Transformer中的LayerNorm输出本身是经过归一化的,理论上分布应该温和,但异常值恰恰出现在归一化之后的通道维度上,因为LayerNorm只对特征维度整体做缩放,不改变通道之间的相对差异。这也解释了为什么SmoothQuant要选择在LayerNorm和线性层之间插入平滑操作——这是迁移链条上最自然的切入点。
混合精度:按敏感度分配比特数
另一条思路是承认现实:不是所有层都同样抗量化。与其统一压到4比特,不如给敏感的部分保留更高精度,给不敏感的部分狠狠压缩。这就是混合精度量化,它需要回答两个问题:如何度量敏感度,以及在什么粒度上分配精度。
敏感度度量有几种常见做法。最直接的是逐层量化测试:把某一层替换为目标精度的量化版本,观察整个模型输出指标的变化幅度,变化大的层就是敏感层。还有基于Hessian矩阵的分析方法,GPTQ这类算法会用二阶信息估计每个权重对损失函数的影响,误差大的权重被优先保护。工程上更简单的做法是直接看激活值的动态范围,范围大或异常值密集的位置天然需要更高比特。
# 极简的逐层敏感度评估流程
def layer_sensitivity(model, calib_data, eval_fn, bits_list=(4, 8)):
results = {}
for name, layer in model.named_modules():
for bits in bits_list:
with quantize_layer(layer, bits): # 临时量化该层
score = eval_fn(model, calib_data) # 评估困惑度等指标
results[(name, bits)] = score
# 对比同一层在 4bit 和 8bit 下的分数差,差值大即为敏感层
return results分配粒度可以从粗到细:层级、通道级甚至权重级。层级混合精度实现最简单,把第一层和最后一层的embedding与输出投影保留高精度,中间层压到4比特,往往就能挽回大部分精度损失。通道级方案会更精细,对包含异常值的通道单独用8比特,其余通道用4比特。如果用分组量化配合,每组独立计算缩放因子,效果接近但实现更友好。
工程实践中的组合拳
实际部署中,异常值抑制和混合精度很少单独使用,而是组合起来发挥效用。一个常见的完整流程是:先用校准数据集统计各层的激活分布,识别出异常值通道;然后应用SmoothQuant式的平滑变换处理系统性的通道异常;最后对平滑后仍然敏感的个别层做混合精度保护,其余部分放心压到目标比特数。
校准数据的选择值得多花心思。校准集应该覆盖模型实际会遇到的输入分布,用与业务场景不一致的数据做校准,统计出的缩放因子会有偏差,上线后精度表现会明显差于离线评估。一般准备几百条有代表性的样本就足够,太多反而增加统计噪声之外没有收益。
评估环节也别只看困惑度。困惑度对整体分布敏感,但可能掩盖细粒度的能力退化,比如长上下文推理或多步计算。建议补充一些下游任务评测和人工抽样检查,特别是量化到4比特以下时,模型在边缘case上的行为变化往往比基准分数更早暴露问题。另外,平滑变换中的alpha参数、混合精度的比特分配边界,都建议以小网格搜索的方式确定,不同模型结构的敏感点分布差异不小,直接照搬别人的配置未必合适。
总的来说,低比特量化的精度损失并非不可控,理解异常值的作用机制后,用平滑迁移降低量化难度,再用混合精度保护薄弱环节,多数模型都能在4比特下保持接近FP16的效果。掌握这两条路线的原理和组合方式,就能在面对具体模型的量化掉点问题时,快速定位原因并选择合适的对策。