backward
主线位置:变换主线的「grad / vjp」一步,换成 eager 写法:前向立即算出数,
backward把梯度写进叶子,对照torch.autograd和不 jit 的jax.grad。
版本:PyTorch 2.15.0(CPU,单线程)、JAX 0.12.0.1(CPU,x64)、tenferro-rs 0.8.0(CPU,native,
CpuEngine::serial())
sequenceDiagram participant U as 用户代码 participant R as EagerRuntime participant S as EagerSession U->>R: variable_from(x) Note over U,R: torch 的 requires_grad=True U->>R: with_eager_session(engine) R->>S: 借出 engine U->>S: s.sin s.mul s.reduce_sum S-->>U: loss 已有数值 U->>R: loss.backward(engine) R-->>U: 累加进 x 的槽 U->>R: x.grad() Note over U,R: torch 的 x.grad
三份程序算同一个梯度:f(x) = Σ sin(x)·x²,n = 1000,x_i = −2 + 4i/999,v_i = cos i,精确梯度 cos(x)·x² + 2x·sin(x)。实验代码是 tenferro 的 bin backward_tour(release)和两个脚本 eager_torch.py、eager_jax.py。M2 Max、macOS 27、rustc 1.99.0。PyTorch 用 torch.set_num_threads(1)。eager 内部怎么记录、怎么反向,见 tenferro 卷的 eager AD,本页只对照接口和行为。
同一个梯度,三种写法
PyTorch:
x = torch.tensor(X, requires_grad=True)
p = torch.sin(x) * x**2
loss = p.sum()
loss.backward()
print(x.grad)
tenferro:
let rt = EagerRuntime::new()?;
let mut eng = CpuEngine::serial();
let x = rt.variable_from(Tensor::from_vec_col_major(vec![N], xs.clone())?)?;
let (p, loss) = rt.with_eager_session(&mut eng, |s| {
let sx = s.sin(&x)?;
let x2 = s.mul(&x, &x)?;
let p = s.mul(&sx, &x2)?;
let loss = s.reduce_sum(&p, None)?;
Ok::<_, tenferro_ad::Error>((p, loss))
})?;
let grads = loss.backward(&mut eng)?;
let g1 = x.grad()?.ok_or("no grad")?.as_slice::<f64>()?.to_vec();
JAX 没有张量上的 backward,最接近的是不 jit 的 jax.grad,它逐个算子执行,但仍是函数变换:
def f(x):
return jnp.sum(jnp.sin(x) * x**2)
g = jax.grad(f)(x)
# tenferro
x.grad = [1.972602, 1.993120, 2.013449, ...] max|x.grad - exact| = 4.441e-16
p.grad = None loss.grad = None
p.tracks_grad = true loss.tracks_grad = true
# PyTorch
x.grad = [1.972602, 1.993120, 2.013449, ...] max|x.grad - exact| = 4.441e-16
max|torch - tenferro| = 0.000e+00, bitwise equal 1000/1000
p.grad = None warning: UserWarning: The .grad attribute of a Tensor that is not a leaf Tensor is being accessed. Its .grad att...
p.requires_grad = True p.is_leaf = False
# JAX,不 jit
grad (no jit): max|.-exact|=0.000e+00 ...
no jit vs tenferro eager: max diff 4.441e-16, bitwise equal 956/1000
三边对精确值的误差都不超过 4.4e-16,是 f64 末位的量级。tenferro 和 PyTorch 的 1000 个梯度逐位相同;和 JAX 有 44 个差在末位。差别出在 sin / cos 本身:同一组输入上,JAX 的 sin、cos 和 NumPy 逐位相同,PyTorch 的 sin 和 NumPy 有 39 个不同、cos 有 54 个不同;用 NumPy 的 sin、cos 按 sin(x)·2x + x·x·cos(x) 算出的梯度,和 tenferro 也正好有 956 个相同(实验代码 sin_impl_check.py)。
接口一一对应,但形状不一样:
variable_from就是requires_grad=True的叶子,constant_from是普通张量。tracks_grad()对应requires_grad:由变量算出来的p、loss都是true。运算不是
EagerTensor上的方法,也没有运算符重载,要在with_eager_session的回调里调s.sin、s.mul。回调借走一个调用方持有的 engine(CPU 线程池),backward也要再借一次。回调的错误类型
E只要满足From<tenferro_ad::Error>,外面一个?就够。回调最后一个表达式是s.reduce_sum(..)这样的调用时,E从它推出来;像上面这样返回Ok((p, loss))时没有东西能定下E,不写Ok::<_, tenferro_ad::Error>就编不过:error[E0283]: type annotations needed --> src/bin/annot_check.rs:13:5 | 10 | let (p, loss) = rt.with_eager_session(&mut eng, |s| { | ------------------ type must be known at this point ... 13 | Ok((p, loss)) | ^^ cannot infer type of the type parameter `E` declared on the enum `Result` | = note: the type must implement `From<tenferro_ad::Error>`混用 einsum 时
E要定成tenferro_einsum::Error,见常见坑 the trait From<tenferro_einsum::Error> is not implemented。非叶子的
.grad两边都是None。PyTorch 会发一个UserWarning,提示用retain_grad();tenferro 不提示,也没有retain_grad:backward只往程序输入(叶子)的槽里写。要中间结果的梯度,只能把它的数值拿出来做成下一段的叶子(见 eager 还是 traced 的分段 TRG);rt.grad对中间结果求导会报grad is inactive(见常见坑 第二次 backward 后梯度翻倍)。
PR #1982(「flatten with_eager_session to one Result level」,2026-10-06 合并,首次发布于 v0.8.0)之前,签名是 with_eager_session<R: Send>(self: &Arc<Self>, f: impl FnOnce(&mut EagerSession<'_>) -> R + Send) -> Result<R>:回调自己返回一个 Result,外面再包一层进入 session 的 Result,调用处写 ??。提交说明写改成回调返回 Result<T, E>、E: From<tenferro_ad::Error>,进入 session 的失败以 E::from 交给调用方,「so callers write one ? instead of ??, and downstream error types flow through unchanged」。上面 Ok::<_, tenferro_ad::Error>(..) 那一行要写类型,就是因为 E 现在由回调决定。
第二次 backward:不需要 retain_graph
对同一个 loss 再调一次 backward:
loss.backward() # 第二次
let r = loss.backward(&mut eng);
# PyTorch
second backward: RuntimeError: Trying to backward through the graph a second time (or directly access saved tensors after they have already been freed). Saved intermediate values of the graph...
retain_graph=True, twice: x.grad == 2*first bitwise: True
# tenferro
second backward: Ok
x.grad = [3.945205, 3.986240, 4.026898, ...] x.grad == 2*first bitwise: true
PyTorch 第一次反向默认释放图里存的中间值,第二次要在第一次时传 retain_graph=True。tenferro 的 backward 只拿 &self,trace 和残差挂在 loss 上,反向不消耗它们,第二次照常执行。两边都把梯度累加进槽位,所以第二次之后都是两倍。训练循环里每步之前要 x.clear_grad()(PyTorch 的 x.grad = None 或 optimizer.zero_grad()),否则第 n 步的梯度是 n 步之和,见常见坑 第二次 backward 后梯度翻倍。实验第 3 段在 clear_grad 之后重新前向、反向,梯度和第一次逐位相同。
JAX 没有这个问题:jax.grad 返回一个新数组,不往任何地方累加。
问: 没有 retain_graph,前向留下的中间值什么时候释放?会不会越积越多?
答: 残差挂在结果张量的 trace 上(Arc 链),不挂在运行时上。最后一个引用这条链的张量被丢掉,链就释放;backward 不释放,所以能调第二次。PyTorch 的 retain_graph=False 是「反向时顺手释放」,tenferro 是「跟着张量的生命周期释放」:只要别把每一步的 loss 存进一个越来越长的 Vec,内存就不会涨。tenferro 卷 eager AD 的 1001 步循环里,运行时登记的值记录始终不超过 5 条。
非标量输出:backward_with
p.backward()
p.backward(gradient=torch.tensor(V))
let r = p.backward(&mut eng);
let vt = rt.constant_from(Tensor::from_vec_col_major(vec![N], vs.clone())?)?;
p.backward_with(&mut eng, &vt)?;
_, pullback = jax.vjp(lambda x: jnp.sin(x) * x**2, x)
(vj,) = pullback(jnp.array(V))
# PyTorch
p.backward(): RuntimeError: grad can be implicitly created only for scalar outputs
p.backward(gradient=v): max|x.grad - v*g_exact| = 8.882e-16
# tenferro
p.backward(): Err(grad requires a scalar output, got shape [1000])
NonScalarGrad { shape: [1000] }
p.backward_with(v): max|x.grad - v*g_exact| = 8.882e-16
# JAX
jax.vjp with v: max|. - v*g_exact| = 8.882e-16
三边一致:backward 只收标量;非标量要给出余切,PyTorch 是 gradient=,tenferro 是 backward_with,JAX 是 jax.vjp 返回的 pullback。backward_with(ones) 和对 sum 做 backward 的结果逐位相同。余切必须属于同一个 EagerRuntime,否则报 ContextMismatch。
函数式 grad 与 hvp:没有 create_graph
torch.autograd.grad 对应 rt.grad:返回梯度张量,不碰槽位。差别在能不能再求导。用它算 Hessian 乘向量 H·v = ∇⟨∇f, v⟩:
(gf,) = torch.autograd.grad(loss, x)
torch.autograd.grad(gf @ torch.tensor(V), x) # 报错
(gf,) = torch.autograd.grad(loss, x, create_graph=True)
(hv,) = torch.autograd.grad(gf @ torch.tensor(V), x)
let gf = rt.grad(&mut eng, &loss, &x)?;
let gv = rt.with_eager_session(&mut eng, |s| {
let m = s.mul(&gf, &vt)?;
s.reduce_sum(&m, None)
})?;
let hv = rt.grad(&mut eng, &gv, &x)?;
# PyTorch
autograd.grad == first bitwise: True x.grad = None gf.requires_grad = False
grad of that grad: RuntimeError: element 0 of tensors does not require grad and does not have a grad_fn
create_graph=True: hvp max|hvp - exact| = 1.776e-15
# tenferro
rt.grad == first bitwise: true x.grad slot = None gf.tracks_grad = true
hvp = grad(<grad, v>): max|hvp - exact| = 1.776e-15
PyTorch 默认不为反向本身建图,要二阶就得在第一次传 create_graph=True。tenferro 的 rt.grad 返回的张量总是带着导数程序的 trace(gf.tracks_grad = true),直接就能再求导,和 JAX 的 jax.grad(lambda x: jnp.vdot(jax.grad(f)(x), v)) 一样自然。这份 trace 不是免费的,backward 也会建它再丢掉,代价见 eager 还是 traced。traced 模式下的 hvp 和 jvp(grad),见 高阶导数。
no_grad 与 detach
with torch.no_grad():
y = (x * x).sum()
y.backward()
z = x.detach()
(z * x).sum().backward()
let y = {
let _guard = rt.no_grad();
rt.with_eager_session(&mut eng, |s| {
let m = s.mul(&x, &x)?;
s.reduce_sum(&m, None)
})?
};
let r = y.backward(&mut eng);
let z = x.detach(&mut eng)?;
rt.no_grad() 返回一个守卫,守卫活着时运算只执行、不记录,对应 torch.no_grad()。detach 要借 engine 拷一份数据做新的常量,对应 x.detach() 和 JAX 的 jax.lax.stop_gradient。sum(detach(x) · x) 的梯度三边都是 x 本身(JAX 对 [1, 2, 3] 给出 [1. 2. 3.];tenferro、PyTorch 对 1000 个元素逐位等于 x)。
no_grad 守卫里算出的 y 上调 y.backward(&mut eng),返回什么?x.grad() 是什么?PyTorch 在这里抛 RuntimeError。y 的 tracks_grad() 是 false;backward 的候选是运行时里所有活着的梯度槽,x 的槽还在。
原地运算
PyTorch 的原地运算要靠版本计数器防止改坏反向要用的值:
x.add_(1.0): RuntimeError: a leaf Variable that requires grad is being used in an in-place operation.
exp(w).add_(1).sum().backward(): RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation: [torch.DoubleTensor [1000]], which is output 0 of Exp, is at version 1; expected version 0 instead. Hint...
tenferro 的 eager 没有原地运算:EagerSession 上的运算全部接收 &EagerTensor、返回新的 EagerTensor,scatter 也是。残差和 trace 共享的是只读的值,不会被改,也就不需要版本检查。这一点和 JAX 的不可变数组一样。
动脑筋答案
== 6. no_grad
y.tracks_grad = false
y.backward(): Ok, Gradients.len() = 0, x.grad = None
backward返回Ok,Gradients是空的,x.grad()还是None。没有报错、没有警告。y没有记录,身上没有 semantic trace。backward照常把活着的x当候选交给semantic_eager_vjp_many,它的第二个检查就是let Some(raw_output_trace) = output.semantic_trace.as_ref() else { return Ok(vec![None; wrts.len()]); };,给每个候选返回None,store_grads没有东西可写。- PyTorch 在同一处报
element 0 of tensors does not require grad and does not have a grad_fn。从 PyTorch 搬过来的代码如果靠这个异常发现「忘了requires_grad」或「误放进了no_grad」,在 tenferro 里只会看到梯度是None,或者一个从没被更新的参数。拿backward返回的Gradients.len()或loss.tracks_grad()检查一下。
小结
variable_from/constant_from/backward/grad()/clear_grad()对应requires_grad=True/ 普通张量 /backward()/.grad/grad = None;运算在with_eager_session的回调里调,engine 由调用方持有、显式借入。- 同一个梯度和 PyTorch 逐位相同,和 JAX 差在
sin/cos实现的末位。 - 梯度累加进叶子的槽;第二次
backward不需要retain_graph,结果翻倍;非叶子的grad()是None,不警告,没有retain_grad。 - 非标量用
backward_with(&mut eng, &cotangent),对应backward(gradient=v)和jax.vjp。 rt.grad的结果总能再求导,不需要create_graph=True,hvp 和 PyTorch、JAX 一样准。no_grad里算出的结果调backward静默返回空梯度,PyTorch 会报错;没有原地运算,也就没有版本检查。