没有 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=False | 268566528(256 MiB) |
PyTorch,requires_grad=True | 571162624(545 MiB) |
| candle,普通张量 | 40304640(38 MiB) |
candle,Var | 376193024(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()?)?;
和 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 个拿到梯度。