第 3 章 粒度选择:从 per-tensor 到 per-group
第 3 章 粒度选择:从 per-tensor 到 per-group
第 2 章的量化和器给整张张量算了一个 S 和一个 Z——这就是最粗的粒度:per-tensor。但真实模型的权重和激活,不同通道/不同位置的数值范围差异巨大,一个全局缩放因子往往顾此失彼。这一章研究:把缩放因子细化到多细,才能在不显著增加开销的前提下大幅降低误差。
原书仓库 ch3/ 目录提供了完整实验:3.1 resnet18_dist.ipynb、3.2 resnet18_quant.ipynb、3.3_activation_quantization.py、3.4_kv_cache_quantization.py、3.5_group_quantization_analysis.py。
3.1 per-tensor 的问题:范围利用率
先在 ResNet-18 上做一次诚实的测量。3.1 resnet18_dist.ipynb 统计了各层权重的分布,3.2 resnet18_quant.ipynb 做了整模型量化实验。核心观察是:CNN 的不同卷积核(output channel)之间,数值范围可以相差一个数量级。
如果一个 layer 的权重张量形状是 [out_channels, in_channels, k, k],per-tensor 量化把整个张量的 absmax 当成 S 的分母。结果:
- 范围小(数值都集中在零点附近)的通道,格子间距被拉大,分辨率被浪费;
- 只要有一个通道存在较大权重,其他所有通道都跟着被"惩罚"。
3.2 resnet18_quant.ipynb 里的实验直观展示了这一点:per-tensor 量化后,某些层权重的 range utilization(范围利用率,即 实际使用格子 / 总格子)很低。这在数学上对应第 2 章的 granular error——格子间距越大,量化噪声越大。
3.2 per-channel:每个输出通道一个缩放因子
解法很直接:既然不同通道范围不同,就给每个输出通道单独算 S(和 Z)。对权重来说,per-channel 是零额外运行时开销的——因为缩放因子可以"折进"后续的归一化/批归一化层,推理时不需要额外乘一次。
# 来自 ch3 相关实验的 per-channel 缩放示意
def compute_per_channel_scales(weight, bits=8, axis=0):
# weight: [out_channels, in_channels, ...]
absmax = weight.abs().amax(dim=tuple(i for i in range(weight.dim()) if i != axis))
qmax = (1 << (bits - 1)) - 1
return absmax / qmax # 每个 out_channel 一个 scale3.2 resnet18_quant.ipynb 的对照结果是教科书级别的:per-channel 相比 per-tensor,INT8 量化后的 top-1 精度损失显著更小(在 ImageNet 上,per-tensor 通常掉 1-2 个百分点,per-channel 基本不掉或只掉零点几个点)。
为什么对权重几乎免费? 权重是离线静态的,per-channel 缩放因子可以提前算好并固化。更重要的是,卷积/线性的 per-channel 缩放可以吸收进 bias 或下一层的归一化,运行时矩阵乘不需要额外的逐通道乘法。相比之下,per-channel 量化激活要昂贵得多(激活是逐 token 变化的,缩放因子无法提前固化),所以实践中"权重 per-channel + 激活 per-tensor"是默认组合。
3.3 激活量化的特殊之处
3.3_activation_quantization.py 用真实模型(bert-base-uncased)在推理时挂 hook 抓取中间激活,统计每个算子的 min/max/mean/std/abs_max。它揭示了一个关键事实:激活的分布远没有权重那么稳定。
# 3.3_activation_quantization.py 的核心思路(摘录)
activation_stats = {}
def capture_hook(name):
def hook(module, input, output):
tensor = output[0] if isinstance(output, tuple) else output
activation_stats[name] = {
'min': tensor.min().item(),
'max': tensor.max().item(),
'mean': tensor.mean().item(),
'std': tensor.std().item(),
'abs_max': tensor.abs().max().item()
}
return hook
# 对每个 nn.Linear / nn.Conv2d 注册 hook,前向时抓取激活统计激活量化的难点在于:
- 范围是动态的:不同输入 batch、不同序列位置,激活范围都会变。量化范围只能通过校准(第 4 章)统计得出。
- 异常值出现在激活里:BERT 某些层的激活会出现远超 99% 数据的异常大值,直接拉爆 absmax 范围。
- 逐层差异大:不同层的激活范围相差几个数量级,不能共用一套缩放。
这也是为什么激活通常退而求其次用 per-tensor——动态数据的逐通道量化在推理引擎里实现成本太高(需要逐通道重新统计 + 逐通道修正)。"权重尽量细,激活尽量粗" 是工业界的普遍妥协。
3.4 KV cache 量化:Key 与 Value 的不对称
3.4_kv_cache_quantization.py 和 3.4_kv_cache_granularity.py 研究的是一个非常实用的话题:KV cache(键值缓存)量化。长上下文场景下,KV cache 的内存占用远超权重,把 KV cache 从 FP16 压到 INT8/INT4 能直接决定"能不能撑起 128k 上下文"。
这个实验的核心发现是 Key 和 Value 的统计特性截然不同:
- Key 有通道级异常值(channel-wise outliers):某些固定 channel 在所有 token 上持续偏大(和注意力模式有关),异常值在通道维度上稳定。
- Value 是 token 级波动(token-wise variation):Value 的异常值出现在少数 token 上(尤其是开头的 attention sink token),没有固定的通道模式。
# 3.4_kv_cache_quantization.py 的模拟数据构造(摘录)
# Keys: 通道级异常值(跨 token 一致)
keys = torch.randn(batch, heads, seq_len, head_dim) * 0.5
outlier_channels = [7, 23, 64, 89, 120] # 永远热的通道
for ch in outlier_channels:
keys[..., ch] *= torch.tensor([8.0, 12.0, 15.0, 10.0, 7.0])[outlier_channels.index(ch)]
# Values: token 级波动(无固定通道模式)
values = torch.randn(batch, heads, seq_len, head_dim) * 0.5
values[:, :, :4, :] *= 3.0 # attention sink token 更大仓库里还带了真实模型的验证日志 kv_cache_verification_logs.txt,在 TinyLlama-1.1B 上确认了这两条 claim:
Layer 0: Keys consistency=0.821, outliers=1, ratio=3.6x
Values outlier_tokens=6, ratio=3.3x
Layer 11: Keys consistency=0.777, outliers=2, ratio=3.1x
Values outlier_tokens=0, ratio=1.6x
Layer 21: Keys consistency=0.790, outliers=2, ratio=3.4xconsistency接近 0.8 说明 Key 的异常通道确实跨 token 稳定存在。- Key 的异常是"通道固定放大几倍",Value 的异常是"少数 token 放大几倍"。
这对量化策略意味着什么:Key 适合按通道/维度做分组量化(因为异常在通道上稳定),Value 适合更细的分组或保留更多位宽。KV cache 量化的分组策略(per-channel、per-group、甚至 Key 和 Value 分开设位宽)是第 7 章 TurboQuant 的前置知识。
3.5 per-group:细粒度的极致
3.5_group_quantization_analysis.py 把粒度再往前推一步:per-group——把每个输出通道的输入维度切成若干组,每组一个 S/Z。这是 LLM 量化(GPTQ/AWQ)里 groupsize=128 的出处。
仓库的日志 group_quantization_analysis_logs.txt 在 TinyLlama-1.1B 的 q_proj(形状 [2048, 2048])上跑了一组完整实验,数据非常干净:
Group Size Analysis:
---------------------------------------------------------------------------
Group Size MSE Max Error Overhead Compression
---------------------------------------------------------------------------
32 6.65e-06 0.0269 11.1% 3.56x
64 8.43e-06 0.0275 5.9% 3.76x
128 1.04e-05 0.0275 3.0% 3.88x
256 1.26e-05 0.0280 1.5% 3.94x
512 1.51e-05 0.0280 0.8% 3.97x
1024 1.79e-05 0.0280 0.4% 3.98x
2048 2.12e-05 0.0280 0.2% 3.99x读这张表要注意两件事:
- 分组越细,MSE 越低:
g=32的 MSE(6.65e-06)比g=2048(即 per-row)低 3 倍多。细粒度确实让误差显著下降。 - 分组越细,开销越高:
g=32需要 131,072 个缩放因子,压缩率只有 3.56x;g=2048只要 2,048 个缩放因子,压缩率接近理论极限 3.99x(INT4 理论上把 4 字节压到 0.5 字节 = 8x,但这里maxq=15是不对称 4bit,且要存 scale,所以是 ~4x)。
权衡点:g=128 是工业界的甜点——MSE 已经接近最佳,overhead 只有 3%,压缩率 3.88x。这解释了为什么 GPTQ/AWQ/GGUF 默认 groupsize=128。
日志里还对比了 Group(1D)和 Block(2D)两种布局:
Scheme Config MSE Scales Compression
Group (1D) g=32 6.65e-06 131,072 3.56x
Group (1D) g=128 1.04e-05 32,768 3.88x
Block (2D) (32, 32) 2.31e-05 4,096 3.98x
Block (2D) (64, 64) 3.23e-05 1,024 4.00x
Block (2D) (128, 128) 5.23e-05 256 4.00x2D Block 用极少的 scale(256 个)就拿到 4.00x 的压缩率,但 MSE 显著更差。1D Group 用更多的 scale 换来了更低的误差。这个对比会在第 8 章的 FP4 blockwise scaling 里再次出现——Block 布局省空间,Group 布局省误差。
3.6 粒度选择的决策框架
把这一章的实验结论浓缩成一张决策表:
| 粒度 | 缩放因子数量 | 误差 | 运行时开销 | 典型场景 |
|---|---|---|---|---|
| per-tensor | 1 | 最差 | 无 | 快速原型、激活量化 |
| per-channel | out_channels | 好 | 权重≈0(可吸收),激活较高 | 权重 INT8 默认 |
| per-group (g=128) | out×in/128 | 很好 | 中等 | GPTQ/AWQ/GGUF 的 LLM 权重 |
| per-block (2D) | 极少 | 取决于块大小 | 低 | FP4 blockwise(第 8 章) |
选型逻辑一句话:能用粗粒度解决就不上细粒度。per-channel 对权重几乎免费所以默认用;per-group 用在误差仍然不够的 LLM 权重上;激活因为动态特性只能退到 per-tensor(或靠校准兜底);KV cache 要根据 Key/Value 的统计特性分别设计。
下一章进入 PTQ 的核心——校准:范围到底怎么估计?校准集怎么构建?这是从"玩具量化"走向"能上线的量化"的关键一步。
作者: itech001 来源: 公众号:AI人工智能时代(the-ai-era) 网站: https://www.theaiera.top/ 关注每日最新AI新闻和技术博客,主页有更多的文章的AI 技术参考:https://www.theaiera.top
本文首发于 AI人工智能时代,转载请注明出处。