第 3 章 量化per-channelper-group

第 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 一个 scale

3.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,前向时抓取激活统计

激活量化的难点在于:

  1. 范围是动态的:不同输入 batch、不同序列位置,激活范围都会变。量化范围只能通过校准(第 4 章)统计得出。
  2. 异常值出现在激活里:BERT 某些层的激活会出现远超 99% 数据的异常大值,直接拉爆 absmax 范围。
  3. 逐层差异大:不同层的激活范围相差几个数量级,不能共用一套缩放。

这也是为什么激活通常退而求其次用 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.4x
  • consistency 接近 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

读这张表要注意两件事:

  1. 分组越细,MSE 越低:g=32 的 MSE(6.65e-06)比 g=2048(即 per-row)低 3 倍多。细粒度确实让误差显著下降。
  2. 分组越细,开销越高: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.00x

2D 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人工智能时代,转载请注明出处。