量化格式
主线位置:推理主线里
QMatMul底下的数据。GGUF 文件里的每个权重张量是一串「块」,Content::tensor原样读进来(见 GGUF 文件);本页讲这些块的布局,以及 CPU 上量化、反量化和量化点积vec_dot怎么算。块怎么拼成一次矩阵乘、Metal 上怎么算,见 QTensor 与 QMatMul。
flowchart TB w["f32 权重"] -->|from_float| blk["权重块 Q8_0 Q4K 等"] blk -->|to_float| deq["反量化 f32"] x["f32 激活"] -->|from_float| act["激活块 Q8_0 或 Q8K"] blk --> dot["vec_dot"] act --> dot dot --> y["一个 f32 输出"]
代码在 candle-core/src/quantized/:mod.rs 定义 GgmlDType,k_quants.rs(2841 行)定义每种块的结构体和标量实现,neon.rs、avx.rs、simd128.rs 是三套 SIMD 点积,utils.rs 是 K-quants 选 scale 的辅助函数。块的布局、量化和反量化算法都照搬 llama.cpp(源码注释里留着 ggml.c、k_quants.c 对应函数的链接),所以 candle 和 llama.cpp 读写的是同一种字节。
GgmlDType:15 种
GgmlDType 有 15 个变体:F32、F16、BF16 三种不量化的,Q4_0、Q4_1、Q5_0、Q5_1、Q8_0、Q8_1 六种块大小 32 的,Q2K 到 Q8K 六种块大小 256 的 K-quants。llama.cpp 后来加的 IQ 系列(IQ2_XXS 等)不在里面;quantized.metal 里有这些类型的内核,但 Rust 这一侧没有对应的类型。两个方法给出每种的尺寸,block_size(每块几个数)和 type_size(每块几个字节,就是 size_of::<BlockXxx>())。实验程序 formats 打出来:
dtype block_size type_size bits/weight
Q4_0 32 18 4.5000
Q4_1 32 20 5.0000
Q5_0 32 22 5.5000
Q5_1 32 24 6.0000
Q8_0 32 34 8.5000
Q2K 256 84 2.6250
Q3K 256 110 3.4375
Q4K 256 144 4.5000
Q5K 256 176 5.5000
Q6K 256 210 6.5625
Q8K 256 292 9.1250
名字里的数字是每个量化值的位数,实际每个权重的位数都多一点:多出来的是 scale。实验 crate 是 exp-candle/quant-exp,release 构建,M2 Max、macOS 27、rustc 1.99.0。
块结构
每种块是一个 #[repr(C)] 结构体,后面跟一句编译期断言把大小钉死,例如 BlockQ8_0:
#[derive(Debug, Clone, PartialEq)]
#[repr(C)]
pub struct BlockQ8_0 {
pub(crate) d: f16,
pub(crate) qs: [i8; QK8_0],
}
const _: () = assert!(std::mem::size_of::<BlockQ8_0>() == 34);
repr(C) 保证字段顺序和文件里一样,f16 的对齐是 2,所以 34 字节没有填充。读文件时 qtensor_from_ggml 直接把字节切片看成 &[BlockQ8_0],靠的就是这一点。几种主要的块:
| 类型 | 一块 | 字段 | 一个数怎么还原 |
|---|---|---|---|
BlockQ8_0 | 32 个数,34 字节 | d: f16,qs: [i8; 32] | d * q |
BlockQ4_0 | 32 个数,18 字节 | d: f16,qs: [u8; 16](每字节两个 4 位数) | d * (q - 8) |
BlockQ5_0 | 32 个数,22 字节 | d: f16,qh: [u8; 4](32 个第 5 位),qs: [u8; 16](低 4 位) | d * (q - 16) |
BlockQ4K | 256 个数,144 字节 | d、dmin: f16,scales: [u8; 12](8 个 6 位 scale、8 个 6 位 min),qs: [u8; 128] | 第 j 个子块:d * sc[j] * q - dmin * m[j] |
BlockQ6K | 256 个数,210 字节 | ql: [u8; 128](低 4 位),qh: [u8; 64](高 2 位),scales: [i8; 16],d: f16 | 第 j 个子块(16 个数):d * sc[j] * (q - 32) |
BlockQ8K | 256 个数,292 字节 | d: f32,qs: [i8; 256],bsums: [i16; 16] | d * q |
两类思路不同。块大小 32 的类型一块一个 f16 scale(_1 结尾的再加一个 f16 最小值 m)。K-quants 是两级的:256 个数的超级块存一个 f16 的 d(Q4K、Q5K 还有 dmin),里面再分 8 个 32 个数或 16 个 16 个数的子块,每个子块的 scale 只存 6 位或 8 位整数,乘上超级块的 d 才是真 scale。scale 本身也被量化了,所以 Q4K 能在 4.5 位里放下「每 32 个数一个 scale 加一个 min」,而 Q4_1 要 5 位。Q4K 那 12 字节 scales 的位拼法由 get_scale_min_k4 拆开:前 4 个子块的 scale 和 min 在前 8 字节的低 6 位,后 4 个子块的低 4 位在后 4 字节,高 2 位借前 8 字节的最高 2 位。
BlockQ8K 和 BlockQ8_1 不是为权重准备的:GGUF 文件里不会出现它们(qtensor_from_ggml 遇到会报错),它们是 K-quants 和 _1 类型做点积时,激活值被量化成的格式,见下面的 VecDotType。BlockQ8K 的 d 是 f32 而不是 f16,bsums 预先存好每 16 个 qs 的和,用来算 Q4K 那个 - dmin * m 项:Σ (d·sc·q − dmin·m) · y 里减去的部分只需要 y 每 16 个一组的和,不用逐个乘。
一个 Q8_0 块,逐字节
formats 把 32 个数 (i − 15.5) / 10(−1.55 到 1.55)量化成一个 Q8_0 块:
Q8_0 block of x = (i - 15.5)/10, i = 0..32:
bytes[0..2] (d as f16) = [40, 22] -> d = 0.012207031
qs = [-127, -119, -111, -102, -94, -86, -78, -70, -61, -53, -45, -37, -29, -20, -12, -4, 4, 12, 20, 29, 37, 45, 53, 61, 70, 78, 86, 94, 102, 111, 119, 127]
x[0..4] = [-1.55, -1.45, -1.35, -1.25] back = [-1.550293, -1.4526367, -1.3549805, -1.2451172]
BlockQ8_0::from_float 找出块里绝对值最大的数 1.55,d = amax / 127,每个数除以 d 再四舍五入成 i8:
let d = amax / ((1 << 7) - 1) as f32;
let id = if d != 0f32 { 1. / d } else { 0. };
ys.d = f16::from_f32(d);
for (y, &x) in ys.qs.iter_mut().zip(xs.iter()) {
*y = f32::round(x * id) as i8
}
最大的数正好落在 ±127;d 存成 f16 时从 0.0122047 舍入成 0.012207031,反量化回来的 −1.550293 和原值差 0.0003,这一点误差来自 f16,不来自 8 位整数。
Q4_0 的取法不一样:BlockQ4_0::from_float 记下的是带符号的最大值 max,d = max / -8,让绝对值最大的那个数正好落在 −8,其余的映射到 −8 到 7。4 位只有 16 个格子,−8 到 7 不对称,这样取能把对称那一端的格子用上。K-quants 的 from_float 要为每个子块挑 scale 和 min(make_qkx1_quants 迭代几次找误差小的),再把 scale 量化成 6 位,代码长得多,思路相同。
一些边界值
块里有特殊值时,from_float 不报错,结果会悄悄变掉(q8edge):
NaN at [1] in [0.0, NaN, 0.06451613] -> out [0.0, 0.0, 0.06298828]
inf at [1] in [0.0, inf, 0.06451613] -> out [NaN, NaN, NaN]
amax 3e-6 in [0.0, 9.6774194e-8, 1.9354839e-7] -> out [0.0, 0.0, 0.0]
amax 8e-6 in [0.0, 2.5806452e-7, 5.1612903e-7] -> out [0.0, 2.3841858e-7, 4.7683716e-7]
amax 9e6 in [0.0, 290322.56, 580645.1] -> out [NaN, inf, inf]
NaN 在 amax.max(x.abs()) 里被忽略,自己 round 后转成 i8 变成 0;inf 让 d 变成无穷,整块反量化成 NaN;d 小于 f16 能表示的最小值时舍成 0,整块变成 0;d 超过 f16 的最大值 65504 时变成 inf。这几条和 动手:mini-quant 里自己写的 Q8_0 对得上。
GgmlType:一种块一组函数
所有块类型实现同一个 trait GgmlType:
pub trait GgmlType: Sized + Clone + Send + Sync {
const DTYPE: GgmlDType;
const BLCK_SIZE: usize;
const DIRECT_COPY: bool = false;
type VecDotType: GgmlType;
// ...
fn to_float(xs: &[Self], ys: &mut [f32]);
fn from_float(xs: &[f32], ys: &mut [Self]);
// ...
fn vec_dot(n: usize, xs: &[Self], ys: &[Self::VecDotType]) -> f32;
// ... vec_dot_2、vec_dot_4 有默认实现
fn vec_dot_unopt(n: usize, xs: &[Self], ys: &[Self::VecDotType]) -> f32;
}
to_float / from_float 是反量化和量化,vec_dot 是一行权重和一行激活的点积,vec_dot_unopt 是同一件事的纯标量版本。关键是关联类型 VecDotType:点积的另一边不是 f32,而是先量化成的另一种块。
| 权重块 | VecDotType |
|---|---|
Q4_0、Q5_0、Q8_0 | BlockQ8_0 |
Q4_1、Q5_1、Q8_1 | BlockQ8_1 |
Q2K 到 Q6K、Q8K | BlockQ8K |
f32、f16、bf16 | 自己(DIRECT_COPY = true,不量化) |
这样点积的内层循环是「i8 乘 i8 累加到 i32」,每块最后乘一次两个 scale。CPU 上的量化矩阵乘也因此会先把激活值逐行量化成 VecDotType,这一步带来的误差和 Metal 上的结果为什么不同,见 QTensor 与 QMatMul。
Vec<T: GgmlType> 再实现一个不带泛型方法的 trait QuantizedType(源码注释:「A version of GgmlType without vec_dot so that it can be dyn boxed」),CPU 上的 QStorage::Cpu 存的就是 Box<dyn QuantizedType>:dtype 在运行时才从文件里知道,只能用 trait 对象,GgmlType 带关联类型和泛型参数,做不成 trait 对象。
往返误差
formats 用三组数据把每种类型量化再反量化:262144 个标准正态分布的数;同一组数每 256 个里放大一个到 20 倍(模拟离群值);SmolLM2 第 0 层 mlp.down_proj.weight 的原始 BF16 权重(576 × 1536,转成 f32)。误差是「误差的均方根 / 原数据的均方根」:
-- N(0,1), 262144 values: 262144 values
Q4_0 4.500 bits/w rel RMSE 8.5901% max |err| 0.4327
Q4_1 5.000 bits/w rel RMSE 7.8137% max |err| 0.2426
Q5_0 5.500 bits/w rel RMSE 4.2788% max |err| 0.2156
Q5_1 6.000 bits/w rel RMSE 3.7896% max |err| 0.1153
Q8_0 8.500 bits/w rel RMSE 0.5365% max |err| 0.0173
Q2K 2.625 bits/w rel RMSE 29.0381% max |err| 1.3146
Q3K 3.438 bits/w rel RMSE 15.0997% max |err| 0.6201
Q4K 4.500 bits/w rel RMSE 7.4467% max |err| 0.2920
Q5K 5.500 bits/w rel RMSE 3.6969% max |err| 0.1544
Q6K 6.562 bits/w rel RMSE 1.8350% max |err| 0.0658
Q8K 9.125 bits/w rel RMSE 0.6948% max |err| 0.0175
-- N(0,1) with one 20x outlier per 256: 262144 values
Q4_0 4.500 bits/w rel RMSE 13.6423% max |err| 3.6823
...
Q8_0 8.500 bits/w rel RMSE 1.0396% max |err| 0.3492
...
Q4K 4.500 bits/w rel RMSE 9.4808% max |err| 2.3281
Q5K 5.500 bits/w rel RMSE 4.7981% max |err| 1.4531
Q6K 6.562 bits/w rel RMSE 3.0339% max |err| 1.3095
Q8K 9.125 bits/w rel RMSE 2.8580% max |err| 0.3496
-- model.layers.0.mlp.down_proj.weight (BF16 -> f32): 884736 values
Q4_0 4.500 bits/w rel RMSE 8.9513% max |err| 0.1738
Q4_1 5.000 bits/w rel RMSE 8.0287% max |err| 0.1096
Q5_0 5.500 bits/w rel RMSE 4.4520% max |err| 0.0869
Q5_1 6.000 bits/w rel RMSE 3.8859% max |err| 0.0533
Q8_0 8.500 bits/w rel RMSE 0.5585% max |err| 0.0111
Q2K 2.625 bits/w rel RMSE 29.5045% max |err| 0.4852
Q3K 3.438 bits/w rel RMSE 15.5051% max |err| 0.3457
Q4K 4.500 bits/w rel RMSE 7.6564% max |err| 0.1047
Q5K 5.500 bits/w rel RMSE 3.8001% max |err| 0.0511
Q6K 6.562 bits/w rel RMSE 1.8944% max |err| 0.0477
Q8K 9.125 bits/w rel RMSE 0.7591% max |err| 0.0113
- 每多一位,误差约减半。 Q4 系列 7.4–8.6%,Q5 系列 3.7–4.3%,Q6K 1.8%,Q8 系列 0.5–0.7%。真实权重和正态分布的数字几乎一样,SmolLM2 这一层的权重分布在这个意义上很「正常」。
- 同样的位数,K-quants 好一点。 Q4K 和 Q4_0 都是 4.5 位,7.4% 对 8.6%;Q5K 和 Q5_0 都是 5.5 位,3.7% 对 4.3%:K-quants 把 scale 量化成 6 位省下来的空间换成了 min(非对称)。
- 离群值伤的是整块。 每块的 scale 由最大的那个数决定,一个 20 倍的数让同一块里其余数的格子变粗 20 倍。块越大伤得越多:Q8K(256 个数一块)的误差从 0.69% 涨到 2.86%,Q8_0(32 个数一块)只从 0.54% 涨到 1.04%。
- Q8K 比 Q8_0 多 0.6 位,误差反而大。 它是给激活值用的,256 个数共用一个 scale,
f32的 scale 不能弥补粒度变粗的损失。
和 GGUF 文件逐字节对照
把同一个 BF16 权重用 candle 量化,再和 bartowski 的 GGUF 文件里对应张量(blk.0.ffn_down.weight)的字节比较:
Q8_0 candle vs GGUF file blk.0.ffn_down: 940032 bytes each, 0 bytes differ
Q6K candle vs GGUF file blk.0.ffn_down: 725760 bytes each, 198555 bytes differ; rel RMSE file 1.8685% candle 1.8944%
Q8_0 一字节不差:算法没有自由度,candle 和 llama.cpp 必然得到同一个结果。Q6K 有 27% 的字节不同,文件里那份的误差还更小一点:元数据里的 quantize.imatrix.* 说明量化时用了重要性矩阵(每个输入维度一个权重,让常被大激活值乘到的那些列量化得更准),挑出来的 scale 不同。candle 也有这条路(QTensor::quantize_imatrix),默认的 QTensor::quantize 不用。
量化一块数,就是先按这块里最长的那根(amax)定一把尺子的量程(d),再把每根都读成「第几格」(qs)。Q8_0 的尺子有 255 格,Q4_0 只有 16 格。K-quants 是一个大工具箱(超级块)里装 8 把小尺子(子块),每把小尺子的量程只用 6 位记成「大尺子量程(d)的几分之几」(scales),还可以把零点挪开(dmin、m)。
vec_dot 和 NEON
以 Q8_0 为例,标量版本 vec_dot_unopt 每块先算整数点积,再乘两边的 scale:
let mut sumf = 0f32;
for (xs, ys) in xs.iter().zip(ys.iter()) {
let sum_i = xs
.qs
.iter()
.zip(ys.qs.iter())
.map(|(&x, &y)| x as i32 * y as i32)
.sum::<i32>();
sumf += sum_i as f32 * f16::to_f32(xs.d) * f16::to_f32(ys.d)
}
vec_dot 按编译期的 target feature 选实现:
#[allow(unreachable_code)]
fn vec_dot(n: usize, xs: &[Self], ys: &[Self::VecDotType]) -> f32 {
#[cfg(target_feature = "avx2")]
return super::avx::vec_dot_q8_0_q8_0(n, xs, ys);
#[cfg(target_feature = "neon")]
return super::neon::vec_dot_q8_0_q8_0(n, xs, ys);
#[cfg(target_feature = "simd128")]
return super::simd128::vec_dot_q8_0_q8_0(n, xs, ys);
Self::vec_dot_unopt(n, xs, ys)
}
是 cfg,不是运行时检测:二进制编译时的目标有没有 neon,决定走哪条。aarch64 上 neon 总是开的,Apple 芯片的默认目标还开着 dotprod(rustc --print cfg 里有 target_feature="dotprod")。neon::vec_dot_q8_0_q8_0 每块把 32 个 i8 分两次各 16 个装进寄存器,用 vdotq_s32 做「16 对 i8 乘加成 4 个 i32」:
#[inline(always)]
unsafe fn vdotq_s32(a: int8x16_t, b: int8x16_t) -> int32x4_t {
#[cfg(target_feature = "dotprod")]
{
let mut acc: int32x4_t = vdupq_n_s32(0);
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
// ...
);
acc
}
#[cfg(not(target_feature = "dotprod"))]
{
let p0 = vmull_s8(vget_low_s8(a), vget_low_s8(b));
let p1 = vmull_s8(vget_high_s8(a), vget_high_s8(b));
vaddq_s32(vpaddlq_s16(p0), vpaddlq_s16(p1))
}
}
有 dotprod 时是一条 sdot 指令,用内联汇编写(函数名和 core::arch 里的内建函数 vdotq_s32 同名,那个内建函数在这台机器 rustc 1.99.0 的标准库源码里标着 1.98.0 才稳定);没有时用「乘成 i16、两两相加成 i32」三步拼出来。
sdot#3630「Add neon sdot behind target_feature = "dotprod" gate」(2026-06-18,首次发布于 0.11.0)之前,vdotq_s32 的函数体只有一行注释 // TODO: dotprod 加上 vmull_s8 / vpaddlq_s16 那三步,即使在支持点积指令的 Apple 芯片上也是用乘加拼出来的。这个 PR 只改了 neon.rs 的 22 行:有 dotprod 时换成内联汇编的 sdot,旧的三步留作 #[cfg(not(target_feature = "dotprod"))] 的分支。同一周的 #3643 在它上面加了 vec_dot_4 和 Q4K 的 8 列重排路径,后者整个只在 dotprod 下编译。用 RUSTFLAGS="-C target-feature=-dotprod" 重编实验 crate,就回到了 #3630 之前的指令:vec_dot_4 从 326 ns 变成 509 ns,单个 Q8_0 vec_dot 从 122 ns 变成 141 ns,标量版本从 114 ns 变成 177 ns(LLVM 也不能再用 sdot)。
SIMD 覆盖并不齐。NEON 有 Q4_0、Q8_0 和 Q2K 到 Q8K 的 vec_dot,vec_dot_4(一行激活同时和四行权重做点积,激活只装载一次)只有 Q8_0、Q4K、Q6K 三种。Q4_1、Q5_0、Q5_1、Q8_1 在任何平台都只有标量实现:BlockQ5_0::vec_dot 检查完参数就调 vec_dot_unopt。这一点在 SmolLM2 的 Q4_K_M 文件上很要紧,因为它的 166 个张量是 Q5_0(见 GGUF 文件)。
实测:一次点积
formats 把两组各 4096 个正态分布的数量化后做点积,和 f64 精确值比较,再计时(每种 5 万次一批,9 批取最快):
dot over 4096: f64 exact -53.84741; Q8_0.Q8_0 vec_dot -54.03127 (unopt -54.03128)
Q4_0.Q8_0 vec_dot -56.48751 (unopt -56.48749)
Q4K.Q8K vec_dot -56.79199 (unopt -56.79198); Q6K.Q8K vec_dot -51.62843 (unopt -51.62843)
Q8_0 vec_dot 121.9 ns per 4096-dot (min of 9; -24300910.0)
Q8_0 vec_dot_unopt 113.8 ns per 4096-dot (min of 9; -24300910.0)
Q4K vec_dot 99.9 ns per 4096-dot (min of 9; -25477096.0)
Q4K vec_dot_unopt 231.3 ns per 4096-dot (min of 9; -25477096.0)
Q6K vec_dot 152.3 ns per 4096-dot (min of 9; -23333918.0)
Q6K vec_dot_unopt 508.7 ns per 4096-dot (min of 9; -23333918.0)
f32 dot (iter) 3597.0 ns per 4096-dot (min of 9; -24285078.0)
f32 dot (8 lanes) 453.3 ns per 4096-dot (min of 9; -24285078.0)
Q8_0 vec_dot_4 (4 dots) 325.6 ns per 4096-dot (min of 9; 456741600.0)
- NEON 和标量版本的结果只在最后一位不同(求和顺序不同),两者都和精确值差 0.3% 到 5%,这是两边都被量化的结果,Q4 比 Q8 差得多。
- Q4K 的 NEON 版本快 2.3 倍,Q6K 快 3.3 倍:解包 4 位、6 位数、查子块 scale 这些步骤编译器没法自动向量化。
- 4096 维的点积,Q4K 100 ns,Q8_0 120 ns,手写 8 路累加的 f32 是 450 ns:权重少读 4 到 7 倍,乘法是 i8。直接
iter().sum()的 f32 要 3.6 µs,因为浮点加法不能重排,编译器不敢向量化。 vec_dot_4一次算 4 个点积 326 ns,平均每个 81 ns,比单个调用快 1/3。
vec_dot(手写 NEON + sdot)和 vec_dot_unopt(纯标量 Rust),哪个快、快多少?先猜,再看文末。动脑筋答案
一样快,标量版本还快一点:
Q8_0 vec_dot 121.9 ns per 4096-dot (min of 9; -24300910.0)
Q8_0 vec_dot_unopt 113.8 ns per 4096-dot (min of 9; -24300910.0)
第二次运行是 118.6 ns 对 111.1 ns。反汇编 BlockQ8_0::vec_dot_unopt 能看到原因:
10006bac0: sdot.4s v5, v4, v2
10006bac4: sdot.4s v5, v3, v1
i8 as i32 相乘再 sum::<i32>() 是整数运算,可以随意重排,LLVM 在开着 dotprod 的目标上把它自动向量化成了两条 sdot,和手写的一样。手写 NEON 的价值在 Q4K、Q6K 这些要先解包位域的格式上(快 2.3 到 3.3 倍),以及 vec_dot_4 这种跨多行复用激活的写法。
小结
GgmlDType15 种:3 种浮点,6 种块大小 32,6 种块大小 256 的 K-quants;每种一个#[repr(C)]块结构体,大小用编译期断言钉死,和 llama.cpp 的字节布局一致。- 块大小 32 的类型一块一个
f16scale;K-quants 是超级块一个f16的d加子块的 6 位 / 8 位 scale,所以同样 4.5 位能多存一个 min,误差更小。 - 实测往返误差:Q4 系列约 8%,Q5 约 4%,Q6K 约 1.9%,Q8_0 约 0.55%;离群值让整块变粗,块越大越伤。candle 的 Q8_0 和 GGUF 文件逐字节相同,Q6K 不同(文件用了 imatrix)。
- 点积的另一边先量化成
VecDotType(Q8_0、Q8_1 或 Q8K),内层循环是 i8 乘加;vec_dot用cfg(target_feature)在编译期选 NEON / AVX / wasm SIMD / 标量。 - NEON 覆盖 Q4_0、Q8_0 和 K-quants;Q5_0、Q4_1、Q5_1 只有标量。Q8_0 的标量版本被 LLVM 自动向量化成
sdot,和手写的一样快;Q4K、Q6K 的手写版本快 2 到 3 倍。