Rust in Everything
基于 candle 0.11.0

没有 no_grad

主线位置:推理主线的「前向」一步:权重加载进来之后跑一次 forward,要不要记图。也包括训练主线里不该进图的计算:中途评估、留一份参数快照、冻结一部分参数。
版本:PyTorch 2.15.0 / transformers 5.22.0(CPU)、candle 0.11.0(CPU,accelerate;快照和融合算子两节另在 Metal 上跑)
flowchart TB
  st["safetensors 权重"] --> pl["普通 Tensor"]
  vm["VarMap 或 Var"] --> vr["Var"]
  pl --> f1["前向 不记 Op"]
  vr --> f2["前向 记 Op"]
  f2 --> keep["图跟着结果活"]
  keep -->|"丢掉结果"| free["图释放"]
  f2 --> fused["融合算子"]
  fused --> cut["图在这里断"]

实验代码:PyTorch 侧 pt_nograd.py、pt_chain.py、snapshot.py、fused.py;candle 侧 crate autodiff-exp 的 bin nograd、mem、fused。release 构建,平台 M2 Max、macOS 27、rustc 1.99.0。模型是本地的 SmolLM2-135M-Instruct 权重,f32。PyTorch 固定 torch.set_num_threads(4)。峰值内存是 /usr/bin/time -l 的 maximum resident set size,每种各跑两次,两次相差不到 1%,下面给第一次的(最后那个「跑 5 遍」只跑了一次)。

推理:权重不是 Var

PyTorch 的 from_pretrained 出来的参数都是 requires_grad=True。推理时忘了 torch.no_grad(),前向照样建图,保存反向要用的中间值:

model = AutoModelForCausalLM.from_pretrained(model_dir, dtype=torch.float32, attn_implementation="eager").eval()
print("params requires_grad:", all(p.requires_grad for p in model.parameters()))
ids = torch.tensor([[504, 3575, 282, 4649, 314] * 102 + [504, 3575]])  # 512 个 token
if sys.argv[1] == "nograd":
  with torch.no_grad():
    logits = model(ids).logits
else:
  logits = model(ids).logits
print(f"logits.requires_grad {logits.requires_grad} grad_fn {type(logits.grad_fn).__name__}")
params requires_grad: True
logits.requires_grad True grad_fn UnsafeViewBackward0
          2071068672  maximum resident set size
params requires_grad: True
logits.requires_grad False grad_fn NoneType
          1143259136  maximum resident set size

.eval() 只关掉 dropout 这类训练行为,不管记不记图。不加 no_grad 峰值多出 0.93 GB,是 autograd 为反向保存的中间值。

candle 里同样的加载和前向(推理主线用的就是这一段):

let vb = unsafe { VarBuilder::from_mmaped_safetensors(&[&path], DType::F32, &dev)? };
let w = vb.get((49152, 576), "model.embed_tokens.weight")?;
println!("embed_tokens.weight is_variable {} track_op {}", w.is_variable(), w.track_op());
let model = Llama::load(vb, &cfg)?;
let ids = Tensor::new(&[504u32, 3575, 282, 4649, 314], &dev)?.unsqueeze(0)?;
let mut cache = Cache::new(false, DType::F32, &cfg, &dev)?;
let logits = model.forward(&ids, 0, &mut cache)?;
println!("logits track_op {} sorted_nodes {}", logits.track_op(), logits.sorted_nodes().len());
embed_tokens.weight is_variable false track_op false
logits track_op false sorted_nodes 0

从 safetensors 读出来的权重是普通张量,不是 Var;输入的 token id 也不是。没有一个输入要记录,每个算子的结果都不带 Op(规则见 Var 与梯度),logits 往回一个节点也没有。candle 的推理不需要 no_grad,也没有地方放它。同样 512 个 token 的 candle 前向,峰值 RSS 是 883638272 字节(0.88 GB)。这个数不能和上面的 1.14 GB 直接比:candle_transformers::models::llama 只算最后一个位置的 logits(形状 [1, 49152]),transformers 算了 512 个位置的(约 100 MB),两边的加载方式也不同。能比的是各自内部:PyTorch 要靠 no_grad 省下的那部分,candle 本来就没有。

图占的内存只看有没有 Var

把权重换成 Var,candle 就和忘了 no_grad 的 PyTorch 一样建图。一个 2048×2048 的 f32 张量(16 MB)连做 20 次 tanh,只留最后的结果:

x = torch.rand(2048, 2048) * 2 - 1
x.requires_grad_(sys.argv[1] == "grad")
a = x
for _ in range(20):
  a = a.tanh()
let x = Tensor::rand(-1f32, 1., (2048, 2048), &dev)?;
let x = if mode == "chainvar" { Var::from_tensor(&x)?.as_tensor().clone() } else { x };
let mut a = x;
for _ in 0..20 {
  a = a.tanh()?;
}
峰值 RSS
PyTorch,requires_grad=False268566528(256 MiB)
PyTorch,requires_grad=True571162624(545 MiB)
candle,普通张量40304640(38 MiB)
candle,Var376193024(359 MiB)

