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_varmap | VarMap | 名字已存在就返回同一个 Var;不存在就按 hints 新建 | 新建时用 | 只在名字已存在时比 |
from_mmaped_safetensors(unsafe) | MmapedSafetensors | mmap 的文件,取时复制到设备 | 忽略 | to_dtype 之后比 |
from_buffered_safetensors / from_slice_safetensors | BufferedSafetensors / SliceSafetensors | 内存里的整份文件 | 忽略 | 同上 |
from_npz | NpzTensors | 每次取都重新打开 zip,读到 CPU,再 to_device | 忽略 | 同上 |
from_pth / from_pth_with_state | PthTensors | 同上(PyTorch 的 zip 格式 pickle) | 忽略 | 同上 |
from_tensors | HashMap<String, Tensor> | 已有的张量 | 忽略 | to_device 之前比 |
zeros | Zeros | 每次新建全零 | 忽略 | 不比,任何名字都「存在」 |
推理最常用的是 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,也不报错。
问: 自己写的层用 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,两个都改。
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,没有只改一半的问题。
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。