Rust in Everything
基于 candle 0.11.0

定义一个模块

主线位置:推理主线的第二步「建模块」:config 和权重文件已经在手,按 transformers 的结构把层一个个建出来,权重对号入座。
版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB
  subgraph pt ["PyTorch"]
    init["__init__ 赋属性"] --> reg["登记子模块"]
    reg --> sd["state_dict 键"]
    file1["权重文件"] -->|"load_state_dict"| sd
  end
  subgraph cd ["candle"]
    vb["VarBuilder"] -->|"pp 拼前缀"| name["完整名字"]
    file2["权重文件"] --> vb
    name -->|"get"| st["结构体字段"]
  end
  sd --> fwd["forward"]
  st --> fwd

实验代码:PyTorch 侧 define.py、define_stages.py、seq.py、typo.py;candle 侧 crate module-exp 的 bin define、seq、typo(candle 0.11.0,features metal、accelerate,release)。模型是本地的 SmolLM2-135M-Instruct 权重,两边都载成 f32。PyTorch 固定 torch.set_num_threads(4)。平台 M2 Max、macOS 27、rustc 1.99.0。

两份同样的半层

要搬的是 SmolLM2 一个解码层的后一半:post_attention_layernorm(RMSNorm)、SwiGLU 的 MLP,再加残差。transformers 里它们是 LlamaDecoderLayer 的两个属性 post_attention_layernorm 和 mlp,MLP 里有 gate_proj、up_proj、down_proj 三个不带偏置的 nn.Linear。

PyTorch 手写一份,属性名照抄 transformers:

class Mlp(nn.Module):
  def __init__(self, hidden: int, inter: int):
    super().__init__()
    self.gate_proj = nn.Linear(hidden, inter, bias=False)
    self.up_proj = nn.Linear(hidden, inter, bias=False)
    self.down_proj = nn.Linear(inter, hidden, bias=False)

  def forward(self, x):
    return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))


class FfnHalf(nn.Module):
  def __init__(self, hidden: int, inter: int, eps: float):
    super().__init__()
    self.post_attention_layernorm = RmsNorm(hidden, eps)  # weight * x * rsqrt(mean(x²) + eps)
    self.mlp = Mlp(hidden, inter)

  def forward(self, x):
    return x + self.mlp(self.post_attention_layernorm(x))


block = FfnHalf(576, 1536, 1e-5)
prefix = "model.layers.0."
sub = {k[len(prefix):]: v for k, v in model.state_dict().items() if k.startswith(prefix)}
missing, unexpected = block.load_state_dict(sub, strict=False)

candle 一份:

use candle_core::{Module, Tensor};
use candle_nn::{linear_no_bias, rms_norm, Linear, RmsNorm, VarBuilder};

struct Mlp {
  gate_proj: Linear,
  up_proj: Linear,
  down_proj: Linear,
}

impl Mlp {
  fn load(vb: VarBuilder, hidden: usize, inter: usize) -> candle_core::Result<Self> {
    Ok(Self {
      gate_proj: linear_no_bias(hidden, inter, vb.pp("gate_proj"))?,
      up_proj: linear_no_bias(hidden, inter, vb.pp("up_proj"))?,
      down_proj: linear_no_bias(inter, hidden, vb.pp("down_proj"))?,
    })
  }
}

impl Module for Mlp {
  fn forward(&self, x: &Tensor) -> candle_core::Result<Tensor> {
    let gate = x.apply(&self.gate_proj)?.silu()?;
    (gate * x.apply(&self.up_proj)?)?.apply(&self.down_proj)
  }
}

struct FfnHalf {
  post_attention_layernorm: RmsNorm,
  mlp: Mlp,
}

impl FfnHalf {
  fn load(vb: VarBuilder, hidden: usize, inter: usize, eps: f64) -> candle_core::Result<Self> {
    Ok(Self {
      post_attention_layernorm: rms_norm(hidden, eps, vb.pp("post_attention_layernorm"))?,
      mlp: Mlp::load(vb.pp("mlp"), hidden, inter)?,
    })
  }
}

