Rust in Everything
基于 tenferro-ad 0.8.0

整张图求导,几步之后就停不下来

主线位置:TRG 主线「分段求导得内能」:为什么要分段,而不是像 JAX 那样整张图 grad。

版本:JAX 0.12.0.1(CPU,x64)、tenferro-rs 0.8.0(CPU,native,单线程)
flowchart TB
  jx["jax.grad 整张图"] --> lin["变换随步数线性"]
  port["照搬 ad.grad 整张图"] --> reuse["值被反复使用"]
  reuse --> exp["变换随深度指数"]
  exp --> hang["5 步不返回"]
  port --> stage["按步分段"]
  stage --> vjp["每步一个 vjp"]
  vjp --> fast["20 步 0.56 s"]

实验代码:tenferro 一侧是 bin p25_whole_graph(release,CpuEngine::serial())和动手页的分段程序 ising_trg,JAX 一侧是 p25_whole_graph.py。χ = 16,截断数 [2, 4, 4, 16, …],β = 0.3。负载均值 2.4 到 3.0。M2 Max、macOS 27、rustc 1.99.0。

现象

JAX 里整张 TRG 图直接 jax.grad,20 步也没有问题:

jax.make_jaxpr(jax.grad(f))(x)          # 追踪加变换
jax.jit(jax.grad(f))(x).block_until_ready()   # 第一次调用,含编译
trg steps= 1 eqns  266  make_jaxpr(grad)   39.06 ms  first jit(grad) call  104.86 ms  grad=1.667309214024311
trg steps= 4 eqns  837  make_jaxpr(grad)   68.01 ms  first jit(grad) call  252.37 ms  grad=0.844054073623855
trg steps= 5 eqns 1038  make_jaxpr(grad)   68.52 ms  first jit(grad) call  361.30 ms  grad=0.744793433247367
trg steps=20 eqns 4053  make_jaxpr(grad)  211.57 ms  first jit(grad) call 1104.63 ms  grad=0.704498679529762

照搬到 tenferro,同样一张图,同样一次 ad.grad:

let beta = TracedTensor::input_concrete_shape(DType::F64, &[])?;
let lnz = trg_with(&beta, &schedule(16, n), &mut |_| Ok(()))?;
let g = ad.grad(&lnz, &beta)?;      // 计时
// 编译、准备、执行 g;最后单独计时 drop(g)
trg n= 1 ad.grad      4.36 ms | drop      0.21 ms | grad ops  266 | run  17.867 ms | grad[0]=1.667309214024311
trg n= 2 ad.grad      5.27 ms | drop      3.63 ms | grad ops  465 | run   0.622 ms | grad[0]=1.277612005080206
trg n= 3 ad.grad     88.68 ms | drop     84.98 ms | grad ops  665 | run   1.835 ms | grad[0]=1.023802944795145
trg n= 4 ad.grad   2088.32 ms | drop   2093.47 ms | grad ops  863 | run   3.564 ms | grad[0]=0.844054073623855
trg: stopped after n=4 (ad.grad > 1500 ms cap)

单独让 5 步跑,timeout 60 到点终止(exit=124),一分钟里 ad.grad 没有返回:

20:25:28
...
trg n= 4 ad.grad   2092.07 ms | drop   2082.67 ms | grad ops  863 | run   2.267 ms | grad[0]=0.844054073623855
exit=124
20:26:28
  • 3 步到 4 步变换乘了 24;释放导数图(drop)又花一份同样的时间。
  • 导数程序的算子数是线性的(每步约 200 个,1 步时和 JAX 的 266 个方程相同),执行只要几毫秒。慢的只是变换。
  • 梯度在 1、2、4 步和 JAX 逐位相同,3 步差末位。

出处

不限于 SVD。最小的复现是 y ← (y·y)/2 的链,同一个值每层用两次:

mul n=16 ad.grad      4.13 ms | drop      4.00 ms | grad ops   81 | ...
mul n=20 ad.grad     62.46 ms | drop     62.08 ms | grad ops  101 | ...
mul n=24 ad.grad   1004.20 ms | drop   1004.57 ms | grad ops  121 | ...
mul n=25 ad.grad   2013.58 ms | drop   2003.01 ms | grad ops  126 | ...

每层正好乘 2;JAX 同样的链 30 层 make_jaxpr(grad) 7.0 ms。时间耗在 computegraph 的 ValueKey::eq 上:它按结构递归比较两个值的键,把 DAG 展开成树来走,底数就是值被使用的次数。栈采样、源码和来历见 求导变换的代价。TRG 的一步里 T 用了好几次、两次 SVD 的三个输出都用上,所以底数比 2 大得多。eager 的 backward 用同一个变换,整图 4 步每次约 7 秒(eager 还是 traced)。这是上游的性能问题,0.8.0 复现。

修法

不让一次变换看见很深的图:每一步单独建图、单独求 vjp,步与步之间手工把余切传回去,相当于在 JAX 里对每一步调 jax.vjp(分段,完整程序见 Ising 模型的 TRG):

// 前向:每步在一个新叶子上建一张 eager 图
let leaf = rt.variable_from(cur.to_tensor(eng)?)?;
let (tn, ln) = rt.with_eager_session(eng, |s| step(s, &leaf, d, k))?;
// 反向:从最后一步往前,T_i 经 T_{i+1} 和 ln‖T_i‖ 两条路到 lnZ
let a = rt.vjp(eng, tn, leaf, &ct)?;
let b = rt.vjp(eng, ln, leaf, &c)?;
tenferro 0.8.0 eager staged chi=16 steps=20 width=1 h=1e-5 ks=[2, 4, 4, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16]
beta=0.3000 lnZ/N=0.790559063800966 u=-0.704498679529762 c=0.286288378175 | value_and_grad 555.8 ms

20 步的值加梯度 556 ms(第一次之后再跑 3 次取最小值;c 是另外两次求梯度的差分,不在其中);u = −grad 和 JAX 整图的 0.704498679529762 打印出来相同。

怎么确认没中招

  • 写好一步就量一次 ad.grad 的时间,再加一步量一次:比值稳定在 2 以上就是指数增长,别等到整张图。
  • 慢的程序先看是不是卡在变换或 drop 上:执行只要毫秒,变换却要秒,就是这个坑。
  • 用循环反复复用上一轮结果的图(迭代求解、多步演化)都要分段,不只 TRG。

小结

  • tenferro 0.8.0 的 ad.grad 在值被反复使用的深图上随深度指数增长,释放导数图同样慢;JAX 的 grad 是线性的。
  • TRG 整图求导 3 步 88 ms、4 步 2.1 s、5 步一分钟不返回。
  • 按步分段、逐步 vjp,20 步 556 ms。

💬 关联讨论

发起讨论
加载中...