Rust in Everything
基于 candle-core 0.11.0

计算图怎么记下来

主线位置:求导主线的前半段,从 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:要不要记,完全由输入里有没有变量决定。

❗IMPORTANT
动脑筋: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 算出了新存储,可以。优化器每一步就是这样写回参数的,见优化器与训练循环。

📝NOTE
没有蠢问题

问: 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

Tensor::detach:

    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:整数张量在排序时就被剪掉了,见下一页。
📝NOTE
冷知识:argmax 以前也记 Op

PR #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 就留着全部中间结果。

💬 关联讨论

发起讨论
加载中...