一个算子的反向
主线位置:求导主线的第二步,前向时发生。
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),每个方法只在特定状态上存在,把「登记一个算子」的步骤顺序交给编译器检查:
| 当前状态 | 方法 | 下一个状态 | 做了什么 |
|---|---|---|---|
Init | compute_bound() | ComputePropertyDone | 标成计算密集,不会被重算 |
Init | memory_bound() | MemoryBound | 标成访存密集,接下来必须给出重算方法 |
MemoryBound | retro_forward(r) | MemoryBoundRetroForward | 交给 C::compute_property(r) 决定最终属性 |
MemoryBoundRetroForward | parents([..]) | ComputePropertyDone | C::checkpoint_parents 备份父节点,失败就退回计算密集 |
ComputePropertyDone | stateful() | 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)),
}
}
前向这一半按顺序做了这些事:
retro_binary!(RetroMul, B::float_mul)生成一个只存两个NodeId的结构体,实现RetroForward:需要时从检查点里取出两个输入、重新乘一遍。它只在BalancedCheckpointing下用得上。BinaryOpsBroadcast::new比较两个输入的形状,有任何一维不同就记成Broadcasted(lhs 形状, rhs 形状),反向时用来把梯度求和回原形状(后面一节)。.stateful()看requirement:两个输入都不要梯度时走UnTracked,只算结果。Tracked分支里,lhs只有在rhs要梯度时才保存,反过来也一样:∂(l·r)/∂r = l,算rhs的梯度要用lhs,算lhs自己的梯度用不上它。乘一个常量权重时,常量那一侧的梯度不用算,被乘的那个张量也就不用存。prep.checkpoint(&lhs)返回的只是lhs的NodeId。张量本身交给检查点构建器:CheckpointerBuilder::checkpoint对计算密集或属性未定的节点记一个CheckpointingAction::Computed { state_content: Box::new(tensor.primitive.clone()) }。所以Mul的State是两个Option<NodeId>加一个广播信息,没有张量。prep.finish(state, output)调AutodiffTensor::from_parents建输出节点:order是父节点最大深度加 1,parents只留要梯度的输入,Parent.is_leaf记成「父节点深度是 0」;再把父节点、输出节点和状态装进Ops,连同Mul一起包成OpsStep(实现Steptrait 的类型擦除外壳),交给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 什么也不做。
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_scalar | Scalar | 只存那个标量 |
float_sum | Shape | 反向是把梯度扩成输入形状 |
float_exp | NodeId(输入) | 存输入,反向时再算一次 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 也是同样的写法。
问: 状态里直接放张量不是更简单?为什么绕一圈,存 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。
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求和;实验里四组算子的梯度与有限差分一致。