Rust in Everything
基于 burn 0.22.1

打开求导

主线位置:训练主线的 forward 之前:决定哪些张量要梯度、前向要不要记图。
版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备)
flowchart TB
  subgraph pt ["PyTorch"]
    t1["torch.tensor"] -->|"requires_grad=True"| l1["叶子要梯度"]
  end
  subgraph bn ["burn"]
    d["Device flex"] -->|".autodiff()"| d2["autodiff 设备"]
    d2 --> t2["Tensor from_floats"]
    t2 -->|".require_grad()"| l2["叶子要梯度"]
  end
  l1 --> fwd["前向记图"]
  l2 --> fwd

实验代码:PyTorch 侧 rg.py,burn 侧 crate autodiff-exp 的 bin rg(burn =0.22.1,feature std、flex、ndarray、autodiff、optim,debug)。平台 M2 Max、macOS 27、rustc 1.99.0。

一个参数变成两步

PyTorch 里任何浮点张量都能要梯度,requires_grad=True 一个参数就够了:

a = torch.tensor([1.0, 2.0])
w = torch.tensor([3.0, 4.0], requires_grad=True)
aa = a * a
aw = a * w

burn 要两步。先让设备带上 autodiff 标记,再对要梯度的叶子调 require_grad():

let ad = Device::flex().autodiff();
let a = Tensor::<1>::from_floats([1.0, 2.0], &ad);
let w = Tensor::<1>::from_floats([3.0, 4.0], &ad).require_grad();
let aa = a.clone() * a.clone();
let aw = a.clone() * w.clone();

Device::flex() 本身不会记图。.autodiff() 只是在设备值上打一个标记,Dispatch 在运行期看到标记,才把算子交给 autodiff 那一层(机制见 Autodiff 装饰器)。在 autodiff 设备上新建的张量默认不要梯度,和 PyTorch 新建的张量默认 requires_grad=False 一样。Tensor::require_grad 按值拿走张量、返回一个新的,所以要接住返回值;它带 #[must_use],丢掉返回值会有警告。

已经建在普通设备上的张量,也可以单独抬上去:Tensor::autodiff 只挂标记,不打开梯度,要梯度还得再 require_grad():

let lifted = Tensor::<1>::from_floats([1.0, 2.0], &plain).autodiff().require_grad();

一个标志对三个查询

两边各打印一遍上面四个张量的状态:

def flags(name, t):
  print(f"{name:10} requires_grad={t.requires_grad!s:5} is_leaf={t.is_leaf!s:5} grad_fn={type(t.grad_fn).__name__ if t.grad_fn else None}")
pub fn flags<const D: usize>(name: &str, t: &Tensor<D>) {
  println!(
    "{name:10} is_autodiff={:5} is_tracked={:5} is_require_grad={}",
    t.is_autodiff(),
    t.is_tracked(),
    t.is_require_grad()
  );
}
a          requires_grad=False is_leaf=True  grad_fn=None
w          requires_grad=True  is_leaf=True  grad_fn=None
a*a        requires_grad=False is_leaf=True  grad_fn=None
a*w        requires_grad=True  is_leaf=False grad_fn=MulBackward0
a          is_autodiff=true  is_tracked=false is_require_grad=false
w          is_autodiff=true  is_tracked=true  is_require_grad=true
a*a        is_autodiff=true  is_tracked=false is_require_grad=false
a*w        is_autodiff=true  is_tracked=true  is_require_grad=false

PyTorch 的 requires_grad 会沿着运算传下去:a*w 也是 True,它不是叶子,有 grad_fn。burn 的 is_require_grad() 只对 require_grad() 过的叶子为真,a*w 是 false;「参与了要求导的计算」要看 is_tracked。对照起来:

想问的事PyTorchburn
这个张量的运算会不会被记下来没有对应,CPU 张量总能求导is_autodiff()
反向会不会经过它requires_gradis_tracked()
backward 之后能不能取到它的梯度requires_grad and is_leafis_require_grad()

三个查询背后是 burn 节点上的 Requirement 三个取值:require_grad() 的叶子是 Grad,有输入要梯度的算子输出是 GradInBackward,其余是 None(见 Autodiff 装饰器的「Requirement」一节)。从 PyTorch 搬代码时,if t.requires_grad: 多半应该换成 t.is_tracked(),不是 is_require_grad()。

错误在哪报

把几种常见的误用在两边各跑一遍:

