graph tape has already been consumed
主线位置:求导主线的第一步。
backward()先只读地收集并校验从根能到达的步骤,非叶子节点缺步骤就 panic;校验通过才把这些步骤从图里移走执行。机制见 backward 与 Gradients 的「同一张图只能 backward 一次」。
flowchart TB
feats["feats = x.exp 在循环外"] --> l0["第 0 步 loss"]
l0 --> b0["backward 消耗 feats 的步骤"]
feats --> l1["第 1 步 loss"]
l1 --> b1["collect_steps 走到 feats"]
b1 --> miss{"非叶子缺步骤"}
miss --> boom["panic"]
实验 crate exp-burn/pb-pit,bin twice、twice_fix,Device::flex().autodiff(),debug,M2 Max、macOS 27、rustc 1.99.0。
现象
最常见的触发方式不是对同一个 loss 调两次 backward(),而是把一段前向「优化」到循环外:
let x = Tensor::<1>::from_floats([0.5, 1.0], &device).require_grad();
let w = Tensor::<1>::from_floats([1.0, 2.0], &device).require_grad();
// "Features" computed once, outside the loop, to save time.
let feats = x.clone().exp();
for step in 0..3 {
let loss = (feats.clone() * w.clone()).sum();
let grads = loss.backward();
println!("step {step}: w.grad = {:?}", w.grad(&grads).map(|g| g.into_data().to_vec::<f32>().unwrap()));
}
step 0: w.grad = Some([1.6487212, 2.7182817])
thread 'main' (3476155) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/burn-autodiff-0.22.1/src/runtime/server.rs:74:17:
Cannot run backward: graph tape has already been consumed at NodeId(2). Burn does not support retain_graph. Combine losses before backward or recompute the forward pass. Use detach() only to intentionally sever gradients through the previous computation.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
exit=101
第 0 步正常,第 1 步 panic。NodeId(2) 是 feats:节点 id 按创建顺序递增,x、w 之后它是第三个。每步新建的 loss 和乘法都有自己的步骤,缺的是循环外那个 exp。
同样的错误还有两种来法:对同一个 loss 调两次 backward();两个 loss 共享一个中间结果、分别 backward(典型的是一个前向出两个损失项、各自记日志)。backward 与 Gradients 里有这两种的输出。
出处
AutodiffServer::backward 的第一步:
// The graph mutex stays locked through validation and consumption. Reject invalid
// ancestry before removing even the root so fresh branches survive the rejection.
let nodes = GraphTraversal
.collect_steps(node_id, &self.steps)
.unwrap_or_else(|missing| {
panic!(
"Cannot run backward: graph tape has already been consumed at {missing}. \
Burn does not support retain_graph. Combine losses before backward or \
recompute the forward pass. Use detach() only to intentionally sever \
gradients through the previous computation."
);
});
collect_steps 从根出发收集步骤,叶子没有步骤是正常的,非叶子没有就返回它的 id。第 0 步的 backward 在 build_tape 里把 feats 的步骤(连同它为求导保存的状态)移出了 steps,第 1 步走到它时就缺了。
步骤是 Box<dyn Step>,执行时按值消耗(step(self: Box<Self>, ...)),为求导保存的张量随之释放,backward 之后内存能降下来靠的就是这一点;0.22.1 没有保留步骤的选项。panic 发生在消耗任何步骤之前,所以第 1 步失败后图还是完整的,只是这个进程已经 panic 了。
修法
看 feats 需不需要梯度,三选一(bin twice_fix):
// A: features need no gradient -> cut them off once
let feats = x.clone().exp().detach();
for step in 0..2 {
let grads = (feats.clone() * w.clone()).sum().backward();
println!("A step {step}: w.grad = {:?} x.grad = {:?}", v(w.grad(&grads)), v(x.grad(&grads)));
}
// B: gradients must reach x -> recompute the forward every step
for step in 0..2 {
let feats = x.clone().exp();
let grads = (feats * w.clone()).sum().backward();
println!("B step {step}: w.grad = {:?} x.grad = {:?}", v(w.grad(&grads)), v(x.grad(&grads)));
}
// C: two losses on one graph -> add them, one backward
let feats = x.clone().exp();
let l1 = (feats.clone() * w.clone()).sum();
let l2 = (feats * 2.0).sum();
let grads = (l1 + l2).backward();
println!("C l1+l2: w.grad = {:?} x.grad = {:?}", v(w.grad(&grads)), v(x.grad(&grads)));
A step 0: w.grad = Some([1.6487212, 2.7182817]) x.grad = None
A step 1: w.grad = Some([1.6487212, 2.7182817]) x.grad = None
B step 0: w.grad = Some([1.6487212, 2.7182817]) x.grad = Some([1.6487212, 5.4365635])
B step 1: w.grad = Some([1.6487212, 2.7182817]) x.grad = Some([1.6487212, 5.4365635])
C l1+l2: w.grad = Some([1.6487212, 2.7182817]) x.grad = Some([4.9461637, 10.873127])
- A:
exp的结果不是要梯度的叶子,detach()之后是一个普通的未追踪张量,可以无限次复用;代价是x拿不到梯度(None)。适合固定的预处理、冻结的特征提取器。 - B:每步重算前向,
x的梯度是eˣ · w = [1.65, 5.44]。这就是训练循环的正常写法,循环外只放数据,不放计算。 - C:两个损失先加起来,一次 backward,
x的梯度是两项之和eˣ · (w + 2);需要分别看两项时,对两个l1、l2的值单独into_scalar()记日志,不要分别 backward。
怎么确认没中招
- 在循环外算出来、在循环里参与 loss 的张量,要么是数据(不在 autodiff 设备上,或者没有要梯度的祖先),要么已经
detach()。检查它的is_tracked():为真就会在第二步出事。 - 一个前向里有几次
backward(),就数一数它们的 loss 有没有共同的祖先;有就合并。 - panic 信息里的
NodeId(n)是缺步骤的那个节点,按创建顺序数一数,通常能对上是哪个中间结果。
小结
- burn 不支持
retain_graph:backward 按值消耗沿途非叶子节点的步骤和保存的张量。 - 缓存在循环外的前向结果、共享中间结果的两个 loss,第二次 backward 都会 panic,信息里给出缺步骤的节点 id。
- 不需要梯度就
detach();需要就每步重算;两个 loss 先相加再 backward。