把 PagedAttention、Continuous Batching、投机解码、MoE 专家并行都调完之后,你会发现推理吞吐还是卡在一个地方——显存带宽。

这不是玄学。2026 年的大模型推理已经进入了「内存墙」主导的时代:GPU 的算力每年翻一倍,HBM 带宽三年才翻一倍。当你的 decode 阶段每生成一个 token 都要把几十 GB 的模型权重从头到尾读一遍时,瓶颈从来不在 CUDA Core 上,而在 HBM 到 SM 的那根总线上。

量化是唯一能同时砍掉显存占用、带宽压力和访存延迟的手段。这篇文章不讲「量化就是把 float 变成 int」这种科普,我们直接从 Roofline 模型算起,一路拆到 GPTQ 的 Hessian 逆矩阵、AWQ 的激活感知缩放、SmoothQuant 的 outlier 迁移,以及真正决定长上下文成本的 KV Cache 量化。

一、先算清楚账:为什么 decode 是带宽瓶颈

以 Llama-3-70B 为例,先做一次最朴素的资源核算:

精度单参数字节总权重显存H100 80G 单卡可放decode 理论最低延迟
FP16/BF162140 GB否(需 2 卡)~70 ms/token
FP8170 GB是(余量小)~35 ms/token
INT8 (W8A8)170 GB是~35 ms/token
INT4 (W4A16)0.535 GB是(余量大)~18 ms/token

最后那列的算法很简单:H100 的 HBM3 带宽约 3.35 TB/s,140 GB / 3.35 TB/s ≈ 42 ms,加上非理想访存模式打个 1.6 倍折扣就是 70 ms。

关键在于算术强度(Arithmetic Intensity)。prefill 阶段处理整段 prompt,是矩阵-矩阵乘法(GEMM),算术强度高,跑在算力受限区;decode 阶段每步只有 batch 个 query 向量,退化成矩阵-向量乘法(GEMV),算术强度趋近于 1 flop/byte,死死钉在带宽受限区。

def roofline_bound(bytes_to_read: float, flops: float,
                   peak_flops: float = 989e12,      # H100 BF16 ~989 TFLOPS
                   peak_bw: float = 3.35e12) -> dict:
    """算一下这层到底是算力受限还是带宽受限"""
    ai = flops / bytes_to_read                       # 算术强度 flop/byte
    ridge = peak_flops / peak_bw                     # 分水岭 ~295 flop/byte
    return {
        "arithmetic_intensity": round(ai, 2),
        "ridge_point": round(ridge, 2),
        "bound": "compute" if ai > ridge else "memory",
        "attainable_tflops": min(peak_flops, ai * peak_bw) / 1e12,
    }

# Llama-3-70B 的 q_proj: [8192, 8192], decode 阶段 batch=1
w_bytes = 8192 * 8192 * 2                 # BF16 权重 134 MB
gflops  = 2 * 8192 * 8192                 # 2*M*N (K 被 GEMV 折叠)
print(roofline_bound(w_bytes, gflops))
# -> arithmetic_intensity ≈ 1.0,bound = 'memory',attainable ≈ 3.3 TFLOPS
#    只有峰值算力的 0.33%! 这就是内存墙

结论极其刺眼:decode 时你花了几万美元买的 Tensor Core,利用率只有 0.33%。剩下的 99.67% 时间,SM 在等 HBM 把权重搬过来。

所以量化的第一性价值不是「省显存省成本」,而是直接砍掉搬运的字节数。权重从 2 字节降到 0.5 字节,decode 延迟理论上直接砍到 1/4——这个收益比任何 kernel 融合、任何调度优化都来得直接。

二、量化的数学骨架:scale、zero-point 与粒度之争

所有量化方案都逃不出这个仿射映射:

$$X_{int} = \text{clamp}\left(\left\lfloor \frac{X}{s} \right\rceil + z,\ q_{min},\ q_{max}\right), \qquad \hat{X} = s \cdot (X_{int} - z)$$

其中 $s$ 是 scale,$z$ 是 zero-point。$z=0$ 时叫对称量化(symmetric),计算时能省掉一次减法,硬件最喜欢;$z\ne0$ 时叫非对称量化(asymmetric),能更好拟合非对称分布(比如 ReLU 之后全是正数的激活)。

