自己量化
主线位置:推理主线的最后一步「量化与部署」,不下载 GGUF,从 safetensors 自己量化、写文件、再用
quantized_llama读回来。
版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB st["bf16 safetensors"] --> name["改成 GGUF 名字"] name --> perm["q k 行重排"] perm --> q["QTensor quantize"] md["下载的 GGUF"] -->|"拷 8 个键"| meta["元数据"] q --> w["gguf_file write"] meta --> w w --> back["from_gguf 读回"] back --> cmp["对参照和原文件"]
实验代码:candle 侧 crate quant-exp 的 bin qown(量化并写文件)、qcmp(两个 GGUF 逐张量比字节)、qrun(teacher forcing 和自由生成,见 GGUF);Python 侧 ao_dynamic.py、ao_split.py、ao_batchdep.py。release 构建,平台 M2 Max、macOS 27、rustc 1.99.0;PyTorch 固定 torch.set_num_threads(8)。参照和比较方法与 GGUF 一页相同:transformers eager f32 CPU 贪心生成的 270 个 token,prompt 最后一个位置的 logits 最大 |ref| 34.31。生成的 GGUF 都写在实验目录里。
PyTorch:quantize_dynamic
这台机器上 PyTorch 能跑的量化只有 torch.ao.quantization 的动态量化(GGUF 一页列了 transformers 的其他入口为什么跑不了)。它把每个 nn.Linear 换成 int8 权重的 DynamicQuantizedLinear,激活在运行时按实际数值量化:
torch.backends.quantized.engine = "qnnpack"
model = AutoModelForCausalLM.from_pretrained(model_dir, dtype=torch.float32, attn_implementation="eager").eval()
qmodel = torch.ao.quantization.quantize_dynamic(model, {nn.Linear}, dtype=torch.qint8)
with torch.inference_mode():
got = qmodel(seq).logits[0, p - 1 : p - 1 + len(r["ids"])].float().numpy()
不设 engine 时,quantize_dynamic 打包权重(linear_prepack)这一步就失败;设了之后能跑,但一路是弃用警告(ao_dynamic.py,警告各取第一行):
quantized engines: ['qnnpack'] current: none
default engine: RuntimeError Didn't find engine for operation quantized::linear_prepack NoQEngine
warning: DeprecationWarning torch.ao.quantization is deprecated and will be removed in 2.10.
...
lm_head: DynamicQuantizedLinear(in_features=576, out_features=49152, dtype=torch.qint8, qscheme=torch.per_tensor_affine)
q_proj: DynamicQuantizedLinear(in_features=576, out_features=576, dtype=torch.qint8, qscheme=torch.per_tensor_affine)
qnnpack int8 dynamic: step0 max|diff| 6.892 (max|ref| 34.31), all steps median 10.756 max 19.972, argmax 182/270, first differ at 5
greedy generate first diff: 5
torch.save(state_dict) f32: 538160905 bytes
torch.save(state_dict) int8 dynamic: 249019179 bytes
- 这台 Mac 上只有
qnnpack一个量化引擎,而且默认没选上。 - 警告说
torch.ao.quantization会在 2.10 移除,这里是 2.15.0,它还在;警告原文里让迁移到 torchao,venv 里没有 torchao。 - 误差很大: prompt 最后一个位置差 6.89,270 步 teacher forcing 只有 182 步 argmax 相同,生成第 5 个 token 就分叉。下文 candle 的 Q8_0 同样是 8 位,只差 0.72。
- 体积只省一半: 249 MB 对 538 MB。
embed_tokens不是nn.Linear,还是 f32(2830 万个参数);原来和它共享权重的lm_head被量化成了一份独立的 int8 副本。
差距来自 scale 的粒度。per_tensor_affine 是整个权重矩阵一个 scale;GGUF 的 Q8_0 每 32 个数一个 scale(块结构见 量化格式)。把误差拆开(ao_split.py):只用 quantize_dynamic 的 int8 权重,反量化回 f32,激活不量化:
211 Linear weights per-tensor int8, activations f32: step0 max|diff| 2.649, median 3.186, max 7.233, argmax 237/270, first differ at 5
单是权重就差 2.65,是 Q8_0 权重误差(0.41,见 GGUF 的 cpu-deq)的 6 倍;剩下的来自激活的动态量化,它也是整个输入一个 scale。
a = model(seq[:, :p]).logits[0, -1] # p = 37,只送 prompt
b = model(seq).logits[0, p - 1] # 送整段 307 个 token,取同一个位置
candle:四步
candle 侧没有 quantize_dynamic 这样一个调用:要自己把 safetensors 里的每个张量变成 QTensor,起好 GGUF 的名字,带上 quantized_llama 要的元数据,再写文件。
1. 改名
quantized_llama 按 llama.cpp 的名字取张量(blk.{i}.attn_q.weight 这类,全表见量化 llama 的一次前向)。safetensors 里是 transformers 的名字,逐个对:
fn gguf_name(hf: &str) -> Option<String> {
match hf {
"model.embed_tokens.weight" => return Some("token_embd.weight".into()),
"model.norm.weight" => return Some("output_norm.weight".into()),
_ => {}
}
let rest = hf.strip_prefix("model.layers.")?;
let (i, part) = rest.split_once('.')?;
let g = match part {
"input_layernorm.weight" => "attn_norm",
"post_attention_layernorm.weight" => "ffn_norm",
"self_attn.q_proj.weight" => "attn_q",
"self_attn.k_proj.weight" => "attn_k",
"self_attn.v_proj.weight" => "attn_v",
"self_attn.o_proj.weight" => "attn_output",
"mlp.gate_proj.weight" => "ffn_gate",
"mlp.up_proj.weight" => "ffn_up",
"mlp.down_proj.weight" => "ffn_down",
_ => return None,
};
Some(format!("blk.{i}.{g}.weight"))
}
lm_head.weight 不在文件里(tie_word_embeddings,见键名对不上),from_gguf 找不到 output.weight 时也会用 token_embd.weight,两边对得上。
2. q、k 投影的行重排
这一步不做,文件照样能写、能读、能生成,只是生成的是胡话。transformers 的 Llama 用 rotate_half:一个头里第 i 维和第 i + 32 维配成一对旋转。GGUF 的 llama 架构按相邻的第 2i、2i+1 维配对,quantized_llama 对 llama 在 apply_rotary_emb 里用 rope_i,而 llama.rs 读 safetensors 时用的是 rope。llama.cpp 的转换脚本于是把 q、k 投影每个头的输出行重排一次,让相邻配对算出同样的结果。transformers 5.22.0 读 GGUF 时做的是反向操作(LlamaTensorProcessor._reverse_permute_weights):
dim = weights.shape[0] // n_head // 2
w = weights.reshape(n_head, dim, 2, *weights.shape[1:])
return w.swapaxes(2, 1).reshape(weights.shape)
candle 侧照正向写,q 用 9 个头,k 用 3 个 KV 头:
fn permute(w: &Tensor, n_head: usize) -> Result<Tensor> {
let (out, inp) = w.dims2()?;
Ok(w.reshape((n_head, 2, out / n_head / 2, inp))?.transpose(1, 2)?.reshape((out, inp))?)
}
重排的是输出行,不动每行里的顺序,所以不影响量化块(块沿一行切)。
3. 量化
QTensor::quantize 接一个普通 Tensor 和目标类型。一维的 RmsNorm 权重保持 F32,和下载的文件一样:
let mut w = w.to_dtype(DType::F32)?;
if !noperm && name.ends_with("attn_q.weight") {
w = permute(&w, n_head)?;
} else if !noperm && name.ends_with("attn_k.weight") {
w = permute(&w, n_kv_head)?;
}
let dt = match (kind, w.rank()) {
(_, 1) | ("f32", _) => GgmlDType::F32,
("q8_0", _) => GgmlDType::Q8_0,
("q4_0", _) => GgmlDType::Q4_0,
("q4k", _) => GgmlDType::Q4K,
_ => anyhow::bail!("unknown kind {kind}"),
};
let q = match QTensor::quantize(&w, dt) {
Ok(q) => q,
// K-quants 一块 256 个数,行长 576 的矩阵量化不了;照下载的 Q4_K_M 文件的做法退到 Q5_0
Err(e) if dt == GgmlDType::Q4K => {
// ... 打印第一次失败的 e
QTensor::quantize(&w, GgmlDType::Q5_0)?
}
Err(e) => return Err(e.into()),
};
Q4K 的第一个失败(check_shape 查的是最后一维):
blk.5.attn_q.weight [576, 576] as Q4K: Err(quantized tensor must have their last dim divisible by block size [576, 576] 256)
q4k: 272 tensors [("F32", 61), ("Q4K", 30), ("Q5_0", 181)], 8 metadata keys, 89294240 bytes
SmolLM2 只有 ffn_down(行长 1536)能用 Q4K,其余 181 个矩阵都退到 Q5_0。下载的 Q4_K_M 文件也是这样,原因见 GGUF 文件。PyTorch 一侧没有这个限制:per_tensor_affine 对形状没有要求。
4. 元数据和写文件
gguf_file::write 接元数据和张量两个列表。元数据从下载的 Q8_0 文件里只拷 from_gguf 读的那 8 个键:
let ct = gguf_file::Content::read(&mut std::io::BufReader::new(std::fs::File::open(&src)?))?;
let mut keys = vec![
"general.architecture",
"llama.attention.head_count",
"llama.attention.head_count_kv",
"llama.block_count",
"llama.embedding_length",
"llama.rope.dimension_count",
"llama.attention.layer_norm_rms_epsilon",
"llama.rope.freq_base",
];
// ... norope 时 keys.pop(),nometa 时 keys.clear()
let metadata: Vec<(&str, &gguf_file::Value)> =
keys.iter().map(|k| Ok((*k, ct.metadata.get(*k).context(*k)?))).collect::<Result<_>>()?;
// ...
let refs: Vec<(&str, &QTensor)> = qtensors.iter().map(|(n, q)| (n.as_str(), q)).collect();
gguf_file::write(&mut std::fs::File::create(out)?, &metadata, &refs)?;
少了键会怎样:
一个都不写(
nometa):from_gguf第一个必需的键就报错。candle 自带的tensor-tools quantize从 safetensors 量化时正是这样写的(run_quantize_safetensors里是gguf_file::write(&mut out_file, &[], &qtensors),名字也保持 transformers 的),它的输出不能直接给quantized_llama用。Error: cannot find llama.attention.head_count in metadata漏了
llama.rope.freq_base(norope):不报错。from_gguf对这个键默认 10000,SmolLM2 是 100000。teacher forcing 只有 215/270 步 argmax 相同,自由生成在第 10 个 token 分叉(下表)。和 transformers 5 重存的 config 让llama.rs静默用 10000 是同一类问题(对齐 logits)。
读回来对一遍
f32 GGUF:先证明转换是对的
同一个程序用 GgmlDType::F32 写一份不量化的 GGUF(538 MB),走同样的 quantized_llama:
tf_own-f32_cpu.bin step0 4.39e-05 median 5.15e-05 max 1.05e-04
tf_own-f32_metal.bin step0 6.58e-05 median 5.08e-05 max 1.00e-04
prompt 最后一个位置差 4.4e-5(CPU)、6.6e-5(Metal),270 步全部 argmax 相同,自由生成 270 个 token 和参照一字不差。A3 的标准是最大 |ref| 20.6 时 1e-4;这里最大 |ref| 34.31,270 步里最大的一步是 1.05e-4。改名、重排、元数据都对了,下面各行的误差只来自量化。
和下载的 Q8_0 逐字节比
qcmp 用 Content::tensor 把两个文件里同名的张量读出来,比 QTensor::data() 的字节:
272 tensors in a, 272 in b; 272 byte-identical
自己量化的 Q8_0 和下载的文件 272 个张量逐字节相同,包括重排过的 q、k。Q8_0 每块的 scale 就是 max|x| / 127,量化没有可选的余地,从同一份 bf16 权重出发只能得到同一组字节(单个张量的对照见 量化格式)。teacher forcing 的 270 行 logits 也和下载的文件逐字节相同(cmp 无输出),CPU 和 Metal 都是。文件小 1769408 字节:下载的文件里还有 29 个键,主要是分词器的 49152 个 token 和 48900 条 merge。
反向检查:去掉重排(noperm)再比,正好是 q、k 那 60 个张量不同:
272 tensors in a, 272 in b; 212 byte-identical
differ: blk.attn_k.weight Q8_0 30 tensors, 3377146 of 3525120 bytes
differ: blk.attn_q.weight Q8_0 30 tensors, 10130014 of 10575360 bytes
再拿自己的 Q4K 方案和下载的 Q4_K_M 比:类型不同的张量(下载的文件里 token_embd 是 Q8_0,14 层的 ffn_down 是 Q6K,14 层的 attn_v 是 Q8_0)不比字节,类型相同的也只有 61 个 F32 逐字节相同:
...
token_embd.weight: Q5_0 [49152, 576] vs Q8_0 [49152, 576]
272 tensors in a, 272 in b; 61 byte-identical
differ: blk.attn_k.weight Q5_0 30 tensors, 530689 of 2280960 bytes
differ: blk.attn_output.weight Q5_0 30 tensors, 1524344 of 6842880 bytes
differ: blk.attn_q.weight Q5_0 30 tensors, 1598532 of 6842880 bytes
differ: blk.attn_v.weight Q5_0 16 tensors, 269707 of 1216512 bytes
differ: blk.ffn_down.weight Q4K 16 tensors, 3246483 of 7962624 bytes
differ: blk.ffn_gate.weight Q5_0 30 tensors, 4018042 of 18247680 bytes
differ: blk.ffn_up.weight Q5_0 30 tensors, 4033663 of 18247680 bytes
Q5_0 和 Q4K 各有 20% 到 40% 的字节不同。下载文件的元数据里有 quantize.imatrix.*:量化时用了重要性矩阵挑 scale,QTensor::quantize 不用。Q8_0 不受影响,所以上面那份逐字节相同。源码卷的 量化格式 在一个 Q6K 张量上量过这个差别。
大小和误差
各文件走 qrun,和 GGUF 一页同样的比较。logits 差是 prompt 最后一个位置(最大 |ref| 34.31),argmax 是 teacher forcing 270 步里相同的步数:
| 文件 | 字节 | CPU logits 差 | Metal logits 差 | CPU argmax | Metal argmax | 自由生成分叉 |
|---|---|---|---|---|---|---|
| bf16 safetensors(原文件) | 269060552 | |||||
| 自己的 F32 | 538076576 | 4.39e-5 | 6.58e-5 | 270 | 270 | 不分叉 |
| 自己的 Q8_0 | 143041952 | 0.718 | 0.416 | 262 | 263 | 26 |
| 下载的 Q8_0 | 144811360 | 0.718 | 0.416 | 262 | 263 | 26 |
| 自己的 Q4K(多数是 Q5_0) | 89294240 | 3.776 | 3.769 | 217 | 216 | 5 |
| 下载的 Q4_K_M | 105454432 | 2.441 | 2.478 | 230 | 238 | 5 |
| 自己的 Q4_0 | 75802016 | 8.413 | 8.210 | 203 | 205 | 3 |
自己的 Q8_0,漏了 freq_base | 143041888 | 4.348 | 215 | 10 | ||
| 自己的 Q8_0,不重排 q、k | 143041952 | 13.529 | 47 | 2 | ||
torch quantize_dynamic(state_dict) | 249019179 | 6.892 | 182 | 5 |
- Q8_0 误差最小:比 bf16 原文件小一半,270 步里只翻转 7 到 8 个近平局的位置。
- 自己的 Q4K 比下载的 Q4_K_M 小 15%、误差大 50%:下载的文件给
token_embd留了 Q8_0、一半的ffn_down用 Q6K,还用了重要性矩阵。llama.cpp 的「M」配方就是这些选择,QTensor::quantize只管一个张量。 - 同样 8 位,torch 的动态量化差 6.89,GGUF 的 Q8_0 差 0.72;同样不到一半的体积,torch 留着一份 f32 的 embedding。
- 不重排时生成第 2 个 token 就分叉,接着退化成重复:
A CPU is a computer system that is a computer.\n\n\n\n\n\nA\nA\nA user\nA user ...。
问: 不重排 q、k,改成告诉 quantized_llama 用 rope(和 llama.rs 一样的配对)不行吗?
答: 行,只是要借别的架构名。from_gguf 按 general.architecture 选 RoPE 约定,qwen2 等 20 个名字走 rope。把它写成 qwen2、q 和 k 不重排(neox),和重排的 Q8_0 文件比 teacher forcing 的 logits:
cpu neox vs permuted: step0 6.28e-01 median 6.66e-01 max 1.38e+00 argmax same 262
metal neox vs permuted: step0 3.81e-05 median 1.38e-05 max 3.81e-05 argmax same 270
cpu deq neox vs permuted: step0 3.81e-05 max 4.20e-05 argmax same 270
Metal 和 CPU 反量化时只差 4e-5:两种写法算的是同一个数学,只是点积 q·k 里 64 个维度的求和顺序不同。CPU 的量化路径差 0.63:每个 QMatMul 的输入都要先量化成 Q8_0,求和顺序带来的微小差别只要让某个激活跨过舍入边界,就落到另一个格子上,30 层下来是另一份激活量化误差(同一个 GGUF,CPU 和 Metal 生成的文字不一样)。但架构名写成 qwen2 的文件,别的工具会当成 Qwen2 去读 qwen2.* 键,llama.cpp 生态里的 llama 文件都是重排过的,自己写就照着重排。RoPE 约定的来历见量化 llama 的一次前向。
动脑筋答案
ao_batchdep.py,两个模型分别算两次位置 36:
f32: position 36, prompt only vs whole sequence: max|diff| 0.000e+00
int8 dynamic: position 36, prompt only vs whole sequence: max|diff| 6.637e+00
- f32 下逐位相同:因果 mask 让位置 36 看不到后面的 token。
- int8 动态量化差 6.64,和它离 f32 参照的整个误差(6.89)同一个量级。激活的 scale 由整个输入张量的取值范围决定,后面 270 个 token 改变了这个范围,前面位置的量化格子跟着变粗或变细。数学上的因果性还在,数值上不在了:同一个 prompt,单独算和放在长序列里算,logits 不同。
- candle 的 CPU 量化路径不会这样:
QMatMul把激活逐行、每 32 个数(Q8_0)或 256 个数(Q8K)一组量化,一个 token 的量化只看它自己那一行(QTensor 与 QMatMul)。
小结
- PyTorch 在这台 Mac 上只有
quantize_dynamic能跑,要手动选qnnpack,有弃用警告;权重和激活都是整个张量一个 scale,SmolLM2 的 logits 差 6.89,第 5 个 token 就分叉,体积 249 MB(embedding 仍是 f32)。 - candle 从 safetensors 到 GGUF 四步:改成 llama.cpp 的名字;q、k 每个头的行重排(transformers 读 GGUF 时做反向操作);
QTensor::quantize;gguf_file::write带上from_gguf要的 8 个元数据键。 - 漏了元数据:一个都没有时报
cannot find llama.attention.head_count in metadata;漏了llama.rope.freq_base不报错,静默用 10000。不重排 q、k 也不报错,生成胡话。 - 不量化的 F32 GGUF 对 transformers 差 4.4e-5,270 个 token 全对,证明转换正确;自己量化的 Q8_0 和下载的文件 272 个张量逐字节相同。
- K-quants 要求行长是 256 的倍数,SmolLM2 的 576 宽矩阵退到 Q5_0;下载的 Q4_K_M 用了重要性矩阵和混合类型,比只用
QTensor::quantize做的 Q4K 方案误差小三分之一。