Rust in Everything
基于 candle 0.11.0

键名对不上

主线位置:推理主线第一步「读 config 和权重」的最后一关:文件里的键名、形状和模型代码要的对不上。
版本:PyTorch 2.15.0 / transformers 5.22.0 / safetensors 0.8.0(CPU)、candle 0.11.0(CPU,accelerate)
flowchart TB
  m["模型要 名字 形状"] --> rn["rename_f 闭包"]
  rn --> f{"文件里有吗"}
  f -->|没有| e1["cannot find tensor"]
  f -->|有| s{"形状对吗"}
  s -->|不对| e2["shape mismatch"]
  s -->|对| ok["Tensor"]
  e1 --> blk{"在层里吗"}
  e2 --> blk
  blk -->|是| pn["Llama load panic"]
  blk -->|否| er["Err"]

实验代码:PyTorch 侧 rename_py.py、strict_py.py,candle 侧 crate weights-exp 的 bin rename(release)。权重是本地的 SmolLM2-135M-Instruct。平台 M2 Max、macOS 27、rustc 1.99.0。VarBuilder 怎么拼出完整名字,见源码卷的 VarBuilder;前缀拼错、配置漏字段的几种报错,见 cannot find tensor。

改名:rename_f

PyTorch 里键名多一层前缀很常见。torch.compile 包过的模型就是一例:

compiled = torch.compile(model)
keys = list(compiled.state_dict())
print("compiled keys:", keys[:2], "...", len(keys))
sd = {k: v for k, v in compiled.state_dict().items() if k != "_orig_mod.lm_head.weight"}
save_file(sd, f"{out}/compiled.safetensors", metadata={"format": "pt"})
compiled keys: ['_orig_mod.model.embed_tokens.weight', '_orig_mod.model.layers.0.self_attn.q_proj.weight'] ... 273

torch.compile 返回的 OptimizedModule 把原模型放在 _orig_mod 属性下,state_dict 的每个键就多了 _orig_mod.(这里还没真正编译,state_dict 不触发编译)。DistributedDataParallel 的 module. 是同一回事。PyTorch 里的处理是加载前把键改掉。candle 里改的是另一个方向:rename_f 接一个闭包,模型每要一个名字,先过一遍闭包,得到的才是到文件里查的名字:

let vb = unsafe { VarBuilder::from_mmaped_safetensors(&[path], DType::F32, &Device::Cpu)? };
let e = Llama::load(vb.clone(), &cfg).err();
eprintln!("as is: {:?}", e.map(|e| e.to_string()));
let vb = vb.rename_f(|name: &str| format!("_orig_mod.{name}"));
let model = Llama::load(vb, &cfg)?;
as is: Some("cannot find tensor model.embed_tokens.weight")
with rename_f: ok
logits                   n=49152  max|diff|=2.98e-05
worst=2.98e-05 tol=1e-04 OK

改名后的 logits 和 transformers 差 2.98e-5(最大 |ref| 20.6,容差 1e-4),和不改名读原文件一样。闭包收到的是拼好前缀的完整名字(model.layers.0.self_attn.q_proj.weight),返回值是文件里的完整名字;不需要改的原样返回。老 BERT 的 gamma / beta 也是这样改,见 cannot find tensor embeddings.word_embeddings.weight。

先 rename_f,再 pp

rename_f 要包在根 VarBuilder 上。在 pp 过的 VarBuilder 上调用,前缀会出现两次:

let renamed = root.pp("model").rename_f(move |name: &str| {
  log.lock().map(|mut s| s.push(name.to_string())).ok();
  name.to_string()
});
eprintln!("prefix of renamed vb: {:?}", renamed.prefix());
let r = renamed.get(576, "norm.weight");
// ...
let r = root.rename_f(|name: &str| name.to_string()).pp("model").get(576, "norm.weight");
prefix of renamed vb: "model"
renamed.get(576, "norm.weight"): Err("cannot find tensor model.model.norm.weight")
closure saw: ["model.norm.weight"]
root.rename_f(..).pp("model").get: Ok([576])

