Rust in Everything
基于 burn 0.22.1

为什么要转

主线位置:部署主线的最后一步,不带自动求导地推理。本页只比较交付出去的东西:同一个前向推理,两边要装多大的环境、启动要多久、占多少内存;再列出在哪些情况下转过来不划算。
版本: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.dylib402.3 MiB(421 820 912 字节)
其中 torch/include(C++ 头文件)62.9 MiB
torch 声明的依赖(sympy、networkx、mpmath、setuptools、jinja2、fsspec、filelock、typing_extensions、markupsafe)加 torchgen、functorch64.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 + flex2.3 MiB(2 454 832 字节)libSystem、libiconv
打开默认 feature,flex(Flex 带 simd、rayon,另带 autodiff、optim)5.3 MiB(5 591 776 字节)libSystem、libiconv
前一行的依赖再加 metal,程序仍然只用 Flex39.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%,下面取第一次:

命令峰值 RSSpeak memory footprint
python -I -c pass15.7 MiB6.6 MiB
python -I -c "import torch"205.8 MiB149.0 MiB
PyTorch 推理脚本208.1 MiB149.9 MiB
burn,std + flex2.6 MiB1.3 MiB
burn,默认 feature + flex2.8 MiB1.3 MiB
burn,加 metal(只用 Flex)9.5 MiB4.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, matching torch.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 里要自己写反向。

📝NOTE
冷知识: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(要开 linalg feature);没有 unique、searchsorted、bincount、linalg.inv。svd 不能求导,返回的是精简分解,对应 PyTorch 的 full_matrices=False。
  • 不值得转的情况:以试验为主、离不开 REPL;用到缺的算子或缺反向的算子;要复用大量 PyTorch 模型代码。

💬 关联讨论

发起讨论
加载中...