Rust in Everything
基于 candle-core 0.11.0

VarBuilder

主线位置:推理主线里 safetensors 那条路的第一步。candle-transformers 的模型构造函数(例如 llama::Llama::load(vb, &cfg))不读文件,只向传进来的 VarBuilder 要「名叫 model.layers.0.self_attn.q_proj.weight、形状 (576, 576) 的张量」。本页讲这个请求怎么拼出完整的名字、交给哪个后端、在什么情况下报错。文件本身怎么读、要复制几次,在 safetensors 与 mmap;训练时 VarMap 怎么和优化器接上,在优化器与训练循环。

flowchart TB
  model["模型构造函数"] -->|"pp 和 get"| vb["VarBuilderArgs"]
  vb -->|"完整名字 形状 hints dtype"| be["Box dyn SimpleBackend"]
  be --> vm["VarMap 没有就新建"]
  be --> st["MmapedSafetensors"]
  be --> zip["npz 或 pth"]
  be --> map["HashMap 或 Zeros"]
  st --> chk["to_dtype 后比形状"]
  zip --> chk
  map --> chk
  chk --> t["Tensor"]
  vm --> t

本页的实验是 nn-exp 的 vb(debug),读的是本地 SmolLM2-135M-Instruct 的 model.safetensors,平台 M2 Max、macOS 27、rustc 1.99.0。

两层:VarBuilderArgs 和后端

VarBuilderArgs 只有四个字段:

pub struct VarBuilderArgs<'a, B: Backend> {
    data: Arc<TensorData<B>>,
    path: Vec<String>,
    pub dtype: DType,
    _phantom: std::marker::PhantomData<&'a B>,
}

data 里是后端、设备和一个 dtype,整个模型共用一份(Arc);path 是当前前缀,每个子模块各有一份。所以 clone 和 pp 都很便宜:克隆一个 Arc,再复制几段短字符串。

后端有两个 trait。Backend 带一个关联类型 Hints,表示「取张量时除了名字和形状还能附带什么」;SimpleBackend 把 hints 固定成 crate::Init(初始化方式),因此可以做成 trait 对象。用户平时用的 VarBuilder 就是:

pub type VarBuilder<'a> = VarBuilderArgs<'a, Box<dyn SimpleBackend + 'a>>;

Box<dyn SimpleBackend> 实现了 Backend<Hints = Init>,于是各种后端换来换去,模型代码的类型不变。需要别的 hints 的只有一个:ShardedSafeTensors 的 Hints 是 Shard { dim, rank, world_size },张量并行时每个进程只读自己那一块(ShardedVarBuilder 是另一个类型)。两层 trait 就是为它留的。

前缀:pp

push_prefix 复制 path 再追加一段,pp 是它的别名。取张量时 path 用 . 把前缀和名字接起来。前缀只是字符串,pp(0) 里的整数经 ToString 变成 "0":

  let attn = vb.pp("model").pp("layers").pp(0).pp("self_attn");
  println!("prefix = {:?}", attn.prefix());
  let q = attn.get((576, 576), "q_proj.weight")?;
  let k = attn.get((192, 576), "k_proj.weight")?;
prefix = "model.layers.0.self_attn"
q_proj [576, 576] F32, k_proj [192, 576]
contains model.embed_tokens.weight = true
contains lm_head.weight = false

k_proj 是 (192, 576):SmolLM2 有 9 个查询头、3 个键值头(分组查询注意力,GQA),每头 64 维。文件里没有 lm_head.weight,config.json 写着 "tie_word_embeddings": true,输出层复用 embed_tokens,模型代码要自己处理这件事。

get 一路到后端

get 用默认 hints 调 get_with_hints,再到 get_with_hints_dtype,最后是 backend.get(shape, 完整名字, hints, dtype, device)。VarBuilderArgs 自己不检查任何东西,名字找不到、形状不对,都是后端判断。各后端做的事:

构造函数后端数据从哪来hints形状检查
from_varmapVarMap名字已存在就返回同一个 Var;不存在就按 hints 新建新建时用只在名字已存在时比
from_mmaped_safetensors(unsafe)MmapedSafetensorsmmap 的文件,取时复制到设备忽略to_dtype 之后比
from_buffered_safetensors / from_slice_safetensorsBufferedSafetensors / SliceSafetensors内存里的整份文件忽略同上
from_npzNpzTensors每次取都重新打开 zip,读到 CPU,再 to_device忽略同上
from_pth / from_pth_with_statePthTensors同上(PyTorch 的 zip 格式 pickle)忽略同上
from_tensorsHashMap<String, Tensor>已有的张量忽略to_device 之前比
zerosZeros每次新建全零忽略不比,任何名字都「存在」

