Rust in Everything
基于 burn 0.22.1

Param::map 改值后参数不再训练

主线位置:训练主线开始之前(或两步之间):手工改一个参数的值,然后照常 forward → loss → backward → optimizer step。
版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备,autodiff)
flowchart TB
  w["Param fc1.weight"] -->|"map w x 0.5"| m["非叶子 带历史"]
  w -->|"map 再 detach"| d["叶子 不要梯度"]
  w -->|"detach 再 require_grad"| ok["叶子 要梯度"]
  m --> p1["第二步 backward panic"]
  d --> p2["静默不更新"]
  ok --> t["照常训练"]

实验代码:PyTorch 侧 scale.py、scale_err.py,burn 侧 crate pitb-module 的 bin scale、check(burn =0.22.1,feature std、flex、ndarray、autodiff、optim,debug)。模型和数据是主线的 MLP(A1 的权重和批数据),SGD lr 0.1。平台 M2 Max、macOS 27、rustc 1.99.0。

PyTorch 的写法

剪枝、缩放、手工初始化,PyTorch 里的标准写法是在 torch.no_grad() 里原地改。这里训练前把 fc1.weight 缩小一半,再训练 5 步:

model = a1_model()
x, y = a1_batch()
opt = torch.optim.SGD(model.parameters(), lr=0.1)
with torch.no_grad():
  model.fc1.weight.mul_(0.5)
w0 = model.fc1.weight.clone()
losses = []
for _ in range(5):
  loss = nn.functional.cross_entropy(model(x), y)
  opt.zero_grad()
  loss.backward()
  opt.step()
  losses.append(loss.item())
print("losses:", [round(v, 6) for v in losses])
print("fc1.weight changed:", not torch.equal(model.fc1.weight, w0),
      "| max |w - w0| =", f"{(model.fc1.weight - w0).abs().max().item():.3e}")
losses: [1.117744, 1.090713, 1.066557, 1.044363, 1.025838]
fc1.weight changed: True | max |w - w0| = 4.670e-02

两种写错的方式 PyTorch 都当场拦下(scale_err.py):

w.mul_(0.5) without no_grad: RuntimeError: a leaf Variable that requires grad is being used in an in-place operation.
fc1.weight = w * 0.5: TypeError: cannot assign 'torch.FloatTensor' as parameter 'weight' (torch.nn.Parameter or None expected)

搬到 burn 的现象

burn 没有原地改,换值的入口是 Param::map,它保住 ParamId,看起来正是 mul_ 的对应。同样缩小一半、训练 5 步(bin scale,参数选写法):

let device = Device::flex().autodiff();
let mut model = a1_model(&device);
let w = model.fc1.weight;
model.fc1.weight = match how.as_str() {
  "map" => w.map(|w| w * 0.5),
  "detach" => w.map(|w| (w * 0.5).detach()),
  // ...
};
let fc1 = model.fc1.weight.val();
println!("{how}: fc1.weight is_tracked={} is_require_grad={}", fc1.is_tracked(), fc1.is_require_grad());
let w0 = fc1.clone().detach();
let mut optim = SgdConfig::new().init();
for step in 0..5 {
  let loss = ce.forward(model.forward(x.clone()), y.clone());
  losses.push(loss.clone().into_scalar::<f32>());
  let grads = GradientsParams::from_grads(loss.backward(), &model);
  if step == 0 {
    println!("{how}: GradientsParams::len() = {}", grads.len());
  }
  model = optim.step(0.1, model, grads);
}

直接写 w * 0.5,第一步过去了,第二步 backward panic:

map: fc1.weight is_tracked=true is_require_grad=false
map: GradientsParams::len() = 3

thread 'main' (8770672) panicked at .../burn-autodiff-0.22.1/src/runtime/server.rs:74:17:
Cannot run backward: graph tape has already been consumed at NodeId(6). Burn does not support retain_graph. Combine losses before backward or recompute the forward pass. Use detach() only to intentionally sever gradients through the previous computation.

报错说的是 retain_graph,代码里却只有一次 backward。照着报错的提示加上 detach(),panic 没了,换成静默的错:

detach: fc1.weight is_tracked=false is_require_grad=false
detach: GradientsParams::len() = 3
detach: losses [1.117744, 1.10039, 1.084134, 1.068825, 1.055636]
detach: max |w - w0| = 0.000e0

fc1.weight 5 步一点没动,loss 照样在降(只有其余三个参数在训练),第一步的 loss 和 PyTorch 一样,后面越差越多。和 PyTorch 的输出比:

losses                   n=  5  max|diff|=2.98e-02
fc1.weight               n= 32  max|diff|=4.67e-02
fc1.bias                 n=  8  max|diff|=1.90e-02
fc2.weight               n= 24  max|diff|=4.67e-03
fc2.bias                 n=  3  max|diff|=3.24e-03
worst=4.67e-02 tol=1e-06 MISMATCH

另一个入口 Param::from_tensor 对应 PyTorch 的「换一个新 nn.Parameter」。它对传入的中间结果直接 panic,传 detach() 过的值能用,但 id 换了(bin check):

from_tensor(detached): id n9npnd82j06oi -> n49s5p4cefmae

thread 'main' (8768686) panicked at .../burn-autodiff-0.22.1/src/tensor.rs:190:17:
Can't convert a non leaf tensor into a tracked tensor
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
from_tensor(w * 0.5): panicked

新 id 意味着优化器(Adam 的动量)按旧 id 存的状态对不上了,这一条见重建参数后 Adam 从零开始。

原因

