写一个迷你自动微分
主线位置:把求导主线自己走一遍。前向时每个算子往 tape 末尾追加一个节点(对应 burn 里算子登记的反向步骤,见一个算子的反向)→
backward从根开始倒着走 tape、把梯度分给输入(对应 backward 与 Gradients 的分桶和倒序执行)→ 梯度按节点 id 存 → 训练循环按参数取梯度做 SGD。burn 在这条路上多出来的东西放在最后逐项对照。
flowchart TB par["tape.param 叶子"] --> fwd["add mul matmul relu sum"] con["tape.constant 常量"] --> fwd fwd --> push["追加节点 值 Op 是否要梯度"] push --> root["loss 节点"] root --> seed["根梯度 全 1"] seed --> sweep["下标从大到小扫 tape"] sweep --> local["input_grads 分给输入"] local --> acc["按输入 id 累加"] acc --> sweep sweep --> grads["Gradients 按 id 取"] grads --> sgd["SGD 更新参数"]
mini 是一个没有任何依赖的库 mini-autodiff,外加四个测试文件。只有对照测试 burn_compare 用到 burn,所以 burn 只出现在 [dev-dependencies] 里:
[dependencies]
# 只有对照测试 tests/burn_compare.rs 用 burn;库本身没有依赖
[dev-dependencies]
burn = { version = "=0.22.1", features = ["flex", "autodiff"] }
平台 M2 Max、macOS 27、rustc 1.99.0,全部是 debug 构建;本页没有计时。库里没有 unwrap / expect,出错一律返回 Result;测试里随便用。
范围
| 文件 | 做什么 | 代码行(不含空行和注释) |
|---|---|---|
src/tensor.rs | Tensor:Vec<f32> 加形状,只有 1 维和 2 维;前向和反向用到的数值运算 | 112 |
src/tape.rs | Tape、Var、五个算子、每个算子的反向、backward、Gradients | 149 |
src/lib.rs | 导出 | 4 |
tests/finite_diff.rs | 每个算子和中心差分比 | 97 |
tests/xor.rs | 两层 MLP 拟合 XOR,普通 SGD | 56 |
tests/burn_compare.rs | 同一个 MLP 在 burn(Device::flex().autodiff())上算一遍梯度 | 88 |
tests/extras.rs | 几处和 burn 行为不同的地方 | 19 |
库一共 265 行代码,加上空行和注释 338 行。
两个源文件的分工和 burn 一样:tensor.rs 只管数值,不知道有图,相当于被装饰的后端(Flex);tape.rs 在它外面记图、求导,相当于 Autodiff 装饰器。区别是 burn 的 Autodiff<B, C> 对 B 泛型,mini 的 Tape 直接用 Tensor 这一种。
Tensor:只管数值
/// 行优先存放的 1 维或 2 维 f32 张量。
#[derive(Debug, Clone, PartialEq)]
pub struct Tensor {
data: Vec<f32>,
shape: Vec<usize>,
}
Tensor::new 检查维数是 1 或 2、没有长度为 0 的维、元素个数和形状对得上,不对就返回 Err。后面所有运算都靠这三条成立,所以不再重复检查。
广播只支持一种:[m, n] + [n],把一个向量加到每一行上。两层 MLP 的偏置只需要这一种:
/// 同形状相加;或者 `[m, n] + [n]`,把右边加到每一行上。
pub fn add(&self, other: &Self) -> Result<Self> {
match (self.shape.as_slice(), other.shape.as_slice()) {
([_, n], [k]) if n == k => {
let data = self.data.chunks(*n).flat_map(|row| row.iter().zip(&other.data).map(|(a, b)| a + b)).collect();
Ok(Self { data, shape: self.shape.clone() })
}
_ => self.zip(other, "add", |a, b| a + b),
}
}
前向里有广播,反向里就要把梯度「求和回」原来的形状:偏置的每个元素加到了 m 行上,它的梯度是这 m 行梯度之和。sum_to 做这件事,形状相同时原样返回:
/// 把梯度求和回 `shape`:`add` 广播的反向。
pub fn sum_to(&self, shape: &[usize]) -> Result<Self> {
match (self.shape.as_slice(), shape) {
(a, b) if a == b => Ok(self.clone()),
([_, n], [k]) if n == k => {
let mut data = vec![0.0; *n];
for row in self.data.chunks(*n) {
data.iter_mut().zip(row).for_each(|(acc, x)| *acc += x);
}
Ok(Self { data, shape: shape.to_vec() })
}
(from, to) => err(format!("sum_to: cannot reduce {from:?} to {to:?}")),
}
}
burn 的做法一样,只是更一般:BinaryOpsBroadcast 记下两个输入的形状,反向时对每个被广播的维做一次 sum_dims(一个算子的反向的「广播」一节)。
matmul 是三重循环,只收 [m, k] x [k, n];relu 是 max(0);sum 把所有元素加成形状 [1] 的张量,和 burn 的 Tensor::sum 返回 Tensor<1> 一致。还有两个只在反向里用的:transpose 和 relu_backward:
/// 输入大于 0 的位置梯度照传,其余为 0(和 burn 的 `relu_backward` 一样,0 处取 0)。
pub fn relu_backward(&self, input: &Self) -> Result<Self> {
self.zip(input, "relu_backward", |g, x| if x > 0.0 { g } else { 0.0 })
}
burn 的默认实现 relu_backward 用 float_lower_equal_elem(.., 0) 做掩码,在 0 处同样给 0。它的参数叫 output,但 Autodiff 的 relu 存进检查点的是输入(prep.checkpoint(&tensor)),反向时传进去的也是输入;对 relu 来说输入大于 0 和输出大于 0 是同一回事。
Tape:每个算子追加一个节点
/// 节点 id,也就是它在 tape 里的下标。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Var(usize);
/// 算子和它的输入:反向需要的全部信息。输入的值从 tape 上按 id 取。
#[derive(Debug, Clone, Copy)]
enum Op {
Leaf,
Add(Var, Var),
Mul(Var, Var),
Matmul(Var, Var),
Relu(Var),
Sum(Var),
}
#[derive(Debug)]
struct Node {
value: Tensor,
op: Op,
requires_grad: bool,
}
#[derive(Debug, Default)]
pub struct Tape {
nodes: Vec<Node>,
}
用户手里拿的是 Var,一个 Copy 的下标;值、算子、要不要梯度都在 tape 上。burn 的张量也是这样分开的:AutodiffTensor 里是原语和一个 Arc<Node>,Node 里有 parents、order、id、requirement,反向步骤另外存在图里,按 NodeId 找。
每个算子都是同一个样子:先用 tensor.rs 算出值,再追加一个节点:
fn push_op(&mut self, value: Tensor, op: Op, inputs: &[Var]) -> Var {
let requires_grad = inputs.iter().any(|&v| self.requires_grad(v));
self.push(value, op, requires_grad)
}
pub fn add(&mut self, a: Var, b: Var) -> Result<Var> {
let out = self.value(a)?.add(self.value(b)?)?;
Ok(self.push_op(out, Op::Add(a, b), &[a, b]))
}
requires_grad 的规则和 burn 的 Requirement::from_nodes 一样:任何一个输入要梯度,输出就要。叶子有两种:param 要梯度,constant 不要。burn 把「要梯度」再分成两种,require_grad() 的叶子是 Grad,中间结果是 GradInBackward,后者的梯度用完就删、grad() 取不到(Autodiff 装饰器);mini 不区分,后面会看到这带来的差别。
self.value(a)? 对不存在的下标返回 Err,所以拿一个更大的 Var 来调用不会越界。但 mini 分不出「另一条 tape 上的 Var」:下标碰巧在范围内,就会读到这条 tape 上的别的节点。burn 的 NodeId 来自进程级的计数器,不会撞。
问: 为什么用 Vec 加下标?让每个节点用 Rc<RefCell<..>> 直接指向父节点,不是更像「图」吗?
答: 指针图里节点没有全局的先后,反向前得先做一次拓扑排序,排序要遍历、要记 visited。放在 Vec 里,追加的顺序本身就是一个拓扑序:一个算子只能拿已经存在的 Var 作输入,输入的下标一定比输出小。下一节的 backward 直接用这个性质,一行排序都不用写。burn 也不用指针连节点:Parent 里存的是 NodeId,步骤放在 HashMap<NodeId, StepBoxed> 里,它的顺序来自节点的 order(深度)。
每个算子的反向
一个节点的上游梯度 grad 怎么分给它的输入,全在一个 match 里:
/// 一个节点的上游梯度 `grad` 分给各个要梯度的输入。
fn input_grads(&self, op: Op, grad: &Tensor) -> Result<Vec<(Var, Tensor)>> {
let wants = |v: Var| self.requires_grad(v);
let mut out = Vec::with_capacity(2);
match op {
Op::Leaf => {}
Op::Add(a, b) => {
if wants(a) {
out.push((a, grad.sum_to(self.value(a)?.shape())?));
}
if wants(b) {
out.push((b, grad.sum_to(self.value(b)?.shape())?));
}
}
Op::Mul(a, b) => {
if wants(a) {
out.push((a, grad.mul(self.value(b)?)?));
}
if wants(b) {
out.push((b, grad.mul(self.value(a)?)?));
}
}
Op::Matmul(a, b) => {
// C = A B:dA = dC Bᵀ,dB = Aᵀ dC
if wants(a) {
out.push((a, grad.matmul(&self.value(b)?.transpose()?)?));
}
if wants(b) {
out.push((b, self.value(a)?.transpose()?.matmul(grad)?));
}
}
Op::Relu(a) => {
if wants(a) {
out.push((a, grad.relu_backward(self.value(a)?)?));
}
}
Op::Sum(a) => {
if wants(a) {
let g = grad.data().first().copied().unwrap_or(0.0);
out.push((a, Tensor::filled(self.value(a)?.shape(), g)));
}
}
}
Ok(out)
}
| 算子 | 输入的梯度 | 用到的值 | burn 存的状态 |
|---|---|---|---|
add(a, b) | grad,被广播的一侧再 sum_to | 只要形状 | (Shape, Shape) |
mul(a, b) | grad * b、grad * a | 另一侧的值 | 只存另一侧要用的输入的 NodeId |
matmul(a, b) | grad · bᵀ、aᵀ · grad | 另一侧的值 | 同上 |
relu(a) | a > 0 处照传 | 输入 | 输入的 NodeId |
sum(a) | 标量梯度铺满 a 的形状 | 只要形状 | Shape |
wants 让不要梯度的输入连算都不算:MLP 第一层是 matmul(x, w1),x 是常量,grad · w1ᵀ 这次矩阵乘法就省掉了。burn 在两处做同样的事:前向时 float_mul / float_matmul 只在另一侧要梯度时才保存这一侧(rhs_tracked.then(|| prep.checkpoint(&lhs))),反向时 binary 只为要梯度的父节点调闭包。
最右一列是两边最大的差别。mini 的每个节点都把前向的值留在 tape 上,不管反向用不用得到:add 和 sum 的输出谁也不需要,照样存着。burn 的算子只把反向要用的输入交给检查点,状态里只有 NodeId(一个算子的反向)。
backward:按下标倒序
/// 从 `root` 反向传播。根的梯度是和它同形状的全 1。
///
/// 顺序:节点按创建顺序追加,算子的输入一定早于输出存在,所以下标从大到小
/// 就是一个逆拓扑序。轮到节点 i 时,所有用到它的节点(下标都比 i 大)已经
/// 把梯度加进来了。
pub fn backward(&self, root: Var) -> Result<Gradients> {
let root_value = self.value(root)?;
if !self.requires_grad(root) {
return Err(Error(format!("node {} does not depend on any param", root.0)));
}
let mut grads: Vec<Option<Tensor>> = vec![None; root.0 + 1];
grads[root.0] = Some(Tensor::filled(root_value.shape(), 1.0));
for (id, node) in self.nodes[..=root.0].iter().enumerate().rev() {
// 没有梯度:从根走不到它,或者它不要梯度
let Some(grad) = &grads[id] else { continue };
for (input, g) in self.input_grads(node.op, grad)? {
let slot = &mut grads[input.0];
*slot = Some(match slot.take() {
Some(acc) => acc.add(&g)?,
None => g,
});
}
}
Ok(Gradients { grads })
}
三件事:
- 根的梯度是全 1。 和 burn 的
Gradients::new一样,对一个非标量的根调backward,相当于先把它所有元素加起来。 - 顺序是下标倒序。 根之后追加的节点不可能是根的祖先,所以只扫
..=root。一个节点的所有「使用者」下标都比它大,倒着扫时它们先执行,轮到它时梯度已经累加完整。从根走不到的节点、不要梯度的节点,grads[id]一直是None,continue跳过。 - 同一个输入出现多次,梯度相加。
mul(diff, diff)会给diff推两份梯度,slot.take()取出旧值再加上新的。burn 的Gradients::register做的是同一件事:已有值就float_add。
和 burn 的深度分桶比
burn 的 build_tape 先建「根深度 + 1」个桶,把收集到的步骤按 step.depth() 放进去,execute_steps 从最深的桶执行到最浅的。深度在 from_parents 里定义:父节点 order 的最大值加 1,叶子是 0。一个节点的使用者都比它深,所以按深度倒序同样是逆拓扑序。
两种顺序都对,但不一定相同。下面这张图(按代码推出来的,不是程序打印的)里,b = relu(x) 是第 4 个创建的,深度却只有 1:
| 下标 | 节点 | 深度 |
|---|---|---|
| 0 | x(param) | 0 |
| 1 | r1 = relu(x) | 1 |
| 2 | r2 = relu(r1) | 2 |
| 3 | r3 = relu(r2) | 3 |
| 4 | b = relu(x) | 1 |
| 5 | s = add(r3, b) | 4 |
| 6 | loss = sum(s) | 5 |
mini 按下标倒序执行算子节点:6、5、4、3、2、1。burn 先执行深度 5、4、3、2 的桶(6、5、3、2),最后是深度 1 的桶,里面有 4 和 1:collect_steps 用栈收集,s 的父节点按 [r3, b] 压栈、先弹出 b,所以 4 排在 1 前面。b 在 mini 里先于 r3 执行,在 burn 里排在 r3、r2 之后。b 和 r3、r2 之间没有依赖,先后无所谓。
| mini | burn 0.22.1 | |
|---|---|---|
| 顺序从哪来 | 下标,即创建顺序 | 节点的 order(深度) |
| 哪些节点参与 | 扫 0..=root 的每个下标,没梯度的跳过 | collect_steps 用栈只收集从根能到达的步骤 |
| 步骤存在哪 | 一条 Vec | 每张图一个 HashMap<NodeId, StepBoxed>,图可能是几张合并来的 |
| 同一层内的先后 | 严格按下标 | 按收集顺序 |
| backward 之后 | tape 不变,可以再来一次 | 步骤被移出,再来一次就 panic |
burn 的 NodeId 现在也是创建时从一个 AtomicU64 计数器取的(NodeId::new),同样随创建单调增加;但步骤放在 HashMap 里,没有现成的次序可用,按深度分桶不用排序,一遍就分好。
PR #843(「Use a u64 counter for autodiff NodeIDs」,首次进入 v0.10.0;提交说明只有这一行标题)之前,NodeID 是 struct NodeID { pub value: String },new() 调 burn_common::id::IdGenerator::generate(),用种子随机数生成一个带连字符的 UUID 字符串;Gradients 的键 GradID 也是 String,每次 register 都要 node.id.value.clone() 一个字符串。这个提交把它换成现在的样子:一个 static COUNTER: AtomicU64,fetch_add(1, Ordering::Relaxed),溢出时 panic;GradID 改成 u64,register 里的两处 .clone() 随之去掉。那时的 id 不带任何先后信息,反向的顺序只能来自节点上的 order 字段;按深度分桶的 tape 比这还早,在 #186(v0.6.0 的 autodiff 重构)之前的 graph/node/backward.rs 里就已经是 vec![Vec::new(); self.order]。
有限差分:每个算子都核对
一个算子的反向写错,前向照样能跑、loss 照样会动,只有拿数值去核对才看得出来。核对用中心差分:把某个输入的一个元素加减 h,前向两次,(f(x + h) - f(x - h)) / 2h 应该等于反向算出的梯度在这个位置的值:
/// `f` 用 tape 上的输入算出一个标量。对每个输入的每个元素做一次中心差分。
fn check(name: &str, inputs: &[Tensor], f: impl Fn(&mut Tape, &[Var]) -> Result<Var>) {
let eval = |inputs: &[Tensor]| -> f32 {
let mut tape = Tape::new();
let vars: Vec<Var> = inputs.iter().map(|x| tape.param(x.clone())).collect();
let out = f(&mut tape, &vars).unwrap();
tape.value(out).unwrap().data()[0]
};
let mut tape = Tape::new();
let vars: Vec<Var> = inputs.iter().map(|x| tape.param(x.clone())).collect();
let out = f(&mut tape, &vars).unwrap();
let grads = tape.backward(out).unwrap();
let h = 1e-2;
let mut max_err = 0.0f32;
for (i, input) in inputs.iter().enumerate() {
let analytic = grads.get(vars[i]).unwrap();
assert_eq!(analytic.shape(), input.shape());
for j in 0..input.data().len() {
let shifted = |delta: f32| {
let mut data = input.data().to_vec();
data[j] += delta;
let mut moved = inputs.to_vec();
moved[i] = Tensor::new(data, input.shape()).unwrap();
eval(&moved)
};
let numeric = (shifted(h) - shifted(-h)) / (2.0 * h);
max_err = max_err.max((analytic.data()[j] - numeric).abs());
}
}
println!("{name:<22} max |analytic - numeric| = {max_err:.2e}");
assert!(max_err < 1e-2, "{name}: gradient mismatch {max_err}");
}
除了 sum,每个算子的输出都不是标量,测试再乘一组固定的随机权重、求和(weigh),让输出的每个元素拿到不同的上游梯度;全 1 的上游梯度查不出「梯度放错了位置」这类错。relu 的输入挑成离 0 至少 0.1,h = 0.01 的扰动不会跨过拐点。七个测试:
#[test]
fn add_broadcast_row() {
check("add [3,4]+[4]", &[t(4, &[3, 4]), t(5, &[4])], |tape, v| {
let y = tape.add(v[0], v[1])?;
weigh(tape, y, 99)
});
}
#[test]
fn reused_input() {
// x 出现在两条路径上:梯度要相加
check("x*x + x", &[t(11, &[4])], |tape, v| {
let sq = tape.mul(v[0], v[0])?;
let y = tape.add(sq, v[0])?;
weigh(tape, y, 99)
});
}
// ... sum、add_same_shape、mul、matmul、relu 同样的写法
cargo test --offline --test finite_diff -- --nocapture --test-threads=1:
running 7 tests
test add_broadcast_row ... add [3,4]+[4] max |analytic - numeric| = 4.17e-6
ok
test add_same_shape ... add [3,4]+[3,4] max |analytic - numeric| = 2.42e-5
ok
test matmul ... matmul [3,4]x[4,2] max |analytic - numeric| = 7.15e-6
ok
test mul ... mul max |analytic - numeric| = 9.42e-6
ok
test relu ... relu max |analytic - numeric| = 3.81e-6
ok
test reused_input ... x*x + x max |analytic - numeric| = 2.06e-6
ok
test sum ... sum max |analytic - numeric| = 9.54e-7
ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
误差都在 1e-5 上下,是 f32 做差分的舍入误差;门槛 1e-2 离它有三个数量级。门槛定得这么宽,还能不能抓到错?把 relu_backward 改成不看输入、梯度一律照传(if x > 0.0 { g } else { g }),再跑:
test add_broadcast_row ... ok
test add_same_shape ... ok
test matmul ... ok
test mul ... ok
test relu ... FAILED
test reused_input ... ok
test sum ... ok
failures:
---- relu stdout ----
relu max |analytic - numeric| = 7.32e-1
thread 'relu' (3475402) panicked at tests/finite_diff.rs:52:3:
relu: gradient mismatch 0.73178875
只有 relu 红了,误差 0.73,和正常时差五个数量级。(把 mul 的反向改错则会让除 sum 外的六个测试全红,因为 weigh 自己就用 mul。)burn 自己的测试也用同样的办法核对,一个算子的反向的最后一节在 burn 上对过四组算子。
用它训练:两层 MLP 学 XOR
模型是 relu(x W1 + b1) W2 + b2,隐藏层 8 个单元;loss 是平方误差之和。没有减法,就把标签取负作为常量,用 add 代替:
/// relu(x W1 + b1) W2 + b2,loss = Σ(pred - y)²
fn loss(tape: &mut Tape, params: &[Var; 4], x: Var, neg_y: Var) -> Result<(Var, Var)> {
let [w1, b1, w2, b2] = *params;
let h = tape.matmul(x, w1)?;
let h = tape.add(h, b1)?;
let h = tape.relu(h)?;
let out = tape.matmul(h, w2)?;
let pred = tape.add(out, b2)?;
let diff = tape.add(pred, neg_y)?;
let sq = tape.mul(diff, diff)?;
Ok((tape.sum(sq)?, pred))
}
参数是 tape 外面的四个 Tensor。每一步建一条新 tape,把参数作为 param 放上去,前向、反向,再按 Var 取梯度做 p - lr * grad:
for step in 0..=STEPS {
// 每一步一条新 tape:上一步的图随旧 tape 一起丢掉
let mut tape = Tape::new();
let vars = params.clone().map(|p| tape.param(p));
let x = tape.constant(x.clone());
let neg_y = tape.constant(neg_y.clone());
let (l, pred) = loss(&mut tape, &vars, x, neg_y).unwrap();
let grads = tape.backward(l).unwrap();
if step % 25 == 0 {
let l = tape.value(l).unwrap().data()[0];
println!("step {step:>4} loss {l:.6} nodes {}", tape.len());
}
// ... step == STEPS 时打印预测、断言 loss < 1e-3、退出
for (p, v) in params.iter_mut().zip(vars) {
*p = p.add_scaled(grads.get(v).unwrap(), -lr).unwrap();
}
}
lr = 0.02,STEPS = 300,权重用固定种子的 LCG 生成,b1 初始化成 0.1(让 relu 一开始都处在打开的一侧):
running 1 test
test xor ... step 0 loss 2.073196 nodes 14
step 25 loss 0.204576 nodes 14
step 50 loss 0.049814 nodes 14
step 75 loss 0.009232 nodes 14
step 100 loss 0.001496 nodes 14
step 125 loss 0.000226 nodes 14
step 150 loss 0.000034 nodes 14
step 175 loss 0.000005 nodes 14
step 200 loss 0.000001 nodes 14
step 225 loss 0.000000 nodes 14
step 250 loss 0.000000 nodes 14
step 275 loss 0.000000 nodes 14
step 300 loss 0.000000 nodes 14
pred [8.4117055e-6, 0.99999243, 0.9999897, 1.0021031e-5]
ok
四个输入 (0,0)、(0,1)、(1,0)、(1,1) 的预测分别是 0、1、1、0。每一步 tape 上都是 14 个节点:4 个参数、2 个常量、8 个算子。
「每步一条新 tape」就是 mini 的内存管理:上一步的节点、值、梯度随旧的 Tape 和 Gradients 一起 drop。burn 的用户代码里没有 tape 这个对象,图挂在进程级的 GraphLocator 上,节点在 backward() 结束时由 GraphMemoryManagement 按引用计数清理;只前向不反向,保存的状态会一直留着(backward 与 Gradients)。burn 的训练循环里梯度还要从节点 id 换成参数 id(GradientsParams),优化器按 ParamId 更新(优化器);mini 的参数就是那四个 Tensor,Var 和参数一一对应,用不着这一步。
和 burn 对一对
burn_compare 用同一组输入和参数(隐藏层 3 个单元),在 mini 和 Device::flex().autodiff() 上各做一次前向和反向,比较 loss 和四个参数的梯度。burn 一侧:
fn burn(c: &Case) -> (f32, [Vec<f32>; 4]) {
let device = Device::flex().autodiff();
let t2 = |v: &Vec<f32>, shape: [usize; 2]| BTensor::<2>::from_data(TensorData::new(v.clone(), shape), &device);
let t1 = |v: &Vec<f32>| BTensor::<1>::from_data(TensorData::new(v.clone(), [v.len()]), &device);
let x = t2(&c.x, [4, 2]);
let y = t2(&c.y, [4, 1]);
let w1 = t2(&c.w1, [2, H]).require_grad();
let b1 = t1(&c.b1).require_grad();
let w2 = t2(&c.w2, [H, 1]).require_grad();
let b2 = t1(&c.b2).require_grad();
let h = activation::relu(x.matmul(w1.clone()) + b1.clone().unsqueeze::<2>());
let pred = h.matmul(w2.clone()) + b2.clone().unsqueeze::<2>();
let diff = pred - y;
let loss = (diff.clone() * diff).sum();
let grads = loss.backward();
// ... 用 into_data().try_to_vec::<f32>() 取出 loss 和四个梯度
}
running 1 test
test same_gradients_as_burn ... loss mini 1.293978
loss burn 1.2939781
w1.grad mini [0.0, -0.007851243, -0.003602624, 0.0, 0.59956694, 0.27511376]
w1.grad burn [0.0, -0.007851243, -0.003602624, 0.0, 0.59956694, 0.27511376]
b1.grad mini [0.18811604, -0.35759163, -0.16408247]
b1.grad burn [0.18811604, -0.35759163, -0.16408247]
w2.grad mini [0.035068847, 0.38163775, -0.04492247]
w2.grad burn [0.035068847, 0.38163775, -0.04492247]
b2.grad mini [-0.42864764]
b2.grad burn [-0.42864764]
max |mini - burn| = 0e0
ok
- 四个梯度逐位相同。这组形状很小,每个梯度元素只是几个数的乘加,两边的累加顺序恰好一样。
- loss 在最后一位不同。mini 的
sum是iter().sum(),从头加到尾;Flex 的float_sum走sum_f32_contiguous,simd是 burn-flex 的默认 feature,交给kernels::sum_f32:按 SIMD 向量载入、累加,最后reduce_add做一次水平求和。加法顺序不同,f32 的舍入就不同。梯度不受影响:sum的反向只是把全 1 铺开,和 loss 的值无关。 w1梯度的第 0 列是 0:x W1 + b1的第 0 个隐藏单元只在输入(0,0)时为正,那一行x全是 0,dW1 = xᵀ · grad在这一列得 0;它的偏置梯度b1[0]不受x影响,不是 0。
几处行为差别
extras 用的是 backward 与 Gradients 里同一个函数 sum(x*x + x*3):
let mut tape = Tape::new();
let x = tape.param(Tensor::new(vec![1.0, 2.0], &[2]).unwrap());
let c = tape.constant(Tensor::new(vec![3.0, 3.0], &[2]).unwrap());
let h = tape.mul(x, x).unwrap();
let xc = tape.mul(x, c).unwrap();
let s = tape.add(h, xc).unwrap();
let loss = tape.sum(s).unwrap();
let g1 = tape.backward(loss).unwrap();
let g2 = tape.backward(loss).unwrap();
// ... 打印 x、h、c 的梯度,对常量 backward,对 1 维张量 matmul
test extras ... x.grad (1st backward) = Some([5.0, 7.0])
x.grad (2nd backward) = Some([5.0, 7.0])
h.grad = Some([1.0, 1.0])
c.grad = None
backward(c) = Err(Error("node 1 does not depend on any param"))
matmul [2] x [2] = Err(Error("matmul needs 2-D tensors, got [2] and [2]"))
ok
| mini | burn 0.22.1 | |
|---|---|---|
x.grad | [5, 7] | [5, 7] |
同一个 loss 第二次 backward | 照常,结果相同:backward 只读 tape(&self) | panic「graph tape has already been consumed」,不支持 retain_graph |
中间结果 h 的梯度 | Some([1, 1]),留在 Gradients 里 | None,GradInBackward 的梯度在反向途中被 consume 删掉 |
常量 c 的梯度 | None | None |
对不要梯度的张量 backward | 返回 Err | panic(Tensor::backward 先断言张量被追踪) |
| 形状不对 | 返回 Err | panic,TensorCheck 给出信息(检查与报错) |
mini 能反复 backward,代价就是上一节说的:所有值和梯度都留着,直到整条 tape 被丢掉。burn 选了另一头,backward 一次就把步骤和中间梯度释放掉。
backward 里累加梯度的那几行换成直接覆盖(*slot = Some(g);),重跑全部测试。七个有限差分测试里哪几个会红?XOR 还能学会吗?burn_compare 呢?答案在页末,平台同上,debug 构建。burn 有而这里没有的
| burn 有 | mini 的做法 | 讲它的页 |
|---|---|---|
装饰任何后端:Autodiff<B, C> 对 B 泛型,前向调 B 的算子 | Tape 写死了 Tensor(Vec<f32>) | Autodiff 装饰器、burn 解决什么问题 |
运行期选后端:同一段代码在 Flex、Metal、CUDA 上跑,Dispatch 按设备 match | 只有 CPU 上的三重循环 | Dispatch、Flex:CPU 后端 |
只存反向要用的状态,状态里只有 NodeId | 每个节点的值都留在 tape 上 | 一个算子的反向 |
| 梯度检查点:逐元素算子只记怎么重算,反向时补算 | 没有,值全存 | 梯度检查点 |
| 按需释放节点:backward 后按引用计数删掉不可用的节点,中间梯度用完即删 | 整条 tape 一起丢 | backward 与 Gradients |
图的合并:节点散在全局 GraphLocator 的多张图里,有共同父节点时合并,各图各一把锁 | 一条 tape,&mut 借用,单线程 | backward 与 Gradients |
| 融合:GPU 上把一串逐元素 op 攒成一个内核 | 每个 op 立刻算出一个新 Vec | Fusion 装饰器、生成融合内核 |
任意维、任意广播,每个浮点算子都有反向(burn-autodiff/src/ops/tensor.rs 一个文件 4830 行) | 1 维和 2 维,一种广播,五个算子 | 一个算子的反向 |
| 梯度从节点 id 换到参数 id,交给优化器 | Var 就是参数 | 优化器 |
这些东西多数在回答同一个问题:图很大、跑在 GPU 上、要在多个设备上训练时,内存和速度怎么办。mini 的 XOR 一步只有 14 个节点,这些问题都还没出现。
动脑筋答案
把累加换成覆盖,cargo test --offline --no-fail-fast -- --nocapture --test-threads=1(节选):
test same_gradients_as_burn ... loss mini 1.293978
loss burn 1.2939781
w1.grad mini [0.0, -0.0039256215, -0.001801312, 0.0, 0.29978347, 0.13755688]
w1.grad burn [0.0, -0.007851243, -0.003602624, 0.0, 0.59956694, 0.27511376]
b1.grad mini [0.09405802, -0.17879581, -0.082041234]
b1.grad burn [0.18811604, -0.35759163, -0.16408247]
w2.grad mini [0.017534424, 0.19081888, -0.022461236]
w2.grad burn [0.035068847, 0.38163775, -0.04492247]
b2.grad mini [-0.21432382]
b2.grad burn [-0.42864764]
max |mini - burn| = 2.9978347e-1
thread 'same_gradients_as_burn' (3476128) panicked at tests/burn_compare.rs:102:3:
assertion failed: max_diff < 1e-5
...
test reused_input ... x*x + x max |analytic - numeric| = 9.32e-1
thread 'reused_input' (3476140) panicked at tests/finite_diff.rs:52:3:
x*x + x: gradient mismatch 0.93168473
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
FAILED
test sum ... sum max |analytic - numeric| = 9.54e-7
ok
failures:
reused_input
test result: FAILED. 6 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
...
test xor ... step 0 loss 2.073196 nodes 14
step 25 loss 0.387116 nodes 14
step 50 loss 0.204980 nodes 14
step 75 loss 0.105003 nodes 14
step 100 loss 0.049699 nodes 14
step 125 loss 0.022374 nodes 14
step 150 loss 0.009434 nodes 14
step 175 loss 0.003916 nodes 14
step 200 loss 0.001581 nodes 14
step 225 loss 0.000625 nodes 14
step 250 loss 0.000249 nodes 14
step 275 loss 0.000097 nodes 14
step 300 loss 0.000037 nodes 14
pred [0.002767101, 0.9974603, 0.996524, 0.0033554286]
ok
- 有限差分只红了
reused_input:七个测试里只有它让同一个张量在图里被用了两次(x*x和+ x)。其余六个里每个输入只有一个使用者,覆盖和累加没有区别。 - XOR 照样学会了,只是慢一倍:第 50 步的 loss 0.204980 约等于正确实现第 25 步的 0.204576,第 100 步的 0.049699 约等于第 50 步的 0.049814。图里唯一被用两次的是
mul(diff, diff)里的diff,覆盖后它只拿到grad * diff,少了一半;diff之前的所有梯度都跟着减半,方向没变,等于把学习率减半。 burn_compare抓到了:四个梯度正好都是 burn 的一半。extras打印的x.grad变成Some([1.0, 2.0])(正确值[5, 7]),但它只打印、不断言,照样通过。
所以「loss 在降」不能证明梯度是对的。一个能学会 XOR 的实现也可以把梯度算错一倍,要靠有限差分或者另一个实现去对。
小结
- 一个能训练的反向模式自动微分只要三样东西:一个只管数值的张量,一条记下「值、算子、输入」的 tape,一个把上游梯度分给输入的
match。mini 的库一共 265 行代码,没有依赖。 - 节点按创建顺序存进
Vec,输入的下标一定小于输出,下标倒序就是逆拓扑序,不用排序;burn 的步骤在HashMap里,靠节点深度分桶、从深到浅执行,两种顺序都对,同层的先后可以不同。 - 同一个张量被用多次时梯度要相加。这个错误训练测不出来(XOR 照样收敛,只是相当于学习率减半),有限差分和对照 burn 都能抓到。
- 同一个 MLP 上,mini 和 burn(Flex + autodiff)的梯度逐位相同;loss 差最后一位,因为 Flex 的
sum按 SIMD 通道累加。 - burn 多出来的东西(对任意后端的装饰、只存需要的状态、检查点、按需释放、图合并、多后端、融合)都是为大图、GPU 和多设备准备的,mini 的 14 个节点用不上它们。