Rust in Everything
基于 candle-core 0.11.0

backward 与 GradStore

主线位置:求导主线的后半段。前向时每个结果张量已经把输入记在自己的 Op 里(见计算图怎么记下来);本页从 loss.backward() 讲起:sorted_nodes 拓扑排序,倒序逐个 Op 求导,梯度进 GradStore。优化器怎么用 GradStore 见优化器与训练循环。

sequenceDiagram
  participant U as 用户代码
  participant L as loss
  participant G as GradStore
  U->>L: backward()
  L->>L: sorted_nodes 拓扑排序
  L->>G: 根的梯度 全 1
  loop 每个非变量节点 根在前
    L->>G: remove 取出梯度 再 detach
    L->>L: 按 Op 套求导规则
    L->>G: or_insert 累加到输入
  end
  L-->>U: GradStore
  U->>G: get(w)

整个反向只有两个函数:Tensor::sorted_nodes 和 Tensor::backward,都在 backprop.rs。本页的实验是 exp-candle/bp-ad 的 backward,CPU,debug,M2 Max、macOS 27、rustc 1.99.0。

sorted_nodes:深度优先,后序,再反转

反向要求一个节点的梯度在用它之前已经收齐:一个张量被几个结果用到,它的梯度就是几份之和。所以要先排一个拓扑序,保证每个节点排在它所有使用者之后。sorted_nodes 用一个递归的 walk 做深度优先遍历:

        fn walk<'a>(
            node: &'a Tensor,
            nodes: Vec<&'a Tensor>,
            already_seen: &mut HashMap<TensorId, bool>,
        ) -> (bool, Vec<&'a Tensor>) {
            if let Some(&tg) = already_seen.get(&node.id()) {
                return (tg, nodes);
            }
            let mut track_grad = false;
            let mut nodes = if node.is_variable() {
                // Do not call recursively on the "leaf" nodes.
                track_grad = true;
                nodes
            } else if node.dtype().is_int() {
                nodes
            } else if let Some(op) = node.op() {
                match op {
                    // ...
                    | Op::Binary(lhs, rhs, _)
                    // ...
                    | Op::Matmul(lhs, rhs)
                    | Op::SliceScatter0(lhs, rhs, _) => {
                        let (tg, nodes) = walk(lhs, nodes, already_seen);
                        track_grad |= tg;
                        let (tg, nodes) = walk(rhs, nodes, already_seen);
                        track_grad |= tg;
                        nodes
                    }
                    // ...
                }
            } else {
                nodes
            };
            already_seen.insert(node.id(), track_grad);
            if track_grad {
                nodes.push(node);
            }
            (track_grad, nodes)
        }
        let (_tg, mut nodes) = walk(self, vec![], &mut HashMap::new());
        nodes.reverse();
        nodes

几个要点:

  • 后序,再反转。 一个节点在它的所有输入都走完之后才 push,所以 nodes 里输入在前、结果在后;最后 reverse,根(loss)排第一,变量排最后。
  • 返回值带一个 bool:这个节点通不通向变量。 变量返回 true 而且不再往下走(它是叶子);其他节点的 track_grad 是输入的「或」。只有为真的节点才 push。所以不通向任何变量的输入(比如上一页的 x)根本不进列表,反向也不会为它们算梯度。
  • 剪枝。 整数张量直接返回 false;Op 的 match 里还有几处不往下走:Affine 的 mul == 0、Ceil / Floor / Round / Sign、从整数转来的 ToDType、ArgMin / ArgMax。这些节点的梯度处处为零(或没有意义),剪掉它们省掉一整枝的计算。
  • already_seen 是记忆表,不只是「访问过」的标记。 值是那个 bool:同一个节点第二次遇到时,直接返回上次的结论,不重走它的子图。

最后一条决定了复杂度。一个中间结果被用两次很常见(a * a,残差连接)。如果不记,每条路径都会把下面的子图重走一遍;路径数随共享的层数指数增长。实验第 8 段让每一层都被用两次:

let x = Var::new(&[1f32], &dev)?;
let mut a = x.as_tensor().clone();
for _ in 0..60 {
  a = (&a + &a)?;
}
let n = a.sorted_nodes().len();
let g = a.backward()?;
sorted_nodes = 61, x.grad = [1.1529215e18], sorted+backward took 331µs

从 a 到 x 有 2⁶⁰ 条路径,梯度也正好是 2⁶⁰ ≈ 1.1529215e18;但 walk 每个节点只真正展开一次,61 个节点(60 个加法加上 x),排序加反向一共 0.3 ms。

