Rust in Everything
基于 burn 0.22.1

File already exists 与 Sequential 键名

主线位置:训练主线每个 epoch 末尾的存档,以及部署主线的「加载」。两件事都发生在 burn-store 和文件之间,和计算无关。
版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备)
flowchart TB
  ep["每个 epoch 存档"] --> same["同一个文件名"]
  same --> tsave["torch.save 覆盖"]
  same --> bstore["burn-store 默认不覆盖"]
  bstore --> log["只打日志 继续"]
  log --> stale["文件停在 epoch 0"]
  seq["nn.Sequential 键"] --> gap["net.0 net.2"]
  gap --> pts["PytorchStore 默认重编号"]
  gap --> sts["SafetensorsStore 默认不动"]

实验代码:PyTorch 侧 pit_store.py(epochs、seq、readburn 模式),burn 侧 crate pitb-store-io 的 bin pit(default-features = false,feature std、flex、autodiff、optim、store、safetensors、pytorch,debug)。模型是主线的两层 MLP。平台 M2 Max、macOS 27、rustc 1.99.0。存取的基本写法见保存与加载,键名映射见导入 PyTorch 权重。

PyTorch 的写法

每个 epoch 覆盖同一个存档

训练脚本里最常见的存档写法是「最新的一份」:每个 epoch 末尾存到同一个文件名,torch.save 和 safetensors.torch.save_file 都直接覆盖。为了看出存的是哪一轮,每个 epoch 把 fc2.bias 填成 epoch 号:

model = a1_model()
for epoch in range(3):
  with torch.no_grad():
    model.fc2.bias.fill_(float(epoch))
  torch.save(model.state_dict(), f"{out}/last.pt")
  save_file(model.state_dict(), f"{out}/last.safetensors")
  print(f"epoch {epoch}: saved")
print("last.pt fc2.bias =", torch.load(f"{out}/last.pt")["fc2.bias"].tolist())
epoch 0: saved
epoch 1: saved
epoch 2: saved
last.pt fc2.bias = [2.0, 2.0, 2.0]

nn.Sequential 的键名

seq = nn.Module()
seq.net = nn.Sequential(nn.Linear(IN, HID), nn.ReLU(), nn.Linear(HID, OUT))
print("sequential keys:", list(seq.state_dict().keys()))
save_file(seq.state_dict(), f"{out}/a1_seq.safetensors")
torch.save(seq.state_dict(), f"{out}/a1_seq.pt")
sequential keys: ['net.0.weight', 'net.0.bias', 'net.2.weight', 'net.2.bias']

ReLU 没有参数,但占了下标 1,键名里有一个空洞。

搬到 burn 的现象

存档:第二个 epoch 起静默失败

照搬成 burn-store,存档失败时只打一条日志、训练继续(不想因为一次存档失败丢掉整轮训练):

let mut model = a1_model(&device);
for epoch in 0..3 {
  model = with_bias(model, epoch as f32, &device);   // fc2.bias 填成 epoch 号
  let res = model.save_into(&mut SafetensorsStore::from_file(&file));
  match res {
    Ok(()) => println!("epoch {epoch}: saved"),
    Err(e) => eprintln!("epoch {epoch}: save failed: {e}"),
  }
}
// 训练结束,读回存档
epoch 0: saved
epoch 1: save failed: File already exists: ../pitb-store-out/last_burn.safetensors. To replace it, use .overwrite(true), or AtomicFile::commit instead of commit_new.
epoch 2: save failed: File already exists: ../pitb-store-out/last_burn.safetensors. To replace it, use .overwrite(true), or AtomicFile::commit instead of commit_new.
reloaded st: fc2.bias = [0.0, 0.0, 0.0]

换成 BurnpackStore 结果一样(last_burn_store.bpk,同样的两条 File already exists,读回 [0.0, 0.0, 0.0])。训练跑完,「最新的存档」是第一个 epoch 的。错误信息说得很清楚,但它混在训练日志里;如果写成 let _ = model.save_into(..) 或 .ok(),连日志都没有。用 ? 传出去的写法反而好:第二个 epoch 就停下来。

同样的循环换成 Module::save_file,三个 epoch 都 saved,读回 [2.0, 2.0, 2.0],和 torch.save 一样。

键名:两个 store 默认相反

burn 里照着 nn.Sequential 有两种写法,加载 PyTorch 的同一组权重时,.pt 和 .safetensors 的结果正好相反。

第一种写法 net: Vec<Linear>,激活单独放(导入 PyTorch 权重用的就是它),路径是 net.0、net.1:

safetensors: Err Tensor not found: 
pt: Ok, applied ["net.0.weight", "net.0.bias", "net.1.weight", "net.1.bias"]