真正决定精度损失的,是粒度(granularity):

  • per-tensor:整个矩阵一个 scale。最快,但一个 outlier 毁掉整个张量。
  • per-channel(权重)/ per-token(激活):每行/每列一个 scale。显存开销可忽略,精度提升巨大。
  • per-group / group-wise:每 32 或 128 个元素一组共享 scale。这是 INT4 的甜点区,也是 GPTQ/AWQ 的默认配置。
import torch

def quantize_per_group(w: torch.Tensor, bits: int = 4, group_size: int = 128):
    """Group-wise 对称量化:INT4 的主力形态

    w: [out_features, in_features] 的 fp16 权重
    返回打包后的 int4 权重 + 每组的 scale
    """
    assert w.shape[-1] % group_size == 0
    oc, ic = w.shape
    w = w.reshape(oc, ic // group_size, group_size)

    qmax = 2 ** (bits - 1) - 1            # int4 -> 7
    # 沿 group 维度取绝对最大值作为 scale 基准
    scale = w.abs().amax(dim=-1, keepdim=True).clamp(min=1e-12) / qmax

    w_int = torch.round(w / scale).clamp_(-qmax - 1, qmax).to(torch.int8)

    # 反量化校验(生产环境用它算量化误差)
    w_deq = w_int.float() * scale
    mse = (w_deq - w).pow(2).mean()
    return w_int.reshape(oc, ic), scale.squeeze(-1), mse.item()


# 实测:随机权重下 group=128 的 INT4 相对误差
w = torch.randn(4096, 4096, dtype=torch.float16) * 0.02
for gs in (4096, 512, 128, 32):
    _, _, mse = quantize_per_group(w, bits=4, group_size=gs)
    print(f"group_size={gs:5d}  MSE={mse:.6e}")
# group_size= 4096  MSE=1.87e-06   <- 近似 per-channel,误差最大
# group_size=  128  MSE=4.21e-07   <- 甜点
# group_size=   32  MSE=2.03e-07   <- 收益递减,scale 显存开销翻倍

实战观点:group_size 从 128 再往下调到 32,精度收益已经很有限,但 scale 的存储开销涨 4 倍,而且 CUDA kernel 的 dequant 次数变多会拖慢速度。128 是当前几乎所有生产方案的默认值,不要自作聪明去改它。

三、LLM 量化的核心矛盾:权重好压,激活难压

如果你直接把 Transformer 的激活值画成分布图,会看到一个非常诡异的现象:绝大多数元素在 [-1, 1] 之间,但有极少数的「巨无霸」维度,幅值能达到其他维度的 100 倍以上。

这就是检测器(outlier)。它们在 Transformer 里是有功能的——相当于恒定的注意力汇聚点(attention sink)。但你一旦 per-tensor 量化激活,这些 outlier 会把 scale 拉得极大,导致剩下 99.9% 的元素全被压成 0。

所以业界分化出两条路:

  • Weight-only 量化(W4A16):只压权重,激活保持 FP16。精度几乎无损,部署简单,decode 提速明显。这是中小团队的首选。
  • Weight-Activation 量化(W8A8):两边都压,能用上 INT8 Tensor Core 真算力,prefill 阶段收益巨大,但必须先解决 outlier。

3.1 SmoothQuant:把难度从激活「搬」到权重

SmoothQuant 的想法极其优雅:既然激活有 outlier 而权重平滑,那就用一个对角矩阵把难度转移过去。

$$Y = (X \cdot \text{diag}(s)^{-1}) \cdot (\text{diag}(s) \cdot W)$$

激活除以 $s$ 把 outlier 压平,权重乘以 $s$ 相应地被放大。矩阵乘法结果数学上等价,但量化友好度天差地别。平滑因子用校准集离线算:

$$s_j = \frac{\max(|X_j|)^\alpha}{\max(|W_j|)^{1-\alpha}}$$

$\alpha$ 是迁移强度,一般取 0.5;激活 outlier 特别猛的模型(如某些 MoE)要调到 0.7-0.8。

@torch.no_grad()
def smoothquant_scales(act: torch.Tensor, w: torch.Tensor, alpha: float = 0.5):
    """act: [seq, in_features] 校准集激活
       w:   [out_features, in_features] 权重
       返回逐通道平滑因子 s"""
    # 沿 token 维度取激活的最大幅值 —— 这才是 outlier 的度量口径
    act_amp = act.abs().amax(dim=0)                 # [in_features]
    w_amp   = w.abs().amax(dim=0)                   # [in_features]
    s = act_amp.pow(alpha) / w_amp.pow(1 - alpha).clamp(min=1e-12)
    return s.clamp(min=1e-5)                        # 防止除零


# 应用:离线融合进权重,推理时零开销
def apply_smoothquant(act, w, alpha=0.5):
    s = smoothquant_scales(act, w, alpha)
    w_smoothed = w * s.unsqueeze(0)                 # 提前算好,写回 checkpoint
    # 运行时只需在 RMSNorm 的 bias/weight 上除 s,或插一个极轻量的 mul
    return w_smoothed, s

关键工程点:$\text{diag}(s)\cdot W$ 是离线算好写回 checkpoint 的,推理时零成本;$X \cdot \text{diag}(s)^{-1}$ 则可以融合进前一个 RMSNorm / LayerNorm 的权重里。所以 SmoothQuant 的运行时开销近乎为 0——这是它比很多方案高明的地方。

四、GPTQ:用二阶信息做「误差补偿」的逐列量化

GPTQ 的本质是把经典的最优脑外科手术(OBS)思想搬到了量化上:每量化一列权重,就把产生的误差重新分配给后面还没量化的列,让整体输出的重建误差最小。

目标函数是层输出的重建误差:

$$\min_{\hat{W}} \lVert WX - \hat{W}X \rVert_2^2$$

用二阶泰勒展开后,误差增量可以用 Hessian 矩阵 $H = 2XX^\top$ 的逆来加权。核心循环伪代码如下:

# GPTQ 核心(简化版,省略 Hessian Cholesky 数值稳定技巧)
def gptq_quantize_layer(W, H, bits=4, group_size=128, block_size=128):
    """W: [in, out] 权重;H: [in, in] 校准集 Hessian = 2*[email protected]"""
    import math
    W = W.float().clone()
    Q = torch.zeros_like(W)
    Hinv = torch.linalg.inv(H + 1e-6 * torch.eye(H.shape[0]))  # 阻尼保证可逆
    damping = 0.01

    for i in range(0, W.shape[0], block_size):
        # 按 block 处理,兼顾并行度与精度
        for j in range(i, min(i + block_size, W.shape[0])):
            if j % group_size == 0:
                scale = W[j].abs().max() / (2 ** (bits - 1) - 1)

            # 1. 量化当前列
            Q[j] = torch.round(W[j] / scale).clamp_(-8, 7) * scale

            # 2. 计算量化误差 e = (W_j - Q_j) / Hinv_jj
            err = (W[j] - Q[j]) / (Hinv[j, j] + damping)

            # 3. 误差补偿:把 err 按 Hessian 逆重新分配给后续未量化列
            W[j:] -= Hinv[j, j:].outer(err)

    return Q

这段代码里最反直觉的是第 3 步。它不是简单地「量化错了就算了」,而是主动修改后续列,让它们联合起来抵消这次量化误差。这正是 GPTQ 在 INT4 下还能把 PPL 损失压到 0.1 以内的原因。

生产踩坑提醒:

  1. Hessian 必须用和真实部署分布一致的校准集算。用 Wikipedia 校准、拿去跑代码生成,精度会明显掉。
  2. Hinv 必须加阻尼(damping,通常 0.01-0.1),否则 Hessian 接近奇异时数值会炸。
  3. GPTQ 量化是逐层顺序的,70B 模型在单卡上要跑 1-3 小时,务必做好 checkpoint 断点续跑。

五、AWQ:不做二阶优化,只保护 1% 的关键权重

AWQ 走了一条更「工程直觉」的路。它的观察是:权重里只有约 0.1%-1% 的通道是「显著」的(salient),这些通道对激活值的贡献远超其他。只要保住这 1%,剩下 99% 随便压。

如何识别显著通道?看激活值的幅值——激活大的通道,对应的权重就是显著的。

AWQ 不用重训练也不用二阶矩阵,只做一件事:对显著通道的权重乘以一个放大系数 $s>1$,让它在量化时占据更大的动态范围,从而相对误差更小。然后同样把 $1/s$ 融进前面的归一化层。

为了不让 $s$ 手动调参,AWQ 用一个极简的搜索:

$$\mathbf{s}^* = \arg\min_{\mathbf{s}} \mathcal{L}(\mathbf{s}), \quad s_j = s_X^{\alpha_j}$$

其中 $s_X$ 是激活的逐通道平均幅值,$\alpha_j$ 只在一个很小的候选集(如 $\{0, 0.5, 1\}\times$ 一个全局系数)里搜。

@torch.no_grad()
def awq_search_scale(w: torch.Tensor, act: torch.Tensor, n_grid: int = 20):
    """AWQ 的快速缩放因子搜索:不用反向传播,只做前向重建误差扫描"""
    w = w.float(); act = act.float()
    best_loss, best_s = float('inf'), 1.0

    # 显著性 = 激活的逐通道平均幅值
    saliency = act.abs().mean(dim=0)                      # [in_features]
    saliency = saliency / saliency.max()

    for ratio in torch.linspace(0, 1, n_grid):
        s = saliency.pow(ratio)                           # 放大系数 ∈ (0, 1]
        # 量化 (w * s) 再反量化回 (w*s)/s,比较重建误差
        w_s = w * s.unsqueeze(0)
        scale = w_s.abs().amax(dim=-1, keepdim=True) / 7
        w_q = torch.round(w_s / scale).clamp_(-8, 7) * scale
        loss = ((w_q / s.unsqueeze(0)) - w).pow(2).mean().item()
        if loss < best_loss:
            best_loss, best_s = loss, ratio.item()

    return saliency.pow(best_s), best_loss

GPTQ vs AWQ 怎么选

维度GPTQAWQ
核心机制二阶 Hessian 误差补偿激活感知的显著权重保护
量化耗时(70B)1-3 小时10-30 分钟
需要校准集是(且敏感)是(较鲁棒)
INT4 精度略优接近
推理速度一般更快(无需混合精度分支)
生态成熟度老牌,AutoGPTQvLLM/TensorRT-LLM 一等公民

我的实战建议:优先 AWQ。原因不是精度,而是 AWQ 的量化形态对 kernel 极其友好——所有权重统一 INT4,不需要为显著通道保留 FP16 分支,kernel 可以走最规整的 fast path。GPTQ 那点精度优势(通常 PPL 差 0.02-0.05)在真实业务指标上根本看不出来,但推理速度的差距是实打实的。

六、FP8:让硬件原生接管

INT4/INT8 本质上是「软件模拟」,dequant 有开销。而 Hopper / Blackwell 架构原生支持 FP8,有两个变体:

  • E4M3(4 位指数 3 位尾数):精度高,动态范围小,适合权重和激活的前向。
  • E5M2(5 位指数 2 位尾数):动态范围大,精度低,适合梯度。

FP8 的麻烦在于溢出控制。E4M3 的最大值只有 448,一旦张量里有元素超过这个数就变成 Inf。所以必须使用延迟缩放(delayed scaling):维护一个 history 窗口记录历史 amax,用它的最大值来推算当前 scale。

import transformer_engine.pytorch as te

class FP8Linear(torch.nn.Module):
    def __init__(self, in_f, out_f):
        super().__init__()
        self.weight = torch.nn.Parameter(torch.empty(out_f, in_f))
        # TE 的 amax history:长度 1024 的环形缓冲
        self.register_buffer('amax_history', torch.zeros(1024))
        self.fp8_meta = {'scale': torch.ones(1), 'amax_ptr': 0}

    @torch.no_grad()
    def update_scale(self, tensor):
        """延迟缩放:用历史窗口的 amax 外推当前 scale"""
        cur_amax = tensor.abs().max()
        self.fp8_meta['amax_ptr'] = (self.fp8_meta['amax_ptr'] + 1) % 1024
        self.amax_history[self.fp8_meta['amax_ptr']] = cur_amax
        hist_max = self.amax_history.max()
        # E4M3 上限 448,留 10% 安全边距
        self.fp8_meta['scale'] = hist_max / (448 * 0.9)

    def forward(self, x):
        self.update_scale(x); self.update_scale(self.weight)
        return te.fp8_gemm(x, self.weight, self.fp8_meta)   # 走 FP8 Tensor Core

判断标准很简单:如果你在 H100/H200/B200 上,且模型是 dense 架构,FP8 (W8A8) 通常是精度和速度的最优平衡点——它不用像 INT4 那样担心 outlier,又能直接吃到 2 倍于 BF16 的 Tensor Core 吞吐。只有在显存实在放不下的边缘场景,才退到 W4A16。

七、KV Cache 量化:长上下文时代真正的成本大头

前面讲的都是权重量化,但当你把上下文拉到 128K 甚至 1M,成本结构就变了。

算一笔账。Llama-3-70B(80 层、8 个 KV head、head_dim=128):

$$\text{KV bytes/token} = 2 \times 80 \times 8 \times 128 \times 2\ (\text{BF16}) = 327{,}680 \approx 0.32\ \text{MB}$$

上下文长度单请求 KV Cache (BF16)FP8 量化后INT8 量化后
8K2.6 GB1.3 GB1.3 GB
128K42 GB21 GB21 GB
1M335 GB168 GB168 GB

单卡 80G 连一个 1M 上下文的请求都扛不住。KV Cache 量化在这里不是「优化」,是「能不能跑」的问题。

但 KV Cache 量化的坑比权重量化深得多:

def quantize_kv_cache(k: torch.Tensor, v: torch.Tensor, mode: str = 'per_head_fp8'):
    """k, v: [batch, num_kv_heads, seq_len, head_dim]
       注意:不同粒度的坑完全不同"""
    if mode == 'per_tensor':
        # ❌ 千万别这么干。不同 head 的幅值差异极大(有的 head 是 attention sink)
        scale = torch.cat([k.abs().max().unsqueeze(0),
                           v.abs().max().unsqueeze(0)]).max()
        return (k / scale).to(torch.float8_e4m3fn), (v / scale).to(torch.float8_e4m3fn), scale

    if mode == 'per_head':      # 每个 head 一个 scale —— 长上下文的底线
        scale = k.abs().amax(dim=(-1, -2), keepdim=True)   # [b, h, 1, 1]
        return (k / scale).to(torch.float8_e4m3fn), v, scale

    if mode == 'per_token':     # 每个 token 一个 scale,精度最好但 scale 显存翻倍
        scale = k.abs().amax(dim=-1, keepdim=True)         # [b, h, s, 1]
        return (k / scale).to(torch.float8_e4m3fn), v, scale

三个必须用血换来的经验:

  1. K 和 V 要分开量化。K 参与 $QK^\top$ 的矩阵乘,对精度极其敏感;V 只是被 attention 权重加权求和,容错度高得多。很多方案对 K 用 FP8、对 V 用 INT8,是性价比最优解。
  2. 必须 per-head 或更细粒度。per-tensor 量化 KV Cache 在长上下文下会有灾难性的精度崩塌——因为 attention sink 现象会让前几个 token 的 K 幅值异常大。
  3. 量化要和 PagedAttention 对齐。vLLM 里 KV Cache 是分页存储的,scale 必须跟着 block 走。如果 scale 粒度比 block 粗,读取时就要额外访存,反而抵消了收益。

八、怎么评估:别只看 Perplexity

最后说个方法论问题。很多人量化完就跑个 PPL,看到涨了 0.05 就说「无损」。这是自欺欺人。

PPL 对量化极其不敏感,因为它是个全局平均指标,而量化误差是高度局部化的——它可能只在某几个 head、某几个 token 位置上炸掉,而 PPL 根本反映不出来。

我的评估清单:

# 1. 长上下文召回(最容易被量化打崩的能力)
python -m needle_in_a_haystack --model quantized_model --ctx 128k

# 2. 下游任务,尤其是推理密集的
lm_eval --model quantized --tasks gsm8k,humaneval,mmlu --limit 500

# 3. 逐层重建误差定位(精准找到是哪一层在拖后腿)
python layerwise_reconstruction_error.py --bits 4

重点看第 3 项。如果某一层的重建误差显著高于其他层(通常是最初几层和最后几层),那就把这几层单独跳过量化保留 FP16。这个「混合精度」策略的收益,往往比把 group_size 再砍一半大得多,而且成本几乎为零。

结语:量化的决策树

把上面所有内容压缩成一份可执行的决策流程:

  1. 显存够不够放 FP8? 够 → 直接 FP8 (W8A8),在 H100+ 上是精度/速度最优解。
  2. 放不下,或者需要大 batch? → W4A16 (AWQ),activate 保持 FP16,精度几乎无损,速度最快。
  3. 上下文超过 32K? → 必须上 KV Cache 量化,per-head FP8 起步,K/V 分级处理。
  4. 精度掉了? → 先跑逐层重建误差定位,把最差的几层退回 FP16;再去调 group_size。
  5. 别碰的:per-tensor 量化激活、per-tensor 量化 KV Cache、用不匹配的校准集跑 GPTQ。

量化的本质是在信息论下限和硬件约束之间找最优点。理解了这个,你就能看穿未来出现的任何新方案——它们无非是在粒度、误差补偿、硬件亲和性这三个维度上做不同的排列组合而已。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部