Rust in Everything
基于 burn 0.22.1

一个算子的反向

主线位置:求导主线的第二步,前向时发生。Dispatch 选出 Autodiff<B, C> 之后(见 Autodiff 装饰器),调用的是 burn-autodiff/src/ops/tensor.rs 里这个算子的实现:它照常调内层后端算出结果,同时登记一个「反向步骤」和步骤需要的状态。本页讲登记了什么,以及这个步骤在 backward 时怎么算梯度;步骤按什么顺序被执行见 backward 与 Gradients。

flowchart TB
  prep["Backward prepare"] --> bound{"计算密集还是访存密集"}
  bound -->|compute_bound| done["ComputePropertyDone"]
  bound -->|memory_bound| retro["retro_forward 记重算"]
  retro --> par["parents 备份父节点"]
  par --> done
  done --> sf{"stateful 看 requirement"}
  sf -->|Tracked| ck["checkpoint 需要的输入"]
  ck --> fin["finish 状态加输出"]
  sf -->|UnTracked| fin2["finish 只有输出"]
  fin --> reg["register_step 登记"]
  fin2 --> reg

Backward 与 prepare

每个可求导的算子在自己的函数体里定义一个零大小的结构体(struct Mul;),为它实现 Backward<B, N>,N 是输入个数:

pub trait Backward<B, const N: usize>: Send + core::fmt::Debug
where
    Self: Sized + 'static,
    B: Backend,
{
    /// Associated type to compute the backward pass.
    type State: Clone + Send + core::fmt::Debug + 'static;

    /// The backward pass.
    fn backward(
        self,
        ops: Ops<Self::State, N>,
        grads: &mut Gradients,
        checkpointer: &mut Checkpointer,
    );

    /// Prepare the backward ops, retaining input references through registration.
    ///
    /// Obtain guards from the input tensors before consuming their primitives.
    fn prepare<C: CheckpointStrategy>(
        self,
        nodes: [NodeGuard; N],
    ) -> OpsPrep<Self, B, Self::State, C, N> {
        let requirement =
            Requirement::from_nodes(&nodes.each_ref().map(|guard| guard.node_ref().clone()));
        OpsPrep::new(
            nodes,
            requirement,
            self,
            ComputingProperty::Ambiguous, // If not specified we start with ambiguous
            CheckpointerBuilder::default(),
        )
    }
}

trait 的文档要求实现类型本身不带状态(「Concrete types implementing this trait should not have any state」),反向需要的东西放进关联类型 State,在前向时随步骤一起存下。prepare 拿到每个输入的 NodeGuard,用 Requirement::from_nodes 算出输出的要求(规则见上一页),得到一个处于 Init 状态的 OpsPrep。

OpsPrep 的最后一个类型参数 Mode 是一串类型状态(typestate),每个方法只在特定状态上存在,把「登记一个算子」的步骤顺序交给编译器检查:

当前状态方法下一个状态做了什么
Initcompute_bound()ComputePropertyDone标成计算密集,不会被重算
Initmemory_bound()MemoryBound标成访存密集,接下来必须给出重算方法
MemoryBoundretro_forward(r)MemoryBoundRetroForward交给 C::compute_property(r) 决定最终属性
MemoryBoundRetroForwardparents([..])ComputePropertyDoneC::checkpoint_parents 备份父节点,失败就退回计算密集
ComputePropertyDonestateful()OpsKind::Tracked / UnTracked按 requirement 分成两支
ComputePropertyDone,State = ()stateless(output)直接得到输出张量无状态算子的捷径

memory_bound 那一支的三步是为梯度检查点准备的:同一个算子在 NoCheckpointing 下仍然被当成计算密集,在 BalancedCheckpointing 下才真的只记重算方法。策略的细节在梯度检查点,本页按 NoCheckpointing 讲。

float_mul:前向登记了什么

