量化
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,模型因此小两到四倍;如果做对了,也快两到四倍。
代价是你在有意给模型的每一个权重引入误差。手艺全在于决定这些误差落到哪里。
核心思路
解法
仿射量化把一段浮点范围映射到一小组整数码上:
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
Q4_K_M 是约 4.8 bpw)。比较格式时请比较有效位宽,别看标签。工作原理
工作原理
权重量化的三大流派
- 就近取整(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×
- 原始 fp16 权重
- 重建值
- 量化误差
虚线是分组边界,每组有自己的 scale。把分组设为「整张量」并打开离群通道,会看到误差全面爆炸:两个极端权重把量化范围拉得极宽,其余 126 个普通权重全都塌缩到寥寥几个量化级上。缩小分组,就只有离群值所在的那一组受损。AWQ、GPTQ、SmoothQuant 都建立在这一点观察之上。
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 内部、在寄存器里反量化,绝不把浮点权重写回显存。
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,并把显存流量减半的功劳记在它头上。$ python code/s08_quantization.py只需要 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同时实现按通道(按输出行)和按分组的 scale,在固定位宽预算下测量哪一种帮助更大。
- 2实现 SmoothQuant 的迁移:从激活统计中选出按通道的因子,把它们折进权重,并证明乘积不变、而两个张量都更好量化了。
- 3把 S06 的 KV 缓存用按 token 的 scale 量化到 int8,然后加上一个 128 token 的 fp16 窗口,在一段固定文本上测量困惑度差异。确认这个窗口对 key 比对 value 更重要。
继续学习
接下来
显存已经被压到我们能做到的最小,也共享到了最充分。可 decode 期间 GPU 依然大部分时间闲着,因为单个请求填不满它。S09 开启批处理这一层,吞吐正是在这里成倍增长。
自测
习题
先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。
为什么在有离群通道的层上,整张量量化会灾难性地失败?