Rust in Everything
基于 burn 0.22.1

运算消费张量

主线位置:张量主线的「计算」一步。每个运算都按值拿走输入张量,本页讲这件事在用户代码里的样子:报错、clone 的代价和模块里的写法。

版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备)
sequenceDiagram
  participant U as 用户代码
  participant A as 张量 a
  participant B as 运算 add
  U->>A: let a = ones
  U->>B: a.clone() + b
  Note over A: 计数 2 再回到 1
  B-->>U: c
  U->>A: 还能用 a
  U->>B: a + b
  Note over A: a 被移走
  U--xA: 再用 a 是 E0382

实验 crate tensorb-own(burn =0.22.1,feature std、flex、autodiff),三个编不过的小程序各是一个 bin;PyTorch 侧是 own.py。平台 M2 Max、macOS 27、rustc 1.99.0。

a + b 之后的 a

a = torch.ones(3)
b = torch.ones(3)
c = a + b
print("after c = a + b, a =", a.tolist())
let a = Tensor::<1>::ones([3], &device);
let b = Tensor::<1>::ones([3], &device);
let c = a + b;
println!("{c} {a}");
# PyTorch
after c = a + b, a = [1.0, 1.0, 1.0]
# burn(cargo build --bin moveerr)
error[E0382]: borrow of moved value: `a`
  --> src/bin/moveerr.rs:8:18
   |
 5 |   let a = Tensor::<1>::ones([3], &device);
   |       - move occurs because `a` has type `Tensor<1>`, which does not implement the `Copy` trait
 6 |   let b = Tensor::<1>::ones([3], &device);
 7 |   let c = a + b;
   |           ----- `a` moved due to usage in operator
 8 |   println!("{c} {a}");
   |                  ^ value borrowed here after move
   |
note: calling this operator moves the left-hand side
...
help: consider cloning the value if the performance cost is acceptable
   |
 7 |   let c = a.clone() + b;
   |            ++++++++

(note 下面指向标准库 arith.rs 的两行省略了。)

+ 落到 core::ops::Add::add(self, rhs),burn 的 Add 实现转给 Tensor::add(self, other),两个操作数都按值进去。rustc 的建议就是改法:要再用哪个,就在运算前 clone() 哪个。b 也被移走了,后面要用同样得 clone。

不能改成借用。burn 没有给 &Tensor 实现运算符:

let c = &a + &b;
error[E0369]: cannot add `&Tensor<1>` to `&Tensor<1>`
 --> src/bin/referr.rs:7:14
  |
7 |   let c = &a + &b;
  |           -- ^ -- &Tensor<1>
  |           |
  |           &Tensor<1>

只读元数据的方法拿的是 &self,不消费张量:dims、shape、dtype、device,以及读数据的 to_data(into_data 才按值)。println!("{a}") 经 Display 借用,同样不消费。

clone 不复制数据

在 PyTorch 里写 .clone() 是要一份独立的拷贝,b = a 才是共享。burn 的 clone() 名字一样,代价却接近 b = a:

n = 4096
x = torch.rand(n, n)
before = rss_mib()
y = x.clone()
after = rss_mib()
print(f"x {x.nelement() * 4 / 2**20:.0f} MiB, clone shares storage: {y.data_ptr() == x.data_ptr()}, peak RSS +{after - before:.0f} MiB")
let x = Tensor::<2>::random([n, n], Distribution::Default, &device);
let (y, mib) = measure(|| x.clone()); // 只计数的全局分配器
println!("x 64 MiB, clone allocated {mib:.3} MiB, x.can_mut() {} while clone alive", x.can_mut());
drop(y);
println!("after drop(clone): x.can_mut() {}", x.can_mut());
# PyTorch
x 64 MiB, clone shares storage: False, peak RSS +65 MiB
# burn(--release)
x 64 MiB, clone allocated 0.000 MiB, x.can_mut() false while clone alive
after drop(clone): x.can_mut() true

PyTorch 的 clone 换了一块新内存,进程的峰值 RSS 涨了 65 MiB(ru_maxrss)。burn 的 clone 向分配器要的字节不到 0.001 MiB:Flex 的张量把字节放在 Arc 里,clone 只是计数加一。burn 卷 Tensor 与 Kind 用同样的计数分配器量过,结果是 16 字节、几百纳秒。clone 活着时 can_mut 是 false,释放后回到 true。

所以「到处 clone() 会不会很慢」的担心,在 burn 里基本不成立。两边的三种写法这样对应:

想要的效果PyTorchburn
同一个值再用一次直接用名字a.clone(),只加计数
共享内存、互相看见修改b = a没有:张量不能被原地改,见原地操作
一份独立的拷贝a.clone()a.clone() 就够了;之后谁要写,后端发现缓冲被共享,会另开一块
📝NOTE
没有蠢问题

问: 既然 clone 共享缓冲,let b = a.clone(); let c = b + 1.0; 会不会把 a 也改了?

答: 不会。按值运算的结果写在哪里由后端决定:Flex 只在左操作数的缓冲没有别人持有时才原地写(is_unique,即 Arc 强引用计数为 1)。a 还活着,b 的缓冲计数是 2,结果就写进新缓冲。共享的缓冲永远不会被写,所以 clone 在语义上就是一份拷贝。切片的最后一节用 slice 演示了同一件事。

模块里的 &self

PyTorch 的 forward 里直接写 x @ self.weight。照搬到 burn:

struct Layer {
  weight: Tensor<2>,
}

impl Layer {
  fn forward(&self, x: Tensor<2>) -> Tensor<2> {
    x.matmul(self.weight)
  }
}
error[E0507]: cannot move out of `self.weight` which is behind a shared reference
 --> src/bin/selferr.rs:9:14
  |
9 |     x.matmul(self.weight)
  |              ^^^^^^^^^^^ move occurs because `self.weight` has type `Tensor<2>`, which does not implement the `Copy` trait
  |
help: consider cloning the value if the performance cost is acceptable
  |
9 |     x.matmul(self.weight.clone())
  |                         ++++++++

forward 拿的是 &self,不能把字段移走,否则模型下一次调用时就没有权重了。改法还是 clone()。burn 自己的 Linear 就是这么写的:权重是 Param<Tensor<2>>,Linear::forward 第一行 let weight = self.weight.val();,而 Param::val 的第一行是 let base = self.deref().clone();。每次 forward 克隆一次权重,代价是一次计数,不是一次拷贝。#[derive(Module)] 和 Param 怎么定义模型,见定义模型。

输入 x 是按值传进 forward 的,调用方如果之后还要用它(比如残差连接 x + block(x)),同样写成 x.clone() + block(x),或者 block(x.clone()) + x。

小结

  • burn 的运算按值拿走操作数:a + b 之后再用 a 是 E0382;&a + &b 没有实现,是 E0369。
  • 要复用就 clone()。它只加引用计数:64 MiB 的张量 clone 一次,分配不到 0.001 MiB;PyTorch 的 clone() 多占 65 MiB。
  • dims、shape、dtype、device、to_data 拿 &self,不消费张量。
  • forward(&self) 里用字段要 clone()(E0507);Param::val() 内部就是一次 clone。
  • clone 出来的张量共享缓冲,但共享的缓冲不会被写,语义上等于一份拷贝。

💬 关联讨论

发起讨论
加载中...