Rust in Everything
基于 candle 0.11.0

rename_f 之后前缀出现两次

主线位置:推理主线第二步「建模块」,手写一个子模块,权重文件的键名带着 torch.compile 的 _orig_mod. 前缀。

版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate)
sequenceDiagram
  participant M as Mlp load
  participant O as 外层 VarBuilder
  participant F as 闭包
  participant I as 里层 VarBuilder
  M->>O: get gate_proj
  O->>F: 拼上前缀后的全名
  F->>I: 加上 _orig_mod.
  I->>I: 再拼一次前缀
  I-->>M: cannot find tensor

实验代码:PyTorch 侧 make_compiled.py,candle 侧 crate pitb-exp 的 bin p18_prefix(release)。权重是本地的 SmolLM2-135M-Instruct。平台 M2 Max、macOS 27、rustc 1.99.0。rename_f 的一般用法见键名对不上。

PyTorch 的写法

torch.compile 包过的模型,state_dict 的每个键都多了 _orig_mod.。存下来,再在 PyTorch 里读回:

compiled = torch.compile(model)
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"})
# PyTorch 的习惯:在字典上改键名,然后整个模型 load_state_dict
fresh = AutoModelForCausalLM.from_pretrained(model_dir, dtype=torch.float32).eval()
r = fresh.load_state_dict({k.removeprefix("_orig_mod."): v for k, v in sd.items()}, strict=False)
x = torch.linspace(-1, 1, 576).reshape(1, 576)
y = fresh.model.layers[0].mlp(x)
keys: ['_orig_mod.model.embed_tokens.weight'] ... 272
load_state_dict: _IncompatibleKeys(missing_keys=['lm_head.weight'], unexpected_keys=[])
layer0 mlp y[:4]: [-80.592041, -6.956567, 60.557873, 40.253838]

(lm_head.weight 列为缺失是因为它和词嵌入绑定、存的时候去掉了。)PyTorch 里改名发生在字典上,和模块的层级无关:键名是一个完整的字符串,removeprefix 一次就完事。

搬到 candle 的现象

candle 里改名是在 VarBuilder 上挂一个闭包。只搬第 0 层的 MLP,先 pp 到这一层,再挂上「加 _orig_mod.」的闭包,看起来很自然:

let add_prefix = move |name: &str| {
  log.lock().map(|mut s| s.push(name.to_string())).ok();
  format!("_orig_mod.{name}")
};
let vb = root.pp("model.layers.0.mlp").rename_f(add_prefix.clone());
println!("pp then rename_f: {:?}", Mlp::load(vb).err().map(|e| e.to_string()));
println!("closure saw: {:?}", seen.lock().map_err(|_| anyhow!("poisoned"))?.last());

Mlp::load 里是三个 linear_no_bias(.., vb.pp("gate_proj")) 之类。输出:

pp then rename_f: Some("cannot find tensor model.layers.0.mlp._orig_mod.model.layers.0.mlp.gate_proj.weight")
closure saw: Some("model.layers.0.mlp.gate_proj.weight")

闭包收到的是完整、正确的名字,返回的是 _orig_mod.model.layers.0.mlp.gate_proj.weight,正好是文件里的键;真正去查的却在前面又多了一个 model.layers.0.mlp.。

原因

VarBuilder::rename(rename_f 只是把闭包装箱后调它)把当前的 VarBuilder 整个包进 Rename 后端,新的 VarBuilder 又复制了同一个前缀 path:

    pub fn rename<R: Renamer + Send + Sync + 'a>(self, renamer: R) -> Self {
        let dtype = self.dtype();
        let device = self.device().clone();
        let path = self.path.clone();
        let backend = Rename::new(self, renamer);
        // ...
        Self {
            data: Arc::new(data),
            dtype,
            path,
            _phantom: std::marker::PhantomData,
        }

Rename 的 get 拿到外层拼好的全名,过一遍闭包,再交给里面那个 VarBuilder 的 get_with_hints_dtype,而里面那个还带着 model.layers.0.mlp 这个前缀,又拼一次:

        let name = self.renamer.rename(name);
        self.inner
            .get_with_hints_dtype(s, &name, h, dtype)?
            .to_device(dev)

在根 VarBuilder 上调用时前缀是空的,拼两次也看不出来;rename_f 的文档注释里的例子正是在根上调用的。这个行为从 rename_f 加进来(#2052)就是这样,origin/main 上 var_builder.rs 和 0.11.0 相同。我认为是上游的 bug:rename 包进去的应该是去掉前缀的 VarBuilder。

改法

顺序反过来:先在根上 rename_f,再 pp:

let vb = root.rename_f(add_prefix).pp("model.layers.0.mlp");
let mlp = Mlp::load(vb)?;
let y = mlp.forward(x)?;
rename_f then pp: y[:4]=[-80.59204, -6.956567, 60.557873, 40.253838] max|diff|=1.53e-5 (max|ref| 230.2)

和 PyTorch 的第 0 层 MLP 输出差 1.53e-5,输出最大 230.2,相对差 6.6e-8,是 f32 矩阵乘求和顺序的量级(容差按相对 1e-6)。pp 在 rename_f 之后,前缀只在外层;Rename 里包着的是根,前缀为空。

一个项目里只在一处(加载入口)对根 VarBuilder 调 rename_f,把全部改名规则写在这一个闭包里,子模块只拿 pp 过的 VarBuilder,就不会碰到这个问题。

怎么检查

  • vb.prefix() 不为空时不要调 rename_f。可以写成一个只接受根的辅助函数,在里面 assert!(vb.prefix().is_empty())。
  • 报错里的名字有一段重复(a.b.X.a.b.c)就是这个坑。闭包里记一下收到的名字(上面的 log)能确认闭包本身没错。
  • 加载完用记账的 rename_f 和文件里的键做差集,见键名对不上的「自己找多余的键」。

小结

  • PyTorch 在 state_dict 字典上改键名,和模块层级无关;candle 在 VarBuilder 上挂闭包。
  • vb.pp("x").rename_f(f) 让前缀 x 在外层和 Rename 里层各拼一次,报 cannot find tensor x.<f 的结果>,闭包收到的名字是对的。
  • 改法:root.rename_f(f).pp("x"),SmolLM2 第 0 层 MLP 和 PyTorch 差 1.53e-5(最大 230.2)。
  • origin/main 上没有改;在根上调用就碰不到。

💬 关联讨论

发起讨论
加载中...