Rust in Everything
基于 burn 0.22.1

Failed to load burnpack file

主线位置:部署主线的「加载」一步,模型来自 PyTorch 导出的 ONNX。代码在构建时生成,权重文件在哪里读,也是构建时定的。
版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备)、burn-onnx 0.22.0
flowchart TB
  onnx["a1.onnx"] --> gen["build.rs ModelGen"]
  gen --> rs["a1.rs"]
  gen --> bpk["OUT_DIR 里的 a1.bpk"]
  rs -->|"File 策略"| def["default 读绝对路径"]
  def -->|"本机"| ok["能跑"]
  def -->|"别的机器"| boom["panic"]
  rs -->|"Embedded 策略"| emb["include_bytes"]
  emb --> ok2["到哪都能跑"]

实验代码:PyTorch 侧 pit_export.py(torch.export 存取);burn 侧 crate pitb-store-onnx,构建依赖 burn-onnx = "=0.22.0",build.rs 用 ModelGen 把 部署形态里导出的 a1.onnx 生成代码,feature embed 时用 LoadStrategy::Embedded(burn default-features = false,feature std、flex;release,strip = true)。平台 M2 Max、macOS 27、rustc 1.99.0。burn-onnx 不在 burn 仓库里,本页引用的是 crates.io 上 burn-onnx 0.22.0 的源码,没有行号链接。

PyTorch 的写法

PyTorch 的部署产物是「程序 + 模型文件」,模型文件的路径在运行时给出。用 torch.export 存一个 .pt2,复制到一个新目录,另一个进程按路径加载:

if sys.argv[1] == "save":
  ep = torch.export.export(a1_model(), (a1_input(),))
  torch.export.save(ep, sys.argv[2])
else:
  ep = torch.export.load(sys.argv[2])
  with torch.no_grad():
    print(json.dumps({"logits": ep.module()(a1_input()).tolist()}))

在复制过去的目录里 load a1.pt2,结果和原模型逐位相同(max|diff|=0.00e+00)。换一个工作目录再用同样的相对路径:

...
W1030 11:14:50.525000 32070 torch/export/__init__.py:403] RuntimeError: open file failed because of errno 2 on fopen: No such file or directory, file path: a1.pt2
FileNotFoundError: [Errno 2] No such file or directory: 'a1.pt2'

(前面十几行是 torch.export.load 打印的回溯警告,这里略去。)找不到文件的情况只有一种:运行时给的路径不对,而路径是使用者自己给的。ONNX 那条路也一样,onnxruntime.InferenceSession(path) 在运行时接路径(虚拟环境里没有装 onnxruntime,这一条没有实测)。

搬到 burn 的现象

burn-onnx 在构建脚本里把 ONNX 变成 Rust 源码和一个 .bpk 权重文件,生成步骤见部署形态的 ONNX 一节。程序照 burn-onnx 文档的例子写:

let model = model::Model::default();
let y = model.forward(x);

release 构建之后,把二进制复制到实验目录下的 ship/,在那里运行:

logits                   n= 12  max|diff|=1.49e-08
worst=1.49e-08 tol=1e-06 OK

和 PyTorch 的前向差 1.49e-8,看起来已经是一个可以分发的二进制(3 078 912 字节)。看一下二进制里的字符串:

...linear1Struct:Modellinear2weightStruct:Linear/.../target-pitb/release/build/pitb-store-onnx-f1fb7cfbc1db81d1/out/model/a1.bpkShape

权重的位置是构建目录 OUT_DIR 里的绝对路径(本页的输出里,这个路径中构建目录之前的部分都换成了 /...)。在本机上,复制到哪里这个文件都在,所以测不出问题。模拟「另一台机器」:把构建目录里的 a1.bpk 改名,再运行 ship/ 里的同一个二进制:

thread 'main' (8794067) panicked at /.../target-pitb/release/build/pitb-store-onnx-f1fb7cfbc1db81d1/out/model/a1.rs:39:17:
Failed to load burnpack file /.../target-pitb/release/build/pitb-store-onnx-f1fb7cfbc1db81d1/out/model/a1.bpk: I/O error: cannot open '/.../target-pitb/release/build/pitb-store-onnx-f1fb7cfbc1db81d1/out/model/a1.bpk': No such file or directory (os error 2)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

容器镜像里只拷了二进制、在 CI 机器上构建再发到服务器、本机 cargo clean 之后运行旧的二进制,都是「这个路径上没有文件」,本页只用改名模拟了这一种情形。

另一个看起来能绕过去的写法更糟。生成的代码里有 Model::new(&device),不读任何文件,不会 panic:

logits                   n= 12  max|diff|=5.30e-01
worst=5.30e-01 tol=1e-06 MISMATCH

它返回的是随机初始化的参数,程序照常输出,结果和 PyTorch 差 0.53。

原因