闭包本身什么都没改,收到的也是对的 model.norm.weight,查的却是 model.model.norm.weight。rename 把当前的 VarBuilder(连同它的前缀 path)整个包进 Rename 后端,新的 VarBuilder 又复制了同一个前缀:外层拼一次,交给闭包,里层 get_with_hints_dtype 再拼一次。这个行为从 rename_f 加进来(#2052)就是这样,origin/main 上没变;我认为是上游的 bug,文档注释的例子只在根上调用,碰不到它。

绑定权重:tie_word_embeddings

SmolLM2 的 config.json 写着 "tie_word_embeddings": true:输出层 lm_head 和词嵌入用同一个矩阵。PyTorch 里是同一个 Parameter,state_dict 里却有两个键:

tied: True
state_dict keys: 273 has lm_head.weight: True

(tied 是 m.lm_head.weight.data_ptr() == m.model.embed_tokens.weight.data_ptr()。)存成 safetensors 时,两个键指着同一块内存,各个工具的处理不同:

try:
  save_file(model.state_dict(), f"{out}/tied_save_file.safetensors")
except Exception as e:
  print("save_file:", type(e).__name__, str(e)[:400])
save_model(model, f"{out}/tied_save_model.safetensors")
save_file: RuntimeError 
            Some tensors share memory, this will lead to duplicate memory on disk and potential differences when loading them again: [{'lm_head.weight', 'model.embed_tokens.weight'}].
            A potential way to correctly save your model is to use `save_model`.
            More information at https://huggingface.co/docs/safetensors/torch_shared_tensors
            
save_model keys: 272 embed: False lm_head: True
  • safetensors.torch.save_file 拒绝共享内存的张量。
  • save_model 去重,留下的是 lm_head.weight:safetensors 0.8.0 的 _remove_duplicate_names 在完整覆盖存储的名字里取 sorted(...)[0],l 排在 m 前面。
  • transformers 的 save_pretrained 留的是 model.embed_tokens.weight(272 个键,没有 lm_head.weight),和原始文件一样。

candle 的 Llama::load 按配置决定要不要 lm_head.weight:

    pub fn load(vb: VarBuilder, cfg: &Config) -> Result<Self> {
        let wte = embedding(cfg.vocab_size, cfg.hidden_size, vb.pp("model.embed_tokens"))?;
        let lm_head = if cfg.tie_word_embeddings {
            Linear::from_weights(wte.embeddings().clone(), None)
        } else {
            linear(cfg.hidden_size, cfg.vocab_size, vb.pp("lm_head"))?
        };
        // ...

绑定时 lm_head 用的是词嵌入张量的 clone(),Tensor 的 clone 只加一个引用计数(引用与不可变),两者是同一份数据,和 PyTorch 的同一个 Parameter 对应。文件里有没有 lm_head.weight 都不看。

📝NOTE
冷知识:SmolLM2 这种模型曾经加载不了

PR #2508(「Add some llama-3.2 examples.」,首次进入 0.7.2)之前,Llama::load 只有一行 let lm_head = linear(cfg.hidden_size, cfg.vocab_size, vb.pp("lm_head"))?;,总是去文件里要 lm_head.weight,LlamaConfig 里也没有 tie_word_embeddings 字段。输出层和词嵌入绑定、文件里没有 lm_head.weight 的模型就报 cannot find tensor lm_head.weight。这个 PR 的提交说明里有一条「Support tie-word-embeddings for llama.」:加了字段,into_config 里用 unwrap_or(false) 补默认值,load 改成上面的 if。这个默认值留到了今天,所以手写配置漏了这个字段,又回到「去要 lm_head.weight」(cannot find tensor)。

反过来,save_model 存的那个文件没有 model.embed_tokens.weight,词嵌入那一步就找不到,用 rename_f 指过去:

let vb = vb.rename_f(|name: &str| match name {
  "model.embed_tokens.weight" => "lm_head.weight".to_string(),
  _ => name.to_string(),
});
as is: Some("cannot find tensor model.embed_tokens.weight")
with rename_f: ok
logits                   n=49152  max|diff|=2.98e-05
worst=2.98e-05 tol=1e-04 OK
❗IMPORTANT
动脑筋:不用配置里的绑定,而是把 tie_word_embeddings 设成 false,再用 rename_f 把 lm_head.weight 指到 model.embed_tokens.weight。logits 一样吗?CPU 上 F32 加载的峰值 RSS 比用配置绑定(841 MiB)多多少?

缺键、多余的键、形状不对

PyTorch 的 load_state_dict 默认 strict=True,三类问题一次列全。下面的字典删掉了 model.layers.3.mlp.up_proj.weight、改名成 model.layers.3.mlp.up.weight,再把 k_proj 换成一个 [576, 576] 的张量:

sd = load_file(f"{model_dir}/model.safetensors")  # 272 个键,没有 lm_head.weight
bad = dict(sd)
del bad["model.layers.3.mlp.up_proj.weight"]
bad["model.layers.3.mlp.up.weight"] = sd["model.layers.3.mlp.up_proj.weight"]
bad["model.layers.0.self_attn.k_proj.weight"] = torch.zeros(576, 576, dtype=torch.bfloat16)
try:
  model.load_state_dict(bad)
except RuntimeError as e:
  print("strict:", e)
r = model.load_state_dict({k: v for k, v in bad.items() if k != "model.layers.0.self_attn.k_proj.weight"}, strict=False)
print("strict=False:", r)
strict: Error(s) in loading state_dict for LlamaForCausalLM:
	Missing key(s) in state_dict: "model.layers.3.mlp.up_proj.weight", "lm_head.weight". 
	Unexpected key(s) in state_dict: "model.layers.3.mlp.up.weight". 
	size mismatch for model.layers.0.self_attn.k_proj.weight: copying a param with shape torch.Size([576, 576]) from checkpoint, the shape in current model is torch.Size([192, 576]).
strict=False: _IncompatibleKeys(missing_keys=['model.layers.0.self_attn.k_proj.weight', 'model.layers.3.mlp.up_proj.weight', 'lm_head.weight'], unexpected_keys=['model.layers.3.mlp.up.weight'])

lm_head.weight 也被列为缺失:load_state_dict 只比键,不知道绑定,绑定是 from_pretrained 做的。from_pretrained 本身对缺键更宽容,读一个少了 up_proj 的文件(PyTorch 的输出里有加粗用的终端控制符,这里去掉了):

[transformers] LlamaForCausalLM LOAD REPORT from: ./missing
Key                               | Status  | 
----------------------------------+---------+-
model.layers.3.mlp.up_proj.weight | MISSING | 

Notes:
- MISSING:	those params were newly initialized because missing from the checkpoint. Consider training on your downstream task.
up_proj after load: mean -2.085178675770294e-05 std 0.04164252057671547

模型照样建出来,这个权重按 initializer_range(0.0417)随机初始化(没设种子,每次运行的值不同),标准差 0.0416,接着就能生成,只是输出是错的。

candle 没有 strict 这个开关,缺一个就停,停在第一个:

--- missing

thread 'main' (9059046) panicked at .../candle-transformers-0.11.0/src/models/llama.rs:524:75:
called `Result::unwrap()` on an `Err` value: cannot find tensor model.layers.3.mlp.up_proj.weight
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
--- errors
k_proj.weight: Err: shape mismatch for model.layers.0.self_attn.k_proj.weight, expected: [576, 576], got: [192, 576]
q_proj.weght: Err: cannot find tensor model.layers.0.self_attn.q_proj.weght
  • 缺的键在某一层里,Llama::load 的逐层加载是 unwrap(),进程 panic(退出码 101);词嵌入、norm、lm_head 缺了才是 Err。为什么是 panic、服务里怎么兜住,见 cannot find tensor。
  • 形状不对是 shape mismatch for <名字>, expected: <模型要的>, got: <文件里的>,和 PyTorch 的 size mismatch 一样给出两个形状,顺序相反:PyTorch 先说文件、后说模型。
  • 每个报错只有一个名字,不提示近似的名字;burn 会列出全部缺失和未用的键,还给「Did you mean」(导入 PyTorch 权重)。

自己找多余的键

candle 也不报 Unexpected key(s):文件里多出来的张量没人要,就一直不读。rename_f 的闭包每次请求都会经过,正好用来记账,再和文件里的名字做差集:

let asked = Arc::new(Mutex::new(BTreeSet::new()));
let log = asked.clone();
let vb = base.rename_f(move |name: &str| {
  if let Ok(mut s) = log.lock() {
    s.insert(name.to_string());
  }
  name.to_string()
});
let _model = Llama::load(vb, &cfg)?;
let asked = asked.lock().map_err(|_| anyhow::anyhow!("poisoned"))?;
let unused: Vec<_> = in_file.difference(&asked).collect();
let missing: Vec<_> = asked.difference(&in_file).collect();

in_file 对 safetensors 来自 MmapedSafetensors::tensors(),对 .pt 来自 pickle::read_pth_tensor_info:

model.safetensors: in file 272, requested 272, unused [], missing []
sd_zip.pt: in file 273, requested 272, unused ["lm_head.weight"], missing []

torch.save 存的 state_dict 多一个 lm_head.weight,绑定时没人要它,这是预期的;如果 unused 里出现 up.weight 这种名字,就是改名没改到。闭包要求 Fn + Send + Sync + 'static,所以用 Arc<Mutex<..>> 而不是一个 &mut Vec。

动脑筋答案

tie_config: loaded
logits                   n=49152  max|diff|=2.98e-05
worst=2.98e-05 tol=1e-04 OK
tie_rename: loaded
logits                   n=49152  max|diff|=2.98e-05
worst=2.98e-05 tol=1e-04 OK
tie_config 841.3 MiB
tie_config 841.5 MiB
tie_config 843.6 MiB
tie_rename 949.4 MiB
tie_rename 949.4 MiB
tie_rename 949.3 MiB
  • logits 一样:两种写法和 transformers 都差 2.98e-5,lm_head 的值是同一组数。
  • 峰值多 108 MiB,三次都一样。tie_word_embeddings 为 false 时,Llama::load 走 linear(.., vb.pp("lm_head")),rename_f 把名字指到词嵌入,VarBuilder 又从文件里读一遍、转一遍 F32,得到一份独立的张量:49152 × 576 × 4 字节 = 113246208 字节,正好 108 MiB。
  • rename_f 只改名字,不知道两个名字该共享一份数据;共享要在模型代码里写(Linear::from_weights(wte.embeddings().clone(), None))。

小结

  • rename_f 的闭包把模型要的完整名字翻译成文件里的名字:torch.compile 的 _orig_mod.、DDP 的 module.、save_model 只留下的 lm_head.weight 都这样处理。要在根 VarBuilder 上调用,pp 之后再 rename_f 前缀会拼两次。
  • 绑定权重:PyTorch 是同一个 Parameter、state_dict 里两个键;candle 的 Llama::load 按 tie_word_embeddings 用词嵌入张量的 clone(),共享一份。用 rename_f 指过去也能算对,但多读一份(SmolLM2 F32 多 108 MiB)。
  • PyTorch strict=True 一次列出缺失、多余和形状不对的全部键;from_pretrained 缺键时随机初始化并打一份报告。candle 停在第一个:cannot find tensor <名字> 或 shape mismatch for <名字>, expected: .., got: ..,在 Llama::load 的层里是 panic。
  • candle 不报多余的键;用记账的 rename_f 和文件里的名字做差集。

💬 关联讨论

发起讨论
加载中...