跳到正文
LLM 推理

量化

decode 速度取决于每个 token 搬运的字节数,位宽减半,延迟也几乎减半。剩下要决定的,是让误差落在哪里。

  • INT8/INT4
  • 分组 scale
  • GGUF K-quant
  • AWQ/GPTQ
  • KV 缓存量化

为什么重要

问题

S05 已经确立:decode 延迟等于字节数除以带宽。8B 模型 fp16 是 16 GB;在 3.3 TB/s 的 GPU 上,kernel 写得再好,生成一个 token 也不可能快于约 5 ms。换成带宽 100 GB/s 的笔记本,这个数字是 160 ms,也就是每秒六个 token。

绕过带宽下限的办法只有一个:搬运更少的字节。量化把每个权重从 16 bit 存成 4 或 8 bit,模型因此小两到四倍;如果做对了,也快两到四倍。

代价是你在有意给模型的每一个权重引入误差。手艺全在于决定这些误差落到哪里。

核心思路

解法

仿射量化把一段浮点范围映射到一小组整数码上:

量化 / 反量化python
def quantize_group(w, bits=4):
    """One scale per GROUP of weights, not per tensor."""
    qmax = 2 ** (bits - 1) - 1
    scale = np.abs(w).max() / qmax        # symmetric: no zero-point
    q = np.clip(np.round(w / scale), -qmax - 1, qmax).astype(np.int8)
    return q, np.float16(scale)

def dequantize_group(q, scale):
    return q.astype(np.float32) * scale

关键就在分组二字。权重是好对付的一方;真正极端的 10–100 倍离群值出现在激活里,那是本章后面更难的问题。即便如此,各通道的取值范围差异也足够大:用一个 scale 量化整个张量,scale 会被最宽的通道撑开,绝大部分码空间被浪费,每个普通权重都塌缩到两三个码上,模型就毁了。

改成每 32 或 128 个权重一个 scale,离群值就只会损害它自己那一组。仅此一个改动,就是「4-bit 能用」和「4-bit 没用」之间的分界。

所谓「4-bit」从来都不是 4 bit

每一组都需要一个 scale,有时还需要一个零点,都以 fp16 存储。分组为 32 时,每 32 个权重就带 16 bit 的元数据,摊到每个权重是半个 bit。真实的 4-bit 格式落在每权重 4.5 bit 左右;GGUF 的命名很诚实(Q4_K_M 是约 4.8 bpw)。比较格式时请比较有效位宽,别看标签。

工作原理

工作原理

示意图分组、超块,以及融合反量化
分组仿射量化q = round(w / scale) + zerow ≈ (q − zero) × scale —— scale 是按组的,不是按张量的权重矩阵的一行第 0 组 · scale 0.9第 1 组 · scale 8.2第 2 组 · scale 0.9第 3 组 · scale 0.9离群值只会毁掉它自己那一组。若整个张量共用一个 scale,128 个权重会一起遭殃。GGUF K-QUANT 超级块(Q4_K)256 个权重 = 8 个大小为 32 的子块b0b1b2b3b4b5b6b7每个子块:6 bit scale + 6 bit min(它们自己也被量化)超级块:一个 fp16 的 scale-of-scales + 一个 fp16 的 min-of-mins→ 实际是 4.5 bit/权重,不是 4。多出的半个 bit 是元数据。只有这样才是真的快载入 4-bit 块在寄存器里反量化累加进点积永远不要把反量化后的权重写回显存。那样搬运的字节比 fp16还要多 —— 这就是经典的「我的 int4 模型怎么更慢了」的坑。三大流派,以及各自在保护什么RTN —— 直接四舍五入GPTQ —— 最小化该层输出误差AWQ —— 保护显著通道三者产出的格式完全相同;区别只在于它们怎么挑选量化码。
左:分组为什么存在。中:GGUF 如何把 scale 嵌套进 scale。右:决定你的量化模型是比 fp16 快还是更慢的那条规则。