float_mul 全文:

    fn float_mul(lhs: FloatTensor<Self>, rhs: FloatTensor<Self>) -> FloatTensor<Self> {
        #[derive(Debug)]
        struct Mul;

        retro_binary!(RetroMul, B::float_mul);

        impl<B: Backend> Backward<B, 2> for Mul {
            type State = (Option<NodeId>, Option<NodeId>, BinaryOpsBroadcast);

            fn backward(
                self,
                ops: Ops<Self::State, 2>,
                grads: &mut Gradients,
                checkpointer: &mut Checkpointer,
            ) {
                let (lhs, rhs, broadcast) = ops.state;
                let lhs = lhs.map(|lhs| checkpointer.retrieve_node_output(lhs));
                let rhs = rhs.map(|rhs| checkpointer.retrieve_node_output(rhs));

                binary::<B, _, _>(
                    ops.parents,
                    ops.node,
                    grads,
                    |grad| {
                        let grad = B::float_mul(grad, rhs.unwrap());
                        broadcast.backward_lhs::<B>(grad)
                    },
                    |grad| {
                        let grad = B::float_mul(grad, lhs.unwrap());
                        broadcast.backward_rhs::<B>(grad)
                    },
                );
            }
        }

        let lhs_tracked = lhs.is_tracked();
        let rhs_tracked = rhs.is_tracked();
        let broadcast = BinaryOpsBroadcast::new::<B>(&lhs.primitive, &rhs.primitive);

        match Mul
            .prepare::<C>([lhs.node(), rhs.node()])
            .memory_bound()
            .retro_forward(RetroMul::<B>::new(lhs.node.id, rhs.node.id))
            .parents([&lhs, &rhs])
            .stateful()
        {
            OpsKind::Tracked(mut prep) => {
                let lhs_state = rhs_tracked.then(|| prep.checkpoint(&lhs));
                let rhs_state = lhs_tracked.then(|| prep.checkpoint(&rhs));

                prep.finish(
                    (lhs_state, rhs_state, broadcast),
                    B::float_mul(lhs.primitive, rhs.primitive),
                )
            }
            OpsKind::UnTracked(prep) => prep.finish(B::float_mul(lhs.primitive, rhs.primitive)),
        }
    }

前向这一半按顺序做了这些事:

  1. retro_binary!(RetroMul, B::float_mul) 生成一个只存两个 NodeId 的结构体,实现 RetroForward:需要时从检查点里取出两个输入、重新乘一遍。它只在 BalancedCheckpointing 下用得上。
  2. BinaryOpsBroadcast::new 比较两个输入的形状,有任何一维不同就记成 Broadcasted(lhs 形状, rhs 形状),反向时用来把梯度求和回原形状(后面一节)。
  3. .stateful() 看 requirement:两个输入都不要梯度时走 UnTracked,只算结果。
  4. Tracked 分支里,lhs 只有在 rhs 要梯度时才保存,反过来也一样:∂(l·r)/∂r = l,算 rhs 的梯度要用 lhs,算 lhs 自己的梯度用不上它。乘一个常量权重时,常量那一侧的梯度不用算,被乘的那个张量也就不用存。
  5. prep.checkpoint(&lhs) 返回的只是 lhs 的 NodeId。张量本身交给检查点构建器:CheckpointerBuilder::checkpoint 对计算密集或属性未定的节点记一个 CheckpointingAction::Computed { state_content: Box::new(tensor.primitive.clone()) }。所以 Mul 的 State 是两个 Option<NodeId> 加一个广播信息,没有张量。
  6. prep.finish(state, output) 调 AutodiffTensor::from_parents 建输出节点:order 是父节点最大深度加 1,parents 只留要梯度的输入,Parent.is_leaf 记成「父节点深度是 0」;再把父节点、输出节点和状态装进 Ops,连同 Mul 一起包成 OpsStep(实现 Step trait 的类型擦除外壳),交给 register_step。
pub(crate) fn register_step<B: Backend, S: Step + 'static>(
    input_guards: impl IntoIterator<Item = NodeGuard>,
    output: AutodiffTensor<B>,
    step: S,
    checkpointer_builder: CheckpointerBuilder,
) -> AutodiffTensor<B> {
    let output = output.register_step(step, checkpointer_builder);
    // Registration publishes the dependency before its input references are released.
    drop(input_guards);
    output
}

output.register_step 经 node.client 把 Box<dyn Step> 和这个算子的检查点构建器送进图(AutodiffServer::register,第三页讲存在哪),然后才放掉输入的 NodeGuard。

UnTracked 分支看起来什么都没登记,其实也登记了。OpsPrep<.., UnTracked>::finish 同样建节点、调 register_step,登记的是一个 step 为空的 UntrackedOpsStep,注释给了原因:

        // We register the ops in the graph even if untracked, otherwise memory bound operations
        // that have an untracked parent would not be able to retrieve it

所以在 autodiff 设备上做纯常量运算,图里也会多出节点,只是不带状态,反向时 step 什么也不做。

📝NOTE
冷知识:prepare 收的是 NodeGuard,不是节点的克隆

