Rust in Everything
基于 burn 0.22.1

验证循环里内存一直涨

主线位置:训练主线的旁路:每个 epoch 训练完跑一遍验证集,只有 forward,没有 backward。部署主线的「推理」一步也是同一回事。

版本:PyTorch 2.15.0(CPU,torch.set_num_threads(1))、burn 0.22.1(Flex 设备加 autodiff,release 构建)

sequenceDiagram
  participant T as 训练一步
  participant V as 验证 50 批
  participant G as 计算图
  T->>G: backward 清理
  V->>G: 每批 forward 留下节点
  Note over G: 涨约 1 MiB 每批
  T->>G: 下一次 backward 才清理
  V->>G: 训练结束后的验证
  Note over G: 再也没有 backward

实验 crate pita-t 的 p11,PyTorch 侧 p11_eval.py。模型是 Linear(512, 512)、ReLU、Linear(512, 10);训练 batch [64, 512],验证 batch [256, 512];3 个 epoch,每个 epoch 训练一步、验证 50 批,最后再验证 200 批。RSS 用 ps -o rss= 读,相对开始时的增量。两边都固定了种子(burn ad.seed(42),PyTorch torch.manual_seed(42)),但两边的随机数不通用,只看各自的变化。平台 M2 Max、macOS 27、rustc 1.99.0。

PyTorch 的写法

正规写法是 model.eval() 加 torch.no_grad()。忘了 no_grad 也能跑:

def evaluate(n):
  score = 0.0
  for _ in range(n):
    if mode == "no_grad":
      with torch.no_grad():
        out = model(val)
    else:
      out = model(val)
    score += out.mean().item()
  return score
plain    epoch 1 after train step: rss +   6.5 MiB
plain    epoch 1 after 50 val batches: rss +  11.1 MiB
plain    epoch 2 after train step: rss +  11.1 MiB
plain    epoch 2 after 50 val batches: rss +  11.1 MiB
plain    epoch 3 after train step: rss +  11.1 MiB
plain    epoch 3 after 50 val batches: rss +  11.1 MiB
plain    final eval, 200 batches: rss +  11.1 MiB
no_grad  epoch 1 after train step: rss +   7.5 MiB
...
no_grad  final eval, 200 batches: rss +  10.7 MiB

不加 no_grad 时每批会建图,但 out 在下一轮被覆盖,图挂在 out.grad_fn 上,跟着一起释放。内存是平的,代价只是多算了一点。

搬到 burn 的现象

模型建在 Device::flex().autodiff() 上用来训练,验证时直接拿同一个模型 forward:

for epoch in 1..=3 {
  let loss = model.forward(x.clone()).powi_scalar(2).mean();
  let grads = GradientsParams::from_grads(loss.backward(), &model);
  model = optim.step(1e-3, model, grads);
  let mut score = 0.0f32;
  for batch in 0..50 {
    let out = model.forward(val.clone());
    score += out.mean().into_scalar::<f32>();
  }
}
// 训练结束后的最终验证:200 批,之后没有 backward
autodiff  epoch 1 after train step: rss +   5.2 MiB
autodiff  val output: is_autodiff=true is_tracked=true
autodiff  epoch 1 after 50 val batches: rss +  55.5 MiB (score 0.341)
autodiff  epoch 2 after train step: rss +  57.2 MiB
autodiff  epoch 2 after 50 val batches: rss +  65.3 MiB (score 0.338)
autodiff  epoch 3 after train step: rss +  65.3 MiB
autodiff  epoch 3 after 50 val batches: rss +  66.8 MiB (score 0.335)
autodiff  final eval, 200 batches: rss + 258.1 MiB
  • 第一轮 50 批验证,RSS 涨了约 50 MiB,每批约 1 MiB,相当于两个 [256, 512] 的 f32 张量(各 0.5 MiB);前向里 fc1 的输出和 ReLU 的输出就是这个形状。
  • 第二、三轮不再明显上涨:中间那次训练的 backward 清掉了上一轮验证留下的图,内存被复用(RSS 本身不降,分配器不把页还给系统)。
  • 训练结束后的 200 批验证没有后续的 backward,一路涨到 +258 MiB。验证集更大、模型更大时,这里就是 OOM。

输出的 is_tracked=true 说明验证的前向在记图:模型参数要梯度,图就从参数长出来。

原因

PyTorch 的图挂在输出张量上,输出没人引用就释放。burn 的图存在进程级的结构里,AutodiffTensor 没有 Drop 去通知它,节点只在 backward 结束时被清理。所以在 autodiff 设备上只做前向,图一直涨到下一次 backward;一直没有 backward,就一直涨。机制和源码见不求导的区域的最后一节和 burn 卷推理时内存一直涨,那一页还量了「完全没有 require_grad 时每个算子约 1 KB」的情形。

burn 也没有一个像 torch.no_grad() 那样的作用域可以包住验证循环:记不记图看的是张量所在的设备,不看调用的位置。

改法

验证前用 Module::valid() 拿一份不带 autodiff 的模型,输入也去掉 autodiff:

let out = model.valid().forward(val.clone().without_autodiff());
valid     epoch 1 after train step: rss +   5.1 MiB
valid     val output: is_autodiff=false is_tracked=false
valid     epoch 1 after 50 val batches: rss +   6.8 MiB (score 0.341)
valid     epoch 2 after train step: rss +   7.7 MiB
valid     epoch 2 after 50 val batches: rss +   7.7 MiB (score 0.338)
valid     epoch 3 after train step: rss +   7.7 MiB
valid     epoch 3 after 50 val batches: rss +   7.7 MiB (score 0.335)
valid     final eval, 200 batches: rss +   7.7 MiB

内存平了,三轮的 score 和 autodiff 版本逐位相同:valid() 只是去掉 autodiff(顺带关掉 dropout 这类训练行为,见训练和推理模式),前向的数值不变。valid() 借用 &self 返回一个新模型。实验里每批调一次,内存和数值都没有问题;放在验证循环外面调一次更清楚。

部署时更直接:模型一开始就建在不带 autodiff 的设备上,或者加载后 valid(),见部署。

怎么检查

  • 验证、推理入口处断言 !out.is_tracked(),或者 !x.device().is_autodiff()。
  • 长时间的验证或推理,看 RSS 每批涨不涨。burn 的「涨到下一次 backward」在训练循环里会被下一步的 backward 掩盖,最容易在训练结束后的最终评估、生成样本、导出前的冒烟测试里暴露。
  • 训练和推理别共用一个 device 变量:Device 的相等比较不看 autodiff(burn 卷推理时内存一直涨的修法一节),推理代码单独建一个 Device::flex()。

小结

  • PyTorch 忘了 no_grad 的验证不会涨内存,图随输出释放;burn 在 autodiff 模型上 forward,图留到下一次 backward。
  • 实测每个验证 batch 约 1 MiB;训练结束后 200 批涨到 +258 MiB。
  • 验证用 model.valid() 加 without_autodiff() 的输入,数值不变,内存平。

💬 关联讨论

发起讨论
加载中...