walk 是递归的,图有多深,调用栈就有多深。sorted_nodes 的文档注释写着「This assumes that the op graph is a DAG」:图由 Arc 引用组成,张量建好之后不可变,结果只能引用比它早建的张量,环在构造上就不可能出现。

倒序执行:每个 Op 一段规则

backward 的骨架:

    pub fn backward(&self) -> Result<GradStore> {
        let sorted_nodes = self.sorted_nodes();
        let mut grads = GradStore::new();
        grads.insert(self, self.ones_like()?.contiguous()?);
        for node in sorted_nodes.iter() {
            if node.is_variable() {
                continue;
            }
            let grad = grads
                .remove(node)
                .expect("candle internal error - grad not populated");
            // https://github.com/huggingface/candle/issues/1241
            // Ideally, we would make these operations in place where possible to ensure that we
            // do not have to allocate too often. Here we just call `.detach` to avoid computing
            // the backprop graph of the backprop itself. This would be an issue for second order
            // derivatives but these are out of scope at the moment.
            let do_not_detach = CANDLE_GRAD_DO_NOT_DETACH.with(|b| *b);
            let grad = if do_not_detach { grad } else { grad.detach() };
            if let Some(op) = node.op() {
                match op {
                    // ...
                }
            }
        }
        Ok(grads)
    }

根的梯度是和 loss 同形状的全 1(loss 不必是标量;不是标量时相当于先对所有元素求和)。之后按拓扑序处理每个非变量节点:从 GradStore 里取出(remove)它已经收齐的梯度,detach 一下,再按它的 Op 把梯度分给输入。变量跳过,它们的梯度留在 GradStore 里交给用户。

每段规则都是同一个模式:算出这个输入的那一份梯度,or_insert 取出(或新建全零的)累加槽,加上去。规则本身是用普通张量算子写的,所以它们也走算子主线,在张量所在的设备上算;Metal 上的反向就是一串 Metal 内核。

Matmul(规则):C = A·B 时,∂L/∂A = G·Bᵀ,∂L/∂B = Aᵀ·G:

                    Op::Matmul(lhs, rhs) => {
                        // Skipping checks, the op went ok, we can skip
                        // the matmul size checks for now.

                        let lhs_grad = grad.matmul(&rhs.t()?)?;
                        let lhs_sum_grad = grads.or_insert(lhs)?;
                        *lhs_sum_grad = lhs_sum_grad.add(&lhs_grad)?;

                        let rhs_grad = lhs.t()?.matmul(&grad)?;
                        let rhs_sum_grad = grads.or_insert(rhs)?;
                        *rhs_sum_grad = rhs_sum_grad.add(&rhs_grad)?;
                    }

t() 交换最后两维,所以带批维的 matmul 也适用。

Binary(Mul)(规则):两边各乘对方。它要用到前向的输入 lhs、rhs,这就是 Op 必须持有输入张量本身的原因:

                    Op::Binary(lhs, rhs, BinaryOp::Mul) => {
                        let lhs_grad = grad.mul(rhs)?;
                        let lhs_sum_grad = grads.or_insert(lhs)?;
                        *lhs_sum_grad = lhs_sum_grad.add(&lhs_grad)?;
                        let rhs_grad = grad.mul(lhs)?;
                        let rhs_sum_grad = grads.or_insert(rhs)?;
                        *rhs_sum_grad = rhs_sum_grad.add(&rhs_grad)?;
                    }

有的规则用的是前向的输出 node:exp 的导数是 grad * node,tanh 的是 grad * (1 - node²)。前向结果不用重算,因为 node 就是排序列表里的那个张量。

Reduce(规则):Op::Reduce 的第三个字段是「keepdim=true 时的形状」(上一页 Op 定义里的注释),反向先用 broadcast_back 把梯度还原成那个形状、再广播回输入的形状:

fn broadcast_back(arg: &Tensor, node: &Tensor, reduced_dims: &[usize]) -> Result<Tensor> {
    if arg.rank() == node.rank() {
        // keepdim = true
        node.broadcast_as(arg.shape())
    } else {
        // keepdim = false
        // first expand the reduced dims.
        node.reshape(reduced_dims)?.broadcast_as(arg.shape())
    }
}
                    Op::Reduce(arg, ReduceOp::Sum, reduced_dims) => {
                        let grad = broadcast_back(arg, &grad, reduced_dims)?;
                        let sum_grad = grads.or_insert(arg)?;
                        *sum_grad = sum_grad.add(&grad)?;
                    }
                    Op::Reduce(arg, ReduceOp::Max, reduced_dims) => {
                        let node = broadcast_back(arg, node, reduced_dims)?;
                        let grad = broadcast_back(arg, &grad, reduced_dims)?;
                        let grad = node.eq(arg)?.to_dtype(grad.dtype())?.mul(&grad)?;
                        let sum_grad = grads.or_insert(arg)?;
                        *sum_grad = sum_grad.add(&grad.broadcast_as(sum_grad.dims())?)?;
                    }