SafetensorsStore 的完整报错(Did you mean: 'net.2.bias'?)见那一页。

第二种写法是逐层照抄,ReLU 也占一个位置,下标和 PyTorch 一样:

#[derive(Module, Debug)]
enum Layer {
  Linear(Linear),
  Relu(Relu),
}

#[derive(Module, Debug)]
struct MirrorMlp {
  net: Vec<Layer>,   // [Linear, Relu, Linear]
}

这次两个 store 都失败,原因各不相同。PytorchStore 默认:

pt (defaults): Err
Tensor not found: 
┌─ Tensor Loading Summary ─────────────────────────
│
│ ✓ Successfully applied: 2 tensors
│ ⊘ Skipped (filtered):  0 tensors
│ ✗ Missing in source:    2 tensors
│ ? Unused in target:     2 tensors
│ ! Errors:               0 errors
│
├─ Missing Tensors (requested by model but not found in source)
│
│  ⚠️  2 paths contain enum variants (detected from container stack)
│      Burn includes enum variant names in paths, but PyTorch doesn't.
│      Example: Burn has 'field.BaseConv.weight', PyTorch has 'field.weight'
│
│      💡 Solution 1: Enable skip_enum_variants flag (simplest):
│
│         let mut store = PytorchStore::from_file("model.pth")
│             .skip_enum_variants(true);  // ← Add this
...
│  First 10 missing tensors:
│    • net.2.bias
│        Did you mean: 'net.1.bias'?
│    • net.2.weight
│        Did you mean: 'net.1.weight'?
...

模型要 net.2,文件里的 net.2 却被改成了 net.1。摘要里的第一条建议是打开 skip_enum_variants,可 PytorchStore 默认已经打开了,照做什么也不会变。SafetensorsStore(挂了 PyTorchToBurnAdapter)默认:

safetensors (defaults): Err
...
│ ✓ Successfully applied: 0 tensors
...
│  First 10 missing tensors:
│    • net.0.Linear.bias
│        Did you mean: 'net.0.bias'?
│    • net.0.Linear.weight
│        Did you mean: 'net.0.weight'?
│    • net.2.Linear.bias
│        Did you mean: 'net.2.bias'?
│    • net.2.Linear.weight
│        Did you mean: 'net.2.weight'?
...

这次下标是对的,路径里却多了枚举变体名 Linear。

反方向也有一个坑:burn 的 Vec<Linear> 挂 BurnToPyTorchAdapter 存成 safetensors,PyTorch 读进 nn.Sequential:

burn_seq_plain keys: ['net.0.bias', 'net.0.weight', 'net.1.bias', 'net.1.weight']
burn_seq_plain RuntimeError: Error(s) in loading state_dict for Module:
	Missing key(s) in state_dict: "net.2.weight", "net.2.bias". 
	Unexpected key(s) in state_dict: "net.1.bias", "net.1.weight". 

burn-store 只在加载时把空洞压掉,存的时候不会把空洞补回来。

原因

不覆盖是默认

SafetensorsStore 和 BurnpackStore 的构造函数把 overwrite 设成 false,SafetensorsStore::overwrite 和 BurnpackStore::overwrite 的文档都写着默认 false。保存时先查一次目标是否存在,存在就直接返回错误,一个张量都不读:

                if !p.overwrite && std::fs::symlink_metadata(&p.path).is_ok() {
                    return Err(pack(burn_pack::Error::AlreadyExists(
                        p.path.display().to_string(),
                    )));
                }

错误信息的原文来自 burn-pack 的 Error::AlreadyExists。Module::save_file 走 burn-core 自己的路径,不经过这个检查。为什么检查要做两次(开头一次、最后用硬链接发布时再一次),见保存与加载的冷知识。

两个 store 的两个默认值

键名问题来自两个开关:map_indices_contiguous 把 net.0、net.2 重编成 net.0、net.1;skip_enum_variants 在比对路径时略过枚举变体名。PytorchStore::from_file 两个都开:

            // PyTorch models never include enum variant names in paths
            skip_enum_variants: true,
            // Enable contiguous index mapping by default for PyTorch files
            // This handles nn.Sequential models with gaps in layer indices
            map_indices_contiguous: true,

SafetensorsStore::from_file 两个都关:

            skip_enum_variants: false,
            // Contiguous index mapping is off by default for SafeTensors
            // (SafeTensors files typically have clean, contiguous indices)
            map_indices_contiguous: false,

注释的假设是「safetensors 文件的下标通常是连续的」,而 PyTorch 用 safetensors.torch.save_file 存的 nn.Sequential 不是。于是:

burn 侧写法PytorchStore 默认(重编号、略过变体)SafetensorsStore 默认(都不做)
Vec<Linear>,路径 net.0 net.1成功net.1 找不到
Vec<Layer>,路径 net.0.Linear net.2.Linearnet.2 被改成 net.1,找不到变体名对不上,找不到