impl Module for FfnHalf {
  fn forward(&self, x: &Tensor) -> candle_core::Result<Tensor> {
    x + x.apply(&self.post_attention_layernorm)?.apply(&self.mlp)?
  }
}

let model_dir = "SmolLM2-135M-Instruct";
let path = format!("{model_dir}/model.safetensors");
let vb = unsafe { VarBuilder::from_mmaped_safetensors(&[&path], DType::F32, &dev)? };
let block = FfnHalf::load(vb.pp("model.layers.0"), 576, 1536, 1e-5)?;
let y = x.apply(&block)?;

结构是一一对上的,变的是三件事。

__init__ 变成一个返回 Result 的构造函数,它同时完成「建层」和「装权重」。 PyTorch 先用随机数建好层,再 load_state_dict 把值拷进去,两步。candle 的 linear_no_bias 直接向 VarBuilder 要一个名字和形状确定的张量,拿到的就是文件里的值;没有「先随机、再覆盖」这一步,也就没有 strict=False 这种半装好的状态:要的名字不在文件里,构造函数当场返回 Err(见下文)。

forward 变成 Module trait 的一个方法。 这个 trait 只有一个方法 fn forward(&self, xs: &Tensor) -> Result<Tensor>;Tensor::apply 就是 m.forward(self),于是可以像上面那样链式写 x.apply(&a)?.apply(&b)?。没有 __call__,也就没有挂在 __call__ 上的 hook(下一页逐层对数会碰到这一点)。闭包也实现了 Module(impl<T: Fn(&Tensor) -> Result<Tensor>> Module for T),一个激活函数不必专门写结构体。

Module 只管「一个张量进、一个张量出」。 注意力层要位置和 KV 缓存,就不实现 Module,写成普通方法。candle-transformers 自己就是这样:llama 的 Block::forward 签名是 (&self, x, index_pos, block_idx, cache)。PyTorch 的 forward(self, *args, **kwargs) 什么参数都能收,candle 里多一个参数就是改一次签名。

两边喂同一个输入:prompt The capital of France is 的 5 个 token 过 embed_tokens 之后的 [1, 5, 576],由 PyTorch 侧算好存成 safetensors,candle 读同一份。

missing []
unexpected ['self_attn.q_proj.weight', 'self_attn.k_proj.weight', 'self_attn.v_proj.weight', 'self_attn.o_proj.weight', 'input_layernorm.weight']
hand block vs transformers modules max diff 0.0
[Cpu] FfnHalf out [1, 5, 576] max_abs_diff=4.196167e-5 (max |ref|=53.606655)
[Metal(MetalDevice(DeviceId(1)))] FfnHalf out [1, 5, 576] max_abs_diff=6.67572e-6 (max |ref|=53.606655)

PyTorch 的手写版和 transformers 自己的模块逐位相同(unexpected 是这一层前一半的权重,半层用不上)。candle 和它差 4.20e-5(CPU)和 6.68e-6(Metal),输出最大 |ref| 53.6,用怎么读这一卷的容差 1e-4 都算对上。

不是 0 的原因和 logits 一样是求和顺序。分段看,再拿 PyTorch 的 f64 结果当「真值」:

f32 vs f64: norm 5.494803190231323e-08 mlp 4.567097710150847e-05 max|mlp| 53.762926948947964
[Cpu] norm vs pt32 8.34465e-7 vs pt64 8.34465e-7; mlp vs pt32 4.196167e-5 vs pt64 7.6293945e-5
[Metal(MetalDevice(DeviceId(1)))] norm vs pt32 1.1920929e-7 vs pt64 1.1920929e-7; mlp vs pt32 6.67572e-6 vs pt64 4.9591064e-5

差几乎都在 MLP 里:输出到了 50 多,f32 在这个量级的 ulp 是 3.8e-6,down_proj 是 1536 项的点积。PyTorch 自己的 f32 离 f64 就有 4.57e-5,candle CPU 7.63e-5、Metal 4.96e-5,三者是同一个量级。candle 和 PyTorch 之间的差,就是两份 f32 各自的舍入之差。

pp 拼出来的就是 state_dict 的键

