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.Linear | net.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 的。