ModelGen 默认的 LoadStrategy 是 File(burn-onnx 0.22.0 src/import/model_gen.rs 里 #[default] File),文档写着它「Generates from_file(), from_bytes(), and a Default impl that calls from_file()」。生成 Default 的是 src/import/burn/graph.rs 里的 register_burnpack_loaders,把构建时 .bpk 的路径原样写进源码:

            LoadStrategy::File => {
                let file = path_to_str(&file);
                // ...
                default_impl = quote! {
                    impl Default for Model {
                        fn default() -> Self {
                            Self::from_file(#file, &Default::default())
                        }
                    }
                    _blank_!();
                };

在 build.rs 里用 run_from_script 时,这个路径是 OUT_DIR 下的绝对路径。生成出来的就是:

impl Default for Model {
    fn default() -> Self {
        Self::from_file(
            "/.../target-pitb/release/build/pitb-store-onnx-f1fb7cfbc1db81d1/out/model/a1.bpk",
            &Default::default(),
        )
    }
}

from_file 在 load_from 失败时直接 panic!("Failed to load burnpack file {}: {e}", ..),没有 Result 版本。burn-onnx 的 crate 文档把这两点都写明了:Model::default()「uses the default device and, with LoadStrategy::File, the .bpk path recorded at generation time」,「That is fine for development; ship the .bpk alongside your binary and call from_file (or use Embedded) for anything you distribute」;Model::new(&device)「only builds the module structure: layers get fresh random parameters and ONNX constants read as zeros. Never run a model built with new alone」。

和 PyTorch 的区别在于时机:PyTorch 的模型文件是运行时的输入;burn-onnx 的模型是编译进程序的 Rust 代码,权重文件的位置在构建时就定了,构建机器上的测试发现不了。

改法

把权重编进二进制

build.rs 里换成 LoadStrategy::Embedded(本页的 crate 用 feature embed 切换):

ModelGen::new()
  .input("model/a1.onnx")
  .out_dir("model/")
  .load_strategy(LoadStrategy::Embedded)
  .run_from_script();

生成的代码用 include_bytes! 在编译时读入 .bpk,Default 改为调用 from_embedded。同样复制到 ship/、把构建目录里的 a1.bpk 改名后运行:

logits                   n= 12  max|diff|=1.49e-08
worst=1.49e-08 tol=1e-06 OK

二进制 3 079 328 字节。注意 Embedded 不生成 from_file,原来调用它的代码会编译失败:

error[E0599]: no associated function or constant named `from_file` found for struct `Model` in the current scope
  --> src/main.rs:17:32
   |
17 |       Some(p) => model::Model::from_file(p, &device),
   |                                ^^^^^^^^^ associated function or constant not found in `Model`
...

自带 .bpk,按二进制所在的目录找

权重要单独更新、或者模型很大不想编进二进制时,保留 File 策略,把 .bpk 和二进制放在一起,不用 Model::default(),自己调 from_file。路径不要写相对路径:和 PyTorch 一样,相对路径按当前工作目录解析,从别的目录启动就找不到:

thread 'main' (...) panicked at /.../target-pitb/release/build/pitb-store-onnx-f1fb7cfbc1db81d1/out/model/a1.rs:39:17:
Failed to load burnpack file ./a1.bpk: I/O error: cannot open './a1.bpk': No such file or directory (os error 2)

(线程号换成了 ...。)按可执行文件所在的目录拼路径:

let dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf()));
match dir {
  Some(d) => model::Model::from_file(d.join("a1.bpk"), &device),
  None => {
    eprintln!("cannot locate the executable");
    std::process::exit(2);
  }
}

构建目录里的 a1.bpk 改名、从另一个工作目录启动 ship/ 里的二进制(旁边放着 a1.bpk),结果和 PyTorch 差 1.49e-8;同一个二进制不带参数走 Model::default(),仍是上面那个 panic。from_file 的第二个参数是设备,这也顺带避开了 Default 只用默认设备的问题。

📝NOTE
没有蠢问题

问: Embedded 版的二进制里还能搜到构建目录里 a1.bpk 的绝对路径,它运行时是不是还会去读?

答: 不会。那个字符串只出现在 from_embedded 的 panic 信息里(「Failed to load embedded burnpack (built from …)」),权重本身由 include_bytes! 在编译时放进二进制。上面那次运行时构建目录里的 a1.bpk 已经改名,结果照样对得上。它带出来的只是构建机器上的路径。

怎么检查

  • 在构建机器上测不出来。测试分发产物时,先把构建目录挪开(或 cargo clean),或者在一台干净的机器、一个只放了二进制的容器里运行。
  • strings <二进制> | grep '\.bpk':File 策略下能看到构建目录的绝对路径,就说明 Model::default() 会去读它。
  • 代码里搜 Model::default() 和 Model::new(:前者只该出现在 Embedded 策略下,后者不该出现在推理路径上。
  • 部署后和 PyTorch 的输出对一次数(compare.py),随机参数的模型也能正常输出,只有对数能发现。

小结

  • burn-onnx 0.22.0 的默认 LoadStrategy::File 让 Model::default() 读构建时 OUT_DIR 里 .bpk 的绝对路径;本机怎么复制都能跑,构建目录不在了就 panic:Failed to load burnpack file ...: No such file or directory。
  • Model::new 不 panic,但参数是随机的,和 PyTorch 差 0.53。
  • 分发时用 LoadStrategy::Embedded(Default 改调 from_embedded,没有 from_file),或者带上 .bpk,按 current_exe() 所在目录调 from_file;两种都和 PyTorch 差 1.49e-8。
  • PyTorch 的模型文件是运行时输入,burn-onnx 的权重位置在构建时定死;两边的相对路径都按当前工作目录解析。

💬 关联讨论

发起讨论
加载中...