权重量化的三大流派

  • 就近取整(RTN) —— 每个权重独立量化。免费、不需要数据,8 bit 下够用。到 4 bit 时质量损失就明显了。
  • GPTQ —— 逐列量化,每量化完一列,就更新剩余未量化权重以补偿刚引入的误差。它用一小份校准集的二阶信息,最小化的是该层输出的误差,而不是权重的误差。
  • AWQ —— 观察到一小部分通道的重要性不成比例,从激活统计中把它们识别出来,在量化前放大它们,使其落在更细的码上。比 GPTQ 便宜,效果通常相当。

三者归根结底是同一种结构,即按组的低位整数加 scale,尽管它们发布的 checkpoint 在磁盘上是不同的格式。引擎正是利用这层共同结构:把每种格式转换成同一种内部布局,再用一套 kernel 服务全部三种。vLLM 的 Marlin 路径就是这么同时支持 GPTQ 和 AWQ 的。

激活量化是另一个问题,而且更难

权重是静态的,可以离线量化,想花多少算力都行。激活在运行时产生,随输入变化,离群值极端得多,而且集中在少数几个通道上。

SmoothQuant 的做法是把难度从激活迁移到权重:把激活的第 i 个通道除以 s_i,同时把对应的权重列乘以 s_i。乘积不变,两个张量却都变得好量化了。这一点很重要:W8A8 两者都量化,能真正用上 INT8 tensor core,而 W4A16 只省带宽。

只赢带宽
W4A16
对 batch 1 的 decode 极好;大 batch 下没有收益
还赢算力
W8A8
用上 INT8 tensor core —— 对 prefill 和大 batch 也有帮助
当下的默认
FP8
Hopper/Ada 原生支持;接近无损,无需校准

量化 KV 缓存

长上下文下 KV 缓存比权重还大,因此它是更值钱的目标。它也更难:key 有很强的按通道离群值,而且误差会累积,因为一个被污染的 key 会影响之后每一个对它做注意力的 token。

实践中有效的技术是:key 按通道量化、value 按 token 量化(两者的离群结构不同);保留一个全精度窗口,覆盖最近 64–128 个 token,因为注意力集中在那里;以及在量化前施加一次随机 Hadamard 旋转,把离群能量摊到所有维度上。quant.cpp 把三者结合,报告约 6× 的 KV 压缩,困惑度代价约 +0.1%。

动手观察

动手试试

模拟器量化误差浏览器
量化对象
位宽
有效位宽 / 权重
4.50
信噪比
13.2 dB
最大绝对误差
0.291
压缩比
3.56×
128 个权重 · 原值 vs 重建值
  • 原始 fp16 权重
  • 重建值
  • 量化误差

虚线是分组边界,每组有自己的 scale。把分组设为「整张量」并打开离群通道,会看到误差全面爆炸:两个极端权重把量化范围拉得极宽,其余 126 个普通权重全都塌缩到寥寥几个量化级上。缩小分组,就只有离群值所在的那一组受损。AWQ、GPTQ、SmoothQuant 都建立在这一点观察之上。

换来了什么 —— 8B 模型,H100
fp16 基线4.78 毫秒/token · 209 token/秒
4-bit, 分组大小 321.25 毫秒/token · 799 token/秒

decode 延迟 = 字节数 ÷ 带宽,所以加速比几乎精确等于压缩比,前提是反量化被融合进 matmul。如果先反量化到一块单独的缓冲区,你搬运的字节反而 fp16 更多,结果更慢。

显存占用
仅权重
4.2 GB
KV(未量化)
16.0 GB
scale 开销
11.1%
能装进 24 GB 吗?

scale 以 fp16 存储,是纯粹的额外开销。分组为 8 时,4-bit 权重要额外付出每权重 2 bit,相当于 50% 的税。所以分组取 32–128 最划算,也所以所谓「4-bit」模型其实是 4.5 bit

