数据截至 (上游 commit d7a2074112d2)
03 · 量化体系:block 量化与 K-quants
这一章讲什么: llama.cpp 的立身之本。为什么 4-bit 量化质量损失很小?K-quants 的「超块 + 双层 scale」是什么?为什么说量化在这里是加速手段而不只是压缩手段?读完你会能看懂任何
Q4_K_M这类档位名背后的结构。
1. 它要解决的小问题
FP16 权重每参数 2 字节:8B 模型 ≈ 16 GB。本地推理的瓶颈在内存带宽——解码每生成一个 token 都要把全部权重从内存读一遍(GEMV),带宽决定 token/秒。
问题就一句话:怎么让权重更小,而且变小之后不增加计算量? 朴素做法「存 4-bit、用时解压回 FP16」省了带宽却加了解压计算;llama.cpp 要的是「解压开销为零」的方案。
2. 思路:分组量化 + 压缩态直算
两个设计叠出答案:
- 分组(block)量化。 不做整行一个 scale,而是每 32 个权重一组,每组一个 FP16 的 scale
d:x ≈ d · q,q 是 4-bit 整数。组内动态范围小,4-bit 就够用。 - 压缩态直算。 点积不还原权重:激活也量化成 8-bit 整数,两边做整数点积,最后乘
d_w · d_x两个 scale 得到浮点结果。整数 SIMD 比 FP 快,解压步消失。
一句话:量化误差被分组摊小,解压成本被整数点积消掉。
3. 原理演示:32 值块怎么量、怎么算
先看「量」——Q8_0 的参考实现几乎就是数学本身(quantize_row_q8_0_ref,ggml/src/ggml-quants.c:276-298):
// 摘自 ggml/src/ggml-quants.c:281-296(精简)
float amax = 0.0f;
for (int j = 0; j < QK8_0; j++) amax = MAX(amax, fabsf(v)); // 组内最大绝对值
const float d = amax / 127; // scale:把 [-amax,amax] 映到 int8
y[i].d = GGML_FP32_TO_FP16(d); // 存成 fp16
y[i].qs[j] = roundf(v / d); // 每个权重 → 一个 int8
再看「算」——Q4_0 权重 × Q8_0 激活的点积(generic 版,ggml/src/ggml-cpu/quants.c:225-257):
// 摘自 ggml/src/ggml-cpu/quants.c:243-255(精简)
for (; ib < nb; ++ib) { // 每 32 个一组
int sumi = 0;
for (int j = 0; j < qk/2; ++j) { // 一个字节装两个 4-bit
const int v0 = (x[ib].qs[j] & 0x0F) - 8; // 低半字节,去偏移
const int v1 = (x[ib].qs[j] >> 4) - 8; // 高半字节
sumi += v0 * y[ib].qs[j] + v1 * y[ib].qs[j + qk/2]; // 整数乘加
}
sumf += sumi * GGML_CPU_FP16_TO_FP32(x[ib].d) * GGML_CPU_FP16_TO_FP32(y[ib].d);
// ↑ 整数和 ↑ 权重 scale ↑ 激活 scale —— 每组只乘一次
}
重点看: 内层循环只有整数;浮点只在每组末尾出现一次。真实路径里,这段 generic 代码会被换成 ARM NEON / x86 AVX 的手工 SIMD 版本(ggml/src/ggml-cpu/arch/ 按架构一目录),数学不变。
4. 类型谱系:从 Q4_0 到 K-quants
ggml_type 枚举(ggml/include/ggml.h:389-434)有 43 种类型,主线三代:
| 代 | 代表 | 结构要点 | 有效位宽 |
|---|---|---|---|
| 初代块量化 | Q4_0 / Q4_1 / Q5_0 / Q8_0 | 32 值一组,组一个 scale(Q4_1/Q5_1 多一个 min) | 4.5 / 5.0 / 5.5 / 8.5 bpw |
| K-quants | Q2_K ~ Q6_K、Q8_K | 256 值超块(QK_K),内分 16/32 值子块,子块 scale 再被量化 | 2.625 ~ 6.5625 bpw |
| 查表/特殊 | IQ*、TQ*、MXFP4、NVFP4 | 码本查表或硬件对齐的 4-bit 浮点 | 1.56 ~ 4.5 bpw |
bpw(bits per weight,含 scale 摊销)直接写在各 block 结构注释里(ggml/src/ggml-common.h:297、:314、:326、:343、:361)。
块结构本体全是定长 POD,例如(ggml/src/ggml-common.h):
#define QK4_0 32
typedef struct {
ggml_half d; // delta ← 组 scale
uint8_t qs[QK4_0 / 2]; // nibbles ← 32 个 4-bit,共 16 字节
} block_q4_0; // ggml-common.h:194-199 —— 32 权重占 18 字节 = 4.5 bpw
5. K-quants:双层 scale 的超块
K-quants 的核心观察:scale 本身也是一笔开销。Q4_0 里每 32 个权重花 16 bit 存 scale(摊销 0.5 bpw);想压到 2~4 bit 档,scale 的开销占比会爆炸。
解法是把块放大到 QK_K = 256(ggml/src/ggml-common.h:89),做两级量化:
256 个权重 = 1 个超块
│
├─ 8 或 16 个子块,各有一个 float scale(由数据拟合出来)
│
├─ 第一级:子块 scale 量化为 6-bit 整数 ──► scales[12] 字节
│
└─ 第二级:超块一个 FP16 scale,把 6-bit scale 还原回 float
x = (d · sc) · q (+ dmin · m)
以 Q4_K 为例(block_q4_K,ggml/src/ggml-common.h:322-338):
| 字段 | 内容 | 字节 |
|---|---|---|
d / dmin | 超块级 scale / min(FP16) | 4 |
scales[12] | 8 个子块的 scale+min,各压到 6 bit | 12 |
qs[128] | 256 个 4-bit 权重 | 128 |
合计 144 字节装 256 个权重 = 4.5 bpw——和 Q4_0 相同的位宽,但因为子块 scale 是 32 值一拟合、且超块共享,精度显著更好。2~3 bit 档(Q2_K 2.625 bpw、Q3_K 3.4375 bpw)只有靠这种结构才压得到。
5.1 量化器本身也要「拟合」
Q4_K 的参考量化(quantize_row_q4_K_ref,ggml/src/ggml-quants.c:1457-1510)展示了第二件事:scale 不是 max/min 一算就完,而是搜索出来的。
- 对每个 32 值子块,先拿幅度统计出一个候选,再调
make_qkx2_quants(ggml/src/ggml-quants.c:799):围绕初值做nstep步迭代,每步对「加权量化误差」做最小二乘,解出更好的 (scale, min)。 - 权重
weights[l] = av_x + fabsf(x[l])(ggml/src/ggml-quants.c:1473-1475):幅度大的权重误差权重大——大值对输出影响大,优先保。 - 最后把 8 个子块 scale 归一到 6-bit 存进
scales[],超块d = max_scale/63(ggml/src/ggml-quants.c:1486-1505)。
所以「量化」在 K-quants 里是一个小型优化问题,不是一次取整。
6. 运行时:激活去哪了
权重是离线量化好的,激活(FP32)是运行时现算的——两者怎么点积?答案在 mul_mat 的 CPU 实现里(ggml_compute_forward_mul_mat,ggml/src/ggml-cpu/ggml-cpu.c:1254):
- 查表
type_traits_cpu[src0->type](ggml/src/ggml-cpu/ggml-cpu.c:214)拿到三样东西:这个量化类型配对的激活类型vec_dot_type、把 FP32 转过去的from_float、点积内核vec_dot。 - 若激活还不是目标类型,先用
from_float把它即时量化——绝大多数权重类型配对 Q8_0(8-bit),K-quants 配对 Q8_K(ggml_vec_dot_q4_K_q8_K等,声明见ggml/src/ggml-cpu/quants.h:52-54)。 - 调
vec_dot做整数点积(§3 的模式)。
权重(磁盘即 Q4_K)────────┐
├─► vec_dot_q4K_q8K ──► f32 结果
激活(现算 f32)─► 即时量化 Q8_K ┘
为什么激活用 8-bit 就够? 点积误差主要来自权重侧(权重固定、误差累积在每一层);激活是单次通过,8-bit 的动态范围足够,而它换来了纯整数 SIMD 的速度。
7. 离线量化:哪些张量配哪一档
llama-quantize 的底层是 llama_model_quantize(src/llama-quant.cpp:1371)。它并不是「全模型一刀切」:
llama_tensor_get_type(src/llama-quant.cpp:683)按张量角色选档——embedding、output、attention、FFN 各有多少参数、对质量多敏感,经验规则写死在这里;这就是Q4_K_M这种混合档位(M = medium,部分张量提到 Q6_K)的来历。- 更精细的引导来自 imatrix(importance matrix):
tools/imatrix先拿一批语料跑一遍模型,统计每个权重对激活的贡献;量化时按重要度分配误差预算。这是「数据感知量化」在 llama.cpp 的落点。
8. 关键细节/坑
- 质量损失是模型相关、任务相关的。 2~3 bit 档可能在某些模型上明显变傻;纪律是实测:
tools/perplexity跑困惑度对比,别凭位宽拍脑袋。 - 位宽数字不含「档位内差异」。 同为 4.5 bpw,Q4_0 与 Q4_K 精度不同——比较档位要看结构,不只看 bpw。
- 量化类型绑定了 kernel 生态。 每个类型要每种后端各写一套
vec_dot/kernel;这就是老类型(Q4_2/Q4_3)被直接移除、枚举里留注释占位的原因(ggml/include/ggml.h:394-395)——维护成本按「类型 × 后端」乘算。 - 行长度必须是块大小的整数倍。 GGUF 加载时会校验
ne[0] % blck_size == 0(ggml/src/gguf.cpp:723-730),这也是模型维度设计上的一个隐含约束。 - 量化是单向的。 GGUF 里存的就是量化块,没有「还原成 FP16」的无损路径——想要别的档位,从 FP16/BF16 母本重新量化。
9. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 类型枚举 | ggml/include/ggml.h | ggml_type、ggml_ftype |
| 块结构定义 | ggml/src/ggml-common.h | block_q4_0、block_q8_0、block_q4_K、QK_K |
| 参考量化器 | ggml/src/ggml-quants.c | quantize_row_q8_0_ref、quantize_row_q4_K_ref、make_qkx2_quants |
| 点积内核(generic) | ggml/src/ggml-cpu/quants.c | ggml_vec_dot_q4_0_q8_0_generic |
| 类型→内核配对表 | ggml/src/ggml-cpu/ggml-cpu.c | type_traits_cpu |
| 矩阵乘中的即时量化 | ggml/src/ggml-cpu/ggml-cpu.c | ggml_compute_forward_mul_mat |
| 离线量化入口 | src/llama-quant.cpp | llama_model_quantize、llama_tensor_get_type |
| 重要度矩阵 | tools/imatrix | (工具主程序) |
| 质量评测 | tools/perplexity | (工具主程序) |