摘要里那条「Enable skip_enum_variants」的建议,由 ApplyResult 的 Display 生成:只要缺失的路径的容器栈里有 Enum: 就打印,不看 store 是否已经开了这个选项。这是 0.22.1 的一个上游问题(提示误导,不影响加载结果),origin/main 上 apply_result.rs 没有改动。map_indices_contiguous_except 的文档也提到了第二种写法:「a Vec whose odd entries are parameter-free」。

origin/main 上 overwrite、map_indices_contiguous、skip_enum_variants 三个默认值都没有变。

改法

存档

要覆盖就说出来:

model.save_into(&mut SafetensorsStore::from_file(&file).overwrite(true))
epoch 0: saved
epoch 1: saved
epoch 2: saved
reloaded st: fc2.bias = [2.0, 2.0, 2.0]

BurnpackStore::from_file(&file).overwrite(true) 同样读回 [2.0, 2.0, 2.0]。只存 burn 自己读的存档,用 save_file 也行。存档失败要么用 ? 中止,要么至少计数并在训练结束时报出来。

键名

按 burn 侧的写法选开关,不依赖默认值:

  • Vec<Linear> 读 safetensors:.map_indices_contiguous(true)。

    safetensors + map_indices_contiguous(true): Ok, applied ["net.0.weight", "net.0.bias", "net.1.weight", "net.1.bias"]
    
  • Vec<Layer> 读 .pt:PytorchStore::from_file(..).map_indices_contiguous(false);读 safetensors:.skip_enum_variants(true)。

    pt + map_indices_contiguous(false): Ok, applied ["net.0.weight", "net.0.bias", "net.2.weight", "net.2.bias"]
    safetensors + skip_enum_variants(true): Ok, applied ["net.0.weight", "net.0.bias", "net.2.weight", "net.2.bias"]
    
  • burn 的 Vec<Linear> 存给 PyTorch 的 nn.Sequential:存的时候改名,with_key_remapping 在保存方向也生效。

    SafetensorsStore::from_file(&remap)
      .overwrite(true)
      .with_to_adapter(BurnToPyTorchAdapter)
      .with_key_remapping(r"^net\.1\.", "net.2.")
    
    burn_seq_remap keys: ['net.0.bias', 'net.0.weight', 'net.2.bias', 'net.2.weight']
    burn_seq_remap <All keys matched successfully>
    burn_seq_remap max|diff| vs a1: 1.4901161193847656e-08
    

burn 侧加载成功的四种(Vec<Linear> 读 .pt、Vec<Linear> 加 map_indices_contiguous(true) 读 safetensors、Vec<Layer> 的两种),前向分别和 PyTorch 的 nn.Sequential 比,都是 max|diff|=1.49e-08(tol 1e-6),差来自两边 matmul 的求和顺序。最后一行的 1.49e-8 是 PyTorch 自己算的:读回的权重和 PyTorch 的主线模型逐位相同,只有 fc1.bias 差 3.73e-9,因为 burn 侧的主线模型在 f32 里算偏置,Python 侧先在 f64 里算再舍入(见参数与遍历冻结一节)。

怎么检查

  • 存档写完马上读回一个能区分轮次的值(上面的 fc2.bias,或者把 epoch 号写进 safetensors 的元数据),不要只看「没有 panic」。
  • 不要丢掉 save_into 的 Result。ModuleSnapshot::save_into 返回 Result,let _ = 会把 File already exists 吞掉。
  • 加载 PyTorch 文件时打印 load_from 返回的 ApplyResult,看 Missing in source 和 Unused in target 是不是 0;有 Did you mean 时先看下标,再看有没有枚举变体名,别只照着第一条提示改。
  • 两种文件格式都读的代码,把 map_indices_contiguous 和 skip_enum_variants 显式写出来,两个 store 写成一样的值。

小结

  • burn-store 的 SafetensorsStore / BurnpackStore 默认不覆盖,每个 epoch 存到同一个文件名,第二个 epoch 起返回 File already exists;错误被吞掉时存档停在第一个 epoch。.overwrite(true),或用 save_file。
  • PytorchStore 默认重编号下标、略过枚举变体名,SafetensorsStore 默认都不做:Vec<Linear> 读 .pt 成功、读 safetensors 失败;逐层照抄的 Vec<Layer> 两边都失败。
  • 摘要里「Enable skip_enum_variants」的提示不看这个选项是否已经打开,在 PytorchStore 上会误导(0.22.1 和 origin/main 都是如此)。
  • 存给 nn.Sequential 时用 with_key_remapping 把下标改回 PyTorch 的。

💬 关联讨论

发起讨论
加载中...