PyTorch 的键名是属性名一层层用 . 接起来:self.mlp = Mlp(...)、self.gate_proj = nn.Linear(...),参数 weight,于是 mlp.gate_proj.weight。登记发生在 nn.Module.__setattr__ 里,属性名就是键名。

candle 的结构体字段名没有任何作用。名字来自构造时传给 VarBuilder 的字符串:pp 往前缀上追加一段,linear_no_bias 再要 "weight",拼起来用 . 连接。

把 FfnHalf::load 换成接一个 VarMap 的 VarBuilder(from_varmap):VarMap 对每个没见过的名字新建一个变量,构造完它里面就是模块要过的全部名字。和 PyTorch 的 state_dict() 并排:

print("block keys:", [(k, list(v.shape)) for k, v in block.state_dict().items()])
let vm = VarMap::new();
let _ = FfnHalf::load(VarBuilder::from_varmap(&vm, DType::F32, &Device::Cpu).pp("model.layers.0"), 576, 1536, 1e-5)?;
let mut names: Vec<(String, Vec<usize>)> =
  vm.data().lock().map_err(|e| anyhow::anyhow!("{e}"))?.iter().map(|(k, v)| (k.clone(), v.dims().to_vec())).collect();
names.sort();
block keys: [('post_attention_layernorm.weight', [576]), ('mlp.gate_proj.weight', [1536, 576]), ('mlp.up_proj.weight', [1536, 576]), ('mlp.down_proj.weight', [576, 1536])]
requested by FfnHalf::load: [("model.layers.0.mlp.down_proj.weight", [576, 1536]), ("model.layers.0.mlp.gate_proj.weight", [1536, 576]), ("model.layers.0.mlp.up_proj.weight", [1536, 576]), ("model.layers.0.post_attention_layernorm.weight", [576])]

四个名字、四个形状一样,candle 这边多了调用方给的前缀 model.layers.0。Linear 的权重两边都是 [out, in],不用转置(常用层)。

📝NOTE
没有蠢问题

问: candle 的结构体字段要不要和 transformers 的属性同名?

答: 不用。字段名只给 Rust 代码自己看,权重按 pp("...") 里的字符串找。把 mlp 字段改名叫 ffn,只要 pp("mlp") 不变,加载和结果都不变;反过来,字段名抄对了、pp 里写错一个字母,照样找不到权重。这一点和 PyTorch 正好相反:那边属性名就是键名,self.mlps = Mlp() 会让三个键都变成 mlps.*。

整个模型也一样。用同一个办法列出 Llama::load 要的名字,和 transformers 的 model.state_dict().keys() 比:

Llama::load asked 272 names; state_dict has 273; only in state_dict ["lm_head.weight"]; only in candle []
model.state_dict() keys 273 lm_head.weight in it True same storage as embed True

多出来的一个是 lm_head.weight。SmolLM2 的 tie_word_embeddings 是 true,transformers 的 state_dict 里 lm_head.weight 和 model.embed_tokens.weight 是同一块存储,文件里只存了一份(272 个张量);candle 的 Llama::load 在 tie_word_embeddings 为真时直接拿词嵌入当 lm_head,不去要这个名字。键名对不上时怎么改名、不绑定权重的模型怎么办,见键名对不上。

名字错了在构造时就报

PyTorch 里写错属性名(self.mlps = Mlp()),strict=False 下不报错,load_state_dict 只把问题装进返回值,三个线性层留着随机初值:

_IncompatibleKeys(missing_keys=['mlps.gate_proj.weight', 'mlps.up_proj.weight', 'mlps.down_proj.weight'], unexpected_keys=['mlp.gate_proj.weight', 'mlp.up_proj.weight', 'mlp.down_proj.weight'])

默认的 strict=True 才抛异常。candle 没有这个开关,构造函数直接失败:

match linear_no_bias(576, 1536, vb.pp("mlps").pp("gate_proj")) {
  Ok(_) => println!("loaded"),
  Err(e) => println!("load error: {e}"),
}
match linear_no_bias(1536, 576, vb.pp("mlp").pp("gate_proj")) {
  Ok(_) => println!("loaded"),
  Err(e) => println!("load error: {e}"),
}
load error: cannot find tensor model.layers.0.mlps.gate_proj.weight
load error: shape mismatch for model.layers.0.mlp.gate_proj.weight, expected: [576, 1536], got: [1536, 576]