求和的梯度原样分给每个被加的元素;最大值的梯度用一个掩码 node.eq(arg) 只给等于最大值的元素。

❗IMPORTANT
动脑筋:x = Var::new(&[1f32, 3., 3.]),x.max(0)?.backward()? 之后 x 的梯度是什么?换成逐元素的 x.maximum(&c)?.sum_all()?、c = [0, 3, 5] 呢?

看 Reduce(Max) 的掩码,再看一眼 Binary(Minimum | Maximum) 的规则。

广播(规则):candle 的 + 不自动广播,broadcast_add 由宏 broadcast_binary_op! 展开成「需要的一边先 broadcast_as,再做同形状的 add」。broadcast_as 是视图(步长为 0),记 Op::Broadcast。所以加偏置 xw.broadcast_add(&b) 在图里是两个节点:Broadcast(b) 和 Binary(xw, 广播后的 b, Add)。Add 的规则把梯度原样分给两边,Broadcast 的规则再把它压回 b 的形状:

                    Op::Broadcast(arg) => {
                        let arg_dims = arg.dims();
                        let node_dims = node.dims();
                        // The number of dims that have been inserted on the left.
                        let left_dims = node_dims.len() - arg_dims.len();
                        let mut sum_dims: Vec<usize> = (0..left_dims).collect();
                        for (dim, (node_dim, arg_dim)) in node_dims[left_dims..]
                            .iter()
                            .zip(arg_dims.iter())
                            .enumerate()
                        {
                            if node_dim != arg_dim {
                                sum_dims.push(dim + left_dims)
                            }
                        }

                        let mut arg_grad = grad.sum_keepdim(sum_dims.as_slice())?;
                        for _i in 0..left_dims {
                            arg_grad = arg_grad.squeeze(0)?
                        }
                        let sum_grad = grads.or_insert(arg)?;
                        *sum_grad = sum_grad.add(&arg_grad.broadcast_as(sum_grad.dims())?)?;
                    }

被复制出来的维度有两种:左边新插入的维,以及原来是 1、被拉长的维。两种都要求和;新插入的维求和后再 squeeze 掉。对形状 (3,) 的偏置、(4, 3) 的输出,就是对 batch 维求和。

36 个 Op 变体大多有规则,少数报错:ConvTranspose1D 返回 BackwardNotSupported;AvgPool2D / MaxPool2D 在核大小不等于步长时、最近邻上采样在倍数不是整数时、UpsampleBilinear2D 一律 bail!。Ceil 也有一条 BackwardNotSupported 分支,但 walk 已经把 Ceil 剪掉了,这条分支走不到:实验第 9 段对 (x.ceil() + x).sum_all() 求导,得到 x.grad = [1.0, 1.0],没有报错,和 floor 一样。

backward 里的 expect("candle internal error - grad not populated") 假定排序列表里的每个中间节点都会被某条规则填上梯度。自定义算子是例外:CustomOp1::bwd 的返回类型是 Result<Option<Tensor>>,规则在拿到 None 时什么也不填,但 walk 照样把算子的输入放进了列表。输入是变量时没事(变量被跳过);输入是中间结果时就会 panic。实验 customnone:一个前向复制数据、bwd 返回 Ok(None) 的 CustomOp1,先作用在 Var 上,再作用在 x.exp() 上:

on Var: x.grad = None

thread 'main' (3717951) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/candle-core-0.11.0/src/backprop.rs:175:18:
candle internal error - grad not populated
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

想表示「这个输入没有梯度」,返回一个全零张量才安全。

用有限差分验证

规则写对了没有,最直接的办法是和数值导数比:∂L/∂wᵢⱼ ≈ (L(w + ε) − L(w − ε)) / 2ε。实验第 2 段用 f64,覆盖本页讲的四类规则(matmul、广播加、tanh、平方、求和):

