GGUF
主线位置:推理主线的最后一步「量化与部署」,用别人量化好的 GGUF 文件跑生成,看它离 f32 的 transformers 有多远。
版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB hf["transformers f32"] --> ref["参照 270 个 token"] gguf["GGUF 文件"] --> ct["Content read"] ct --> mw["from_gguf"] mw --> tf["teacher forcing"] mw --> free["自由贪心生成"] ref --> tf tf --> cmp["逐步 logits 和 argmax"] free --> fork["第几个 token 分叉"] ref --> fork
实验代码:Python 侧 ref.py(参照)、tfcmp.py(比较)、hf_quant.py(transformers 的量化入口);candle 侧 crate quant-exp 的 bin qrun。release 构建,平台 M2 Max、macOS 27、rustc 1.99.0;PyTorch 固定 torch.set_num_threads(8)。权重是本地的 SmolLM2-135M-Instruct:bf16 的 model.safetensors,以及同一个模型的 Q8_0.gguf 和 Q4_K_M.gguf 两个文件。本页不计时。
参照是 transformers eager、f32、CPU:用户消息 Explain how a CPU cache works. 套上对话模板(37 个 token),generate(do_sample=False) 贪心生成,到 <|im_end|> 停,一共 270 个 token。
transformers 用户会怎么量化
在 transformers 里量化通常是 from_pretrained 多给一个 quantization_config,或者用 gguf_file= 直接读 GGUF。在这台机器上逐个试一遍(hf_quant.py,每个错误取一行):
print("cuda", torch.cuda.is_available(), "mps", torch.backends.mps.is_available())
AutoModelForCausalLM.from_pretrained(model_dir, gguf_file="SmolLM2-135M-Instruct-Q8_0.gguf")
qc = transformers.BitsAndBytesConfig(load_in_8bit=True) # 还有 GPTQConfig、TorchAoConfig、QuantoConfig
AutoModelForCausalLM.from_pretrained(model_dir, quantization_config=qc)
cuda False mps True
gguf_file: ImportError: Please install torch and gguf>=0.10.0 to load a GGUF checkpoint in PyTorch.
BitsAndBytesConfig: ImportError: Using `bitsandbytes` 8-bit quantization requires accelerate: `pip install 'accelerate>=1.1.0'`
GPTQConfig: ImportError: Loading a GPTQ quantized model requires optimum (`pip install optimum`)
TorchAoConfig: ValueError: TorchAoConfig requires torchao to be installed. Install with `pip install torchao`
QuantoConfig: ImportError: Loading an optimum-quanto quantized model requires optimum-quanto library (`pip install optimum-quanto`)
- 这台机器没有 CUDA。bitsandbytes 和 GPTQ 这两条最常见的路线主要面向 NVIDIA 显卡,本页的 venv 也没装这些包,所以都没测,只列报错。
gguf_file=缺gguf包。就算装上,transformers 5.22.0 的load_gguf_checkpoint也是逐个张量dequantize(tensor.data, tensor.tensor_type),把权重还原成浮点再放进普通的nn.Linear:省的是下载,不是内存和计算。- 能在这台机器上跑的只有 PyTorch 自带的
torch.ao.quantization.quantize_dynamic(CPU),放在自己量化一页和 candle 对比。
candle 这边不一样:权重一直是量化块(QTensor),乘法由量化内核直接在块上算,CPU 和 Metal 都有。文件格式和内核见源码卷的 量化。
在 candle 里跑一个 GGUF
transformers 一侧就是普通的 f32 模型。参照存两样东西:贪心生成的 token,和把整段序列一次前向后每个位置的 logits:
tok = AutoTokenizer.from_pretrained(model_dir)
model = AutoModelForCausalLM.from_pretrained(model_dir, dtype=torch.float32, attn_implementation="eager").eval()
enc = tok.apply_chat_template([{"role": "user", "content": prompt}],
add_generation_prompt=True, return_tensors="pt", return_dict=True)
p = enc["input_ids"].shape[1]
with torch.inference_mode():
seq = model.generate(**enc, max_new_tokens=max_new, do_sample=False)[0]
# 位置 p-1 的 logits 预测第一个新 token
logits = model(seq[None]).logits[0, p - 1:].float().numpy()
candle 一侧换成 quantized_llama。GGUF 文件里有超参数,不读 config.json;分词器仍然用 tokenizer.json,对话模板用手写的字符串(分词与对话模板):
let model_dir = "SmolLM2-135M-Instruct";
let mut reader = std::io::BufReader::new(std::fs::File::open(gguf)?);
let content = gguf_file::Content::read(&mut reader)?;
let mut model = ModelWeights::from_gguf(content, &mut reader, &dev)?;
let tok = Tokenizer::from_file(format!("{model_dir}/tokenizer.json")).map_err(anyhow::Error::msg)?;
let prompt = tok.encode(chat_prompt(user).as_str(), false).map_err(|e| anyhow!(e))?.get_ids().to_vec();
fn step(model: &mut ModelWeights, ids: &[u32], pos: usize, dev: &Device) -> Result<Tensor> {
let x = Tensor::new(ids, dev)?.unsqueeze(0)?;
Ok(model.forward(&x, pos)?.squeeze(0)?)
}
// teacher forcing:先 prefill prompt,之后每一步喂参照序列里的 token
let p = prompt.len();
rows.extend(step(&mut model, &prompt, 0, &dev)?.to_vec1::<f32>()?);
for (k, &t) in ref_ids[..ref_ids.len() - 1].iter().enumerate() {
rows.extend(step(&mut model, &[t], p + k, &dev)?.to_vec1::<f32>()?);
}
prompt 37 tokens, equal to transformers apply_chat_template: true
和 llama.rs 那条路比(对齐 logits),用法上有三点不同:
ModelWeights::from_gguf只要 GGUF 的Content和 reader,不要VarBuilder、Config,也不选 dtype:每个张量按文件里的类型读。它要哪些元数据键、哪些张量名,见源码卷的量化 llama 的一次前向。forward(x, index_pos)没有Cache参数,KV 缓存存在模型自己身上;index_pos == 0时丢掉旧缓存。llama.rs不会丢,要新建Cache(KV 缓存)。所以上面同一个model跑完 teacher forcing,可以直接从位置 0 开始自由生成。- 返回的也只有最后一个位置的 logits,形状
(1, 49152)。要每个位置的 logits,只能像上面这样一步一步送。
最后一个位置的 logits
先只看 prompt 最后一个位置(预测第一个新 token)的 49152 个 logits,最大 |ref| 34.31。cpu-deq 是在 CPU 上设 CANDLE_DEQUANTIZE_ALL=1 的运行:加载时把量化权重反量化成 f32,走普通的 f32 matmul,剩下的就只有权重本身的量化误差。
| 文件 | CPU | Metal | cpu-deq |
|---|---|---|---|
| Q8_0 | 0.718 | 0.416 | 0.414 |
| Q4_K_M | 2.441 | 2.478 | 2.490 |
对照:同一个 prompt 用 f32 权重做成的 GGUF 走 quantized_llama(自己量化),差是 4.4e-5,和 A3 的 f32 结果同一个量级。所以上表里的数全是量化带来的,不是 quantized_llama 和 transformers 的实现差别。
- Q8_0:权重误差 0.41,CPU 再多 0.3。 Metal 和 cpu-deq 几乎相同(0.416 对 0.414),CPU 的量化矩阵乘还要先把激活量化成 Q8_0,多出一层误差。机制见源码卷的坑 同一个 GGUF,CPU 和 Metal 生成的文字不一样。
- Q4_K_M:权重误差占了几乎全部。 三列都是 2.4 到 2.5,激活量化那点误差被盖住了。这个文件里大半张量其实是 Q5_0(行长 576 用不了 K-quants,见 GGUF 文件)。
- 量级:Q8_0 是最大 |ref| 的 1% 到 2%,Q4_K_M 是 7%。
逐步比 argmax:teacher forcing
只比一个位置说明不了生成会怎样。沿参照的 270 个 token 一步一步喂(teacher forcing,每一步都用参照里的 token,不让分叉传下去),每一步比 logits 和 argmax(tfcmp.py,270 步合起来最大 |ref| 45.39):
tf_Q8_0_cpu.bin step0 max|diff| 0.718 all steps median 0.817 max 1.380 argmax 262/270 first differ at 26
differing steps (ref margin): 26(0.07) 59(0.01) 107(0.10) 116(0.29) 125(0.04) 150(0.08) 168(0.13) 179(0.02)
tf_Q8_0_metal.bin step0 max|diff| 0.416 all steps median 0.540 max 0.901 argmax 263/270 first differ at 26
differing steps (ref margin): 26(0.07) 59(0.01) 125(0.04) 150(0.08) 166(0.00) 167(0.05) 202(0.05)
tf_Q8_0_cpudeq.bin step0 max|diff| 0.414 all steps median 0.540 max 0.900 argmax 263/270 first differ at 26
differing steps (ref margin): 26(0.07) 59(0.01) 125(0.04) 150(0.08) 166(0.00) 167(0.05) 202(0.05)
tf_Q4_K_M_cpu.bin step0 max|diff| 2.441 all steps median 2.785 max 5.624 argmax 230/270 first differ at 5
differing steps (ref margin): 5(0.34) 8(0.19) 26(0.07) 30(0.17) 36(0.10) 37(0.16) 43(0.15) 45(0.30) 48(0.21) 53(0.22) 55(0.53) 59(0.01) ...
tf_Q4_K_M_metal.bin step0 max|diff| 2.478 all steps median 2.629 max 5.702 argmax 238/270 first differ at 5
differing steps (ref margin): 5(0.34) 26(0.07) 30(0.17) 36(0.10) 37(0.16) 43(0.15) 48(0.21) 59(0.01) 66(0.21) 76(0.25) 77(0.38) 82(0.10) ...
tf_Q4_K_M_cpudeq.bin step0 max|diff| 2.490 all steps median 2.630 max 5.703 argmax 238/270 first differ at 5
differing steps (ref margin): 5(0.34) 26(0.07) 30(0.17) 36(0.10) 37(0.16) 43(0.15) 48(0.21) 59(0.01) 66(0.21) 76(0.25) 77(0.38) 82(0.10) ...
括号里是 f32 参照在这一步第一名领先第二名多少(margin)。
- Q8_0:262 到 263 步相同。 不同的 7 到 8 步,参照的领先都不到 0.3,大多不到 0.1;整段 270 步里领先不到 0.1 的有 17 步。误差中位数 0.5 到 0.8,只够翻转这种几乎平局的位置。
- Q4_K_M:230 到 238 步相同。 误差是 Q8_0 的 4 到 5 倍,领先 0.3、0.5 的位置也会翻。
- Metal 和 cpu-deq 的每一步几乎一样(两行翻转的步完全相同)。CPU 的量化路径翻转的步不完全相同:Q8_0 多了 107、116、168、179,少了 166、167、202。近平局的位置上选哪个 token 是舍入问题,和半精度一页的结论一样;Q8_0 的误差和那里的 bf16(0.5 到 0.6)同一个量级,Q4_K_M 再大 4 到 5 倍。
自由生成:从第几个 token 分叉
再让每个设置自己贪心生成(最多比参照多 20 个 token),和参照比第一个不同的位置:
| 文件 | 设备 | 第一个不同的 token | 生成长度 |
|---|---|---|---|
| Q8_0 | CPU | 26 | 290(没停) |
| Q8_0 | Metal | 26 | 290(没停) |
| Q4_K_M | CPU | 5 | 290(没停) |
| Q4_K_M | Metal | 5 | 223(遇到 `< |
分叉点和 teacher forcing 第一个 argmax 不同的步完全一致(26 和 5):分叉之前两边的前缀相同,走的又是同一套 prefill 加逐个 decode,算出来的就是同一组 logits。分叉处的上下文:
SmolLM2-135M-Instruct-Q4_K_M.gguf cpu first_diff 5 len 290 eos False
common: 'A CPU cache is a' | ref: ' specialized memory location within a computer' | got: ' data structure that stores frequently accessed'
SmolLM2-135M-Instruct-Q4_K_M.gguf metal first_diff 5 len 223 eos True
common: 'A CPU cache is a' | ref: ' specialized memory location within a computer' | got: ' data structure that stores frequently accessed'
SmolLM2-135M-Instruct-Q8_0.gguf cpu first_diff 26 len 290 eos False
common: ' that stores frequently accessed data and instructions.' | ref: ' It acts as a "cache' | got: ' The process of creating and maintaining'
SmolLM2-135M-Instruct-Q8_0.gguf metal first_diff 26 len 290 eos False
common: ' that stores frequently accessed data and instructions.' | ref: ' It acts as a "cache' | got: " Here's how it works:"
再拿 CPU、Metal 和 cpu-deq 三份生成互相比(None 表示整段相同):
Q8_0 cpu vs metal first diff 26 cpudeq vs metal None cpudeq vs ref first 26
Q4_K_M cpu vs metal first diff 15 cpudeq vs metal None cpudeq vs ref first 5
CPU 和 Metal 之间,Q8_0 在第 26 个 token 就互不相同,Q4_K_M 在第 15 个。CPU 设了 CANDLE_DEQUANTIZE_ALL=1 之后,两个文件都和 Metal 一个 token 不差(290 个和 223 个)。
问: Q8_0 在 CPU 和 Metal 上都是第 26 个 token 分叉,可分出去的词又不一样,这是同一个原因吗?
答: 是两个原因叠在一起。第 26 步是三个词几乎并列的位置(第 26 步 logits 的前三名):
f32 ref ' It'=18.492 ' Here'=18.420 ' The'=18.332
Q8_0_cpu ' The'=18.464 ' It'=18.423 ' Here'=18.377
Q8_0_metal ' Here'=18.409 ' The'=18.387 ' It'=18.387
Q8_0_cpudeq ' Here'=18.410 ' The'=18.389 ' It'=18.388
参照里三个词只差 0.16。权重量化的误差(cpu-deq 那一行)已经足够把 It 挤下第一名,这是两个设备都在第 26 步分叉的原因。CPU 又多了激活量化的误差,把第一名换成了 The,于是 CPU 和 Metal 也从这里开始各说各的。
小结
- transformers 的量化入口(bitsandbytes、GPTQ、torchao、quanto)在这台没有 CUDA、也没装这些包的机器上都用不了;
gguf_file=能读 GGUF,但会把权重反量化成浮点。candle 的quantized_llama直接在量化块上算,CPU 和 Metal 都能跑。 ModelWeights::from_gguf不读config.json、不选 dtype;forward(x, index_pos)自己管 KV 缓存,index_pos == 0时清空,只返回最后一个位置的 logits。- 对 f32 参照(最大 |ref| 34.31),最后一个位置的 logits:Q8_0 差 0.42(Metal)到 0.72(CPU),Q4_K_M 差 2.4 到 2.5;f32 GGUF 走同一条路只差 4.4e-5(CPU)。
- teacher forcing 270 步:Q8_0 有 262 到 263 步 argmax 相同,翻转的都是参照领先不到 0.3 的位置;Q4_K_M 230 到 238 步。
- 自由生成:Q8_0 第 26 个 token 分叉,Q4_K_M 第 5 个,CPU 和 Metal 相同,都等于 teacher forcing 第一个不同的步。CPU 多一层激活量化,所以 CPU 和 Metal 之间也会分叉;设
CANDLE_DEQUANTIZE_ALL=1后两者逐 token 相同。