让本章豁然开朗的那个实验:设 bits = 4分组 = 整张量,并打开离群通道。信噪比塌了。现在把分组拖到 32。那两个离群权重依然被表示得很糟,其余 126 个则完全恢复。剩下的差距,两百行 AWQ 或 GPTQ 就能补上。

亲手实现

实现

量化的成败取决于 kernel。规则是绝对的:在 matmul 内部、在寄存器里反量化,绝不把浮点权重写回显存。

code/s08_quantization.py(节选)python
def fused_matmul(x, q, scales, group):
    """
    Dequantize inside the accumulation. Nothing full-precision is ever stored.
    q:      int8 codes, [n_groups, group, out_features]  — 4× smaller read
    scales: fp16,       [n_groups, out_features]
    """
    n_groups, _, out_f = q.shape
    out = np.zeros((x.shape[0], out_f), dtype=np.float64)

    for g in range(n_groups):
        sl = slice(g * group, (g + 1) * group)
        # dequantize this block in registers and accumulate immediately
        out += x[:, sl] @ (q[g].astype(np.float64) * scales[g].astype(np.float64))

    return out

如果你的量化模型更慢了,原因就在这里

朴素实现会调用 dequantize() 构造出完整的 fp16 权重张量,再调用普通 matmul。这样既读了 int4 权重,写入并重新读取了一个 fp16 张量,搬运的字节严格多于压根不量化。所有真实引擎都做融合;picoLM 把自己的版本叫 fused dequant+dot,并把显存流量减半的功劳记在它头上。
在本地运行
在多种分组大小下,把一个合成权重矩阵量化到 2/3/4/8 bit,报告信噪比,用整张量 scale 复现离群灾难,并对比融合与非融合反量化各自搬运的字节数。
$ python code/s08_quantization.py
预期输出: 一张「位宽 × 分组大小」的误差表、离群权重上分组带来的约 13 dB 恢复,以及一张流量表:非融合路径搬运的字节比 fp16 还多。

只需要 NumPy — 查看环境准备.

生产实践

在生产环境中

  • GGUF K-quant(llama.cpp、picoLM、quant.cpp)—— 嵌套 scale:32 个权重的子块配 6-bit scale,再把它们组成 256 权重的超块,配一个 fp16 的「scale 的 scale」。这就是 Q4_K_M 在几乎相同体积下胜过朴素 Q4_0 的原因。
  • picoLM —— 覆盖 Q2_K 到 Q8_0 的反量化 kernel,带 ARM NEON 和 x86 SSE2 两条路径,且融合进点积。
  • quant.cpp —— KV 缓存方面的专家:Lloyd-Max 码本加随机 Hadamard 变换,外加 128 token 的全精度窗口。
  • vLLM / TensorRT-LLM —— Hopper 上默认 FP8,并为显存受限的部署提供 AWQ 与 GPTQ 的 INT4 路径。
  • baseRT —— 自有的 .base 格式,仿射 Q2–Q8 并可选 AWQ 校准,从 GGUF/HF/MLX 检查点转换而来。

练习

  1. 1
    同时实现按通道(按输出行)和按分组的 scale,在固定位宽预算下测量哪一种帮助更大。
  2. 2
    实现 SmoothQuant 的迁移:从激活统计中选出按通道的因子,把它们折进权重,并证明乘积不变、而两个张量都更好量化了。
  3. 3
    把 S06 的 KV 缓存用按 token 的 scale 量化到 int8,然后加上一个 128 token 的 fp16 窗口,在一段固定文本上测量困惑度差异。确认这个窗口对 key 比对 value 更重要。

继续学习

接下来

显存已经被压到我们能做到的最小,也共享到了最充分。可 decode 期间 GPU 依然大部分时间闲着,因为单个请求填不满它。S09 开启批处理这一层,吞吐正是在这里成倍增长。

自测

习题

先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。

自测 1 题 / 共 6

为什么在有离群通道的层上,整张量量化会灾难性地失败?

得分 0/6