let xs = Tensor::new(&[[1f64, -2.], [0.5, 3.], [-1., 0.25], [2., 2.]], &dev)?; // (4, 2)
let w = Var::new(&[[0.1f64, -0.3, 0.2], [0.7, 0.05, -0.4]], &dev)?; // (2, 3)
let b = Var::new(&[0.01f64, -0.02, 0.03], &dev)?; // (3,)
let f = |w: &Tensor, b: &Tensor| -> candle_core::Result<Tensor> {
  xs.matmul(w)?.broadcast_add(b)?.tanh()?.sqr()?.sum_all()
};
let grads = f(w.as_tensor(), b.as_tensor())?.backward()?;
// 对 w 和 b 的每个元素:用普通张量 w ± 1e-6 重算 f,中心差分,与 grads 比
w.grad = [[-0.022252, -2.686380, -0.424579], [1.790514, -0.126415, -4.387318]]
b.grad = [0.092104, -0.923299, -1.083957]
max |analytic - central difference| = 5.692e-10

9 个分量的最大误差 5.7e-10,在中心差分(ε = 1e-6、f64)的截断和舍入误差范围内。扰动时用的是普通 Tensor:数值导数只需要前向,不用建图。candle 自己的 candle-core/tests/grad_tests.rs 是另一种验证:对每个算子手算期望值,逐个断言。

GradStore

GradStore 就是一张 TensorId 到梯度张量的表:

/// A store for gradients, associating a tensor id to the corresponding gradient tensor, used for back propagation.
#[derive(Default, Debug)]
pub struct GradStore(HashMap<TensorId, Tensor>);

get(&tensor) 按 tensor.id() 查。累加用的 or_insert 在槽位不存在时放一个 zeros_like:

    fn or_insert(&mut self, tensor: &Tensor) -> Result<&mut Tensor> {
        let grad = match self.0.entry(tensor.id()) {
            Entry::Occupied(entry) => entry.into_mut(),
            Entry::Vacant(entry) => {
                let grad = tensor.zeros_like()?;
                entry.insert(grad)
            }
        };
        Ok(grad)
    }

backward 结束时表里剩下什么,由三件事决定:中间节点的梯度被 remove 取走;变量被跳过、留下;规则对每个输入都调 or_insert,不管那个输入在不在排序列表里。实验第 4 到第 6 段:

let x = Var::new(&[1f32, 2.], &dev)?;
let k = Tensor::new(&[10f32, 20.], &dev)?; // plain tensor
let h = (x.as_tensor() * &k)?;
let unused = Tensor::new(&[7f32, 7.], &dev)?;
let loss = h.sum_all()?;
let g1 = loss.backward()?;
// ...
let g2 = loss.backward()?;
// ...
let hd = (x.as_tensor() * &k)?.detach();
let loss_d = (hd * 2.0)?.sum_all()?;
let gd = loss_d.backward()?;
== 4. what ends up in GradStore
entries          = 2
x.grad           = Some([10.0, 20.0])
k.grad (Tensor)  = Some([1.0, 2.0])
h.grad (interm.) = None
loss.grad (root) = None
unused.grad      = None
== 5. backward twice on the same loss
second x.grad    = Some([10.0, 20.0])
== 6. detach in the middle
loss_d.track_op  = false
entries          = 1
x.grad           = None
loss_d.grad      = Some([1.0])

和 burn 对照,三种情形各一句:

  • 普通张量的梯度。 k 不是变量,却有梯度 [1, 2]:Mul 的规则对两边都 or_insert,而 k 不在排序列表里,它的槽位从来不会被 remove。中间结果 h 和根 loss 的梯度都被取走了,返回 None。burn 的 grad 对不是 require_grad 的张量一律返回 None(见 backward 与 Gradients)。
  • 对同一个 loss backward 两次。 backward 拿的是 &self,只读地遍历图,什么也不消耗;第二次得到一个新的 GradStore,结果相同(不会和第一次累加)。burn 的 backward 会消耗沿途的步骤,第二次 panic(见 graph tape has already been consumed)。
  • 中途 detach。 hd 没有 Op,于是 loss_d 也不记;backward 不报错,排序列表是空的,表里只有根自己的全 1,x 的梯度是 None。burn 里 detach() 同样让结果成为新的叶子,梯度传不过去。

梯度要不要累加到下一步,是调用方的事:candle 不在张量上存梯度,每次 backward 都返回一张新表,所以也没有「清零梯度」这一步。

📝NOTE
冷知识:GradStore 到 0.11.0 才能由用户新建和合并

PR #1483(「Extend GradStore public functionality」,2026-04-22 合并,首次发布于 0.11.0)之前,GradStore 只有 #[derive(Debug)],唯一的构造函数 new 是私有的,用户手里的 GradStore 只能来自 backward(),也没有办法把两张表合在一起。提交信息写的是「GradStore: Allow user to merge them together and to create an empty one」:这次加了 #[derive(Default)](GradStore::default() 可以建一张空表),加了 extend:同一个 id 两边都有就相加,结果再按同样的 CANDLE_GRAD_DO_NOT_DETACH 规则 detach。有了它,梯度累加(几个小批次各 backward 一次、合并后再交给优化器一次 step)不用再自己按 get_ids 逐个相加。