attempt("int requires_grad", lambda: torch.tensor([1, 2], requires_grad=True))
attempt("backward on constant", lambda: (a * 2).sum().backward())
attempt("requires_grad_(True) on a*w", lambda: aw.requires_grad_(True))
attempt("requires_grad_(False) on a*w", lambda: aw.requires_grad_(False))
attempt("requires_grad_() on a*a", lambda: flags("a*a after", aa.requires_grad_()))
int requires_grad: RuntimeError: Only Tensors of floating point and complex dtype can require gradients
backward on constant: RuntimeError: element 0 of tensors does not require grad and does not have a grad_fn
requires_grad_(True) on a*w: ok
requires_grad_(False) on a*w: RuntimeError: you can only change requires_grad flags of leaf variables. If you want to use a computed variable in a subgraph that doesn't require differentiation use var_no_grad = var.detach().
a*a after  requires_grad=True  is_leaf=True  grad_fn=None
requires_grad_() on a*a: ok

burn 侧用 std::panic::catch_unwind 包住每一次尝试,panic 原文由默认的 panic hook 打到 stderr:

let plain = Device::flex();
attempt("require_grad on Device::flex()", || {
  Tensor::<1>::from_floats([1.0, 2.0], &plain).require_grad()
});
attempt("backward on constant", || (a.clone() * 2.0).sum().backward());
attempt("require_grad on a*w", || aw.clone().require_grad());
attempt("set_require_grad(false) on a*w", || {
  flags("a*w off", &aw.clone().set_require_grad(false))
});
attempt("require_grad on a*a", || flags("a*a after", &aa.clone().require_grad()));
thread 'main' (8533721) panicked at .../burn-tensor-0.22.1/src/tensor/api/float.rs:1185:13:
Tensor::require_grad requires autodiff; call Tensor::autodiff first
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
require_grad on Device::flex(): panicked

thread 'main' (8533721) panicked at .../burn-tensor-0.22.1/src/tensor/api/autodiff.rs:75:9:
Tensor::backward requires a tracked autodiff tensor; call Tensor::autodiff().require_grad() on the source leaf before computing the output
backward on constant: panicked

thread 'main' (8533721) panicked at .../burn-autodiff-0.22.1/src/tensor.rs:190:17:
Can't convert a non leaf tensor into a tracked tensor
require_grad on a*w: panicked
a*w off    is_autodiff=true  is_tracked=false is_require_grad=false
set_require_grad(false) on a*w: ok
a*a after  is_autodiff=true  is_tracked=true  is_require_grad=true
require_grad on a*a: ok

逐条对照:

操作PyTorchburn
普通设备上的张量要梯度不存在这种情况panic:Tensor::require_grad requires autodiff
对常量算出来的 loss 调 backwardRuntimeErrorpanic:Tensor::backward requires a tracked autodiff tensor
对中间结果打开梯度本来就是 True,什么也不做panic:Can't convert a non leaf tensor into a tracked tensor
对中间结果关掉梯度RuntimeError,建议用 detach()成功,结果和 detach() 一样成为不追踪的新起点
对没有历史的算子结果(a*a)打开梯度成功,它是叶子成功,它是 None,可以变成 Grad
整数张量要梯度RuntimeError编译错误

中间结果那两行正好反过来。burn 的 AutodiffTensor::require_grad 只在 Requirement::None 时新建一个 Grad 节点,GradInBackward 一律 panic,不区分「本来就在追踪」;set_require_grad(false) 落到 float_set_require_grad,直接用原语建一个新的 AutodiffTensor,于是和前面的图断开。想拿中间结果的梯度,PyTorch 用 retain_grad(),burn 没有,见 backward 与梯度。

整数张量是唯一在编译期挡住的一条:require_grad 只定义在浮点张量上(crate autodiff-intgrad):

let ad = Device::flex().autodiff();
let i = Tensor::<1, Int>::from_ints([1, 2], &ad).require_grad();
error[E0599]: no method named `require_grad` found for struct `Tensor<1, burn::tensor::Int>` in the current scope
 --> src/main.rs:5:52
  |
5 |   let i = Tensor::<1, Int>::from_ints([1, 2], &ad).require_grad();
  |                                                    ^^^^^^^^^^^^
  |
help: there is a method `is_require_grad` with a similar name
  |
5 |   let i = Tensor::<1, Int>::from_ints([1, 2], &ad).is_require_grad();
  |                                                    +++