两边多出来的都是 16 MiB 一个的中间结果,PyTorch 多 289 MiB,candle 多 321 MiB:PyTorch 的 TanhBackward0 保存每一步的输出,candle 每个结果的 Op::Unary 持有它的输入(计算图怎么记下来)。PyTorch 的基线高,是 Python 和 torch 自己的库。

图挂在结果张量上,结果一丢,图就跟着释放。同一条链在 Var 上跑 5 遍,每遍读一个数后丢掉结果:

chainvar-drop5 round 4: track_op true sum -68.49649
           359464960  maximum resident set size

峰值和只跑一遍一样,没有越跑越涨。PyTorch 也是这样;burn 不是,它的图登记在全局,只前向不反向时内存一直涨(不求导的区域)。

所以从 PyTorch 搬推理代码时,with torch.no_grad(): 直接删掉。要留心的只有一种情况:权重是 Var。

训练中途评估

用 VarMap 建的模型,权重就是 Var,评估时的前向也记图。多数时候不用管:评估的输出读成数之后就丢了,图随之释放。真要一个不记图的版本(比如评估一大批数据、输出要留着),用 detach 出来的张量另建一份模型:

let varmap = VarMap::new();
let l = linear(3, 2, VarBuilder::from_varmap(&varmap, DType::F32, &dev).pp("fc"))?;
println!("train model output track_op {}", l.forward(&x)?.track_op());
let detached: HashMap<String, Tensor> =
  varmap.data().lock()?.iter().map(|(k, v)| (k.clone(), v.as_tensor().detach())).collect();
let eval = linear(3, 2, VarBuilder::from_tensors(detached, DType::F32, &dev).pp("fc"))?;
let before = eval.forward(&x)?;
println!("eval twin output track_op {}", before.track_op());
let mut opt = SGD::new(varmap.all_vars(), 0.1)?;
opt.backward_step(&l.forward(&x)?.sum_all()?)?;
println!("after step: train {:?} eval twin {:?}", l.forward(&x)?.to_vec2::<f32>()?, eval.forward(&x)?.to_vec2::<f32>()?);

(lock() 的错误转换略去。)

train model output track_op true
eval twin output track_op false
before step [[-4.9460845, 2.523826]]
after step: train [[-6.446085, 1.0238259]] eval twin [[-6.446085, 1.0238259]]

Tensor::detach 给一个新 id、不带 Op、is_variable 为假,存储和原来的 Var 共用。优化器的 Var::set 是把新值复制进同一块存储,所以评估用的那份模型只建一次,之后每一步都看到最新的参数,前向不记图。linear 的初值是随机的,before 每次运行不同。

冻结参数也是这个思路。PyTorch 写 p.requires_grad_(False);candle 里冻结的参数不交给优化器(SGD::new / AdamW::new 只拿要训练的那几个 Var),想连反向也省掉,就像上面那样换成 detach 出来的普通张量。普通张量直接参与运算时仍然会算出梯度(Var 与梯度),只是没人用;它前面如果没有别的 Var,那一枝就不会被反向走到。

detach 和参数快照

PyTorch 的 detach() 也共用存储,opt.step() 原地改参数,detach 出来的快照跟着变;clone() 复制数据:

p = torch.tensor([1.0, 2.0], requires_grad=True)
alias, det, cl, dc = p, p.detach(), p.clone(), p.detach().clone()
opt = torch.optim.SGD([p], lr=0.5)
(p ** 2).sum().backward()
opt.step()
p after step       [0.0, 0.0]
p.detach()         [0.0, 0.0]
p.clone()          [1.0, 2.0] requires_grad True
p.detach().clone() [1.0, 2.0]

candle 侧同样一步,四种快照:

let p = Var::new(&[1f32, 2.], &dev)?;
let snap_clone = p.as_tensor().clone();
let snap_detach = p.as_tensor().detach();
let snap_copy = p.as_tensor().copy()?;
let snap_contig = (p.as_tensor() * 1.0)?;
let mut opt = SGD::new(vec![p.clone()], 0.5)?;
opt.backward_step(&p.sqr()?.sum_all()?)?;
❗IMPORTANT
动脑筋:这一步之后 p 是 [0, 0]。CPU 上,四个快照里哪几个还是 [1, 2]?换到 Metal 呢?

Tensor::clone 是什么见引用与不可变。先想好再看本页末尾的答案。

和 burn 对照一句:burn 对一个要梯度的叶子 detach(),结果仍然要梯度;candle 的 detach 和 PyTorch 一样,结果一律不追踪。

融合算子没有反向

推理用的几个快速算子在 candle-nn 里是不记反向的自定义算子:ops::rms_norm 最后一行是 apply_op2_no_bwd,ops::softmax_last_dim 是 apply_op1_no_bwd,layer_norm、sdpa、rotary_emb::rope* 也是。PyTorch 里对应的写法都能求导:

