Rust in Everything
基于 burn 0.22.1

HashMap<String, Linear>: Module is not satisfied

主线位置:训练主线的起点,定义模型:子模块要被 #[derive(Module)] 认出来,参数才会进 backward、optimizer step 和存取。

版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备,autodiff)
flowchart TB
  dict["nn.ModuleDict"] --> hm["HashMap 字段"]
  hm --> e["E0277"]
  e -->|"照 note 加 skip"| skip["num_params 0"]
  skip --> silent["不训练 不加载"]
  dict -->|"具名字段"| named["heads.cls.weight"]
  dict -->|"Vec 加名字表"| vec["heads.0.weight 要改键名"]

实验代码:PyTorch 侧 dict.py、dict_plain.py,burn 侧 crate pitb-dict 的 bin dict_err、dict(burn =0.22.1,feature std、flex、autodiff、optim、store、safetensors,debug)。平台 M2 Max、macOS 27、rustc 1.99.0。

PyTorch 的写法

多任务模型按名字放几个输出头,PyTorch 用 nn.ModuleDict:

class Heads(nn.Module):
  def __init__(self):
    super().__init__()
    self.heads = nn.ModuleDict({"cls": nn.Linear(8, 3), "reg": nn.Linear(8, 1)})

  def forward(self, x):
    return {name: head(x) for name, head in self.heads.items()}
heads.cls.weight   [3, 8]
heads.cls.bias     [3]
heads.reg.weight   [1, 8]
heads.reg.bias     [1]
num params = 36
saved keys: ['heads.cls.bias', 'heads.cls.weight', 'heads.reg.bias', 'heads.reg.weight']

dict.py 用确定的数值填参数,把 state_dict 存成 heads.safetensors,下面 burn 侧读的就是它。用 ModuleDict 而不是普通 dict,是因为普通 dict 里的层不会被登记(dict_plain.py):

plain dict: num params = 0 | state_dict keys = []

搬到 burn 的现象

直译成 HashMap(bin dict_err):

#[derive(Module, Debug)]
struct Heads {
  heads: HashMap<String, Linear>,
}
error[E0277]: the trait bound `HashMap<String, burn::nn::Linear>: Module` is not satisfied
 --> src/bin/dict_err.rs:5:10
  |
5 | #[derive(Module, Debug)]
  |          ^^^^^^ the trait `Module` is not implemented for `HashMap<String, burn::nn::Linear>`
  |
  = note: If `HashMap<String, burn::nn::Linear>` is used as configuration or runtime state in a `#[derive(Module)]` type, mark that field with `#[module(skip)]`.
  = help: the following other types implement trait `Module`:
            (L0, L1)
            (L0, L1, L2)
...
          and 124 others
...
error: could not compile `pitb-dict` (bin "dict_err") due to 10 previous errors

编译器给的路只有一条:#[module(skip)]。照做之后能编译,能 forward,能 backward,什么都不报(bin dict 的 skip 模式):

#[derive(Module, Debug)]
struct SkipHeads {
  #[module(skip)]
  heads: HashMap<String, Linear>,
}

let mut model = SkipHeads { heads: HashMap::from([("cls".to_string(), cls), ("reg".to_string(), reg)]) };
println!("num_params = {}", model.num_params());
match model.load_from(&mut store()) { // 读 heads.safetensors,带 PyTorchToBurnAdapter
  Ok(r) => println!("load: Ok\n{r}"),
  Err(e) => println!("load: Err\n{e}"),
}
let before = model.heads["cls"].weight.val().detach();
let x = input(&device);
let loss = model.heads["cls"].forward(x.clone()).mean() + model.heads["reg"].forward(x).mean();
let grads = GradientsParams::from_grads(loss.backward(), &model);
println!("GradientsParams::len() = {}", grads.len());
let model = optim.step(0.1, model, grads);
// 打印 cls 的权重变没变,以及 valid() 之后还在不在 autodiff 上
num_params = 0
load: Ok
┌─ Tensor Loading Summary ─────────────────────────
│
│ ✓ Successfully applied: 0 tensors
│ ⊘ Skipped (filtered):  0 tensors
│ ✗ Missing in source:    0 tensors
│ ? Unused in target:     4 tensors
│ ! Errors:               0 errors
│
├─ Unused Tensors (in source but not used by model)
│
│  First 10 unused tensors:
│    • heads.cls.bias
│    • heads.cls.weight
│    • heads.reg.bias
│    • heads.reg.weight
│
└───────────────────────────────────────────────────
GradientsParams::len() = 0
heads[cls].weight unchanged after step: true
after valid(): heads[cls].weight is_autodiff = true

四件事同时发生,没有一个报错:参数个数是 0;读 PyTorch 的权重返回 Ok,一个张量都没用上,两个头还是随机初始化;训练一步没有梯度、权重不动;valid() 之后这两层还在 autodiff 设备上。这和 PyTorch 普通 dict 的「参数 0 个」是同一个坑,区别是 PyTorch 那边有 ModuleDict 可换,burn 这边编译器把人引向了它。

原因

