运算消费张量
主线位置:张量主线的「计算」一步。每个运算都按值拿走输入张量,本页讲这件事在用户代码里的样子:报错、
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 里基本不成立。两边的三种写法这样对应:
| 想要的效果 | PyTorch | burn |
|---|---|---|
| 同一个值再用一次 | 直接用名字 | a.clone(),只加计数 |
| 共享内存、互相看见修改 | b = a | 没有:张量不能被原地改,见原地操作 |
| 一份独立的拷贝 | a.clone() | a.clone() 就够了;之后谁要写,后端发现缓冲被共享,会另开一块 |
模块里的 &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 出来的张量共享缓冲,但共享的缓冲不会被写,语义上等于一份拷贝。