0.22.0-pre.4 之前,算子写的是 Mul.prepare::<C>([lhs.node.clone(), rhs.node.clone()]),传进去的是 Arc<Node> 的克隆,AutodiffTensor 的三个字段也都是 pub。PR #5647(「fix(autodiff)!: retain input nodes until child registration」,首次进入 v0.22.0-pre.4,正式版 0.22.0)把参数换成 [NodeGuard; N]:NodeGuard 同时拿着 NodeRef 和引用计数 rc,用 #[must_use] 提醒调用方别丢;新增的 register_step 在子步骤登记之后才 drop(input_guards),注释写的是「Registration publishes the dependency before its input references are released.」。同一个提交把字段改成 pub(crate)、加上 node() / into_parts(),并在 NodeGuard 的文档里写明「Do not store guards in backward steps or checkpoint state: those internal references would prevent abandoned graphs from being reclaimed」。提交带 !,是破坏性改动:给后端扩展写自定义反向的代码要跟着改,仓库里 examples/custom-cubecl-kernel 和 custom-wgpu-kernel 的 backward.rs 在同一个提交里一起改了。

反向:binary 分发梯度

Mul::backward 先用 checkpointer.retrieve_node_output(id) 取回前向存下的输入(NoCheckpointing 下就是那份克隆),然后交给 binary:

pub fn binary<B, FLhs, FRhs>(
    parents: [Option<NodeRef>; 2],
    node: NodeRef,
    grads: &mut Gradients,
    func_lhs: FLhs,
    func_rhs: FRhs,
) where
    B: Backend,
    FLhs: FnOnce(B::FloatTensorPrimitive) -> B::FloatTensorPrimitive,
    FRhs: FnOnce(B::FloatTensorPrimitive) -> B::FloatTensorPrimitive,
{
    let [grad_4lhs, grad_4rhs] = duplicate(&parents, Some(grads.consume::<B>(&node)));
    let [node_lhs, node_rhs] = parents;

    if let Some(node) = node_lhs {
        let grad = func_lhs(grad_4lhs.unwrap());
        grads.register::<B>(node.id, grad)
    }

    if let Some(node) = node_rhs {
        let grad = func_rhs(grad_4rhs.unwrap());
        grads.register::<B>(node.id, grad)
    }
}
  • grads.consume(&node) 取出输出节点上累计好的上游梯度。输出是中间结果,取的同时就从 Gradients 里删掉(第三页)。
  • duplicate 只为要梯度的父节点各克隆一份上游梯度,不要梯度的那一侧得到 None,对应的闭包根本不调用。张量克隆只是多一个句柄,utils.rs 的注释说只剩一个引用时后端可以就地更新。
  • grads.register 把结果加到父节点名下:已经有值就 float_add,这是一个张量在图里被用了多次时梯度相加的地方。

单输入的算子用 unary,结构一样。不同算子存的状态差别很大,挑几个对比:

算子State说明
float_add、float_sub(Shape, Shape)梯度不依赖输入的值,只需形状做广播求和
float_add_scalar()走 stateless,什么也不存
float_mul_scalarScalar只存那个标量
float_sumShape反向是把梯度扩成输入形状
float_expNodeId(输入)存输入,反向时再算一次 B::float_exp(input) 得到输出
float_mul、float_matmul(Option<NodeId>, Option<NodeId>, BinaryOpsBroadcast)只存另一侧要用的输入

exp 的导数就是它的输出,但 float_exp 保存的是输入的 id,反向里 let output = B::float_exp(input); 重新算一遍:不论用哪种检查点策略,每个被追踪的 exp 在反向时都多跑一次 exp。sigmoid 也是同样的写法。

📝NOTE
没有蠢问题

问: 状态里直接放张量不是更简单?为什么绕一圈,存 NodeId 再去 Checkpointer 里取?

答: 存不存、存输出还是存重算方法,是检查点策略说了算,算子自己不知道。状态里只放 id,同一份 Mul 代码在 NoCheckpointing 下取到的是前向存的克隆,在 BalancedCheckpointing 下取到的可能是刚补算出来的结果。另外,同一个张量常被好几个算子需要(x 同时是 exp 和 mul 的输入),检查点按节点 id 只存一份,并记下还有几处要用(n_required),最后一次取走时就释放,见梯度检查点。

float_matmul:计算密集

