把 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/BF16 | 2 | 140 GB | 否(需 2 卡) | ~70 ms/token |
| FP8 | 1 | 70 GB | 是(余量小) | ~35 ms/token |
| INT8 (W8A8) | 1 | 70 GB | 是 | ~35 ms/token |
| INT4 (W4A16) | 0.5 | 35 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 以内的原因。
生产踩坑提醒:
- Hessian 必须用和真实部署分布一致的校准集算。用 Wikipedia 校准、拿去跑代码生成,精度会明显掉。
Hinv必须加阻尼(damping,通常 0.01-0.1),否则 Hessian 接近奇异时数值会炸。- 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 怎么选
| 维度 | GPTQ | AWQ |
|---|---|---|
| 核心机制 | 二阶 Hessian 误差补偿 | 激活感知的显著权重保护 |
| 量化耗时(70B) | 1-3 小时 | 10-30 分钟 |
| 需要校准集 | 是(且敏感) | 是(较鲁棒) |
| INT4 精度 | 略优 | 接近 |
| 推理速度 | 一般 | 更快(无需混合精度分支) |
| 生态成熟度 | 老牌,AutoGPTQ | vLLM/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 量化后 |
|---|---|---|---|
| 8K | 2.6 GB | 1.3 GB | 1.3 GB |
| 128K | 42 GB | 21 GB | 21 GB |
| 1M | 335 GB | 168 GB | 168 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
三个必须用血换来的经验:
- K 和 V 要分开量化。K 参与 $QK^\top$ 的矩阵乘,对精度极其敏感;V 只是被 attention 权重加权求和,容错度高得多。很多方案对 K 用 FP8、对 V 用 INT8,是性价比最优解。
- 必须 per-head 或更细粒度。per-tensor 量化 KV Cache 在长上下文下会有灾难性的精度崩塌——因为 attention sink 现象会让前几个 token 的 K 幅值异常大。
- 量化要和 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 再砍一半大得多,而且成本几乎为零。
结语:量化的决策树
把上面所有内容压缩成一份可执行的决策流程:
- 显存够不够放 FP8? 够 → 直接 FP8 (W8A8),在 H100+ 上是精度/速度最优解。
- 放不下,或者需要大 batch? → W4A16 (AWQ),activate 保持 FP16,精度几乎无损,速度最快。
- 上下文超过 32K? → 必须上 KV Cache 量化,per-head FP8 起步,K/V 分级处理。
- 精度掉了? → 先跑逐层重建误差定位,把最差的几层退回 FP16;再去调 group_size。
- 别碰的:per-tensor 量化激活、per-tensor 量化 KV Cache、用不匹配的校准集跑 GPTQ。
量化的本质是在信息论下限和硬件约束之间找最优点。理解了这个,你就能看穿未来出现的任何新方案——它们无非是在粒度、误差补偿、硬件亲和性这三个维度上做不同的排列组合而已。

发表评论 取消回复