梯度本身带不带图

backward 对每个取出的梯度 detach,注释说这是为了不给反向本身建图,二阶导数「不在范围内」。但这只发生在中间节点上:变量和普通输入的槽位从不被取出,最后一次累加的结果原样留在表里。如果那一步规则用到了一个记了 Op 的张量,变量的梯度就带着图。实验第 7 段:

== 7. are the gradients themselves tracked?
CANDLE_GRAD_DO_NOT_DETACH=None
x.grad   (loss = sum(x*k))   track_op=false sorted_nodes=0
k.grad   (loss = sum(x*k))   track_op=true  sorted_nodes=3
x.grad   (loss = sum(x*x))   track_op=true  sorted_nodes=5
x.grad   (loss = sum(exp x)) track_op=true  sorted_nodes=4
w.grad   (section 2)         track_op=false sorted_nodes=0
backward of x.grad (x*x): Some([2.0, 2.0])
  • sum(x*k) 里 x 的梯度是 grad * k,两边都不记,所以不带图;k 的梯度是 grad * x,x 是变量,于是带图。
  • sum(x*x) 里 x 的梯度是 grad * x + grad * x,带图;对它再求一次导,得到 [2, 2],碰巧就是二阶导数。exp 的规则用到前向输出 node,所以 x 的梯度持有 exp(x) 这个中间结果。
  • 第 2 段的 w.grad 来自 xs.t().matmul(grad),xs 是普通张量,不带图。

后果主要在内存:GradStore 活着,它持有的那些前向张量也活着。优化器 step 之后把 grads 丢掉就没事。设环境变量 CANDLE_GRAD_DO_NOT_DETACH=1 再跑,中间节点也不 detach,同一个 w.grad 变成 track_op=true sorted_nodes=16:反向的整张图都挂在梯度上。

📝NOTE
冷知识:detach 是为了一个内存问题加的

PR #1243(「Detach all grads during backprop.」,2023-11-05)之前,这里是 grads.remove(node).unwrap(),旁边一行 TODO:「We should perform all these operations in place (or at least not track the whole graph)」。也就是说,反向时每一步算出的梯度都照常记 Op,整个反向的计算过程又建成了一张图。提交把它换成现在的注释(开头链接 issue #1241)和 grad.detach(),提交信息的第二条「Add an environment variable to select the backprop behavior」对应 CANDLE_GRAD_DO_NOT_DETACH 这个 thread_local! 开关,保留旧行为。提交时工作区的版本号是 0.3.0;本地仓库最早的 tag 是 0.5.1,git tag --contains 给出的第一个就是它。

动脑筋答案

== 3. ties: reduce max vs binary maximum
x.max(0)        -> x.grad = [0.0, 1.0, 1.0]
x.maximum(c)    -> x.grad = [1.0, 0.5, 0.0]  c.grad = [0.0, 0.5, 1.0]
  • x.max(0):两个 3 并列最大,掩码 node.eq(arg) 在两处都是 1,每个都拿到完整的梯度 1,加起来是 2,而不是 1。
  • x.maximum(&c):逐元素的二元规则专门处理了并列(源码注释:「If both masks are 1 one the same point, we want to scale the gradient by 0.5 rather than 1」)。第二个位置 x 和 c 都是 3,各拿 0.5;第一个位置 x 大,第三个位置 c 大。
  • 同一个库里两种「取大」对并列的处理不一样。输入里有重复值时(比如 ReLU 之后的一串 0 再取最大)就会碰到。

小结

  • sorted_nodes 深度优先、后序收集通向变量的节点,再反转成根在前;already_seen 记的是「通不通向变量」,共享节点只展开一次;整数张量、取整、argmax 这些分支直接剪掉。
  • backward 从根的全 1 开始,按拓扑序对每个非变量节点取出梯度、detach、按 Op 套规则、or_insert 累加到输入;规则是普通张量算子,在张量所在的设备上执行。
  • 规则举例:Matmul 是 G·Bᵀ 与 Aᵀ·G;Mul 两边各乘对方;Reduce 先 broadcast_back;Broadcast 对复制出来的维求和。用有限差分核对,误差 5.7e-10。
  • GradStore 是 TensorId 到梯度的表:变量和普通输入的梯度留下,中间结果和根的被取走;backward 不消耗图,可以调两次;0.11.0 起可以 default() 新建、extend 合并。
  • 留在表里的梯度不一定被 detach,可能还挂着前向的中间结果。

💬 关联讨论

发起讨论
加载中...