存储与加载
主线位置:训练主线的出口。训练出来的参数要离开进程(存盘、换机器、给推理用),别处训练的权重要进来(PyTorch、Hugging Face 的 safetensors)。
Learner的 checkpoint 也走这里,见 Learner 与数据。参数本身(Param、ParamId、#[derive(Module)]生成的遍历)见 Module 派生。
flowchart TB model["Module"] -->|"into_record 遍历"| rec["ModuleRecord"] rec -->|"burn-pack Writer"| bpk[".bpk 文件"] bpk -->|"按路径对回"| model model -->|"collect"| pt["burn_pack Tensor 列表"] pt --> st["SafetensorsStore"] st --> sf[".safetensors"] torch[".pt 文件"] --> reader["pytorch-reader"] reader --> pt2["burn_pack Tensor 列表"] sf --> pt2 pt2 --> remap["KeyRemapper 改名"] remap --> adapt["Adapter 转置 改名"] adapt -->|"apply 遍历"| model
0.22 里存取分两处。burn-core 的 store 模块很小,只做一件事:把模块参数存成 burnpack、原样读回;模块注释说它「intentionally tiny」,过滤、改名、PyTorch 和 safetensors 适配都在 burn-store crate(store feature)。两边共用 burn-pack crate 定义的文件格式和 burn_pack::Tensor 这个传输类型。实验 crate 是 train-store(从模板 base 复制,加了直接依赖 safetensors = "=0.8.0",Flex,debug),平台 M2 Max、macOS 27、rustc 1.99.0,输出存在 train-store/out-logs/。
Module 上的六个方法
| 方法 | 做什么 |
|---|---|
into_record | 遍历模块,收集所有张量参数成一个 ModuleRecord |
into_record_group | 只收一个 ParamGroup 里的参数,组外的不读数据 |
try_load_record / load_record | 把记录套回模块;后者出错就 panic("Failed to load record") |
save_file | into_record().save(path) |
load_file / try_load_file | ModuleRecord::load(path) 再 try_load_record |
ModuleRecord 不泛型:0.21 的 burn-core/src/record/(Record trait、Recorder<B> 以及各种格式和精度的 recorder)和派生宏为每个模块生成的记录类型(burn-derive/src/module/record*.rs)都删掉了(#5083「Refactor Record - Serialization & Deserialization of Module, Optimizer & LrScheduler」)。Flag 一类模块自带的控制值不进记录,加载后保留目标模块自己的值。
ModuleRecord:按路径存,按路径对回
收集用一个 ModuleVisitor(Collector),进出子模块时压栈出栈字段名,每个参数记下「路径、ParamId、TensorData」三样:
// Record the `on_save` form: it is what the load side validates against and
// un-maps with `on_load`. Recording `val()` breaks any param whose mapper
// changes the shape (a `Col`-layout `Linear` weight).
fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<D>>) {
self.record(param.id, || param.transform_for_save().val().into_data());
}
存的是 transform_for_save 之后的形状:Param 可以带保存、加载、初始化三个映射函数,LinearConfig::with_layout(LinearLayout::Col) 的 Linear 就靠它们在内存里和文件里用互为转置的两种布局(见 nn 模块)。读数据(val().into_data())放在闭包里,被 ParamGroup 过滤掉的参数不会去读。
加载是一个 ModuleMapper(ModuleRecordMapper):把记录按路径放进 HashMap<String, (ParamId, TensorData)>,map 走到每个参数时按当前路径 remove_entry,take 里分三种情况:
- 路径不在表里:记进
missing,参数保持原样。 - 形状和参数的
lazy_shape()不同:记一条"{path}: shape mismatch, expected .. but record has ..",参数保持原样。 - 对上了:按
DTypePolicy定 dtype(默认FromRecord,用文件里的 dtype;CastToModule转成参数现在的 dtype),建张量,再transform_for_load(tensor, record_id):值换成记录里的,ParamId也换成记录里的。
遍历完,表里剩下的就是没有参数认领的条目。apply 按顺序检查三类问题,各有一个开关:
if validate && !mapper.errors.is_empty() {
return Err(RecordError::Validation(format!(
"Apply errors: {:?}",
mapper.errors
)));
}
if !allow_partial && !mapper.missing.is_empty() {
return Err(RecordError::Validation(format!(
"Missing tensors: {:?}",
mapper.missing
)));
}
if !allow_unused && !mapper.unused().is_empty() {
return Err(RecordError::Validation(format!(
"Unused tensors: {:?}",
mapper.unused()
)));
}
默认三项都查:validate = true、allow_partial = false、allow_unused = false。allow_unused 的文档注释说明了为什么连「多出来的条目」也默认拒绝:记录里有条目落空,说明这不是取出记录的那个模块,看起来成功的加载其实少做了事。
恢复 ParamId 是为了续训:优化器状态按 ParamId 存(见优化器),新建模型时的 id 是随机的,只有换回记录里的 id,读回来的优化器状态才找得到自己的参数。这一点由 #5177(「fix(store): restore persisted ParamId on load_record and Applier apply」)补上,首次进入 v0.22.0-pre.1。
burnpack 格式
ModuleRecord::save 把每个条目变成 burn_pack::Tensor::new(path, dtype, shape, Some(id), bytes) 交给 burn_pack::Writer。格式写在 burn-pack 的 crate 文档里,三段:
| 段 | 内容 |
|---|---|
| 头,10 字节 | MAGIC_NUMBER 0x4255524E("BURN",小端写出来是 NRUB)、u16 格式版本(现在是 1)、u32 元数据长度 |
| 元数据,CBOR | 每个张量的 dtype、shape、在数据段里的 (start, end)、可选的 param_id;字符串键值对;可选的标量表(优化器状态的 time 就存这里) |
| 数据段 | 先补齐到 256 字节边界(aligned_data_section_start),每个张量的起点也按 TENSOR_ALIGNMENT = 256 对齐,读文件时可以直接内存映射、零拷贝 |
256 的理由写在常量的注释里:f64 要 8 字节对齐、CPU 缓存行 64 字节、CUDA 偏好 256 字节合并访问,并列出 GGUF、MLX 等十几种格式也用 256,safetensors 是 64。读取方还有一组防恶意文件的上限:元数据最多 100 MB、单个张量最多 10 GB(32 位平台 2 GB)、最多 100 000 个张量、CBOR 嵌套最多 128 层、文件最多 100 GB。和 safetensors 比,多出来的是 param_id 和标量表:前者让模型记录能恢复 ParamId,后者让优化器和学习率调度器的状态也能用同一种文件。
实验 train-store roundtrip:两层的 Net { fc: Linear(4→3), head: Linear(3→2, 无偏置) },save_file 后读文件头,再用 BurnpackStore 列出条目,最后读回一个新建的模型:
#[derive(Module, Debug)]
struct Net {
fc: Linear,
head: Linear,
}
// ...
model.clone().save_file("out/net.bpk").unwrap();
// ...read the 10-byte header by hand, then list entries with BurnpackStore...
let fresh = Net::new(device);
println!("fresh fc.weight id = {}", fresh.fc.weight.id);
let loaded = fresh.load_file("out/net.bpk");
ids: fc.weight=avq43klip2pc6 fc.bias=edntuk51pn2co head.weight=22h48qbgpqs2o
out/net.bpk: 792 bytes, magic "NRUB", version 1, metadata 210 bytes
fc.bias: F32 Shape { dims: [3] } param_id=Some(14755144371902836595)
fc.weight: F32 Shape { dims: [4, 3] } param_id=Some(14101554053607715927)
head.weight: F32 Shape { dims: [3, 2] } param_id=Some(3222552515581354512)
fresh fc.weight id = etelnddevaf54
loaded fc.weight id = avq43klip2pc6, values equal: true
条目名就是模块里的字段路径。ParamId 在文件里是 u64,打印出来的 avq43klip2pc6 是同一个值的 base32 编码(ParamId 的 Display)。新建模型的 id 是 etelnddevaf54,加载后变回保存时的 avq43klip2pc6。Linear 的 weight 形状是 [d_input, d_output],所以 fc.weight 是 [4, 3]。
字段改了名
把 Net 的 fc 改名为 fc1(其余不变),读刚才的文件(train-store rename):
ModuleRecord: Record validation error: Missing tensors: ["fc1.weight", "fc1.bias"]
fc.weight、fc.bias 同时也是「没人认领」的条目,但 apply 先查 missing、查到就返回,报错里只有缺的这一半。ModuleRecord 没有改名的手段;要么把字段改回去,要么换 burn-store。
burn-store:ModuleSnapshot 和 ModuleStore
burn-store 给所有 Module 加了一个扩展 trait ModuleSnapshot:collect 把参数收成 Vec<burn_pack::Tensor>(数据是懒的,写盘时才从设备读回),apply 把一组张量按名字套回模块,save_into / load_from 交给一个 ModuleStore。现成的 store 有三个:BurnpackStore、SafetensorsStore、只读的 PytorchStore。它们都有同一组构造选项:按正则或路径过滤(with_regex、with_full_path)、改名(with_key_remapping、remap)、allow_partial、validate,safetensors 和 burnpack 还能附加元数据、选择是否覆盖已有文件。
用 safetensors 重做改名的实验:Net 存成 out/net.safetensors,读进 fc1 版本,先不改名,再加一条 with_key_remapping(r"^fc\.", "fc1."):
SafetensorsStore without remap: Err
Tensor not found:
┌─ Tensor Loading Summary ─────────────────────────
│
│ ✓ Successfully applied: 1 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)
│
│ First 10 missing tensors:
│ • fc1.bias
│ Did you mean: 'fc.bias'?
│ • fc1.weight
│ Did you mean: 'fc.weight'?
│
├─ Unused Tensors (in source but not used by model)
│
│ First 10 unused tensors:
│ • fc.bias
│ • fc.weight
│
└───────────────────────────────────────────────────
Hint: Use `.allow_partial(true)` on the store to get an `ApplyResult` with structured missing/error info instead of a hard failure.
SafetensorsStore with remap ^fc\. -> fc1.: Ok
┌─ Tensor Loading Summary ─────────────────────────
│
│ ✓ Successfully applied: 3 tensors
│ ⊘ Skipped (filtered): 0 tensors
│ ✗ Missing in source: 0 tensors
│ ? Unused in target: 0 tensors
│ ! Errors: 0 errors
│
└───────────────────────────────────────────────────
fc1.weight equals saved fc.weight: true
报错是一份 ApplyResult 的摘要,两边都列出来,还按名字相似度给了「Did you mean」。注意一个和 ModuleRecord 相反的默认:store 的 apply_to 只在有 errors(validate 开着时)或有 missing(没开 allow_partial 时)时返回 Err,unused 只出现在摘要里,不算失败。从别的框架导入时文件里常有模型用不到的张量,下面 PyTorch 的例子就是。
apply 里为了不复制整个模块(大模型会多占一倍内存)用了 unsafe:
unsafe {
// Read the module out of self (moves it, leaving self in undefined state)
let module = core::ptr::read(self as *const Self);
// ...
let guard = AbortOnUnwind;
// Map the module to create a new one with updated tensors
let new_module = module.map(&mut applier);
// Write the new module back to self
core::ptr::write(self as *mut Self, new_module);
// `self` holds a whole module again, so the window is closed.
core::mem::forget(guard);
}
AbortOnUnwind 来自 PR #5488(「fix(store): abort instead of double-dropping when a mapper unwinds」,首次进入 v0.22.0-pre.4,正式版 0.22.0)。之前 ptr::read 和 ptr::write 之间没有任何保护:map 里会跑调用方的代码(一个 ModuleAdapter 实现、后端的 from_data),其中一个 panic,self 就停在「已被移走」的状态,展开时被它的所有者再 drop 一次。提交说明强调 apply 是公开 trait 上的安全方法,所以「the double free was reachable without any unsafe」。修法是一个守卫:只有在展开途中被 drop 时,它打印一行说明然后 std::process::abort();成功路径上 mem::forget 掉它。为什么不恢复而是中止:原模块已经被 map 吃掉,没有合法的值可以放回去,Module 也没有 Default 约束可以放一个占位值;take_mut 和 replace_with 这类 crate 也是同样的处理。没有 std 时没有 abort,就在展开中再 panic 一次,效果相同。
safetensors 和 Adapter
PyTorch 的 nn.Linear 存 weight 的形状是 [out, in],burn 默认是 [in, out]。下面用 safetensors crate 自己写一个 PyTorch 布局的文件(train-store safetensors):fc.weight 是 [3, 4]、值 0 到 11,head.weight 是 [2, 3]。
let tensors = vec![
("fc.weight", TensorView::new(Dtype::F32, vec![3, 4], &a).unwrap()),
("fc.bias", TensorView::new(Dtype::F32, vec![3], &b).unwrap()),
("head.weight", TensorView::new(Dtype::F32, vec![2, 3], &c).unwrap()),
];
safetensors::serialize_to_file(tensors, Some(info), path).unwrap();
// ...
let mut st = SafetensorsStore::from_file("out/torch_like.safetensors");
model.load_from(&mut st)
// ...
let mut st =
SafetensorsStore::from_file("out/torch_like.safetensors").with_from_adapter(PyTorchToBurnAdapter);
model.load_from(&mut st)
wrote out/torch_like.safetensors (340 bytes)
no adapter: Err
Validation failed: Import errors: [ShapeMismatch { path: "fc.weight", expected: Shape { dims: [4, 3] }, found: Shape { dims: [3, 4] } }, ShapeMismatch { path: "head.weight", expected: Shape { dims: [3, 2] }, found: Shape { dims: [2, 3] } }]
PyTorchToBurnAdapter: Ok
┌─ Tensor Loading Summary ─────────────────────────
...
fc.weight dims [4, 3]
fc.weight [0.0, 4.0, 8.0, 1.0, 5.0, 9.0, 2.0, 6.0, 10.0, 3.0, 7.0, 11.0]
没有适配器时形状对不上,报 ShapeMismatch;加上 PyTorchToBurnAdapter 后 fc.weight 变成 [4, 3],第一行是原来第一列 0, 4, 8。适配器不看张量名猜,而是看它在模块里属于什么类型:遍历时维护一个容器栈(派生宏给每层起名 "Struct:Linear"、"Vec" 这样),ModuleContext::module_type 取最内层的 Struct: / Enum:。adapt_pytorch_tensor 只做两件事:
// Linear: transpose weight (bidirectional - same operation both ways)
if module_type == module_names::LINEAR && param == "weight" && tensor.shape.len() == 2 {
return transpose_2d_tensor(tensor);
}
// Normalization layers: rename parameters based on direction
if is_normalization_layer(module_type) {
let renamed = match direction {
PyTorchConversionDirection::PyTorchToBurn => pytorch_norm_param_to_burn(param),
PyTorchConversionDirection::BurnToPyTorch => burn_norm_param_to_pytorch(param),
};
// ...
}
Linear 的 weight 转置;BatchNorm、LayerNorm、GroupNorm、RmsNorm 的 weight / bias 改名为 burn 的 gamma / beta。卷积层不在其中:burn 的 Conv2d 权重本来就是 [channels_out, channels_in / groups, kernel_size_1, kernel_size_2],适配器不动它。转置是记在数据来源上的一个变换,真正读数据时才做。反方向有 BurnToPyTorchAdapter,另有按 dtype 转换的 HalfPrecisionAdapter(F32 ↔ F16,默认不碰 BatchNorm,注释说 running_var 在 F16 下会下溢)和 FloatCastAdapter,可以用 chain 串起来。
LinearLayout::Col 的 Linear 和这套机制配合得不好。Col 参数初始化时生成 [out, in],init_mapper 转置成内存里的 [in, out],save_mapper / load_mapper 在存取时各转置一次,文件里是 [out, in]。加载时的形状校验用 Param::lazy_shape:参数还没被读过(懒初始化)时它返回初始化值的形状 [out, in],读过之后返回内存里的形状 [in, out]。于是结果取决于参数有没有被碰过(train-store col 和 col2,fc 换成 Col 布局):
a) lazy Col + PytorchStore: Err
...
│ ⚠️ Shape mismatch for 'fc.weight': expected Shape { dims: [3, 4] }, found Shape { dims: [4, 3] }
...
b) lazy target: Ok, dims [4, 3]
c) materialized target: Record validation error: Apply errors: ["fc.weight: shape mismatch, expected Shape { dims: [4, 3] } but record has Shape { dims: [3, 4] }"]
- 没读过的
Col参数配PytorchStore:适配器已经把[3, 4]转成[4, 3],和[3, 4]对不上,报错。 - 自己存、自己读,目标参数没读过:成功(上游的测试
round_trip_a_shape_mapped_param测的就是这种)。 - 自己存、自己读,但目标模型先用过一次(参数已物化):同一个文件被拒绝。
- 已物化的
Col参数配PytorchStore:校验通过,load_mapper再转置一次,加载报告成功,形状却是[3, 4],前向时才 panic:
Col fc.weight dims before [4, 3]
Ok
...
Col fc.weight dims after [3, 4]
thread 'main' (3289502) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/burn-nn-0.22.1/src/modules/linear.rs:136:35:
assert_shape!(input, [.., d_input]): axis 1 expected 3, got 4 (dims [1, 4])
默认的 Row 布局没有映射函数,不受影响。
PyTorch .pt:pytorch-reader
PytorchStore 建在 pytorch-reader crate 上。这个 crate 的文档第一句:「Read PyTorch checkpoint files (.pt, .pth) without PyTorch or Burn」。它自己解析 pickle,按文件内容识别四种容器:PyTorch 1.6 起的 ZIP、0.1.10 到 1.5 的旧 pickle 流、更早的 TAR、纯 pickle;torch.save(model) 存的整个模型也能读,读出来是 state_dict() 的样子(非持久的 buffer 和其他属性不算);只支持小端文件,稀疏、量化、嵌套张量拒绝。数据是懒的:打开时只解析元数据,PytorchReader 给出名字、dtype 和形状,字节在要用时才从文件读。PytorchStore 不能写(collect_from 直接返回「Saving to PyTorch format is not yet supported」),apply_to 总是用 PyTorchToBurnAdapter。
实验 train-store pytorch 读的是 pytorch-reader 测试数据里的 full_model.pt(上游用 torch.save(model) 生成的,模型有 fc = nn.Linear(4, 3)、head = nn.Linear(3, 2, bias=False)、一个 nn.Sequential 和一个和 fc 是同一个对象的 tied),读进上面的 Net:
blocks.0.bias: F32 Shape { dims: [2] }
blocks.0.weight: F32 Shape { dims: [2, 2] }
blocks.1.bias: F32 Shape { dims: [2] }
blocks.1.num_batches_tracked: I64 Shape { dims: [] }
blocks.1.running_mean: F32 Shape { dims: [2] }
blocks.1.running_var: F32 Shape { dims: [2] }
blocks.1.weight: F32 Shape { dims: [2] }
fc.bias: F32 Shape { dims: [3] }
fc.weight: F32 Shape { dims: [3, 4] }
head.weight: F32 Shape { dims: [2, 3] }
running_mean: F32 Shape { dims: [3] }
tied.bias: F32 Shape { dims: [3] }
tied.weight: F32 Shape { dims: [3, 4] }
┌─ Tensor Loading Summary ─────────────────────────
│
│ ✓ Successfully applied: 3 tensors
│ ⊘ Skipped (filtered): 0 tensors
│ ✗ Missing in source: 0 tensors
│ ? Unused in target: 10 tensors
│ ! Errors: 0 errors
...
burn fc.weight dims [4, 3]
raw [3,4] row 0: [-0.00374341, 0.2682218, -0.41152257, -0.3679695]
burn [4,3] col 0: [-0.00374341, 0.2682218, -0.41152257, -0.3679695]
burn == transpose(raw): true
没装 PyTorch,13 个条目都读出来了。tied 和 fc 是同一个子模块,pickle 里只存了一次,读出来照 state_dict() 的样子在两个名字下各出现一次;非持久的 buffer mask 和普通属性 scale 不在其中。Net 只用到 3 个,其余 10 个是 unused,不影响成功。burn 里的 fc.weight 正好是文件里那个矩阵的转置。
同一目录下的 full_model_state_dict.pt(同一个模型的 torch.save(model.state_dict()))在这个实验里打不开:Reader(Zip(InvalidArchive("Invalid Central Directory header")))。Python 的 zipfile 和 unzip 都能正常列出它的 17 个条目。新项目解析到的 zip 是 9.1.3,burn 仓库在 v0.22.1 的 Cargo.lock 里锁的是 9.0.4(离线缓存里没有,没能对照),所以只能确定:用 9.1.3 时这类文件读不了。
KeyRemapper
KeyRemapper 是一串 (Regex, String)。remap 对每个张量名按顺序试每条规则,匹配就 replace_all,后一条看到的是前一条改过的名字,所以规则的顺序有意义。store 的 with_key_remapping 每调一次追加一条,正则写错时 expect("Invalid regex pattern") 直接 panic;KeyRemapper::add_pattern 返回 Result。改名发生在 get_all_tensors 建缓存的时候,所以 keys()、get_tensor 看到的已经是新名字;过滤不在这里,在 apply 时做。
看一个真实文件:本地的 SmolLM2-135M model.safetensors(269 MB,只读它的头)。safetensors 的头是 8 字节小端长度加一段 JSON(train-store smollm):
header 30528 bytes, 273 entries (has __metadata__: true)
model.embed_tokens.weight: "BF16" [49152,576]
model.norm.weight: "BF16" [576]
model.layers.0.input_layernorm.weight: "BF16" [576]
model.layers.0.mlp.down_proj.weight: "BF16" [576,1536]
model.layers.0.mlp.gate_proj.weight: "BF16" [1536,576]
model.layers.0.mlp.up_proj.weight: "BF16" [1536,576]
model.layers.0.post_attention_layernorm.weight: "BF16" [576]
model.layers.0.self_attn.k_proj.weight: "BF16" [192,576]
model.layers.0.self_attn.o_proj.weight: "BF16" [576,576]
model.layers.0.self_attn.q_proj.weight: "BF16" [576,576]
model.layers.0.self_attn.v_proj.weight: "BF16" [192,576]
tensors 272, layers 30
after remapping (272 keys), layers.0.*:
layers.0.attn.k_proj.weight
layers.0.attn.o_proj.weight
layers.0.attn.q_proj.weight
layers.0.attn.v_proj.weight
layers.0.input_norm.gamma
layers.0.mlp.down_proj.weight
layers.0.mlp.gate_proj.weight
layers.0.mlp.up_proj.weight
layers.0.post_attention_norm.gamma
三条规则 ^model\. → 空、\.self_attn\. → .attn.、_layernorm\.weight$ → _norm.gamma,把 Hugging Face 的名字改成一个假想的 burn 模型的字段路径(layers: Vec<Block> 里的 attn、input_norm 等)。投影层的权重都是 [out, in](k_proj 是 [192, 576]),读进 burn 的 Linear 时同样要 PyTorchToBurnAdapter;文件里没有 lm_head(同目录的 config.json 写着 "tie_word_embeddings": true,输出层和 embed_tokens 共用权重)。数据是 BF16,要读进 F32 的模型,可以再 chain 一个 FloatCastAdapter::to(DType::F32)。
动脑筋答案
out/net.bpk: 792 bytes, magic "NRUB", version 1, metadata 210 bytes
- 头 10 字节加元数据 210 字节是 220,数据段从下一个 256 的倍数开始:256。
- 每个张量的起点也对齐到 256。三个张量依次放在数据段内偏移 0、256、512 处,最后一个从文件偏移 256 + 512 = 768 开始,长度是
head.weight的 6 × 4 = 24 字节,文件到 792 结束。 - 84 字节的数据占了 792 字节的文件,多出来的都是对齐的填充。参数大到几 MB 时,每个张量最多浪费 255 字节,可以忽略;这个格式是为大张量的零拷贝映射设计的。
小结
Module::save_file/load_file走 burn-core 的ModuleRecord:按字段路径记下ParamId和数据,加载时按路径对回,默认缺、多、形状不符都报错,并恢复记录里的ParamId,让优化器状态续得上。- burnpack 文件是 10 字节头、CBOR 元数据、256 字节对齐的数据段,比 safetensors 多存了
param_id和标量,模型、优化器、调度器都用它。 burn-store的三个 store 共用过滤、改名、适配器;它们只把缺失当失败,多出来的张量只在摘要里列出。PyTorchToBurnAdapter按模块类型转置Linear的权重、给归一化层的参数改名;pytorch-reader不需要 PyTorch 就能读.pt,PytorchStore只能读。KeyRemapper是按顺序执行的正则替换,后一条看到前一条的结果。- 实测两个问题:
Col布局的Linear加载时的形状校验取决于参数是否已物化,配PytorchStore时可能被多转置一次、加载成功而前向 panic;新项目解析到zip9.1.3 时,一部分 ZIP 格式的.pt打不开。