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) 只给等于最大值的元素。
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 都返回一张新表,所以也没有「清零梯度」这一步。
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:反向的整张图都挂在梯度上。
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,可能还挂着前向的中间结果。