Rust in Everything
基于 candle-core 0.11.0

量化后 NaN 变成了 0

主线位置:推理主线的 QMatMul,以及模型转换时的 QTensor::quantize。Q8_0 的块结构(32 个 i8 加一个 f16 的 scale d)和逐行的量化 / 反量化见 量化格式,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。

出处

BlockQ8_0::from_float:

        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。

💬 关联讨论

发起讨论
加载中...