Rust in Everything
基于 tenferro-runtime 0.8.0

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 后梯度翻倍)。

📝NOTE
冷知识:with_eager_session 原来要写两个问号

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 返回一个新数组,不往任何地方累加。

📝NOTE
没有蠢问题

问: 没有 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)。

❗IMPORTANT
动脑筋:在 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 会报错;没有原地运算,也就没有版本检查。

💬 关联讨论

发起讨论
加载中...