量化后 NaN 变成了 0
主线位置:推理主线的
QMatMul,以及模型转换时的QTensor::quantize。Q8_0 的块结构(32 个i8加一个f16的 scaled)和逐行的量化 / 反量化见 量化格式,CPU 上量化矩阵乘先量化激活见 QTensor 与 QMatMul,自己写一遍 Q8_0 见 动手:mini-quant。
flowchart TB
blk["一块 32 个 f32"] --> amax["amax 跳过 NaN"]
amax --> d["d = amax / 127 存成 f16"]
d --> v{"d 的大小"}
v -->|正常| ok["误差在 0.4% 内"]
v -->|"小于 2^-24 的一半"| zero["整块变 0"]
v -->|"f16 次正规"| big["整块被放大"]
v -->|"大于 65504"| inf["整块 inf"]
blk --> nan["NaN 元素 变 0"]
实验 crate exp-candle/pb-pit,bin q8edge,release,M2 Max、macOS 27、rustc 1.99.0。
现象
一行 64 个数(两个 Q8_0 块),只改第一块,用公开的 QTensor::quantize / dequantize 走一个来回;打印第一块的前三个数和第二块的前两个数:
let t = Tensor::from_vec(v.clone(), (1, 64), &dev)?;
let q = QTensor::quantize(&t, GgmlDType::Q8_0)?;
let back = q.dequantize(&dev)?.flatten_all()?.to_vec1::<f32>()?;
[cpu] plain
in [-0.5, -0.46774194, -0.43548387] | block 1 [-0.5, -0.46774194]
out [-0.49996948, -0.46847534, -0.4369812] | block 1 [-0.49996948, -0.46847534]
[cpu] NaN at [1]
in [-0.5, NaN, -0.43548387] | block 1 [-0.5, -0.46774194]
out [-0.49996948, 0.0, -0.4369812] | block 1 [-0.49996948, -0.46847534]
[cpu] inf at [1]
in [-0.5, inf, -0.43548387] | block 1 [-0.5, -0.46774194]
out [NaN, NaN, NaN] | block 1 [-0.49996948, -0.46847534]
[cpu] block * 1e-5
in [-5e-6, -4.6774194e-6, -4.3548384e-6] | block 1 [-0.5, -0.46774194]
out [-7.56979e-6, -7.0929527e-6, -6.6161156e-6] | block 1 [-0.49996948, -0.46847534]
[cpu] block * 4e-6
in [-2e-6, -1.8709677e-6, -1.7419355e-6] | block 1 [-0.5, -0.46774194]
out [-0.0, -0.0, -0.0] | block 1 [-0.49996948, -0.46847534]
[cpu] block * 2e7
in [-10000000.0, -9354839.0, -8709677.0] | block 1 [-0.5, -0.46774194]
out [-inf, -inf, -inf] | block 1 [-0.49996948, -0.46847534]
Metal 上逐行相同(QTensor::quantize 在 Metal 上也是拷回 CPU 量化,见下)。
- NaN:那一个元素变成 0,同块其他元素正常。
- inf:整块 32 个数全成 NaN。
- 很小的块:最大值 5e-6 时每个数被放大到 1.51 倍;最大值 2e-6 时整块变成 0。
- 很大的块:最大值 1e7 时整块变成 ±inf。
- 第二块始终不受影响:每块有自己的 scale。
量化矩阵乘里更隐蔽(q8edge matmul):权重 (4, 64) 量化成 Q8_0,输入第 1 个元素是 NaN:
[Cpu] QMatMul(Q8_0) with a NaN input: [-0.3755028, -0.8031639, 0.43483, 0.12489978]; f32 matmul: [NaN, NaN, NaN, NaN]
[Metal(MetalDevice(DeviceId(1)))] QMatMul(Q8_0) with a NaN input: [NaN, NaN, NaN, NaN]; f32 matmul: [NaN, NaN, NaN, NaN]
CPU 上量化矩阵乘的输出是正常的有限数,NaN 消失了;同样的输入在 Metal 上、在 f32 matmul 上都是 NaN。
出处
for (i, ys) in ys.iter_mut().enumerate() {
let mut amax = 0f32;
let xs = &xs[i * Self::BLCK_SIZE..(i + 1) * Self::BLCK_SIZE];
for &x in xs.iter() {
amax = amax.max(x.abs())
}
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
}
}
逐条对上现象:
f32::max遇到 NaN 返回另一个数,所以amax跳过 NaN;NaN 那个元素round(NaN * id) as i8是 0(Rust 的浮点转整数对 NaN 给 0)。inf让amax、d都是inf,id = 1/inf = 0,所有qs都是 0(inf * 0是 NaN,转成 0);d存成 f16 的inf。to_float里0 * inf是 NaN,整块 NaN。qs用的是 f32 精度的id,d却舍入成 f16 才存下。f16 最小的正规数是 2^-14(约 6.1e-5),再往下是步长 2^-24(约 6e-8)的次正规数。最大值 5e-6 时d = 3.9e-8,舍入成 6e-8,放大到 1.51 倍;最大值 2e-6 时d = 1.6e-8,不到半个步长,舍成 0,整块为 0。- f16 最大是 65504。最大值超过 127 × 65504 ≈ 8.3e6 时
d舍入成inf,非零的qs乘出来都是 ±inf(qs为 0 的元素是 NaN)。
CPU 的量化矩阵乘 k_quants::matmul 先用 T::VecDotType::from_float 量化每一行激活,Q8_0 权重的 VecDotType 就是 BlockQ8_0,于是激活里的 NaN 也在这一步变成 0。Metal 的量化内核直接用 f32 激活,NaN 照常传播。QMetalStorage::quantize 的注释写着「Quantization only happens on CPU for now」:先 to_cpu,在 CPU 上量化,再上传,所以 Metal 上 QTensor::quantize 的结果和 CPU 一模一样。
from_float 和 to_float 是照 llama.cpp 的参考实现移植的(注释里分别写着 quantize_row_q8_0 和 ggml.c 的链接),本页把它当作格式本身的性质,不算 candle 的 bug;但后果要知道:量化文件里看不到原权重有没有 NaN;CPU 上跑量化模型时,上一层算出的 NaN 会被下一个量化矩阵乘「修好」,变成一串看似正常的数。
修法
量化之前检查每一块:
/// What to verify before quantizing to Q8_0.
fn check_q8_0(v: &[f32]) -> Result<(), String> {
for (b, block) in v.chunks(32).enumerate() {
if let Some(i) = block.iter().position(|x| !x.is_finite()) {
return Err(format!("block {b}: element {i} is {}", block[i]));
}
let amax = block.iter().fold(0f32, |m, x| m.max(x.abs()));
// d = amax / 127 is stored as f16: max 65504; below 2^-14 it is subnormal and coarse
if amax / 127.0 > 65504.0 {
return Err(format!("block {b}: amax {amax:e} overflows the f16 scale"));
}
let d = amax / 127.0;
let stored = half::f16::from_f32(d).to_f32();
if d > 0.0 && (stored - d).abs() / d > 0.01 {
return Err(format!("block {b}: amax {amax:e}, scale {d:e} is stored as {stored:e} in f16"));
}
}
Ok(())
}
[cpu] plain check: Ok(())
[cpu] NaN at [1] check: Err("block 0: element 1 is NaN")
[cpu] inf at [1] check: Err("block 0: element 1 is inf")
[cpu] block * 1e-5 check: Err("block 0: amax 5e-6, scale 3.937008e-8 is stored as 5.9604645e-8 in f16")
[cpu] block * 4e-6 check: Err("block 0: amax 2e-6, scale 1.5748032e-8 is stored as 0e0 in f16")
[cpu] block * 2e7 check: Err("block 0: amax 1e7 overflows the f16 scale")
- 非有限值(NaN、inf)一律拒绝:它们在原模型里就是问题,量化只会把它藏起来或扩散到整块。
- scale 存成 f16 后相对误差超过 1% 才报。真实权重离这个范围很远:用
pb-pit/scripts/scan_d.py扫了一遍 SmolLM2 Q8_0 文件里全部 4202496 个块,最小的非零d是 3.0e-4(块内最大值 0.038),没有一块的d是 0 或次正规数。所以这个检查对正常权重不会误报,报出来的都是异常块。 - 查 NaN 的时候不要在 CPU 的量化模型上查:设
CANDLE_DEQUANTIZE_ALL=1(权重反量化成 f32、走普通 matmul,见 同一个 GGUF,CPU 和 Metal 生成的文字不一样),或者在 Metal 上跑,NaN 才会如实传到输出。
怎么确认没中招
- 转换模型的脚本在
QTensor::quantize之前对每个张量跑一遍check_q8_0(或者至少检查is_finite)。 - 拿到别人量化好的 GGUF,没法知道原权重有没有 NaN;能做的是反量化后检查有没有 NaN / inf(对应 inf 和过大的块),以及整块为 0 的块有多少。
- 量化模型在 CPU 上输出正常、在 Metal 上全是 NaN 时,问题在前面的层,不在 Metal。
小结
- Q8_0 量化把 NaN 当 0,inf 让整块变 NaN;每块的 scale 存成 f16,块内最大值约 8.3e6 以上时溢出成 inf,5e-6 一带被放大一半,2e-6 以下整块为 0。
- CPU 的量化矩阵乘用同一个函数量化激活,输入里的 NaN 被悄悄变成 0;Metal 用 f32 激活,NaN 照常传播。
- 量化之前检查非有限值和 scale 的舍入误差;排查 NaN 时用反量化形态或 Metal。