推理最常用的是 mmap 的 safetensors,它的实现(impl SimpleBackend for MmapedSafetensors)只有几行:

    fn get(
        &self,
        s: Shape,
        name: &str,
        _: crate::Init,
        dtype: DType,
        dev: &Device,
    ) -> Result<Tensor> {
        let tensor = self.load(name, dev)?.to_dtype(dtype)?;
        if tensor.shape() != &s {
            Err(candle::Error::UnexpectedShape {
                msg: format!("shape mismatch for {name}"),
                expected: s,
                got: tensor.shape().clone(),
            }
            .bt())?
        }
        Ok(tensor)
    }

顺序是先把整个张量复制到设备、转 dtype,再比形状。形状写错时,这次复制和转换白做了;文件头里其实已经有形状,不读数据也能比。contains_tensor 对 safetensors 只查头部(self.get(name).is_ok(),拿到的是 TensorView),对 npz 和 pth 却是把整个张量读出来再看是不是 Some。

和 burn 对照一句:burn 的模块由派生宏生成参数遍历,加载时按模块路径把文件里的条目对回去(见 burn 的存储与加载);candle 的模型没有派生,构造函数一个一个按名字去拉,名字就是前缀拼出来的字符串。

hints:只在新建变量时有用

hints 的类型是 Init:常数、正态、均匀、Kaiming。从文件读的后端都忽略它(参数名是 _)。只有 VarMap 在名字不存在时用它新建 Var。层的构造函数各自给 hints:linear 的权重是 DEFAULT_KAIMING_NORMAL、偏置是 ±1/√in 的均匀分布,embedding 是标准正态,layer_norm 的权重和偏置是常数 1 和 0。vb.get 不带 hints,用的是 Init 的 Default,也就是 Const(0.):

  let vm = VarMap::new();
  let vbm = VarBuilder::from_varmap(&vm, DType::F32, &dev);
  let z = vbm.get(3, "plain")?;
  let c = vbm.get_with_hints(3, "const", Init::Const(0.5))?;
  let again = vbm.get_with_hints(3, "const", Init::Const(9.0))?;
  let other_dtype = vbm.to_dtype(DType::F16).get(3, "const")?;
  let err = vbm.get(4, "const").unwrap_err();
varmap get (default hint) = [0.0, 0.0, 0.0]
varmap get_with_hints Const(0.5) = [0.5, 0.5, 0.5]
second request, other hint = [0.5, 0.5, 0.5]
second request, F16 builder -> dtype F32
--- varmap shape mismatch:
shape mismatch on const: [4] <> [3]
linear(2, 3) weight [3, 2] bias Some([3])
varmap names = ["const", "fc.bias", "fc.weight", "plain"]

第二次要同一个名字时,VarMap::get 只比形状,hints 和 dtype 都不看,返回第一次建的那个变量。所以同一个名字被两处代码以不同 dtype 请求时,后一处拿到的不是它要的 dtype,也不报错。

📝NOTE
没有蠢问题

问: 自己写的层用 vb.get(shape, "w") 取权重,加载 safetensors 推理一切正常,换成 VarMap 训练却怎么也训不动,为什么?

答: 推理时后端从文件读,hints 被忽略;训练时 VarMap 用 hints 新建变量,vb.get 的默认 hints 是 Const(0.),权重全是 0。全零的权重让同一层所有输出单元收到一样的梯度,对称性打不破。要像 linear 那样用 get_with_hints 给一个随机初始化。

报错原文

名字少了 model 前缀,以及 k_proj 按 q_proj 的形状去要:

--- wrong name, Display:
cannot find tensor layers.0.self_attn.q_proj.weight
--- wrong name, Debug:
cannot find tensor layers.0.self_attn.q_proj.weight
--- wrong shape, Display:
shape mismatch for model.layers.0.self_attn.k_proj.weight, expected: [576, 576], got: [192, 576]
--- from_tensors missing:
cannot find tensor x

