Rust in Everything
基于 candle 0.11.0

Var 与梯度

主线位置:训练主线的 backward 一步:前向记下的图从 loss 往回走一遍,得到每个参数的梯度,交给优化器之前。
版本:PyTorch 2.15.0(CPU)、candle 0.11.0(CPU,accelerate;主线 MLP 另在 Metal 上跑)
flowchart TB
  subgraph pt ["PyTorch"]
    pw["requires_grad 叶子"] --> pl["loss.backward"]
    pl -->|"累加进"| pg["w.grad"]
  end
  subgraph cd ["candle"]
    cw["Var"] --> cl["loss.backward"]
    cl -->|"返回"| cs["GradStore"]
    cs -->|"按 TensorId"| cg["grads.get w"]
  end

实验代码:PyTorch 侧 grad.py,candle 侧 crate autodiff-exp 的 bin grad、nonleaf(故意编译不过)和主线 MLP 的 a3(就是模板里的 train)。release 构建,平台 M2 Max、macOS 27、rustc 1.99.0。candle 内部怎么记图、backward 怎么走,见 candle 卷的计算图怎么记下来和 backward 与 GradStore;本页只讲写法上的对应。

两份程序

一个要梯度的 w,一个不要的 k,loss = sum(w * k)。PyTorch:

w = torch.tensor([1.0, 2.0], requires_grad=True)
k = torch.tensor([10.0, 20.0])
h = w * k
loss = h.sum()
loss.backward()
print("w.grad", w.grad, "k.grad", k.grad)
print("h.grad", h.grad)

candle:

let k = Tensor::new(&[10f32, 20.], &dev)?;
let w = Var::new(&[1f32, 2.], &dev)?;
let h = (w.as_tensor() * &k)?;
let loss = h.sum_all()?;
let g = loss.backward()?;
println!("w.grad {:?} k.grad {:?} h.grad {:?}", v(g.get(&w)), v(g.get(&k)), v(g.get(&h)));

(v 把 Option<&Tensor> 转成 Option<Vec<f32>> 打印。)

w.grad tensor([10., 20.]) k.grad None
h.grad None
w.grad Some([10.0, 20.0]) k.grad Some([1.0, 2.0]) h.grad None

PyTorch 访问 h.grad 时另外打一条 UserWarning: The .grad attribute of a Tensor that is not a leaf Tensor is being accessed...。

逐行对过去:

PyTorchcandle差别
torch.tensor(..., requires_grad=True)Var::new(..)、Var::from_tensor(&t)「要梯度」是一个类型,不是张量上的标志
w * kw.as_tensor() * &kVar 没有实现运算符,要先取出里面的 Tensor(方法调用如 w.matmul(..) 经 Deref 可以直接用)
loss.backward()let g = loss.backward()?梯度不写到张量上,装在返回的 GradStore 里
w.gradg.get(&w)按张量的 TensorId 查,返回 Option<&Tensor>
k.grad 是 Noneg.get(&k) 是 Some([1, 2])见下文

Var 就是一个 Tensor 的新类型包装,里面那个张量的 is_variable 为真。前向时,一个算子只要有一个输入是 Var、或者自己就是被记录的结果,结果就记下 Op(Tensor::track_op 是 is_variable || op.is_some())。这和 PyTorch 「输入里有 requires_grad 就记」是同一条规则。

Var 不实现 Mul,所以照 PyTorch 写 Var::new(..)? * 0.5 编译不过:

error[E0369]: cannot multiply `Var` by `{float}`
  --> src/bin/nonleaf.rs:5:52
   |
 5 |   let w = (Var::ones(2, DType::F32, &Device::Cpu)? * 0.5)?;
   |            --------------------------------------- ^ --- {float}
   |            |
   |            Var
   |
note: `Var` does not implement `std::ops::Mul<{float}>`
  --> .../candle-core-0.11.0/src/variable.rs:10:1
   |
10 | pub struct Var(Tensor);
   | ^^^^^^^^^^^^^^ `Var` is defined in another crate