float_matmul 和 float_mul 只有三处不同:

        impl<B: Backend> Backward<B, 2> for Matmul {
            type State = (Option<NodeId>, Option<NodeId>, BinaryOpsBroadcast);

            fn backward(
                self,
                ops: Ops<Self::State, 2>,
                grads: &mut Gradients,
                checkpointer: &mut Checkpointer,
            ) {
                let (lhs, rhs, broadcast) = ops.state;
                let lhs = lhs.map(|lhs| checkpointer.retrieve_node_output(lhs));
                let rhs = rhs.map(|rhs| checkpointer.retrieve_node_output(rhs));

                binary::<B, _, _>(
                    ops.parents,
                    ops.node,
                    grads,
                    |grad| {
                        let rhs = B::float_transpose(rhs.unwrap());
                        let grad = B::float_matmul(grad, rhs);

                        broadcast.backward_lhs::<B>(grad)
                    },
                    |grad| {
                        let lhs = B::float_transpose(lhs.unwrap());
                        let grad = B::float_matmul(lhs, grad);

                        broadcast.backward_rhs::<B>(grad)
                    },
                );
            }
        }

        // ...
        match Matmul
            .prepare::<C>([lhs.node(), rhs.node()])
            .compute_bound()
            .stateful()
        {
  • 反向公式:C = A·B 时 ∂L/∂A = G·Bᵀ、∂L/∂B = Aᵀ·G,float_transpose 交换最后两维。
  • .compute_bound():没有 retro_forward 也没有 parents,任何策略下都不会重算 matmul,需要它的输出时总是存下来。
  • BinaryOpsBroadcast::new 比较的是全部维度,所以只要两个矩阵形状不同([2, 3] 乘 [3, 4]),它就记成 Broadcasted。这不会出错:反向时 broadcast_shape 比较的是梯度和输入的形状,G·Bᵀ 的形状本来就等于 A,矩阵那两维不会被当成广播维,真正被求和的只有大小为 1 的批维。

广播:梯度求和回原形状

前向时 [1, 1, 3] 乘 [1, 2, 3] 会把前者沿第 1 维广播成两行;反向时它的梯度是 [1, 2, 3],要沿第 1 维加起来变回 [1, 1, 3]。broadcast_shape 做这件事:

pub fn broadcast_shape<B: Backend>(grad: FloatTensor<B>, shape: &Shape) -> FloatTensor<B> {
    let shape_grad = grad.shape();
    let ndims = shape_grad.num_dims();

    let mut broadcast_dims = Vec::new();
    for i in 0..ndims {
        if shape_grad[i] != shape[i] {
            if shape[i] != 1 {
                panic!(
                    "Invalid broadcast shapes: Next grad shape {:?}, Previous grad shape {:?}. {}",
                    shape, shape_grad, "Expected the shape of the next grad to be 1."
                );
            }
            broadcast_dims.push(i);
        }
    }

    // One reduction over every broadcast dimension rather than one per
    // dimension: a parameter broadcast over batch and space would otherwise
    // pay a launch and an intermediate for each, the first of them nearly the
    // size of the gradient itself.
    match broadcast_dims.as_slice() {
        [] => grad,
        [dim] => B::float_sum_dim(grad, *dim),
        dims => B::float_sum_dims(grad, dims),
    }
}

它逐维比较,形状不同的维必须是原来为 1 的那一维(burn 的广播要求秩相同,不会自动补维),然后对所有这样的维一次求和。float_sum_dim / float_sum_dims 保留维度(结果那一维长度为 1),所以求和后的形状直接就是原形状,不需要再 reshape。

📝NOTE
冷知识:广播维原来是一维一维地求和

PR #5623(「perf(autodiff): reduce a broadcast gradient over all dims at once」,首次进入 v0.22.0-pre.4,正式版 0.22.0)之前,broadcast_shape 对每个广播维各调一次 float_sum_dim。提交说明写:一个在批维和空间维上广播的参数要做三次归约、产生两个中间张量,第一个几乎和梯度本身一样大;在一个每层卷积后接归一化的网络里,反向的归约有一半是这种「链条上的一环」,几乎都不到 20 微秒。这次改动给 FloatTensorOps 加了 float_sum_dims(默认实现仍是逐维折叠,所以各后端不用改),broadcast_shape 只调它一次;Fusion 把它记成一个 NumericOperationIr::SumDims,burn-cubecl 把内存里相邻的几维合成一维一次归约。提交里在 CUDA(一块 L4)上测的一个卷积分类网络,一个训练步从 369.7 ms 降到 362.0 ms,每步的归约启动次数少了 40%。

实验:和有限差分对一对

ad-exp 的 ops 对四组函数求梯度,再用中心差分 (f(x+ε) − f(x−ε)) / 2ε(ε = 0.01)逐元素核对。为了让上游梯度不全是 1,每个函数最后都乘一个固定的权重张量 w 再求和。核对部分(差分在不带 autodiff 的 Device::flex() 上算):

fn check(name: &str, f: F, inputs: &[(Vec<f32>, [usize; 3])]) {
  let ad = Device::flex().autodiff();
  let plain = Device::flex();

  let leaves: Vec<Tensor<3>> = inputs
    .iter()
    .map(|(v, s)| make(v, *s, &ad).require_grad())
    .collect();
  let grads = f(&leaves).backward();

  for (i, (v, s)) in inputs.iter().enumerate() {
    let g = leaves[i].grad(&grads).unwrap();
    // ... 对第 i 个输入的每个元素 k:xs[i][k] ± eps,在 plain 设备上求 f,取中心差分
  }
}

fn main() {
  check(
    "mul broadcast [1,1,3]",
    |t| {
      let w = make(&[0.3, -1.2, 0.7, 2.0, -0.5, 1.1], [1, 2, 3], &t[0].device());
      (t[0].clone() * t[1].clone() * w).sum()
    },
    &[(seq(6, 0.5, -1.0), [1, 2, 3]), (vec![2.0, -1.0, 0.5], [1, 1, 3])],
  );
  // ... "mul"、"matmul"、"matmul batch-broadcast"([2,2,3] 乘 [1,3,2])同理
}

输出(debug):

mul                    input0 shape=[1, 2, 3] grad shape=[1, 2, 3] max|analytic-numeric|=1.46e-6
                       grad=[0.3, -0.90000004, 0.35, 0.5, -0.0, -0.275]
mul                    input1 shape=[1, 2, 3] grad shape=[1, 2, 3] max|analytic-numeric|=1.79e-6
                       grad=[-0.3, 0.6, 0.0, 1.0, -0.5, 1.6500001]
mul broadcast [1,1,3]  input0 shape=[1, 2, 3] grad shape=[1, 2, 3] max|analytic-numeric|=1.14e-5
                       grad=[0.6, 1.2, 0.35, 4.0, 0.5, 0.55]
mul broadcast [1,1,3]  input1 shape=[1, 1, 3] grad shape=[1, 1, 3] max|analytic-numeric|=7.27e-6
                       grad=[0.7, 0.100000024, 1.6500001]
matmul                 input0 shape=[1, 2, 3] grad shape=[1, 2, 3] max|analytic-numeric|=4.53e-6
                       grad=[-0.6, -0.15, 0.3, 2.2, 0.85, -0.5]
matmul                 input1 shape=[1, 3, 2] grad shape=[1, 3, 2] max|analytic-numeric|=4.53e-6
                       grad=[0.049999982, 2.2, 0.54999995, 2.6, 1.05, 3.0]
matmul batch-broadcast input0 shape=[2, 2, 3] grad shape=[2, 2, 3] max|analytic-numeric|=4.35e-5
                       grad=[-0.6, -0.15, 0.3, 2.2, 0.85, -0.5, 1.375, 0.625, -0.125, 0.625, 0.125, -0.375]
matmul batch-broadcast input1 shape=[1, 3, 2] grad shape=[1, 3, 2] max|analytic-numeric|=3.15e-5
                       grad=[0.29999998, 8.45, 1.05, 9.85, 1.8, 11.25]
  • 八个梯度和差分的最大偏差都在 f32 舍入的量级(1e-6 到 4e-5),公式和广播求和都对。
  • mul 的第一个梯度可以手算:∂/∂x (x·y·w) = y·w,y = [1, 0.75, 0.5, 0.25, 0, −0.25],逐元素乘 w 得 [0.3, −0.9, 0.35, 0.5, 0, −0.275]。
  • 广播那组的第二个输入形状是 [1, 1, 3],梯度也回到 [1, 1, 3]:第一列 x[0]·w[0] + x[3]·w[3] = −1·0.3 + 0.5·2.0 = 0.7。
  • 批维广播的 matmul,[1, 3, 2] 那一侧的梯度把两个批次的 Aᵀ·G 加在一起,所以数值明显比单批次的大(2.2 变 8.45)。

小结

  • 每个可求导的算子定义一个无状态的 Backward 实现,反向需要的东西放进关联类型 State;prepare 之后的类型状态链在编译期保证访存密集的算子给出了重算方法。
  • Tracked 分支只保存「另一侧求梯度要用」的输入,状态里只有 NodeId,张量交给 CheckpointerBuilder;UnTracked 的算子也会登记一个空步骤。
  • 反向用 binary / unary:取出并删除输出的上游梯度,只为要梯度的父节点计算,Gradients::register 对同一节点累加。
  • float_matmul 是计算密集,任何策略下都存输出;反向是 G·Bᵀ 和 Aᵀ·G。
  • 广播的梯度在 broadcast_shape 里对所有广播维一次 float_sum_dims 求和;实验里四组算子的梯度与有限差分一致。

💬 关联讨论

发起讨论
加载中...