其余都是运行期的 panic。0.21 时 backward 只定义在 autodiff 后端的张量类型上,在不能求导的后端上调它编译不过;0.22 把后端从类型挪到了设备值上,这类检查就变成了运行期的(来龙去脉见 burn 解决什么问题的冷知识)。从这一点看,0.22 的 burn 反而和 PyTorch 更像:都是跑起来才知道。

📝NOTE
冷知识:在普通设备上 require_grad() 曾经什么也不做

PR #5571(「feat(tensor): add is_autodiff and is_tracked state inspection」,首次发布于 v0.22.0)给 require_grad 写的文档是「This function does nothing when autodiff isn't enabled or when the tensor is quantized.」:在 Device::flex() 上建的张量调 require_grad() 不报错,backward 之后梯度是 None,看不出哪里漏了 .autodiff()。五天后的 PR #5624(「feat(autodiff): support graph-preserving cross-backend transfers」,同样首次发布于 v0.22.0)的提交列表里有一条「Enforce runtime gradient preconditions」,在 set_require_grad_impl 里加了上面那条 assert!,文档改成「For non-quantized tensors, panics if autodiff is disabled or the tensor is a non-leaf.」。所以 0.22.0 之前的预发布版本里,这一行是静默的;0.22.0 起它 panic,信息里直接告诉你先调 Tensor::autodiff。

先 require_grad,再参与计算

两边有一点完全一样:要梯度的标记必须在张量参与前向之前打上。

x = torch.tensor([1.0, 2.0])
used = x * w
x.requires_grad_()
used.sum().backward()
print("late x.grad =", x.grad, "| w.grad =", w.grad)
let x = Tensor::<1>::from_floats([1.0, 2.0], &ad);
let used = x.clone() * w.clone();
let x = x.require_grad();
let grads = used.sum().backward();
println!("late x.grad = {:?} | w.grad = {:?}", v(x.grad(&grads)), v(w.grad(&grads)));
late x.grad = None | w.grad = tensor([1., 2.])
late x.grad = None | w.grad = Some([1.0, 2.0])

乘法在执行的那一刻决定记不记 x 这个输入;之后再打标记,已经记好的图不会回头补。burn 这边的出处见 grad 返回 None 的 #3。

模型参数不用手动标

PyTorch 的 nn.Linear 参数默认 requires_grad=True。burn 的 Linear 在 autodiff 设备上初始化时,参数也已经要梯度;在普通设备上初始化则不要:

fc = torch.nn.Linear(2, 2)
flags("fc.weight", fc.weight)
let fc = burn::nn::LinearConfig::new(2, 2).init(&ad);
flags("fc.weight", &fc.weight.val());
let fc_plain = burn::nn::LinearConfig::new(2, 2).init(&plain);
flags("plain fc.w", &fc_plain.weight.val());
fc.weight  requires_grad=True  is_leaf=True  grad_fn=None
fc.weight  is_autodiff=true  is_tracked=true  is_require_grad=true
plain fc.w is_autodiff=false is_tracked=false is_require_grad=false

LinearConfig::init 经 Initializer::init_with 建一个延迟初始化的 Param::uninitialized,要梯度的设置是 true;第一次 val() 时 initialize 把 is_require_grad && self.device.is_autodiff() 交给初始化函数,为真才 require_grad()。普通设备上这个值是假,「训练时要梯度」只记在 Param 上,等 Module::train() 再打开。手动替换参数用的 Param::from_tensor 也一样,对 autodiff 张量直接打开梯度,对普通张量只记下设置。所以训练主线里,模型建在 Device::flex().autodiff() 上就够了,不用逐个参数 require_grad();冻结部分参数(PyTorch 的 requires_grad_(False))见参数与遍历。

小结

  • requires_grad=True 在 burn 里是两步:设备 .autodiff()(或张量 .autodiff()),叶子再 .require_grad(),并接住返回值。
  • PyTorch 的 requires_grad 对中间结果也为真,对应 burn 的 is_tracked();is_require_grad() 只对要梯度的叶子为真。
  • 对中间结果打开梯度:PyTorch 不做事,burn panic;关掉梯度:PyTorch 报错,burn 断开成新起点。
  • 除了整数张量没有 require_grad(E0599),其余误用都是运行期 panic;0.22.0 起普通设备上的 require_grad() 也 panic。
  • 两边都要在张量参与前向之前打标记;模型建在 autodiff 设备上,参数自动要梯度。

💬 关联讨论

发起讨论
加载中...