Rust in Everything
基于 candle-core 0.11.0

同一个 GGUF,CPU 和 Metal 生成的文字不一样

主线位置:推理主线里的 QMatMul。CPU 上量化矩阵乘的两步(先量化激活、再分块点积)、Metal 上的量化内核,以及三种形态怎么选,见 QTensor 与 QMatMul;各种块格式见 量化格式。

flowchart TB
  x["f32 激活"] --> dev{"设备"}
  dev -->|CPU| q["激活量化成 Q8_0 或 Q8K"]
  q --> idot["整数点积"]
  dev -->|Metal| fdot["f32 激活乘反量化的权重"]
  idot --> diff["logits 差 0.5 到 0.8"]
  fdot --> diff
  diff --> fork["贪心取样某一步分岔"]
  env["CANDLE_DEQUANTIZE_ALL=1"] --> same["两边都走 f32 matmul"]

实验 crate exp-candle/pb-pit,bin qdiff,SmolLM2-135M-Instruct 的 Q8_0 和 Q4_K_M 两个 GGUF,提示「What is Rust?」,贪心 20 个 token,第一步的 49152 个 logits 存盘比较,release,M2 Max、macOS 27、rustc 1.99.0。

现象

[q8-cpu] "Rust is a modern, high-performance, and object-oriented programming language developed by Brian K"
[q8-metal] "Rust is a modern, high-performance, and object-oriented programming language developed by Brian K"
[q4-cpu] "Rust is a modern, high-performance, and efficient language developed by the Rust community. It"
[q4-metal] "Rust is a modern, high-performance, and efficient programming language developed by Rust Core. It"

Q8_0 两边一样;Q4_K_M 在「efficient」之后分岔,CPU 少了「programming」,后面整句都不同。同一个文件、同一个提示、都是贪心,没有随机性。

出处

两边算的不是同一个数学。CPU 上 QMatMul 的量化形态走 k_quants::matmul:先把每一行激活用 T::VecDotType::from_float 量化(Q8_0 / Q4_0 / Q5_0 权重配 Q8_0 激活,K-quants 配 Q8K),再和量化的权重做整数点积。激活多挨了一次 8 位量化。Metal 的量化内核直接读 f32 激活,在内核里把权重块反量化后乘。

用 CANDLE_DEQUANTIZE_ALL=1 把所有量化权重在加载时反量化成 f32,两边都走普通的 f32 matmul,再比第一步的 logits(-deq 是设了这个变量的运行):

q8-cpu vs q8-metal: first-step logits max abs diff 0.53497
q8-cpu-deq vs q8-metal: first-step logits max abs diff 0.01541
q8-cpu-deq vs q8-metal-deq: first-step logits max abs diff 0.00008
q8-metal vs q8-metal-deq: first-step logits max abs diff 0.01538
q4-cpu vs q4-metal: first-step logits max abs diff 0.84364
q4-cpu-deq vs q4-metal: first-step logits max abs diff 0.02602
q4-cpu-deq vs q4-metal-deq: first-step logits max abs diff 0.00004
q4-metal vs q4-metal-deq: first-step logits max abs diff 0.02602
  • 两边都反量化:差 1e-4 以下,只剩浮点求和顺序的差别。
  • Metal 量化内核和反量化后的 f32 matmul:差 0.015 到 0.026,内核里的计算顺序和精度不同,但激活还是 f32。
  • CPU 量化形态和其他所有组合:差 0.5 到 0.8,大了 30 倍,就是激活量化带来的误差。

CPU 设了 CANDLE_DEQUANTIZE_ALL=1 之后生成的文字:

[q4-cpu-deq] "Rust is a modern, high-performance, and efficient programming language developed by Rust Core. It"
[q4-metal-deq] "Rust is a modern, high-performance, and efficient programming language developed by Rust Core. It"

和 Metal 一字不差。

这是设计,不是 bug:把激活量化成 8 位,点积就能用整数 SIMD(neon 的 sdot)算,这正是 CPU 上量化推理快的原因(candle 的 neon.rs 里用了 48 处 vdotq_s32)。只是它意味着「CPU 上的结果」和「Metal 上的结果」都不是「这个 GGUF 的标准答案」,两者各自有误差。

📝NOTE
没有蠢问题

问: Q8_0 的 logits 也差了 0.53,为什么 20 个 token 一个都没变?

答: 贪心取样只看最大的那个 logit。0.53 是 49152 个值里差得最大的一个,多数词的差别小得多,只要每一步第一名和第二名之间的差距大于这点误差,选出的 token 就不变。Q8_0 的权重本身误差小,模型的输出更「笃定」;Q4_K_M(这个文件里大部分是 Q5_0,见 量化格式)权重误差大;在「efficient」之后的那一步,CPU 选了「language」,Metal 选了「programming」,说明这两个候选的 logit 差距小于 CPU 多出来的激活误差。换一个提示,Q8_0 也可能分岔。

修法

先想清楚要的是什么:

  • 只是想两边结果一致(测试、对拍、复现 bug):CPU 上设 CANDLE_DEQUANTIZE_ALL=1。它在加载时决定形态(thread_local 在第一次建 QMatMul 时读环境变量),要在建模型之前设好。代价是 CPU 上权重内存涨到 f32 的大小,decode 也变慢(QTensor 与 QMatMul 量过:Q8_0 每步从约 7 ms 到 15 ms,Q4_K_M 从 12–13 ms 到 16–19 ms)。
  • 不要用 CANDLE_DEQUANTIZE_ALL_F16:开了 accelerate 时 CPU 的 f16 matmul 直接报 the accelerate backend does not support f16 matmul。
  • 产品里:接受差异。测试不要断言「CPU 生成的文本等于 Metal 生成的文本」,改为比 logits 的容差,或者分设备各存一份期望输出。
  • 对质量敏感:两边的误差都存在,要和非量化的模型(safetensors 的 BF16 / F32)比,而不是互相比。

怎么确认没中招

  • 两个设备的输出不同,先在 CPU 上设 CANDLE_DEQUANTIZE_ALL=1 再跑一次:和 Metal 一致了,就是这一条,不是你的代码有问题。
  • 比第一步 logits 的最大差:0.5 以上而反量化后掉到 1e-4,就是激活量化;反量化后还很大,去查别的(例如 CPU 上 BF16 softmax 加起来不是 1)。
  • 测试用例里凡是跨设备比较量化模型输出的,都写成容差比较。

小结

  • CPU 的量化矩阵乘先把激活量化成 Q8_0 / Q8K 再做整数点积,Metal 保持 f32 激活;同一个 GGUF 第一步 logits 差 0.5 到 0.8。
  • 贪心生成里这点差别足以在某一步换掉第一名:SmolLM2 Q4_K_M 在第 13 个 token 分岔,Q8_0 这个提示下没有。
  • 这是为速度做的设计;要两边一致就在 CPU 上设 CANDLE_DEQUANTIZE_ALL=1(建模型之前),测试用容差比较。

💬 关联讨论

发起讨论
加载中...