x = torch.tensor([[1.0, 2.0, 3.0, 4.0]], requires_grad=True)
g = torch.ones(4, requires_grad=True)
c = torch.tensor([[1.0, -1.0, 2.0, 0.5]])
(F.rms_norm(x, (4,), g, 1e-5) * c).sum().backward()
let cases: [(&str, Tensor); 4] = [
  ("ops::rms_norm", ops::rms_norm(&x, &g, 1e-5)?),
  ("ops::rms_norm_slow", ops::rms_norm_slow(&x, &g, 1e-5)?),
  ("ops::softmax_last_dim", ops::softmax_last_dim(&x)?),
  ("ops::softmax(.., D::Minus1)", ops::softmax(&x, D::Minus1)?),
];
for (name, y) in cases {
  let grads = (y * &c)?.sum_all()?.backward()?;
  // ... 打印 x 和 g 的梯度
}
F.rms_norm   x.grad [0.2799470126628876, -0.5355503559112549, 0.4746928811073303, -0.15823042392730713] g.grad [0.36514812707901, -0.73029625415802, 2.1908888816833496, 0.73029625415802]
softmax(-1)  x.grad [0.008314814418554306, -0.1516866236925125, 0.2983214259147644, -0.1549495905637741] g.grad None
ops::rms_norm                x.grad None g.grad is_some false
ops::rms_norm_slow           x.grad Some([0.27994698, -0.53555036, 0.47469288, -0.15823045]) g.grad is_some true
ops::softmax_last_dim        x.grad None g.grad is_some false
ops::softmax(.., D::Minus1)  x.grad Some([0.0083148135, -0.15168662, 0.29832146, -0.15494965]) g.grad is_some false

(softmax 那一行里 g 没有参与运算,None 是对的。)融合版本没有任何梯度,也不报错;拼接版本(_slow、softmax)和 PyTorch 对到 f32 的最后一两位。

这对推理是好事,对拿 candle-transformers 的模型做微调是陷阱。把上面 SmolLM2 的 272 个权重全部换成 Var 再前向:

for (name, t) in candle_core::safetensors::load(&path, &dev)? {
  let v = Var::from_tensor(&t.to_dtype(DType::F32)?)?;
  map.insert(name, v.as_tensor().clone());
  vars.push(v);
}
let model = Llama::load(VarBuilder::from_tensors(map, DType::F32, &dev), &cfg)?;
let logits = model.forward(&ids, 0, &mut cache)?;
let nodes = logits.sorted_nodes();
let g = logits.sum_all()?.backward()?;
logits track_op true sorted_nodes 3 (vars among them 1)
272 vars, 1 with a gradient

图只有 3 个节点:lm_head 的矩阵乘往回走到最后那个 RmsNorm,那里是融合算子,图断了。272 个参数里只有和 lm_head 共用的词嵌入拿到梯度,前面 30 层一个都没有。Metal 上输出相同。现象、哪些算子不记反向、训练时换成哪个版本,见 candle 卷的常见坑 RmsNorm 之后梯度是 None。

动脑筋答案

CPU:

p after step   [0.0, 0.0]
clone()        [0.0, 0.0]
detach()       [0.0, 0.0]
copy()         [1.0, 2.0]
(p * 1.0)      [1.0, 2.0]

Metal:

p after step   [0.0, 0.0]
clone()        [0.0, 0.0]
detach()       [0.0, 0.0]
copy()         [0.0, 0.0]
(p * 1.0)      [1.0, 2.0]
  • clone():candle 的 Tensor 是 Arc 包着的不可变结构,clone 只多一个引用,和 PyTorch 会复制数据的 clone() 正好相反。它和 p 是同一个张量,set 写进去它就看到。
  • detach():新 id、同一块存储,和 PyTorch 的 detach() 一样跟着变。
  • copy():CPU 上真复制了一份,留住旧值;Metal 上 copy() 只多一个缓冲区的引用,又跟着变了。这是 candle 0.11.0 的一个上游问题,见 candle 卷的常见坑改了 copy 出来的张量,原张量也变了。
  • p * 1.0:一次真正的计算,结果写进新存储,两个设备都留住旧值。PyTorch 的 p.detach().clone() 在 candle 里写成它(或者 affine(1.0, 0.0)),不要写 copy()。

小结

  • candle 没有 torch.no_grad()。记不记图只看输入里有没有 Var;从 safetensors 加载的预训练权重是普通张量,推理的前向本来就不记图(sorted_nodes 是 0),with torch.no_grad(): 搬过来直接删。
  • PyTorch 忘了 no_grad,SmolLM2 前向 512 个 token 峰值多 0.93 GB;candle 只有权重是 Var 时才有这笔开销(20 次 tanh 的链:38 MiB 对 359 MiB)。图跟着结果张量活,丢掉结果就释放,不会越跑越涨。
  • 训练中途评估:多数时候让图随输出释放;要不记图的版本,用 detach 出来的张量另建一份模型,它和 Var 共用存储,每步都看到新参数。冻结参数就是不把它交给优化器。
  • 参数快照:clone() 和 detach() 都会跟着 set 变,Metal 上 copy() 也会;用 p * 1.0 这样的真计算留旧值。
  • rms_norm、softmax_last_dim、layer_norm、sdpa、rope 这些融合算子不记反向、不报错。candle-transformers 的 llama 全换成 Var 后,272 个参数只有 1 个拿到梯度。

💬 关联讨论

发起讨论
加载中...