(cargo 缓存里的路径前缀用 ... 略去。)

普通张量也有梯度

k 没有标成要梯度,candle 照样给了它一份 [1, 2]。原因在 backward 的规则里:Mul 的规则对两个输入都往 GradStore 里累加,而只有中间结果的那一份会在处理它时被取走(GradStore 的去留规则见 backward 与 GradStore)。所以留在表里的是:Var,加上直接参与了被记录运算的普通张量。中间结果 h 和根 loss 都是 None。

📝NOTE
没有蠢问题

问: 普通张量 k 也有梯度,优化器会不会把它也更新了?

答: 不会。优化器在构造时拿到一组 Var,step 只按这些 Var 去 GradStore 里查(SGD::step),表里多出来的项没人看。k 本来也不是 Var,没有 set,改不了。代价只在计算上:反向时多算了这些没人要的梯度。训练循环里的输入 x 和目标 y 就属于这一类,每一步都会算出它们的梯度(candle 卷的优化器与训练循环数过:4 个参数,GradStore 里 6 项)。

主线上的梯度对得上

训练主线的 A3:两层 MLP(8→16→4,ReLU),MSE,batch 32,权重和数据在 f64 里生成、舍入成 f32 一次,PyTorch 和 candle 读同一个 safetensors 文件。candle 侧参数是四个 Var:

fn forward(p: &[Var], x: &Tensor) -> Result<Tensor> {
  let h = x.matmul(&p[0].t()?)?.broadcast_add(&p[1])?.relu()?;
  Ok(h.matmul(&p[2].t()?)?.broadcast_add(&p[3])?)
}

let p = vars(&r, &dev)?; // 每个参数 Var::from_tensor
let loss = candle_nn::loss::mse(&forward(&p, &x)?, &y)?;
let grads = loss.backward()?;
let g: Vec<Tensor> = p.iter().map(|v| grads.get(v).cloned().context("grad")).collect::<Result<_>>()?;

PyTorch 侧是 torch.nn.functional.mse_loss(model(p, x), y).backward(),参数 requires_grad_()。逐个数和 PyTorch 的比:

loss diff 2.3841858e-7
  grad.fc1.weight: 0e0
  grad.fc1.bias: 2.9802322e-8
  grad.fc2.weight: 0e0
  grad.fc2.bias: 4.4703484e-8
grad worst 4.4703484e-8

Metal 上(a3 metal):

loss diff 2.3841858e-7
  grad.fc1.weight: 2.9802322e-8
  grad.fc1.bias: 1.4901161e-8
  grad.fc2.weight: 2.9802322e-8
  grad.fc2.bias: 2.9802322e-8
grad worst 2.9802322e-8

loss 是 2.209,2.38e-7 正是 2 附近的一个 f32 ulp;梯度的最大绝对值是 0.760,最大差 4.47e-8 不到 1 附近的半个 ulp。容差 1e-6 通过。不为 0 是因为两边矩阵乘和求和的累加顺序不同(见怎么读这一卷)。

backward 能跑两次,梯度不累加

PyTorch 的 backward 默认在反向结束后释放保存的中间值,再来一次就报错;要两次就 retain_graph=True,这时梯度累加进 .grad:

try:
  loss.backward()          # 上面那个 loss 的第二次
except RuntimeError as e:
  print("RuntimeError:", str(e).splitlines()[0])

w = torch.tensor([1.0, 2.0], requires_grad=True)
loss = (w * k).sum()
loss.backward(retain_graph=True)
loss.backward()
print("w.grad after two backward", w.grad)
RuntimeError: Trying to backward through the graph a second time (or directly access saved tensors after they have already been freed). Saved intermediate values of the graph are freed when you call .backward() or autograd.grad(). Specify retain_graph=True if you need to backward through the graph a second time or if you need to access saved tensors after calling backward.
w.grad after two backward tensor([20., 40.])