Param 里存的是一个张量值,要不要训练取决于这个值是不是「要梯度的叶子」。GradientsParams::from_grads 只收 is_require_grad() 为真的参数(见参数与遍历的「冻结参数」)。map 只是把闭包的返回值放回去:

    pub fn map<F: FnOnce(T) -> T>(self, func: F) -> Self {
        let (id, tensor, param_mapper) = self.consume();
        let tensor = func(tensor);
        Self::from_mapped_value(id, tensor, param_mapper)
    }

它的文档说的是「preserving its configured training state」:保住的是参数上记着的配置(is_active,train() 要恢复的那个),不是张量本身的状态。文档接着写「Use the explicit activation APIs, such as Param::set_require_grad for tensor parameters, to change that state」。于是:

  • w * 0.5:在 autodiff 设备上是一个带历史的中间结果,is_tracked=true、is_require_grad=false。第一次 backward 时梯度沿 * 0.5 流回旧的叶子(旧叶子已不在模型里,参数与遍历里的 oldleaf 实测过),这段图随之被消费;参数没被更新,下一步 forward 还用这个中间结果,backward 走到已消费的节点,AutodiffServer::backward 报上面那条 panic。这条 panic 本身见图只能用一次。
  • (w * 0.5).detach():一个普通叶子,不追踪、不要梯度,等于把参数冻结了。不报错,因为冻结本来就是合法的。
  • from_tensor 对新值调 require_grad(),AutodiffTensor::require_grad 遇到非叶子就 panic;不管成不成功都 ParamId::new()。

PyTorch 不会走到这一步:nn.Parameter 永远是叶子,mul_ 在 no_grad() 里改的是叶子本身的数据,id(param) 不变,优化器和 autograd 都还认得它。

origin/main 上 Param::map、from_tensor 和 Module::train 对参数的处理都没有改。

改法

新值先 detach() 切断历史,再 require_grad() 变回要梯度的叶子:

model.fc1.weight = model.fc1.weight.map(|w| (w * 0.5).detach().require_grad());
fix: fc1.weight is_tracked=true is_require_grad=true
fix: GradientsParams::len() = 4
fix: losses [1.117744, 1.090713, 1.066557, 1.044363, 1.025838]
fix: max |w - w0| = 4.670e-2

5 步的 loss 和 PyTorch 逐位相同,更新后的参数:

losses                   n=  5  max|diff|=0.00e+00
fc1.weight               n= 32  max|diff|=1.49e-08
fc1.bias                 n=  8  max|diff|=3.73e-09
fc2.weight               n= 24  max|diff|=1.49e-08
fc2.bias                 n=  3  max|diff|=3.73e-09
worst=1.49e-08 tol=1e-06 OK

剩下的差是 f32 舍入,量级和 A1 的训练一步相同(见一步);这个实验的偏置在 f32 里生成,初始值就和 PyTorch 差一位(原因见参数与遍历)。

另一种写法是改完值再整体调一次 model.train():Module for Param<Tensor<D>> 的 train 按记着的 is_active 对值调 set_require_grad,map 恰好保住了 is_active。前提是值已经是叶子:

== map+train

thread 'main' (8770690) panicked at .../burn-autodiff-0.22.1/src/tensor.rs:190:17:
Can't convert a non leaf tensor into a tracked tensor
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
== detach+train
detach+train: fc1.weight is_tracked=true is_require_grad=true
detach+train: GradientsParams::len() = 4
detach+train: losses [1.117744, 1.090713, 1.066557, 1.044363, 1.025838]
...
== plain
plain: fc1.weight is_tracked=true is_require_grad=true
plain: GradientsParams::len() = 4
plain: losses [1.117744, 1.090713, 1.066557, 1.044363, 1.025838]

plain 是在普通 Device::flex() 上建模型、map(|w| w * 0.5),再 train() 抬到 autodiff:普通设备上没有历史,乘出来就是叶子,train() 让它要梯度。在 autodiff 设备上改值,就用上面的 detach().require_grad()。不要用 from_tensor 替换已经在优化器里跑过的参数。

怎么检查

  • 改完参数,用一个访问者把每个参数的 val().is_require_grad() 打出来(写法见参数与遍历的 Named)。detach 那种写法:

    fc1.weight       [4, 8]   require_grad=false id=0qmed9t9smlta
    fc1.bias         [8]      require_grad=true  id=9oqtbpgia8scg
    fc2.weight       [8, 3]   require_grad=true  id=f3kcqnfvtjeb6
    fc2.bias         [3]      require_grad=true  id=asl8i3jeaq8ri
    
  • 训练循环里第一步打印 GradientsParams::len(),和应当训练的参数个数比(这里应当是 4,错的写法是 3)。

  • is_tracked() 为真不等于在训练:map(w * 0.5) 的结果 is_tracked=true,is_require_grad=false。判断参数用 is_require_grad()。

  • 只调过一次 backward() 却看到 graph tape has already been consumed,先查有没有参数的值是上一张图里的中间结果。

小结

  • with torch.no_grad(): w.mul_(0.5) 对 param.map(|w| (w * 0.5).detach().require_grad()),5 步训练和 PyTorch 最大差 1.49e-8。
  • 只写 map(|w| w * 0.5):第二步 backward panic,信息说的是 retain_graph;加了 detach() 不加 require_grad():参数静默冻结,loss 照降。
  • map 保住 id 和配置上的 is_active,不管新值是不是要梯度的叶子;train() 能按 is_active 恢复,但只对叶子有效。
  • Param::from_tensor 对中间结果 panic,且总是换新 id。

💬 关联讨论

发起讨论
加载中...