第二个是把 in 和 out 写反了:linear_no_bias(in, out, ..) 的参数顺序和 nn.Linear(in, out) 一样,它向文件要的形状是 [out, in]。两种错误都在构造时出现,前向还没开始。报错的全部变体、名字里多一段少一段怎么查,见键名对不上和 candle 卷的常见坑 cannot find tensor。

没有登记表

PyTorch 的 nn.Module 在运行期维护一张表:named_parameters()、.to(device)、.half()、train() / eval() 都靠它遍历子模块。candle 的结构体没有这张表,也没有对应的方法:

named_parameters: ['post_attention_layernorm.weight', 'mlp.gate_proj.weight', 'mlp.up_proj.weight', 'mlp.down_proj.weight']
  • 列参数:推理时没有这个需求;训练时参数全在 VarMap 里,all_vars 交给优化器(训练一步)。自己用 Linear::new(tensor, None) 拼出来的层,不经过 VarBuilder,谁也不知道它的存在。
  • 设备和 dtype:没有 model.to("mps") / model.half()。设备和 dtype 在建 VarBuilder 时定下(上面的 DType::F32, &dev),换设备就用新的 VarBuilder 重建一遍模型。加载时转 dtype 的细节见 safetensors。
  • 训练 / 推理模式:没有 model.eval() 这个开关,要区分的层在每次调用时收一个 train: bool(常用层的 Dropout)。

和 burn 对照一句:burn 用 #[derive(Module)] 在编译期生成遍历代码,参数靠 ParamId 认,Vec<Linear> 就是子模块(定义模型)。candle 两样都没有,模块只是持有张量的结构体。

nn.Sequential 和 nn.ModuleList

candle-nn 有 seq(),对应 nn.Sequential;但名字仍要自己给。nn.Sequential 的键是下标,没有参数的 ReLU 也占一个下标:

net = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 3))
print("state_dict keys:", list(net.state_dict().keys()))
let net = seq()
  .add(linear(4, 8, vb.pp("0"))?)
  .add(Activation::Relu)
  .add(linear(8, 3, vb.pp("2"))?);
let y = net.forward(x)?;
state_dict keys: ['0.weight', '0.bias', '2.weight', '2.bias']
[[0.08920177072286606, -0.008306492120027542, 0.09059013426303864], [0.19197271764278412, -0.08750583976507187, 0.07556053251028061]]
[[0.08920177, -0.008306492, 0.090590134], [0.19197272, -0.08750584, 0.07556053]]
max_abs_diff=0e0
pp("1"): cannot find tensor 1.weight

按 0, 1, 2 顺序给第二个线性层写 pp("1") 就找不到,要跳过激活占的那个下标。这么小的网络两边结果逐位相同。nn.ModuleList 同理:键是 layers.0.*、layers.1.*,candle 里用 vb.pp("layers").pp(i) 或 vb.pp(format!("model.layers.{i}")),放进一个普通的 Vec,就像 Llama::load 做的那样。

小结

  • nn.Module 子类对一个普通结构体:__init__ 变成返回 Result 的 load(vb, ..),建层和装权重一步完成;forward 是 Module trait 的方法,x.apply(&m) 链式调用;要额外参数的层(注意力)写普通方法。
  • 键名来自 pp 的字符串,不来自字段名。SmolLM2 的半层要的 4 个名字、形状和 PyTorch 的 state_dict 相同;整个 Llama::load 要 272 个,比 state_dict 少一个绑定的 lm_head.weight。
  • 名字或形状不对,构造函数当场返回 Err;没有 strict=False 那种静默留下随机权重的状态。
  • 没有运行期登记表:没有 named_parameters()、.to()、.eval()。训练参数在 VarMap 里,设备和 dtype 在 VarBuilder 上定,模式靠每次调用传 train。
  • 手搬的半层和 transformers 差 4.20e-5(CPU)/ 6.68e-6(Metal),输出最大 |ref| 53.6;PyTorch 自己的 f32 离 f64 就有 4.57e-5。

💬 关联讨论

发起讨论
加载中...