candle 的 Tensor::backward 拿的是 &self,只读地遍历图,什么也不释放。每次调用返回一张新的 GradStore,彼此不相加。要累加,用 GradStore::extend 把第二张表合进第一张,同一个 id 两边都有就相加:

let g2 = loss.backward()?;
println!("second w.grad {:?}", v(g2.get(&w)));

let mut acc = loss.backward()?;
acc.extend(loss.backward()?)?;
println!("w.grad after extend {:?}", v(acc.get(&w)));
second w.grad Some([10.0, 20.0])
w.grad after extend Some([20.0, 40.0])

所以 candle 没有 retain_graph,也没有 zero_grad:图一直留着,直到最后一个引用它的结果张量被丢掉;梯度从来不挂在参数上,没有要清的东西。burn 在这两点上和 candle 都不同:backward 消耗沿途的步骤,第二次 panic(图只能用一次),普通张量的 grad 返回 None。

中间结果的梯度

PyTorch 用 retain_grad() 让一个中间结果留下梯度:

w = torch.tensor([1.0, 2.0], requires_grad=True)
h = w * k
h.retain_grad()
(h * h).sum().backward()
print("h.grad", h.grad, "w.grad", w.grad)
h.grad tensor([20., 80.]) w.grad tensor([ 200., 1600.])

candle 没有对应的开关,backward 处理完一个中间节点就把它的梯度从表里取走。能用的办法是在想看的位置加一个全零的 Var:h + z 的值不变,Add 的规则把梯度原样分给 z,z 是变量,梯度留在表里:

let z = Var::zeros(2, DType::F32, &dev)?;
let h = ((w.as_tensor() * &k)? + z.as_tensor())?;
let g = (&h * &h)?.sum_all()?.backward()?;
println!("h.grad {:?} z.grad {:?} w.grad {:?}", v(g.get(&h)), v(g.get(&z)), v(g.get(&w)));
h.grad None z.grad Some([20.0, 80.0]) w.grad Some([200.0, 1600.0])

z 的梯度就是 PyTorch 的 h.grad,w 的梯度不受影响。这个 z 不要交给优化器。

PyTorch 会报错的三种写法

w = torch.tensor([1.0, 2.0], requires_grad=True)
(w * k).backward()                          # 非标量

torch.tensor([1, 2], requires_grad=True)    # 整数

w = torch.ones(2, requires_grad=True) * 0.5 # 非叶子
(w * k).sum().backward()
print("w.is_leaf", w.is_leaf, "w.grad", w.grad)
torch.optim.SGD([w], lr=0.1)

(三段各包在自己的 try 里。)

RuntimeError: grad can be implicitly created only for scalar outputs
RuntimeError: Only Tensors of floating point and complex dtype can require gradients
w.is_leaf False w.grad None
ValueError: can't optimize a non-leaf Tensor

candle 里同样的三种写法:

let g = (w.as_tensor() * &k)?.backward()?;                 // 非标量
println!("w.grad {:?}", v(g.get(&w)));

let wi = Var::new(&[1u32, 2], &dev)?;                      // 整数
println!("Var::new(u32) ok, dtype {:?}", wi.dtype());
let g = (wi.to_dtype(DType::F32)? * &k)?.sum_all()?.backward()?;
println!("wi.grad is_some {}", g.get(&wi).is_some());
w.grad Some([10.0, 20.0])
Var::new(u32) ok, dtype U32
wi.grad is_some false
写法PyTorchcandle
对非标量 backwardRuntimeError不报错:根的梯度是同形状的全 1,等于先对所有元素求和
整数要梯度RuntimeErrorVar::new(&[1u32, 2]) 不报错;反向的拓扑排序跳过整数张量,梯度是 None;SGD::new、AdamW::new 把非浮点的 Var 滤掉
ones(2, requires_grad=True) * 0.5 当参数结果是非叶子,梯度 None,交给优化器时 ValueError乘出来的是 Tensor,不是 Var,交给优化器是编译错误(见下)

