第 7 章 大语言模型量化实战
第 7 章 大语言模型量化实战
前六章的方法在小模型(ResNet、BERT、MobileNet)上很好用。但到了大语言模型(LLM),事情变了——LLM 的激活里会出现小模型没有的异常值(outliers),把之前所有量化方法打得措手不及。这一章是全书的分水岭:从"通用量化"进入"LLM 专用量化"。
原书仓库 ch7/ 目录:ch7_outlier_profiling.py、ch7_llm_int8_decomposition.py、ch7_llm_int8_flow.py、ch7_gptq_quantization.py、ch7_awq_quantization.py、ch7_turboquant_kv_cache.py、ch7_quant_decision_tree.py。
7.1 异常值问题:LLM 量化和普通量化为什么不同
ch7_outlier_profiling.py 在 OPT 系列模型(125m / 1.3b / 6.7b)上做了系统的异常值画像。核心发现:
1. 异常值随模型规模增长而涌现。 小模型(125m)的激活分布相对干净;当模型大到 6.7B,某些隐藏维度(hidden dimension)的激活值会比其他维度大一个数量级,而且同一个维度在所有 token 上都持续偏大——不是偶发噪声,是结构性的。
# 来自 ch7/ch7_outlier_profiling.py(结构)
def profile_outliers(model_key="opt-6.7b", ...):
# 1. 加载模型,前向跑校准数据,抓取各层激活
# 2. 按通道(hidden dim)统计激活幅度
# 3. 找出"持续偏大"的异常通道,画热力图
...
def outlier_emergence(models_to_compare=None, ...):
# 对比 125m / 1.3b / 6.7b 的异常值涌现曲线
...2. 异常值是"跨层一致"的。 同一个 hidden dimension 在模型每一层都是异常值。这让异常值问题无法通过"某几层特殊处理"来绕开——它存在于整个模型深度上。
3. 为什么 INT8 对 LLM 失效。
回到第 2 章的原理:per-tensor 量化的 scale 由 absmax 决定。一个异常维度把 absmax 撑到几百,其他 99% 的维度都被压进极小的整数区间,量化精度崩塌。quantization_error_decomposition 把这种"异常值导致的误差主导"定量地展示出来——误差被少数异常通道垄断,而非均匀分布。
4. KV cache 的额外压力。
kv_cache_memory_analysis 计算了 KV cache 的内存占用:长上下文下,KV cache 往往比权重更占显存。这呼应了第 3.4 节——KV cache 量化是 LLM 长上下文推理的必答题。
7.2 LLM.int8():用混合精度"绕开"异常值
ch7_llm_int8_decomposition.py 从头实现了 LLM.int8()(Dettmers et al., 2022),它的思路非常巧妙:不试图用 INT8 覆盖异常值,而是把异常值单独拎出来用 FP16 算,正常值用 INT8 算。
# 来自 ch7/ch7_llm_int8_decomposition.py(核心)
def quantize_absmax_int8(tensor):
# 标准 absmax INT8 量化
...
def mixed_precision_matmul(X, W, threshold):
"""LLM.int8() 的混合精度矩阵乘。
1. 找出激活中超过 threshold 的异常列
2. 异常列:保持 FP16,直接 FP16 矩阵乘
3. 正常列:量化到 INT8,INT8 矩阵乘
4. 两个结果相加
"""
outlier_mask = X.abs() > threshold # 异常位置
# 分离异常特征(FP16)与正常特征(INT8)
...graph TB
A[激活矩阵 X] --> B{abs 超过阈值?}
B -->|是| C[异常特征 保留 FP16]
B -->|否| D[正常特征 量化 INT8]
C --> E[FP16 矩阵乘]
D --> F[INT8 矩阵乘]
E --> G[结果相加]
F --> G为什么有效:异常值的数量通常很少(约 0.1%-1% 的特征),所以 FP16 分支的开销可控,而 INT8 分支覆盖了绝大多数计算,整体依然接近 INT8 的速度,精度却几乎无损。
run_threshold_sweep 实验会告诉你阈值的敏感性——阈值定得太松,异常值漏进 INT8,精度崩;定得太紧,太多特征走 FP16,加速没了。好在异常值和正常值之间有明显的"断层",阈值在合理区间内都不敏感。
7.3 GPTQ:海森矩阵感知的逐列量化
ch7_gptq_quantization.py 实现了 GPTQ(Frantar et al., ICLR 2023)——目前最主流的 LLM 权重量化算法之一。它和 LLM.int8() 思路完全不同:LLM.int8() 是"绕开"异常值,GPTQ 是"利用误差补偿"把量化误差吃掉。
GPTQ 的核心观察:量化某一列权重时产生的误差,可以用海森矩阵(Hessian)引导,把它"推"到还没量化的列上,让误差互相抵消。
# 来自 ch7/ch7_gptq_quantization.py 的 gptq_quantize(核心流程)
def gptq_quantize(W, H, bits=4, blocksize=128, groupsize=-1, damp_percent=0.01):
# W: 权重矩阵 [out, in]; H: 海森矩阵 = 2 * X @ X^T(X 是校准激活)
# Step 0: 通过 Cholesky 分解预计算 H^{-1}(数值稳定)
L = torch.linalg.cholesky(H)
Hinv = torch.cholesky_inverse(L)
Hinv = torch.linalg.cholesky(Hinv, upper=True)
# 逐块(block)处理,每块 B 列
for block_start in range(0, cols, blocksize):
for j in range(block_start, block_end):
# 量化第 j 列(RTN 到网格)
q_col = quantize_to_nearest_col(W[:, j], ...)
Q[:, j] = q_col
# 关键:用海森矩阵把误差"推"到后续列
err = (w_col - q_col) / Hinv[j, j]
W[:, j:block_end] -= err.unsqueeze(1) * Hinv[j, j:block_end]
# 块级误差累积,更新所有剩余列
W[:, block_end:] -= Err @ Hinv[block_start:block_end, block_end:]GPTQ 相对朴素 OBQ 的三个关键改进(都写在了源码注释里):
- 固定列顺序:不贪心选列(那是 OBQ),固定从左到右,从而让所有行共享同一个
H^{-1}。 - Lazy Batch Update(延迟批量更新):不是每量化一列就更新所有剩余列,而是攒一个 block(
blocksize=128)再统一更新,GPU 友好。 - Cholesky 重构:用 Cholesky 分解稳定地算
H^{-1},避免原始 OBQ 反复高斯消元的数值不稳定。
代码里还有个细节值得注意:damp_percent = 0.01——给海森矩阵对角线加 1% 的阻尼,防止奇异。这是 GPTQ 工程落地的关键小 trick。
RTN 是什么:Round-to-Nearest(就近取整),即第 2 章的朴素量化。GPTQ 的基线就是 RTN,
quantize_to_nearest函数实现了它。GPTQ 论文的核心卖点就是"我们比 RTN 强多少"。
7.4 AWQ:激活感知的权重保护
ch7_awq_quantization.py 实现了 AWQ(Lin et al., MLSys 2024)——另一种主流的 4bit 权重量化方法。AWQ 的洞察非常反直觉:权重的重要性不该由权重本身决定,而该由它乘以的激活决定。
# 来自 ch7/ch7_awq_quantization.py(核心)
def compute_activation_scales(X):
"""每个输入通道的重要性 = 激活绝对值的均值。"""
return X.abs().mean(dim=0) # [in_features]
def awq_scale_search(W, X, bits=4, groupsize=128, n_grid=20, max_shrink=0.8):
# 对每个权重列分组,搜索最优缩放因子 s
# s_j = (act_scale_j / max(act_scale))^α, α ∈ [0,1]
for alpha in grid:
scales = act_norm.pow(alpha)
W_scaled = W_group * scales # 权重放大:重要通道更精确
X_descaled = X_group / scales # 激活缩小:数学上等价
W_q = quantize_rtn(W_scaled, bits=4) # 量化放大后的权重
mse = reconstruction_error(W_q, W, X) # 评估重建误差
return best_scalesAWQ 的数学直觉(源码注释里有清晰的推导):量化误差 Err = Δ · RoundErr(w/Δ) · x。如果把重要通道的权重 w 放大到 w·s、激活缩小到 x/s,量化对重要通道的相对误差就变小了。而且因为是仅权重量化(weight-only),缩放因子可以提前固化,推理零额外开销。
AWQ 和 GPTQ 的对比:
| 维度 | GPTQ | AWQ |
|---|---|---|
| 核心思想 | 海森矩阵引导误差补偿 | 激活感知的权重缩放保护 |
| 校准依赖 | 需要海森矩阵(二阶信息) | 只需要激活统计(一阶) |
| 计算开销 | 高(H 的 Cholesky) | 低(网格搜索缩放) |
| 部署友好 | 好 | 更好(无梯度、易固化) |
两者都在仓库里被"从零实现 + 与生产库对照"验证过(GPTQ 对照 gptqmodel,AWQ 对照 autoawq)——这是全书反复强调的方法论:自己实现一遍,再和工业实现对拍,才能真正理解算法。
7.5 TurboQuant:KV cache 的向量量化
ch7_turboquant_kv_cache.py 实现了 TurboQuant——针对 KV cache 的量化方法。前面第 3.4 节已经铺垫了 KV cache 的 Key/Value 统计不对称,TurboQuant 进一步用**向量量化(Vector Quantization)**来处理。
# 来自 ch7/ch7_turboquant_kv_cache.py(核心)
def compute_lloyd_max_codebook(d, bits, max_iter=2000, tol=1e-12):
"""Lloyd-Max 算法生成码本:迭代优化码本使量化误差最小。"""
...
class TurboQuantizer:
# 用码本(codebook)量化 KV cache,而不是均匀网格
# 码本从真实 KV 分布学习而来,比均匀量化更能贴合分布
...向量量化 vs 标量量化的区别:标量量化(前面所有方法)对每个数值单独量化;向量量化把若干数值组成一个向量,用"码本"里最接近的码字表示它。码本是离线从数据分布学出来的(Lloyd-Max 迭代),所以能贴合真实分布的形状,比均匀网格效率更高——尤其适合 KV cache 这种"大量重复模式"的数据。
结合第 3.4 节的发现:Key 有通道级异常值、Value 有 token 级波动,TurboQuant 的码本设计会针对这两种统计特性分别做处理,在极小精度损失下把 KV cache 压到 2-3 bit。
7.6 选型决策树
ch7_quant_decision_tree.py 把这一章的方法串成一张决策树。选型逻辑大致是:
graph LR
A[要量化 LLM] --> B{显存瓶颈在哪?}
B -->|权重| C{能接受多少精度损失?}
C -->|几乎无损| D[LLM.int8 混合精度]
C -->|需要 4bit| E{要训练/校准成本?}
E -->|低| F[AWQ]
E -->|可接受| G[GPTQ]
B -->|KV cache| H[TurboQuant / 分组量化]- 权重放不下 → 权重量化(GPTQ / AWQ / LLM.int8)。
- 上下文太长 KV cache 爆显存 → KV cache 量化(TurboQuant)。
- 两者都要 → 组合使用(第 12 章的生产案例就是 4bit 权重 + 优化 KV cache 的典型配置)。
7.7 本章小结
- LLM 量化的分水岭是异常值:跨层一致的激活异常通道让小模型时代的量化方法失效。
- LLM.int8():混合精度分解,异常值走 FP16、正常值走 INT8,绕开而非硬扛。
- GPTQ:海森矩阵引导的误差补偿,逐列量化把误差"推"到未量化列,4bit 主流选手。
- AWQ:激活感知的权重缩放,只量权重、零推理开销,4bit 另一个主流选手。
- TurboQuant:KV cache 的向量量化,解决长上下文显存瓶颈。
下一章把位宽推到极致——sub-8-bit 格式:FP8、FP4、NF4,以及只有 1.58 bit 的三元网络。
作者: itech001 来源: 公众号:AI人工智能时代(the-ai-era) 网站: https://www.theaiera.top/ 关注每日最新AI新闻和技术博客,主页有更多的文章的AI 技术参考:https://www.theaiera.top
本文首发于 AI人工智能时代,转载请注明出处。