找不到时报的是 Error::CannotFindTensor,消息里是拼好的完整名字,不提示相近的名字,也不列出文件里有什么;要知道文件里的名字,得自己遍历 MmapedSafetensors::tensors()(下一页的实验就是这样做的)。这些错误都经过 .bt(),没设 RUST_BACKTRACE 时不带调用栈,Debug 和 Display 一样,见错误。

名字对不上的另一种处理方式是改名:rename_f 把当前 VarBuilder 包进一个 Rename 后端,每次请求先把名字过一遍闭包,再交给里面那个 VarBuilder。

dtype 存了两份

VarBuilderArgs 上有 dtype,TensorData 里也有 dtype。get 用前一个,get_unchecked(不给形状、不检查形状)用后一个:

    pub fn get_unchecked(&self, name: &str) -> Result<Tensor> {
        self.get_unchecked_dtype(name, self.data.dtype)
    }

改 dtype 有两个方法:to_dtype 只改前一个(data 原样共享),set_dtype 新建一份 TensorData,两个都改。

❗IMPORTANT
动脑筋:vb 用 DType::F32 建在 SmolLM2 的 safetensors 上。vb.to_dtype(DType::F16) 得到 vb16,vb16.get(..) 和 vb16.get_unchecked(..) 取同一个 model.embed_tokens.weight,两个结果各是什么 dtype?换成 vb.clone().set_dtype(DType::F16) 呢?

设备只有一份:set_device 新建一份 TensorData,get 和 get_unchecked 用的都是 data.device,没有只改一半的问题。

📝NOTE
冷知识:第二份 dtype 是给 get_unchecked 加的

PR #3157(「Add varbuilder get_unchecked methods」,首次进入 0.9.2-alpha.2,正式版 0.10.0)给 Backend 和 SimpleBackend 各加了一个必须实现的方法 get_unchecked,给 TensorData 加了 dtype 字段,把 backend 改成 Arc<B>,并新增 set_device、set_dtype(提交信息里只有这两条:「Add varbuilder get_unchecked methods」「Add set_device and set_dtype」)。在那之前 TensorData 只有后端和设备,dtype 只有 VarBuilderArgs 上那一个。get_unchecked 读的是新字段,而已有的 to_dtype 没有跟着改。另一个后果:在 0.9 上自己实现了 SimpleBackend 的代码,升到 0.10 要补上这个方法才能编译。

动脑筋答案

  let vb16 = vb.to_dtype(DType::F16);
  let a = vb16.get((49152, 576), "model.embed_tokens.weight")?;
  let b = vb16.get_unchecked("model.embed_tokens.weight")?;
  println!("to_dtype(F16): get -> {:?}, get_unchecked -> {:?}", a.dtype(), b.dtype());
  let vb16s = vb.clone().set_dtype(DType::F16);
  let b = vb16s.get_unchecked("model.embed_tokens.weight")?;
  println!("set_dtype(F16): get_unchecked -> {:?}", b.dtype());
to_dtype(F16): get -> F16, get_unchecked -> F32
set_dtype(F16): get_unchecked -> F16
  • to_dtype 之后 get 是 F16,get_unchecked 仍是 F32:它读的是共享的 data.dtype,to_dtype 没动它。
  • set_dtype 两份都改,get_unchecked 才是 F16。
  • 这个不一致看起来是疏漏而不是设计:两个方法的文档注释都说是「tweaking its dtype」/「Set the dtype」,没有提到 get_unchecked。用 get_unchecked 的代码改 dtype 时用 set_dtype。

小结

  • VarBuilder 是 VarBuilderArgs<Box<dyn SimpleBackend>>:共享的后端、设备、dtype,加上每个子模块自己的前缀;pp 只是拼字符串。
  • 名字、形状、dtype、设备一起交给后端,后端决定从哪取、检查什么;从文件读的后端先复制并转 dtype,再比形状。
  • hints 只有 VarMap 新建变量时用,vb.get 的默认是全零;同名的第二次请求不看 hints 和 dtype。
  • 找不到报 cannot find tensor <完整名字>,形状不对报 shape mismatch for <名字>, expected: .., got: ..,不提示候选名字。
  • dtype 存了两份,to_dtype 只改一份,get_unchecked 读另一份;要两者一致用 set_dtype。

💬 关联讨论

发起讨论
加载中...