backward 与 Gradients
主线位置:求导主线的后半段。前向时每个算子已经登记了一个反向步骤(见一个算子的反向);本页从
loss.backward()讲起:步骤存在哪、按什么顺序执行、梯度放在哪、x.grad(&grads)怎么取,以及图在什么时候释放。Gradients再按参数 id 重组成GradientsParams交给优化器,属于训练主线,见优化器。
sequenceDiagram participant U as 用户代码 participant T as Tensor participant D as Dispatch participant C as GraphMutexClient participant S as AutodiffServer U->>T: loss.backward() T->>D: 断言 is_tracked 后转发 D->>C: AutodiffTensor backward C->>S: 按根节点找图并加锁 S->>S: collect_steps 校验祖先 S->>S: build_tape 按深度分桶 S->>S: execute_steps 倒序执行 S->>S: cleanup 释放不可达节点 S-->>C: Gradients C->>C: 扫描其他孤立的图 C-->>U: Gradients U->>T: x.grad(grads)
本页的实验:ad-exp 的 grads 在 Device::flex().autodiff() 上跑(debug);两个内存实验 mem、mem2 在 Device::metal(DeviceKind::DefaultDevice).autodiff() 上跑,用 Device::memory_pool_usage() 读 cubecl 内存池里在用的字节数,放在 ad-time 里(feature 只开 flex、metal、autodiff、fusion、autotune,release)。平台 M2 Max、macOS 27、rustc 1.99.0。
步骤存在哪:GraphLocator 和 AutodiffServer
算子登记步骤时调的是 node.client.register(..)。client 是零大小的 GraphMutexClient,真正的数据在一个进程级静态变量里:
static STATE: Mutex<Option<GraphLocator>> = Mutex::new(None);
GraphLocator 是一张 BTreeMap<NodeId, Arc<Graph>>:每个登记过的节点 id 都指向它所在的图,每个 Graph 有自己的 Mutex<GraphState>,里面是一个 AutodiffServer。登记时 select 按父节点找图:
- 没有父节点(
require_grad登记的根步骤):以这个节点为起点建一张新图。 - 父节点都在同一张图里:沿用这张图,并把新节点 id 也登记成指向它。
- 父节点分属几张图:把它们合并成一张(
merge,按NodeId从小到大合并,注释说原来用HashMap时合并顺序每次运行都不同,会打乱反向执行的算子顺序)。
所以互不相干的两段计算(比如两个设备上各自的前向)落在两张图里,各用各的锁,文档注释说这对多设备训练是必需的。全局锁 STATE 只在找图时短暂持有。
每张图里的 AutodiffServer 有三样东西:
#[derive(Default)]
pub struct AutodiffServer {
steps: HashMap<NodeId, StepBoxed>,
actions_builder: HashMap<NodeId, CheckpointerBuilder>,
memory_management: GraphMemoryManagement,
}
steps 是每个节点的反向步骤(Box<dyn Step>,里面带着上一页说的 Ops 和状态),actions_builder 是每个节点前向时攒下的检查点动作(保存的张量就在这里),memory_management 记录节点之间的父子关系和每个节点的 rc,决定什么时候可以删。register 往三处各写一份。
backward:校验、分桶、倒序执行
burn-tensor 的 Tensor::backward 先断言张量是被追踪的,经 Dispatch::backward 取出 AutodiffTensor<Flex>,调 AutodiffTensor::backward。开了 std 时这里总是带一个分布式梯度同步的钩子(BackwardMode::Distributed),单设备时钩子不做事。GraphMutexClient::backward 找到根节点所在的图、锁住它,调 AutodiffServer::backward,分四步。
第一步,校验祖先。 GraphTraversal::collect_steps 从根出发,用一个栈把能到达的步骤 id 收集起来,这时一个步骤也不删:
let root = steps.get(&root_id).ok_or(root_id)?;
let mut nodes = vec![root_id];
let mut visited = HashSet::new();
let mut parents = root.parents().to_vec();
visited.insert(root_id);
while let Some(parent) = parents.pop() {
if !visited.insert(parent.id) {
continue;
}
match steps.get(&parent.id) {
Some(step) => {
nodes.push(parent.id);
parents.extend_from_slice(step.parents());
}
None if parent.is_leaf => {}
None => return Err(parent.id),
}
}
叶子没有步骤是正常的(叶子的 RootStep 被上一次 backward 用掉了,或者叶子张量已经被丢掉),梯度到那里为止;非叶子缺步骤就说明它被之前的 backward 消耗过,返回 Err,backward 随即 panic。
第二步,按深度分桶。 build_tape 建一个 Vec<Vec<StepBoxed>>,长度是根节点深度加 1,把收集到的每个步骤从 steps 里移出来(从此这些步骤被「消耗」了),按 step.depth()(也就是节点的 order)放进对应的桶;同时把这些节点的检查点动作合并,建出本次反向用的 Checkpointer。
第三步,倒序执行。 execute_steps:
fn execute_steps(
tape: Vec<Vec<StepBoxed>>,
mut grads: Gradients,
mut checkpointer: Checkpointer,
) -> Gradients {
tape.into_iter().rev().for_each(|steps| {
steps
.into_iter()
.for_each(|step| step.step(&mut grads, &mut checkpointer))
});
// ...
grads
}
从最深的桶执行到最浅的桶。这里没有通用的拓扑排序,靠的是深度的定义:算子输出的 order 是所有父节点 order 的最大值加 1,所以一个节点的所有子节点都比它深,倒序按深度执行时,轮到它的时候它的梯度已经从每个子节点累加完了。同一深度的步骤之间没有依赖,按收集的顺序执行。
第四步,清理。 cleanup 交给内存管理释放已经不可能再用到的节点(后面一节),然后把被消耗的节点 id 从 GraphLocator 里删掉。锁释放以后,GraphCleaner::cleanup_orphaned_entries 再扫一遍所有的图,释放没人引用的孤立部分。扫描用 try_lock,锁不上就跳过:一个反向步骤里如果再调一次 backward(),外层正持有自己那张图的锁,parking_lot 的锁不可重入,原来的写法会自己锁死自己,这是 PR #5194 修的(首次进入 v0.22.0-pre.1)。
Gradients:按节点 id 存,中间的用完即删
Gradients 是一个 TensorContainer<u64>(burn-std 里的 HashMap<u64, Box<dyn Any + Send>>)加一个可选的钩子。键是节点 id 的 u64 值,值是类型擦除的内层后端张量,所以同一个 Gradients 类型能装任何后端的梯度。Gradients::new 先给根节点登记一个和根同形状的全 1 张量:对非标量的输出调 backward(),相当于对它所有元素求和后再求导。
反向步骤读写它只用两个方法。consume 按节点的 Requirement 决定取完删不删:
pub fn consume<B: Backend>(&mut self, node: &NodeRef) -> FloatTensor<B> {
match node.requirement {
Requirement::Grad => self
.container
.get::<TensorPrimitive<B>>(&node.id.value)
.map(|tensor| tensor.tensor())
.expect("Can't consume the gradients before they are registered at least once."),
Requirement::GradInBackward => self
.container
.remove::<TensorPrimitive<B>>(&node.id.value)
.map(|tensor| tensor.tensor())
.expect("Can't consume the gradients before they are registered at least once."),
Requirement::None => panic!("Trying to consume the gradients for an untracked tensor"),
}
}
register 已有值就 float_add 累加。中间结果的梯度是 GradInBackward,它的步骤一执行(binary / unary 第一行就 consume),梯度就从容器里删掉,所以中间梯度占的内存在反向过程中逐层释放,backward 结束时容器里只剩叶子的梯度。
用户侧的 x.grad(&grads) 走 burn-tensor 的 grad_impl:不是浮点张量、没有 autodiff 标记、或者不是 Grad(is_require_grad() 为假)时直接返回 None;否则按 id 去容器里 get,get 克隆一个句柄,梯度仍留在容器里。grad_remove 则是 remove。实验(grads):
let x = Tensor::<1>::from_floats([1.0, 2.0], &device).require_grad();
let unused = Tensor::<1>::from_floats([5.0, 5.0], &device).require_grad();
// x appears on two paths: x*x and x*3
let h = x.clone() * x.clone();
let loss = (h.clone() + x.clone() * 3.0).sum();
let mut grads = loss.backward();
println!("x.grad = {:?}", v(x.grad(&grads)));
println!("h.grad = {:?}", v(h.grad(&grads)));
println!("loss.grad = {:?}", loss.grad(&grads).map(|t| t.dims()));
println!("unused.grad = {:?}", v(unused.grad(&grads)));
println!("x.grad_remove= {:?}", v(x.grad_remove(&mut grads)));
println!("x.grad again = {:?}", v(x.grad(&grads)));
x.grad = Some([5.0, 7.0])
h.grad = None
loss.grad = None
unused.grad = None
x.grad_remove= Some([5.0, 7.0])
x.grad again = None
x 在图里出现在 x*x 和 x*3 两条路径上,register 把两份梯度相加:2x + 3 = [5, 7]。grad 返回 None 的情形汇总如下,返回值一样,原因各不相同:
| 情形 | 为什么是 None |
|---|---|
| 张量不在 autodiff 设备上 | grad_impl 看到 DispatchAutodiffContext::Disabled 直接返回 |
在 autodiff 设备上但没 require_grad() | 不是 Grad,同上 |
中间结果(h)或 loss 本身 | GradInBackward,is_require_grad() 为假;它的梯度在反向途中也已被 consume 删掉 |
叶子 require_grad() 了,但和 loss 没有关系(unused) | 反向没走到它,容器里没有这个 id |
已经 grad_remove 过 | 容器里删掉了 |
拿着另一次 backward() 的 Gradients | 那个容器里只有那次的梯度 |
同一张图只能 backward 一次
步骤在 build_tape 里被移出 steps,所以第二次 backward 走到它时找不到步骤。实验接着上面的代码:
thread 'main' (3168867) 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(5). 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
backward twice on the same loss: panicked
l1 -> x.grad = Some([5.4365635, 14.778112])
thread 'main' (3168867) 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(6). 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.
l2.backward after l1 consumed `shared`: panicked
l3 -> x.grad = Some([4.0, 4.0])
l1+l2 -> x.grad = Some([13.591409, 36.945282])
detached: is_tracked=false is_require_grad=false
- 对同一个
loss第二次backward():根节点自己的步骤已经没了(NodeId(5)就是loss,前面依次是x、unused、h、x*3、加法),collect_steps的第一行ok_or(root_id)就返回错误。 shared = x.exp(),l1、l2都由它算出。l1.backward()消耗了shared的步骤,l2.backward()时根和l2自己的步骤都在,走到NodeId(6)(shared)发现缺了,panic。panic 发生在消耗任何步骤之前。- 只共享叶子的新前向(
l3 = (x * 4).sum())没问题:叶子缺步骤不算错。 - 两个 loss 先加起来再一次 backward,得到
2eˣ + 3eˣ = 5eˣ,和 PyTorch 里retain_graph=True跑两次再相加的结果相同。 detach()让结果成为新的叶子,Requirement::None(原张量不是Grad,所以也不会保留require_grad)。
PR #5645(「fix(autodiff): explicitly reject consumed graph reuse and preserve reusable leaves」,首次进入 v0.22.0-pre.4,正式版 0.22.0)之前,遍历用的是 BreadthFirstSearch::traverse,边走边从 steps 里 remove,遇到找不到的步骤就 None => continue;根节点缺步骤时的报错是「Node should have a step registered, did you forget to call Tensor::register_grad on the tensor where you need gradients?」。于是像上面 l2 那样,中间结果被上一次 backward 消耗掉的图,第二次 backward 不会报错,而是在缺口处停下,返回不完整的梯度。这次改动把遍历拆成先只读地 collect_steps、校验通过再 build_tape 消耗,缺了非叶子步骤就 panic 并在信息里说明 burn 不支持 retain_graph;同时给 Parent 加了 is_leaf,让叶子缺步骤仍然合法,把 BreadthFirstSearch 改名为 GraphTraversal(提交里写「inaccurate」:它本来就不是广度优先,用的是栈)。Tensor::backward 的文档也在同一个提交里改成现在关于 retain_graph 和 detach 的说明。
什么时候释放:GraphMemoryManagement
GraphMemoryManagement 记三样东西:nodes(每个节点的 rc 和它的父节点)、leaves(图的「前沿」:还没有子节点的节点,名字容易和求导的叶子混淆)、statuses(一次清理过程中的临时标记)。判断「用户手里还有没有这个节点的张量」的依据是 Arc::strong_count(rc) > 1:图里自己存了一份 rc,再多就说明有 AutodiffTensor(或者正在登记的 NodeGuard)还活着。
backward 之后的 free_unavailable_nodes 分三遍:
- 从每个前沿节点往上找,祖先里有已经被删掉(被这次 backward 消耗)的节点,这个节点就标成
Unavailable:它的反向已经不可能完整执行。 - 从前沿往上传播
Useful:一个还可用、且有用户引用的节点是有用的,它的所有祖先也是有用的(将来对它 backward 要经过它们)。 - 不是
Useful的全部删掉,连同它们的步骤和检查点动作(保存的张量随之释放);有用的子图的根成为新的前沿。
另一条路径是 free_unused_roots:cleanup_orphaned_entries 对没有任何节点被引用的图(maybe_useful() 为假)调用它,删掉「没有引用、没有父节点」的节点。它一遍只能删掉最上面一层,但扫描时遍历的是 GraphLocator 的 graphs 表的值,同一张图登记了多少个节点 id 就出现多少次,所以一次扫描里一张图会被处理很多遍,逐层删光。
两条路径都只在 backward() 结束时触发。前向本身不会释放任何步骤,丢掉 loss 也不会:图里存的是 rc 和步骤,不依赖用户手里的输出张量。
x 是一个 512×512 的 f32 参数(1 MiB),require_grad()。循环 20 次,每次算 (x.exp() * x).matmul(x).sum() 然后立刻丢掉结果,不调 backward()。循环结束时内存池里在用多少 MiB?之后对同样的前向做一次 backward() 呢?代码是 ad-time 的 mem,release,平台 M2 Max、macOS 27、rustc 1.99.0。
动脑筋答案
fn forward(x: &Tensor<2>) -> Tensor<1> {
(x.clone().exp() * x.clone()).matmul(x.clone()).sum()
}
fn main() {
let gpu = Device::metal(DeviceKind::DefaultDevice);
let ad = gpu.clone().autodiff();
let x = Tensor::<2>::random([512, 512], Distribution::Default, &ad).require_grad();
usage(&ad, "after creating x (1 MiB)");
for i in 1..=20 {
let loss = forward(&x);
drop(loss);
if i % 5 == 0 {
usage(&ad, &format!("forward-only x{i}, loss dropped"));
}
}
let grads = forward(&x).backward();
usage(&ad, "after one backward");
drop(grads);
usage(&ad, "grads dropped");
let xi = x.clone().without_autodiff();
// ... 同样的前向 20 次
}
usage 先 device.sync(),再打印 memory_pool_usage() 的 bytes_in_use 和 number_allocs:
after creating x (1 MiB) in_use= 1.0 MiB allocs=2
forward-only x5, loss dropped in_use= 11.0 MiB allocs=18
forward-only x10, loss dropped in_use= 21.0 MiB allocs=28
forward-only x15, loss dropped in_use= 31.0 MiB allocs=38
forward-only x20, loss dropped in_use= 41.0 MiB allocs=48
after one backward in_use= 2.0 MiB allocs=18
grads dropped in_use= 1.0 MiB allocs=17
without_autodiff forward x10 in_use= 1.0 MiB allocs=18
without_autodiff forward x20 in_use= 1.0 MiB allocs=18
- 每次前向多占 2 MiB:
mul为求x的梯度保存了exp的输出,matmul保存了mul的输出,各 1 MiB;x本身被保存了好几次,但都是同一块内存的句柄。20 次之后是 1 + 40 = 41 MiB。丢掉loss没有用,这些张量挂在图里的检查点动作上。 - 一次
backward()之后降到 2 MiB(x和它的梯度):这 20 次前向都以x为父节点,在同一张图里;backward 的清理发现它们的输出都没人引用,整片删掉。再丢掉grads回到 1 MiB。 - 同样的前向在
without_autodiff()之后不涨。
另一个实验 mem2:每次新建一个参数、前向、连参数一起丢掉,5 次之后是 15 MiB(每张图多了参数本身);做一些不相干的计算并读回数据,仍是 15 MiB;对一个毫不相关的小图调一次 backward(),降到 0:
5 abandoned graphs in_use= 15.0 MiB allocs=24
control: untracked op + read #1 in_use= 15.0 MiB allocs=25
control: untracked op + read #2 in_use= 15.0 MiB allocs=25
after unrelated backward #1 in_use= 0.0 MiB allocs=14
这就是 cleanup_orphaned_entries 的效果。推论:在 autodiff 设备上、参数 require_grad 的情况下只做推理,保存的状态会一直累积,直到某一次 backward。推理时用 Module::valid()(它会去掉张量的 autodiff,见 Module 派生)或不带 autodiff 的设备。
小结
- 反向步骤存在进程级静态
GraphLocator找到的图里,每张图一个AutodiffServer和一把锁;互不相干的计算分在不同的图,有共同父节点时合并。 backward先只读地收集并校验祖先,非叶子缺步骤就 panic(不支持retain_graph);通过后按节点深度分桶、从最深的桶倒序执行,深度的定义保证了拓扑顺序。Gradients按节点 id 存类型擦除的梯度,根是全 1;中间结果的梯度被consume时删除,最后只剩require_grad叶子的;grad返回None有六种原因。- 节点释放只在 backward 结束时发生:先删因这次反向而不可用、且没人引用的节点,再扫所有图里的孤立部分;只前向不反向时,保存的张量会一直留到下一次 backward。