跳到主要内容

数据截至 (上游 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. 思路:分组量化 + 压缩态直算

两个设计叠出答案:

  1. 分组(block)量化。 不做整行一个 scale,而是每 32 个权重一组,每组一个 FP16 的 scale d:x ≈ d · q,q 是 4-bit 整数。组内动态范围小,4-bit 就够用。
  2. 压缩态直算。 点积不还原权重:激活也量化成 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_032 值一组,组一个 scale(Q4_1/Q5_1 多一个 min)4.5 / 5.0 / 5.5 / 8.5 bpw
K-quantsQ2_K ~ Q6_KQ8_K256 值超块(QK_K),内分 16/32 值子块,子块 scale 再被量化2.625 ~ 6.5625 bpw
查表/特殊IQ*TQ*MXFP4NVFP4码本查表或硬件对齐的 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 bit12
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):

  1. 查表 type_traits_cpu[src0->type](ggml/src/ggml-cpu/ggml-cpu.c:214)拿到三样东西:这个量化类型配对的激活类型 vec_dot_type、把 FP32 转过去的 from_float、点积内核 vec_dot
  2. 若激活还不是目标类型,先用 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)。
  3. 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.hggml_typeggml_ftype
块结构定义ggml/src/ggml-common.hblock_q4_0block_q8_0block_q4_KQK_K
参考量化器ggml/src/ggml-quants.cquantize_row_q8_0_refquantize_row_q4_K_refmake_qkx2_quants
点积内核(generic)ggml/src/ggml-cpu/quants.cggml_vec_dot_q4_0_q8_0_generic
类型→内核配对表ggml/src/ggml-cpu/ggml-cpu.ctype_traits_cpu
矩阵乘中的即时量化ggml/src/ggml-cpu/ggml-cpu.cggml_compute_forward_mul_mat
离线量化入口src/llama-quant.cppllama_model_quantizellama_tensor_get_type
重要度矩阵tools/imatrix(工具主程序)
质量评测tools/perplexity(工具主程序)