为什么要转
主线位置:部署主线的最后一步,不带自动求导地推理。本页只比较交付出去的东西:同一个前向推理,两边要装多大的环境、启动要多久、占多少内存;再列出在哪些情况下转过来不划算。
版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备)
flowchart TB task["A1 MLP 前向"] --> pyside["Python 加 torch"] task --> rsside["burn 二进制"] pyside --> pysize["环境约 729 MiB"] pyside --> pystart["启动约 650 ms"] rsside --> rssize["约 2.3 MiB"] rsside --> rsstart["启动约 3 ms"] pyside --> cost["算子 生态 调试"] rsside --> cost
这一页只写在本机测得到的东西。平台 M2 Max、macOS 27、rustc 1.99.0;测量时机器上还有别的任务在跑(uptime 负载 15 到 27),所以计时取多次运行的最小值,同时给出中位数。
同一个推理任务
任务是 A1 那个 MLP(4→8→3,ReLU)的一次前向:权重按固定公式生成(见怎么读这一卷),输入 4 个样本,打印 logits。不训练,不建计算图。PyTorch 一侧:
model = Mlp().eval()
with torch.no_grad():
model.fc1.weight.copy_(w(HID, IN, math.sin, 0.5))
model.fc1.bias.copy_(torch.tensor([0.01 * o for o in range(HID)]))
model.fc2.weight.copy_(w(OUT, HID, math.cos, 0.5))
model.fc2.bias.copy_(torch.tensor([-0.02 * o for o in range(OUT)]))
x = torch.tensor([[0.1 * (r * IN + c) - 0.5 for c in range(IN)] for r in range(4)])
with torch.inference_mode():
logits = model(x)
json.dump({"logits": logits.tolist()}, sys.stdout)
burn 一侧,设备是 Device::flex(),不调 .autodiff(),于是这段程序里没有任何计算图(不带 autodiff 的设备见不求导的区域):
fn main() {
let device = Device::flex();
let mut model = Mlp {
fc1: LinearConfig::new(IN, HID).init(&device),
fc2: LinearConfig::new(HID, OUT).init(&device),
relu: Relu::new(),
};
model.fc1.weight = Param::from_tensor(weight(HID, IN, f64::sin, &device));
model.fc1.bias = Some(Param::from_tensor(bias(HID, 0.01, &device)));
model.fc2.weight = Param::from_tensor(weight(OUT, HID, f64::cos, &device));
model.fc2.bias = Some(Param::from_tensor(bias(OUT, -0.02, &device)));
let xs: Vec<f32> = (0..4 * IN).map(|k| 0.1 * k as f32 - 0.5).collect();
let x = Tensor::<2>::from_data(TensorData::new(xs, [4, IN]), &device);
let logits = model.forward(x);
let v: Vec<f32> = match logits.into_data().try_to_vec::<f32>() {
Ok(v) => v,
Err(e) => {
eprintln!("read back failed: {e:?}");
std::process::exit(1);
}
};
let rows: Vec<&[f32]> = v.chunks(OUT).collect();
println!("{}", json!({ "logits": rows }));
}
Mlp、weight、bias 和 A1 的训练程序一样。依赖只开推理要用的:
[dependencies]
burn = { version = "=0.22.1", default-features = false, features = ["std", "flex"] }
serde_json = "1"
[profile.release]
strip = true
两边的输出:
{"logits": [[-0.09422215819358826, -0.12721742689609528, 0.08542244881391525], [0.0261688269674778, -0.12934733927249908, -0.0343487448990345], [0.056371480226516724, -0.20086252689361572, -0.04374047741293907], [0.11677984893321991, -0.35583826899528503, -0.059050895273685455]]}
{"logits":[[-0.09422216564416885,-0.12721742689609528,0.08542244881391525],[0.0261688232421875,-0.12934733927249908,-0.0343487374484539],[0.056371480226516724,-0.20086251199245453,-0.043740492314100266],[0.1167798638343811,-0.35583826899528503,-0.05905091017484665]]}
logits n= 12 max|diff|=1.49e-08
worst=1.49e-08 tol=1e-06 OK
最大绝对误差 1.49e-8,容差 1e-6。不是 0 的原因是 matmul 内部的求和顺序不同,见怎么读这一卷。打开 burn 的默认 feature 再构建一次,误差同样是 1.49e-8。
要交付多大的东西
PyTorch 一侧,运行这段脚本需要 Python 解释器、torch 和 torch 声明的依赖。按 du -sk 量,1 MiB = 1024 KiB:
| 部分 | 大小 |
|---|---|
| Python 3.13.18 解释器(uv 安装的独立构建) | 70.8 MiB |
torch 包 | 593.7 MiB |
其中 torch/lib/libtorch_cpu.dylib | 402.3 MiB(421 820 912 字节) |
其中 torch/include(C++ 头文件) | 62.9 MiB |
torch 声明的依赖(sympy、networkx、mpmath、setuptools、jinja2、fsspec、filelock、typing_extensions、markupsafe)加 torchgen、functorch | 64.0 MiB |
| 合计:解释器 + torch + 依赖 | 728.5 MiB |
numpy 和 safetensors 不是 torch 声明的依赖,没有算进去(两者共 25.2 MiB)。这是 macOS 上只带 CPU 的 wheel。同一个 wheel 的元数据里,Linux 上还要多装一批 CUDA 包:
Requires-Dist: cuda-toolkit[cublas,cudart,cufft,cufile,cupti,cusolver,cusparse,nvjitlink,nvrtc,nvtx]==13.2.2; platform_system == "Linux"
Requires-Dist: nvidia-cudnn-cu13==9.26.0.51; platform_system == "Linux"
...
Requires-Dist: triton~=3.9.0; platform_system == "Linux" and python_version < "3.16"
这些包本页没有装,大小没有量。
burn 一侧,交付的是一个可执行文件,release 构建、strip 过:
| 构建 | 文件大小 | 动态链接 |
|---|---|---|
default-features = false,std + flex | 2.3 MiB(2 454 832 字节) | libSystem、libiconv |
打开默认 feature,flex(Flex 带 simd、rayon,另带 autodiff、optim) | 5.3 MiB(5 591 776 字节) | libSystem、libiconv |
前一行的依赖再加 metal,程序仍然只用 Flex | 39.1 MiB(41 029 264 字节) | 另加 Metal、QuartzCore、CoreGraphics、Foundation、IOKit、CoreFoundation 这几个系统框架和 libobjc |
otool -L 列出的都是 macOS 自带的库和系统框架,不需要另装任何运行时;本页没有在另一台机器上试过。模型权重在这个程序里是运行时按公式算出来的,只有 67 个 f32,不占文件体积;文件大小几乎全是代码。代码多少取决于编译进去的 feature 和后端:同一个程序打开默认 feature 大了一倍多,再多编译一个 metal 又大了 7 倍,哪怕运行时没用到它。
从文件加载权重、导出 TorchScript / torch.export / ONNX 这些更接近实际部署的形态,在 08 存取与迁移 和部署形态里对照。
启动要多久
每个命令先跑一次让文件进入系统缓存,再连续跑 30 次,量从启动子进程到它退出的墙钟时间。所以这里是「文件已在缓存里」的启动时间;清空文件缓存需要 root 权限,本页没有测真正的冷启动。
for name, cmd in cmds.items():
subprocess.run(cmd, stdout=subprocess.DEVNULL, check=True) # 预热文件缓存
ts = []
for _ in range(N):
t0 = time.perf_counter()
subprocess.run(cmd, stdout=subprocess.DEVNULL, check=True)
ts.append((time.perf_counter() - t0) * 1000)
print(f"{name:30} n={N} min={min(ts):8.1f} ms median={statistics.median(ts):8.1f} ms")
python -I -c pass n=30 min= 13.2 ms median= 13.7 ms
python -I -c 'import torch' n=30 min= 650.0 ms median= 661.2 ms
python -I intro_infer.py n=30 min= 653.8 ms median= 663.9 ms
intro-infer (release) n=30 min= 2.6 ms median= 2.9 ms
intro-infer-default (release) n=30 min= 2.6 ms median= 2.8 ms
intro-infer +metal (release) n=30 min= 5.6 ms median= 5.9 ms
intro-infer 是 default-features = false 的构建,intro-infer-default 打开默认 feature,+metal 是上表第三行。
PyTorch 一侧几乎全部时间花在 import torch 上:只 import、什么都不算,和跑完整个推理脚本的时间一样。在进程里分段计时(time.perf_counter,5 次):
import torch 552.9 ms, build 0.43 ms, forward#1 0.133 ms, forward#2 0.016 ms, threads 8
import torch 542.8 ms, build 0.40 ms, forward#1 0.135 ms, forward#2 0.015 ms, threads 8
import torch 546.9 ms, build 0.38 ms, forward#1 0.127 ms, forward#2 0.017 ms, threads 8
import torch 547.1 ms, build 0.40 ms, forward#1 0.128 ms, forward#2 0.015 ms, threads 8
import torch 546.6 ms, build 0.38 ms, forward#1 0.125 ms, forward#2 0.018 ms, threads 8
import 约 547 ms,前向本身 0.13 ms。整个脚本约 654 ms,import 之外的约 100 ms 是解释器启动、退出和脚本的其余部分,本页没有再细分。这里没有比计算速度:前向只有 0.1 毫秒级,比不出什么。比计算速度要固定线程数、分开第一次和之后,见计时。
对一个常驻的推理服务,0.65 秒只付一次,无关紧要。它要紧的场合是每次请求都起一个新进程:命令行工具、按请求冷启动的无服务器函数、批处理里成千上万次的小调用。
占多少内存
/usr/bin/time -l 报的峰值 RSS(maximum resident set size)和 peak memory footprint,各跑 3 次,3 次之间差不到 1%,下面取第一次:
| 命令 | 峰值 RSS | peak memory footprint |
|---|---|---|
python -I -c pass | 15.7 MiB | 6.6 MiB |
python -I -c "import torch" | 205.8 MiB | 149.0 MiB |
| PyTorch 推理脚本 | 208.1 MiB | 149.9 MiB |
burn,std + flex | 2.6 MiB | 1.3 MiB |
burn,默认 feature + flex | 2.8 MiB | 1.3 MiB |
burn,加 metal(只用 Flex) | 9.5 MiB | 4.5 MiB |
和启动时间一样,PyTorch 一侧的内存几乎都是 import torch 带来的,和模型大小无关。模型大了,两边都会多出权重和中间结果的那部分;这里比的是这部分之外的固定开销。
什么情况下不值得转
上面三个数字的差距都来自同一件事:burn 交付的是只含用到的代码的原生二进制,PyTorch 交付的是一整套通用环境。如果你的问题不在体积、启动和内存上,这个优势就用不上,而转过来要付的代价都还在。
算子覆盖
burn 0.22.1 有很多 PyTorch 常用的算子,但不是全部。挑几个试一下,先看 burn 有的三个:cumsum、einsum、svd。
a = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
print("cumsum(1) =", a.cumsum(1).flatten().tolist())
print("einsum ij,j->i =", torch.einsum("ij,j->i", a, torch.tensor([10.0, 20.0])).tolist())
print("svd S =", torch.linalg.svd(a).S.tolist())
g = a.clone().requires_grad_()
torch.linalg.svd(g).S.sum().backward()
print("svd grad =", g.grad.flatten().tolist())
use burn::linalg::svd;
use burn::tensor::{Device, Tensor};
fn main() {
let device = Device::flex();
let a = Tensor::<2>::from_floats([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], &device);
println!("cumsum(1) = {:?}", a.clone().cumsum(1).into_data().try_to_vec::<f32>());
let v = Tensor::<1>::from_floats([10.0, 20.0], &device);
let mv = Tensor::<1>::einsum("ij,j->i", [a.clone().into(), v.into()]);
println!("einsum ij,j->i = {:?}", mv.into_data().try_to_vec::<f32>());
let (u, s, vt) = svd::<2, 1>(a.clone(), 10);
println!("svd U {:?} Vt {:?}", u.dims(), vt.dims());
println!("svd S = {:?}", s.into_data().try_to_vec::<f32>());
if std::env::args().any(|x| x == "--grad") {
let ad = Device::flex().autodiff();
let g = Tensor::<2>::from_floats([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], &ad).require_grad();
let (_u, s, _vt) = svd::<2, 1>(g, 10);
println!("{:?}", s.into_data().try_to_vec::<f32>());
}
}
burn = { version = "=0.22.1", default-features = false, features = ["std", "flex", "autodiff", "linalg"] }
PyTorch:
cumsum(1) = [1.0, 3.0, 3.0, 7.0, 5.0, 11.0]
einsum ij,j->i = [50.0, 110.0, 170.0]
svd S = [9.525519371032715, 0.5143014788627625]
svd grad = [-0.5510035753250122, 0.7278250455856323, 0.13615891337394714, 0.5610649585723877, 0.8233200311660767, 0.3943061828613281]
burn,不带参数:
cumsum(1) = Ok([1.0, 3.0, 3.0, 7.0, 5.0, 11.0])
einsum ij,j->i = Ok([50.0, 110.0, 170.0])
svd U [3, 2] Vt [2, 2]
svd S = Ok([9.5255165, 0.5143005])
Tensor::cumsum 和 Tensor::einsum 的结果和 PyTorch 逐位相同(都是小整数)。两个奇异值分别差 2.9e-6(9.5255165 对 9.525519371)和 9.8e-7(0.5143005 对 0.51430148),这里容差取 1e-5:两边用的是不同的 SVD 算法,burn 的 svd 按文档是 Householder 双对角化加隐式移位的 QR 迭代,迭代次数有上限(第二个参数 sweeps),PyTorch 的算法本页没有核实。迭代算法在 f32 下的结果本来就只精确到这个量级。
svd 还有两处和 PyTorch 不一样:
- 形状:burn 的
U是[3, 2],是「精简」分解。torch.linalg.svd默认full_matrices=True,U是[3, 3];full_matrices=False时才是[3, 2]。burn 的文档写的是「reduced decomposition, matchingtorch.linalg.svd」,对应的其实是full_matrices=False。 - 不能求导:带
--grad运行,输入是 autodiff 设备上require_grad()的张量:
thread 'main' (8509250) panicked at .../burn-linalg-0.22.1/src/functions/svd.rs:98:9:
linalg::svd: gradients are not implemented; detach the input tensor first
PyTorch 一侧对奇异值求和再 backward(),梯度正常算出来了(上面的 svd grad)。如果你的模型在训练里用到 SVD(谱归一化之类),在 burn 0.22.1 里要自己写反向。
svd 要单独开一个 linalg feature上面的 Cargo.toml 多写了一个 linalg,不写的话 burn::linalg 这个模块不存在:它在 burn 里是 #[cfg(feature = "linalg")] pub mod linalg。之前不是这样:svd、qr、det、lu 这些函数放在 burn-tensor/src/tensor/linalg/ 里,随张量库一起编译。#5572「feat(linalg)!: extract tensor linalg into burn-linalg extension crate」(2026-09-08,首次发布于 v0.22.0-pre.4)把它们挪进单独的 burn-linalg crate,提交说明里的一条是「Make linalg optional and opt-in, not default」。所以 linalg 不在默认 feature 里,哪怕打开默认 feature 也要显式写上。
再看四个 PyTorch 里常用、在 burn 0.22.1 里找不到的:unique、searchsorted、bincount、linalg.inv。
t = torch.tensor([3, 1, 3, 2])
print("unique =", torch.unique(t).tolist())
print("searchsorted =", torch.searchsorted(torch.tensor([0.0, 1.0, 2.0]), torch.tensor([0.5, 1.5])).tolist())
print("bincount =", torch.bincount(t).tolist())
print("inv =", torch.linalg.inv(torch.tensor([[2.0, 0.0], [0.0, 4.0]])).tolist())
unique = [1, 2, 3]
searchsorted = [1, 2]
bincount = [0, 1, 1, 2]
inv = [[0.5, 0.0], [0.0, 0.25]]
照着写成 burn:
use burn::tensor::{Device, Int, Tensor};
fn main() {
let device = Device::flex();
let t = Tensor::<1, Int>::from_ints([3, 1, 3, 2], &device);
let _u = t.clone().unique();
let edges = Tensor::<1>::from_floats([0.0, 1.0, 2.0], &device);
let _i = edges.searchsorted(Tensor::<1>::from_floats([0.5, 1.5], &device));
let _c = t.bincount();
let m = Tensor::<2>::from_floats([[2.0, 0.0], [0.0, 4.0]], &device);
let _inv = burn::linalg::inv(m);
}
error[E0425]: cannot find function `inv` in module `burn::linalg`
--> src/bin/missing.rs:13:28
|
13 | let _inv = burn::linalg::inv(m);
| ^^^ not found in `burn::linalg`
error[E0599]: no method named `unique` found for struct `Tensor<D, K>` in the current scope
--> src/bin/missing.rs:8:22
|
8 | let _u = t.clone().unique();
| ^^^^^^ method not found in `Tensor<1, burn::tensor::Int>`
error[E0599]: no method named `searchsorted` found for struct `Tensor<D, K>` in the current scope
--> src/bin/missing.rs:10:18
|
10 | let _i = edges.searchsorted(Tensor::<1>::from_floats([0.5, 1.5], &device));
| ^^^^^^^^^^^^
|
help: there is a method `argsort` with a similar name
...
error[E0599]: no method named `bincount` found for struct `Tensor<D, K>` in the current scope
--> src/bin/missing.rs:11:14
|
11 | let _c = t.bincount();
| ^^^^^^^^ method not found in `Tensor<1, burn::tensor::Int>`
除了编译器,还在 0.22.1 的 burn-tensor、burn-linalg、burn-nn、burn-signal、burn-vision 源码里搜过这几个名字(以及 bucketize、cholesky、solve),都没有定义。burn::linalg 在 0.22.1 只有 cosine_similarity、det、diag、lu、matvec、outer、qr、svd、trace、vector_norm 这十组函数。发布之后,burn 的 main 分支(2026-10-28)给 burn-linalg 加了 cholesky(#5858)和批量线性求解 solve(#5856),还没有进任何发布版;另外三个和 inv 在 main 上也没有。这些算子都能用已有的算子拼出来(比如用 lu 解线性方程组、用 sort 加比较实现 searchsorted),但要自己写、自己验证,训练时还要确认拼出来的东西能求导。
生态
本页离线写作,没有核实两边生态的规模,只说一个能从上面推出来的判断:如果你要用的模型是别人用 PyTorch 写的,里面每个算子都要在 burn 里有对应,或者由你补上。权重本身可以读进 burn(导入 PyTorch 权重),但模型的 forward 要用 burn 重写一遍。
调试
- 试一行代码的成本差不多。 PyTorch 每跑一次脚本先付 0.65 秒的
import torch。burn 改一行代码(改输入的一个常数)后重新构建:release 和 debug 都是 0.67 秒左右,各测 3 次。但第一次构建要编译整个依赖树:本页这些 crate 在机器负载较高时,release 首次构建 45 到 51 秒。 - 交互式试验。 PyTorch 可以在 REPL 或 notebook 里留着已经算好的张量,一行一行地试。burn 每次改动都是重新编译、从头运行,中间结果要么重算,要么自己存成文件。
- 在调试器里看张量。 PyTorch 在
breakpoint()停下后,p a直接打印张量的值,p a.shape打印形状。burn 的Tensor<D, K>只有一个字段,是一块不透明的存储(Tensor 与 Kind),在调试器里看不到数值,要在代码里println!("{t}")或into_data()读出来,再重新编译。 - 错误在哪里报。 PyTorch 的形状错误在出错的那一行抛异常,比如
a @ torch.ones(2, 3)(a是[2, 3])报RuntimeError: mm: mat1 and mat2 shapes cannot be multiplied (2x3 and 2x3)。burn 把秩放在类型里,秩不对是编译错误(创建与形状);具体维度不对是运行时 panic(检查与报错)。GPU 后端上有的错误要等读回数据时才报出来(错误延迟报出)。
什么时候值得转
- 值得:推理要交付到不方便装 Python 环境的地方(命令行工具、嵌入到 Rust 服务里、每个请求一个新进程),模型用到的算子 burn 都有。这正是体积差约 300 倍、启动差约 250 倍、峰值 RSS 差约 80 倍起作用的场合。
- 不值得:主要工作是研究和试验模型,依赖 REPL 和 notebook;模型用到 burn 没有的算子,或者训练里要对
svd这类还没有反向的算子求导;要直接复用大量 PyTorch 写的模型代码。
小结
- 同一个 MLP 前向,两边结果最大差 1.49e-8(容差 1e-6)。
- 交付体积:Python 解释器加 torch 和依赖 728.5 MiB(只算 macOS 的 CPU wheel);burn 推理程序 2.3 MiB,打开默认 feature 5.3 MiB,多编译一个
metal是 39.1 MiB,都只链接系统库。 - 启动(文件已在缓存里,30 次取最小):PyTorch 约 654 ms,其中
import torch约 547 ms,前向 0.13 ms;burn 2.6 ms。 - 峰值 RSS:PyTorch 208.1 MiB,burn 2.6 MiB;差距来自
import torch,和模型无关。 - 0.22.1 里有
cumsum、einsum、linalg::svd(要开linalgfeature);没有unique、searchsorted、bincount、linalg.inv。svd不能求导,返回的是精简分解,对应 PyTorch 的full_matrices=False。 - 不值得转的情况:以试验为主、离不开 REPL;用到缺的算子或缺反向的算子;要复用大量 PyTorch 模型代码。