Rust in Everything
基于 burn 0.22.1

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)。
📝NOTE
冷知识:消耗过的图以前会被悄悄跳过

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 分三遍:

  1. 从每个前沿节点往上找,祖先里有已经被删掉(被这次 backward 消耗)的节点,这个节点就标成 Unavailable:它的反向已经不可能完整执行。
  2. 从前沿往上传播 Useful:一个还可用、且有用户引用的节点是有用的,它的所有祖先也是有用的(将来对它 backward 要经过它们)。
  3. 不是 Useful 的全部删掉,连同它们的步骤和检查点动作(保存的张量随之释放);有用的子图的根成为新的前沿。

另一条路径是 free_unused_roots:cleanup_orphaned_entries 对没有任何节点被引用的图(maybe_useful() 为假)调用它,删掉「没有引用、没有父节点」的节点。它一遍只能删掉最上面一层,但扫描时遍历的是 GraphLocator 的 graphs 表的值,同一张图登记了多少个节点 id 就出现多少次,所以一次扫描里一张图会被处理很多遍,逐层删光。

两条路径都只在 backward() 结束时触发。前向本身不会释放任何步骤,丢掉 loss 也不会:图里存的是 rc 和步骤,不依赖用户手里的输出张量。

❗IMPORTANT
动脑筋:Metal 上,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。

💬 关联讨论

发起讨论
加载中...