#[derive(Module)] 把认不出的字段类型都当子模块,要求它实现 Module;HashMap 没有实现。0.22.1 里实现了 Module 的容器是 Option<T>、Vec<T>、[T; N] 和元组,没有任何按名字索引的容器。origin/main 在同一个文件里加了 Box<T>(#6059),仍然没有 HashMap / BTreeMap。

E0277 的 note 来自 Module 上的 #[diagnostic::on_unimplemented]。它对所有没实现 Module 的字段说同一句话,写的是配置和运行状态的情形(burn 卷the trait bound X: Module is not satisfied讲的就是那种),不区分字段里装的是不是层。

skip 字段在派生代码里是透明的:num_params、visit、map 不进去(所以没有参数、优化器和 burn-store 看不见它),gen_valid 对它只生成 self.#name.clone(),to_device、train 原样搬过去。所以 valid() 之后里面的 Linear 还在 autodiff 上(上面输出的最后一行),换设备也不会跟着走。

加载返回 Ok 是因为 burn-store 默认只把「模型要、文件里没有」算错(Missing in source);文件里多出来的(Unused in target)只记在报告里。机制见导入 PyTorch 权重。

改法

每个头一个具名字段

头的名字在写代码时就知道,就写成结构体字段。路径是字段名用 . 连起来,heads.cls.weight 和 ModuleDict 的键一模一样,PyTorch 的权重直接能读:

#[derive(Module, Debug)]
struct Named {
  cls: Linear,
  reg: Linear,
}

#[derive(Module, Debug)]
struct NamedHeads {
  heads: Named,
}
num_params = 36
load: Ok
┌─ Tensor Loading Summary ─────────────────────────
│
│ ✓ Successfully applied: 4 tensors
│ ⊘ Skipped (filtered):  0 tensors
│ ✗ Missing in source:    0 tensors
│ ? Unused in target:     0 tensors
│ ! Errors:               0 errors
│
└───────────────────────────────────────────────────

valid() 之后对同一个输入前向,和 PyTorch 的输出比:

cls                      n=  6  max|diff|=0.00e+00
reg                      n=  2  max|diff|=1.49e-08
worst=1.49e-08 tol=1e-06 OK

reg 的 1.49e-8 是 8 项点积的求和顺序不同(为什么不是 0 见怎么读这一卷)。

名字在运行期才知道:Vec 加名字表

头的个数和名字来自配置,用 Vec<Linear> 装层,名字另存一张表,表本身标 skip(它确实是配置):

#[derive(Module, Debug)]
struct VecHeads {
  heads: Vec<Linear>,
  #[module(skip)]
  names: Vec<String>,
}

Vec 的路径用下标,heads.0.weight,和 ModuleDict 的 heads.cls.weight 对不上。直接读:

load: Err
Tensor not found: 
┌─ Tensor Loading Summary ─────────────────────────
│
│ ✓ Successfully applied: 0 tensors
│ ⊘ Skipped (filtered):  0 tensors
│ ✗ Missing in source:    4 tensors
│ ? Unused in target:     4 tensors
│ ! Errors:               0 errors
│
├─ Missing Tensors (requested by model but not found in source)
│
│  First 10 missing tensors:
│    • heads.0.bias
│        Did you mean: 'heads.reg.bias'?
│    • heads.0.weight
│        Did you mean: 'heads.cls.weight'?
...

这回是硬错误,而且「Did you mean」把 heads.0.bias 猜成了 heads.reg.bias,按相似度猜的建议不能照抄。按名字表的顺序写键名映射,with_key_remapping:

let mut st = store()
  .with_key_remapping(r"^heads\.cls\.", "heads.0.")
  .with_key_remapping(r"^heads\.reg\.", "heads.1.");
num_params = 36
load: Ok
...
│ ✓ Successfully applied: 4 tensors
...
cls                      n=  6  max|diff|=0.00e+00
reg                      n=  2  max|diff|=1.49e-08
worst=1.49e-08 tol=1e-06 OK

forward 里按名字取下标:model.heads[model.names.iter().position(|n| n == "cls")...]。

怎么检查

  • 定义完模型先打印 num_params(),和 PyTorch 的 sum(p.numel() for p in model.parameters()) 比(BatchNorm 会多算运行统计,见参数与遍历)。
  • 只在装配置或运行状态的字段上用 #[module(skip)];字段类型里出现 Linear、Conv2d 这类层,或者 Param、Tensor,就不该 skip。
  • 读完权重打印 load_from 返回的报告,Unused in target 不是 0 就说明有权重没用上,Ok 不代表读进去了。
  • 训练第一步打印 GradientsParams::len(),和参数张量的个数比。

小结

  • burn 0.22.1 没有 ModuleDict 的对应,HashMap<String, Linear> 字段报 E0277,note 建议 #[module(skip)]。
  • 照做后 num_params 为 0,读权重返回 Ok 但 4 个张量都没用上,训练不更新,valid() 不把它移出 autodiff。
  • 名字固定:每个头一个具名字段,键名和 PyTorch 相同,输出最大差 1.49e-8。
  • 名字来自配置:Vec<Linear> 加 #[module(skip)] names,加载时用 with_key_remapping 把 heads.cls. 映射成 heads.0.。

💬 关联讨论

发起讨论
加载中...