最后一种在 PyTorch 里是个老坑:* 0.5 把叶子变成了中间结果,.grad 不会被填,要到构造优化器时才报错。candle 里「是不是参数」写在类型上。bin nonleaf 把乘出来的张量交给 SGD::new:

let w = (Var::ones(2, DType::F32, &Device::Cpu)?.as_tensor() * 0.5)?;
let _opt = SGD::new(vec![w], 0.1)?;
error[E0308]: mismatched types
 --> src/bin/nonleaf.rs:6:28
  |
6 |   let _opt = SGD::new(vec![w], 0.1)?;
  |                            ^ expected `Var`, found `Tensor`

改成 Var::from_tensor(&(Tensor::ones(..)? * 0.5)?)?,先算好初值、再包成 Var。

Var::from_tensor 复制数据

PyTorch 的 t.requires_grad_() 原地打标志,返回的就是 t 本身。candle 的 Var::from_tensor 对一个普通张量会分配新存储、把数据复制过去:

t = torch.tensor([1.0, 2.0])
p = t.requires_grad_()
print("same object", p is t)
same object True
let t = Tensor::new(&[1f32, 2.], &dev)?;
let p = Var::from_tensor(&t)?;
p.set(&Tensor::zeros(2, DType::F32, &dev)?)?;
println!("t {:?} p {:?} same id {}", t.to_vec1::<f32>()?, p.to_vec1::<f32>()?, t.id() == p.id());
let p2 = Var::from_tensor(p.as_tensor())?;
println!("from_tensor(var) same id {}", p2.id() == p.id());
t [1.0, 2.0] p [0.0, 0.0] same id false
from_tensor(var) same id true

p 被 set 成全零,t 不变:两者是两块存储、两个 id。之后的训练只改 p,手里留着的 t 一直是初值。参数交给优化器、模型里用的必须是同一个 Var(或者经 as_tensor() 拿到的同一个张量);用 VarMap 建模型时这一点自动成立(见 candle 卷的优化器与训练循环)。传进来的已经是变量时,from_tensor 不复制,原样返回。

📝NOTE
冷知识:from_tensor 对变量不复制,是一次 bug 修复

PR #2124(「Bug Fix: When converting a tensor to a variable, clone if the tensor is already a variable.」,2024-04-29 合并,首次发布于 0.5.1)之前,Var::from_tensor 只有一行 t.make_var(),不管传进来的是什么都复制一份。BatchNorm::new 用 Var::from_tensor 包装传进来的 running_mean / running_var;经 VarBuilder::from_varmap 建出来的 batch_norm,传进来的本身就是 VarMap 里的变量,于是 BatchNorm 手里那份和 VarMap 里那份成了两块存储。这次改成上面那个 if t.is_variable() 分支,同一个提交加了测试 train_batch_norm:经 VarMap 建一个 batch_norm,forward_train 一次,断言 bn.running_mean() 已经离开初值,并且和 VarMap 里那份相同。

小结

  • requires_grad=True 对 Var:「要梯度」是类型。Var 不实现运算符,运算前 as_tensor();乘出来的结果是 Tensor,交给优化器是编译错误,PyTorch 的「非叶子参数」这个坑在 candle 里不存在。
  • loss.backward() 返回 GradStore,按张量取:grads.get(&w)。Var 和直接参与运算的普通张量有梯度,中间结果和根没有;看中间结果的梯度,加一个全零的 Var。
  • backward 不释放图,可以跑两次,每次一张新表、不累加;累加用 GradStore::extend。没有 retain_graph,也没有 zero_grad。
  • 非标量 backward 按求和处理,整数 Var 静默没有梯度,两处 PyTorch 都报错。
  • 主线 MLP 的梯度和 PyTorch 最大差 4.47e-8(CPU)、2.98e-8(Metal),loss 差一个 ulp。
  • Var::from_tensor 复制普通张量的数据,对变量原样返回(0.5.1 修过)。

💬 关联讨论

发起讨论
加载中...