计算图怎么记下来
主线位置:求导主线的前半段,从
Var到「每个结果张量记下Op」。前向的每个算子照常走算子主线,最后一步「新张量带上BackpropOp」就是本页的内容。loss.backward()怎么沿着这些记录倒着走,见下一页 backward 与 GradStore。
flowchart TB x["x 普通张量"] w["w Var"] y["y 记 Matmul"] s["记 Unary Sqr"] r["记 Reduce Sum"] loss["loss 记 Reshape"] loss -->|"Op 持有"| r r -->|"Op 持有"| s s -->|"Op 持有"| y y -->|"Op 持有"| x y -->|"Op 持有"| w
箭头从结果指向输入:结果张量持有输入的引用,反过来没有。本页的实验是 exp-candle/bp-ad 的 graph 和 setvar 两个 bin,CPU,debug,M2 Max、macOS 27、rustc 1.99.0。
图就是张量之间的引用
Tensor 是 Arc<Tensor_>,克隆只加一次引用计数(字段的细节见 Tensor 的结构)。和自动微分有关的是 Tensor_ 里的三个字段:
pub struct Tensor_ {
id: TensorId,
// ...
op: BackpropOp,
is_variable: bool,
// ...
}
id 是一个全局原子计数器发的编号,每建一个 Tensor_ 加一;GradStore 用它当键。op 的类型 BackpropOp 只是包了一层 Option<Op>,源码注释说包这一层是为了「在创建新值时正确地检查依赖」:
/// `BackpropOp` is a wrapper around `Option<Op>`. The main goal is to ensure that dependencies are
/// properly checked when creating a new value
#[derive(Clone)]
pub struct BackpropOp(Option<Op>);
Op 是一个 36 个变体的枚举,每个变体存着这个算子的输入张量和求导要用的参数:
#[derive(Clone)]
pub enum Op {
Binary(Tensor, Tensor, BinaryOp),
Unary(Tensor, UnaryOp),
Cmp(Tensor, CmpOp),
// The third argument is the reduced shape with `keepdim=true`.
Reduce(Tensor, ReduceOp, Vec<usize>),
Matmul(Tensor, Tensor),
// ...
Broadcast(Tensor),
Narrow(Tensor, usize, usize, usize),
Reshape(Tensor),
Transpose(Tensor, usize, usize),
// ...
CustomOp1(
Tensor,
std::sync::Arc<Box<dyn crate::CustomOp1 + Send + Sync>>,
),
// ...
}
这里的 Tensor 就是输入张量本身(一次 Arc 克隆),不是 id,也不是形状。所以 candle 没有单独的「图」或「磁带」(tape)对象:loss 的 Op 里存着它的输入,输入的 Op 里又存着输入的输入,一直到没有 Op 的张量为止。整张图就是这串 Arc 引用,从任何一个结果出发都能走遍它的全部来历。Op 只描述「怎么算出来的」,不带求导代码;求导规则全写在 backprop.rs 的一个 match 里,见下一页。burn 的做法相反:每个算子把一个反向步骤登记进进程级的图里(见 backward 与 Gradients)。
CustomOp1/2/3 变体存的是用户算子的 trait 对象,它们的反向由用户实现的 bwd 方法给出,见自定义算子。
什么时候记:track_op
每个算子先让后端把数算出来,再决定记不记。以逐元素二元运算的宏 binary_op! 为例:
let storage = self.storage().binary_impl::<crate::op::$op_name>(
&*rhs.storage(),
self.layout(),
rhs.layout(),
)?;
let op = BackpropOp::new2(self, rhs, |t1, t2| Op::Binary(t1, t2, BinaryOp::$op_name));
Ok(from_storage(storage, shape.clone(), op, false))
Tensor::matmul 的结尾是同样的两行,只是传 Op::Matmul。BackpropOp::new2 决定记不记:
pub(crate) fn new2(arg1: &Tensor, arg2: &Tensor, f: impl Fn(Tensor, Tensor) -> Op) -> Self {
let op = if arg1.track_op() || arg2.track_op() {
Some(f(arg1.clone(), arg2.clone()))
} else {
None
};
Self(op)
}
track_op 是公开方法:
pub fn track_op(&self) -> bool {
self.is_variable || self.op.is_some()
}
「需要记」是递归定义的:自己是变量,或者自己记了 Op(也就是某个输入需要记)。只要所有输入都既不是变量、也没有 Op,结果的 op 就是 None,Op 根本不会被构造,输入也不会多一次克隆。new1、new3 和接受切片的 new(cat 用)是同样的判断。另外有几处不管输入如何都记 None:建张量的函数(zeros、new、from_vec……)、argmax / argmin(reduce_impl 里对这两个直接 BackpropOp::none())和下面讲的 detach。
op() 是 pub(crate),外面看不到 Op 本身,但能看到两样东西:track_op(),以及 0.8.4 起公开的 sorted_nodes()(下一页讲,返回从这个张量出发、通向变量的节点)。实验 graph 用它们观察同一段计算:
let x = Tensor::new(&[[1f32, 2.], [3., 4.]], &dev)?;
// w 是普通张量
let w = Tensor::new(&[[0.5f32, -1.], [2., 0.]], &dev)?;
let y = x.matmul(&w)?;
let loss = y.sqr()?.sum_all()?;
// w 是 Var
let w = Var::new(&[[0.5f32, -1.], [2., 0.]], &dev)?;
let y = x.matmul(w.as_tensor())?;
let loss = y.sqr()?.sum_all()?;
-- w is a plain Tensor
w id=TensorId(2) is_variable=false track_op=false sorted_nodes=0
y = x.matmul(w) id=TensorId(3) is_variable=false track_op=false sorted_nodes=0
loss id=TensorId(6) is_variable=false track_op=false sorted_nodes=0
-- w is a Var
w id=TensorId(7) is_variable=true track_op=true sorted_nodes=1
x id=TensorId(1) is_variable=false track_op=false sorted_nodes=0
y = x.matmul(w) id=TensorId(8) is_variable=false track_op=true sorted_nodes=2
没有 Var 时整段计算一个 Op 都不记,这就是推理时的状态:从 safetensors 或 GGUF 读出来的权重都是普通张量(见 VarBuilder),前向不会留下任何图。candle 没有 PyTorch no_grad 那样的开关,0.11.0 的 candle-core 和 candle-nn 里也搜不到 no_grad:要不要记,完全由输入里有没有变量决定。
w 是 Var 时,loss = x.matmul(w)?.sqr()?.sum_all()? 的 loss.sorted_nodes().len() 是几?上面的输出已经给出 w 是 1、y 是 2。x 是普通张量。提示:看一眼 sum_all 是怎么实现的。
Var:is_variable 为真的张量
Var 是一个张量的新类型包装,Deref 到 Tensor,所以 &*w 可以直接参与运算,也可以用 w.as_tensor():
// Variables are wrappers around tensors that can be modified, they are typically used for holding
// weights and being modified by gradient descent.
// We do not expose a public way to create variables as this would break the invariant that the
// tensor within a variable is actually with `is_variable` set to `true`.
use crate::{DType, Device, Error, Result, Shape, Tensor};
/// A variable is a wrapper around a tensor, however variables can have their content modified
/// whereas tensors are immutable.
#[derive(Clone, Debug)]
pub struct Var(Tensor);
Var::zeros、Var::new、Var::randn 这些构造函数调的是 Tensor 上 pub(crate) 的 *_impl(.., is_variable: true);对外,is_variable 为真的张量只能经 Var 得到。Var::from_tensor 对普通张量调 make_var,它新分配一块存储、把数据复制过去,所以得到的变量和原张量不共享数据。
Var 比 Tensor 多的是 set:张量是不可变的,变量的内容可以原地改,靠的是 Tensor_ 里 Arc<RwLock<Storage>> 的写锁。
pub fn set(&self, src: &Tensor) -> Result<()> {
if self.same_storage(src) {
let msg = "cannot set a variable to a tensor that is derived from its value";
Err(Error::CannotSetVar { msg }.bt())?
}
let (mut dst, layout) = self.storage_mut_and_layout();
if !layout.is_contiguous() {
let msg = "cannot set a non-contiguous variable";
Err(Error::CannotSetVar { msg }.bt())?
}
// ...
src.copy_strided_src(&mut dst, layout.start_offset(), src_l)?;
Ok(())
}
set 不换 Tensor_,只把新数据复制进原来的存储:变量的 id 不变,所有持有它克隆的地方(模型里的 Linear、优化器里的列表)都看到新值。第一个检查挡住的是和变量共享存储的来源:后面先拿变量存储的写锁、再拿 src 的读锁,两者是同一把 RwLock。实验 setvar:
set(w.t()) -> cannot set variable cannot set a variable to a tensor that is derived from its value
set(w.as_detached_tensor()) -> cannot set variable cannot set a variable to a tensor that is derived from its value
after set(w * 2): [[2.0, 4.0], [6.0, 8.0]]
w.t() 是视图,as_detached_tensor() 共享存储,都被拒绝;w * 2 算出了新存储,可以。优化器每一步就是这样写回参数的,见优化器与训练循环。
问: PyTorch 是 t.requires_grad_(True),在张量上翻一个标志。candle 为什么要单独一个 Var 类型?
答: 因为 Tensor_ 放在 Arc 里、建好之后不可变,is_variable 只是一个普通的 bool 字段,没有地方能「翻」它:要翻就得改所有克隆共享的那一份。candle 的选择是变量在出生时就定下来,Var::from_tensor 也是复制出一个新张量。variable.rs 开头的注释说得很直接:不公开别的建变量的途径,是为了保证 Var 里的张量一定是 is_variable。单独的类型还带来一个好处:能原地改内容的只有 Var(set),普通 Tensor 在类型上就没有这个方法。
detach:换一个 id,丢掉 Op
pub fn detach(&self) -> Tensor {
if self.op.is_none() && !self.is_variable {
self.clone()
} else {
let tensor_ = Tensor_ {
id: TensorId::new(),
storage: self.storage.clone(),
layout: self.layout.clone(),
op: BackpropOp::none(),
is_variable: false,
dtype: self.dtype,
device: self.device.clone(),
};
Tensor(Arc::new(tensor_))
}
}
已经脱离图的张量原样返回(同一个 id);否则新建一个 Tensor_:存储共享(不复制数据),op 是 None,is_variable 是 false,id 是新的。新 id 很重要:GradStore 按 id 存梯度,脱离后的张量和原张量在梯度上是两个东西。对变量 detach 也一样,Var::as_detached_tensor 就是它。实验接着上面的 y(w 是 Var):
-- detach / argmax / views
y.detach() id=TensorId(12) is_variable=false track_op=false sorted_nodes=0
y.detach().exp() id=TensorId(13) is_variable=false track_op=false sorted_nodes=0
y.argmax(1) id=TensorId(15) is_variable=false track_op=false sorted_nodes=0
w.t() id=TensorId(16) is_variable=false track_op=true sorted_nodes=2
y.to_dtype(U32) id=TensorId(17) is_variable=false track_op=true sorted_nodes=0
w.as_detached_tensor() id=TensorId(18) is_variable=false track_op=false sorted_nodes=0
detach之后再算,结果也不记了:它的输入既不是变量也没有Op。argmax不记,与输入无关。它的 id 是 15 而不是 14:reduce_impl先建一个保留维度的结果(14),keepdim为假时再挤掉那一维(15)。- 转置这种视图也记:
w.t()记了Op::Transpose,它只是不复制存储,和记不记图无关。 to_dtype(U32)记了Op::ToDType(track_op为真),但sorted_nodes是 0:整数张量在排序时就被剪掉了,见下一页。
argmax 以前也记 OpPR #865(「Do not backprop through argmin/argmax.」,2023-09-15)之前,reduce_impl 对五种归约一律 BackpropOp::new1(self, |arg| Op::Reduce(arg, op, dims.to_vec())),argmax 的结果也带着 Op::Reduce(.., ArgMax, ..),拓扑排序时 walk 对任何 Op::Reduce 都往下走。这个提交在 reduce_impl 里加了现在这个 match,ArgMin | ArgMax 记 BackpropOp::none();同时让 walk 遇到 Op::Reduce(_, ArgMin | ArgMax, _) 不再往下走。argmax 的输出是 u32 下标,本来就没有梯度可传。提交时工作区的版本号是 0.3.0;本地仓库最早的 tag 是 0.5.1,git tag --contains 给出的第一个就是它。
图活多久
Op 里存的是输入张量的强引用,所以只要 loss 还活着,通向它的每个中间结果(连同它们的存储)都活着,哪怕用户代码早就丢掉了它们的变量名。实验 graph 的最后一段:
let big = Var::zeros((1024, 1024), DType::F32, &dev)?;
let h = big.as_tensor().exp()?; // 4 MiB intermediate
let out = h.sum_all()?;
let h_id = h.id();
drop(h);
let still_there = out.sorted_nodes().iter().any(|n| n.id() == h_id);
dropped h, still reachable from out: true
反过来,丢掉 loss(以及从它算出来的其他张量),整张图的引用计数归零,内存当场释放,不需要等 backward,也没有别的地方还留着它。训练循环里常见的写法 let loss_value = loss.to_scalar::<f32>()?; 只取出一个数,下一步的 loss 一覆盖,上一步的图就没了。在 burn 里,只前向不 backward 时保存的状态会一直留在全局图里,直到下一次 backward(见 backward 与 Gradients)。
代价在另一头:用变量做推理(比如拿训练中的模型跑验证集)时,每一步都会建图、保留所有中间结果,直到输出被丢掉。要避免,就在入口 detach,或者让权重不是变量。
动脑筋答案
loss id=TensorId(11) is_variable=false track_op=true sorted_nodes=5
sorted[0] id=TensorId(11) dims=[] is_variable=false
sorted[1] id=TensorId(10) dims=[1, 1] is_variable=false
sorted[2] id=TensorId(9) dims=[2, 2] is_variable=false
sorted[3] id=TensorId(8) dims=[2, 2] is_variable=false
sorted[4] id=TensorId(7) dims=[2, 2] is_variable=true
- 是 5。
sum_all是sum对所有维求和,sum_impl先建一个保留维度的[1, 1]结果(id 10,记Op::Reduce),再挤掉两维变成标量(id 11,记Op::Reshape)。加上sqr(9)、matmul(8)和w(7),一共 5 个。 x(id 1)不在里面:它不通向任何变量,排序时被剪掉。y的Op::Matmul仍然持有它,只是反向不会给它单独算一遍。- 顺序是根在前、变量在后,这正是
backward要的顺序。
小结
- 图就是
Arc引用:每个结果张量的op: BackpropOp(Option<Op>)里存着输入张量本身;没有单独的图对象,也没有全局登记。 - 记不记由
track_op决定:输入里有变量,或者有记了Op的张量。没有Var的计算一个Op也不记,这就是推理的默认状态;没有no_grad开关。 Var是is_variable为真的张量,只能经Var建;set用写锁把新数据复制进原存储,id 不变,拒绝和自己共享存储的来源。detach共享存储、换新 id、丢掉Op;argmax/argmin总是不记。- 图活得和最后一个持有它的结果一样久:丢掉
loss就释放,留着loss就留着全部中间结果。