求导变换的代价
主线位置:变换主线的「grad / vjp / jvp」一步,量变换本身花的时间;TRG 主线的「分段求导得内能」为什么必须分段,答案在这一页。
版本:JAX 0.12.0.1(CPU,x64)、tenferro-rs 0.8.0(CPU,native,单线程)
flowchart TB y0["y 用了两次"] --> y1["下一层的 y"] y1 --> y2["再下一层"] y2 --> key["结构键 ValueKey"] key --> tree["展开成树 2 的 n 次方"] tree --> eq["逐层比较 相等"] eq --> slow["变换和释放都变慢"]
JAX 用户很少单独量 grad 这个变换:jax.make_jaxpr(jax.grad(f)) 只是 Python 层的追踪,和图的大小成正比,几十毫秒的事。tenferro 的 ad.grad 也是对图做变换,产物还是图(traced AD),但在 0.8.0 里它的耗时可以随深度指数增长。导数程序本身的大小仍是线性的,执行也快;慢的是变换时的元数据簿记,以及释放导数图的时候。
实验代码:tenferro 一侧是 bin ad_blowup、trg_ad_steps(release,CpuEngine::serial()),JAX 一侧是 jax_ad_cost.py。M2 Max、macOS 27、rustc 1.99.0;负载均值 3.5–5.5(动脑筋那次是 45)。会跑很久的地方都加了上限:ad_blowup 在一次变换超过 1.5 s 后停止,trg_ad_steps 整个进程限 90 s(timeout)。
同一条链,两边的变换时间
三种链,输入都是 3×3 矩阵,深度 n,最后 sum:
mul:y ← (y·y)/2,每层把上一层的值用两次。svd3:(u, s, vt) = svd(y),y ← u·s + vt,一个多输出运算的三个输出都用上。- 另外三种只在 tenferro 一侧跑:
svd1(只用u,y ← u + y)、svd2(用u和s)、qr2(y ← q + r)。
def chain(kind, n):
def f(x):
y = x
for _ in range(n):
if kind == "mul":
y = (y * y) * 0.5
else: # svd3
u, s, vt = jnp.linalg.svd(y)
y = u * s[None, :] + vt
return jnp.sum(y)
return f
jp = jax.make_jaxpr(jax.grad(f))(x) # 计时:追踪加变换
lo = jax.jit(jax.grad(f)).lower(x) # 计时:降级
co = lo.compile() # 计时:XLA 编译
let x = TracedTensor::input_concrete_shape(DType::F64, &[3, 3])?;
let mut y = x.clone();
for _ in 0..n {
y = match kind {
"mul" => (&y * &y)?.scale_real(0.5)?,
"svd3" => {
let (u, s, vt) = y.svd()?;
let s = s.reshape([3])?.broadcast_in_dim(&[3, 3], &[1])?;
(&(&u.reshape([3, 3])? * &s)? + &vt.reshape([3, 3])?)?
}
// svd1、svd2、qr2 同理
};
}
let l = y.reduce_sum(None)?;
let d = ad.grad(&l, &x)?; // 计时
// 编译 d、数算子;然后单独计时 drop(d)
# JAX
mul n=10 grad jaxpr eqns 62 make_jaxpr(grad) 3.08 ms lower 5.56 ms compile 14.50 ms
mul n=25 grad jaxpr eqns 152 make_jaxpr(grad) 6.25 ms lower 8.19 ms compile 17.85 ms
svd3 n=10 grad jaxpr eqns 102 make_jaxpr(grad) 7.24 ms lower 11.83 ms compile 78.02 ms
svd3 n=15 grad jaxpr eqns 152 make_jaxpr(grad) 10.60 ms lower 17.52 ms compile 101.78 ms
# tenferro
mul grad n=10 value ops 23 ad.grad 0.41 ms ratio 1.34 derivative ops 51 drop derivative 0.09 ms
mul grad n=20 value ops 43 ad.grad 79.52 ms ratio 2.10 derivative ops 101 drop derivative 77.61 ms
mul grad n=25 value ops 53 ad.grad 2328.13 ms ratio 1.85 derivative ops 126 drop derivative 2239.12 ms
mul grad: stopped after n=25 (transform > 1500 ms cap)
svd3 grad n=10 value ops 71 ad.grad 12.40 ms ratio 2.41 derivative ops 574 drop derivative 10.39 ms
svd3 grad n=15 value ops 106 ad.grad 2552.95 ms ratio 2.99 derivative ops 859 drop derivative 2481.44 ms
svd3 grad: stopped after n=15 (transform > 1500 ms cap)
(ratio 是这一层的变换时间除以上一层的。)
- JAX 是线性的。 导数 jaxpr 的方程数每层加 6(
mul)或 10(svd3),追踪加变换从 3 ms 到 6 ms、从 7 ms 到 11 ms,XLA 编译也只缓慢增长。 - tenferro 的导数程序也是线性的(
mul每层 5 个算子,svd3每层 57 个),但ad.grad的时间在mul上每层翻倍,在svd3上每层乘 3。25 层的y·y求导 2.3 s,126 个算子的程序。 - 释放导数图和变换一样贵。
drop(d)2.2 s 和 2.5 s,几乎等于变换本身。所以只计ad.grad的时间会少算一半。
五种链都量到深处(每层的比值):
| 链 | 每层重复使用 | n = 10 的比值 | 最后几层的比值 | 最深一层 |
|---|---|---|---|---|
mul | y 用 2 次 | 1.10 | 1.94–2.05(n = 18–25) | n = 25,2316 ms |
svd1 | y 用 2 次(svd 和加法) | 1.09 | 1.43–1.72(n = 14–16) | n = 16,10.5 ms |
qr2 | qr(y) 的 2 个输出 | 1.18 | 1.61–1.82(n = 14–16) | n = 16,14.7 ms |
svd2 | svd(y) 的 2 个输出 | 1.25 | 1.67–1.90(n = 14–16) | n = 16,30.6 ms |
svd3 | svd(y) 的 3 个输出 | 2.27 | 2.97–3.01(n = 13–15) | n = 15,2469 ms |
(svd1、qr2、svd2 只跑到 n = 16,比值还在往 2 爬。jvp 也一样:svd3 的 ad.jvp 在 n = 15 是 2627 ms,比值 2.97。)
渐近的比值就是每层把上一层用了几次。试验阶段把这个现象归到「多输出运算(SVD)的输出被重复使用」,范围说窄了:mul 里没有多输出运算,只是 y 用了两次,同样指数增长。多输出运算让它来得更早、底数更大,因为三个输出各算一次使用。
时间去了哪里
用 macOS 的 sample 对 mul 链采样 2 秒(n 在 23 到 25 之间):主线程 1383 个样本里,915 个在释放导数图(TracedTensor 的 drop → GlobalMetadataScope::drop),468 个在 ad.grad(grad_with_optional_rules → derivative_traces_from_frozen_program → register_scoped_global_metadata_batch)。两条路的栈顶都是同一个函数一层套一层地调用自己:computegraph 的 ValueKey::eq。
ValueKey 是一个值的结构身份:输入,或者「某个运算的第几个输出」,运算又按结构记下它的全部输入键(OperationKey)。比较两个键的 PartialEq:
impl<Op: GraphOperation> PartialEq for ValueKey<Op> {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Input(lhs), Self::Input(rhs)) => lhs == rhs,
(
Self::Derived {
operation: lhs_op,
output_slot: lhs_slot,
},
Self::Derived {
operation: rhs_op,
output_slot: rhs_slot,
},
) => {
lhs_slot == rhs_slot
&& (Arc::ptr_eq(lhs_op, rhs_op) || lhs_op.as_ref() == rhs_op.as_ref())
}
_ => false,
}
}
}
两个键指向同一个 Arc 时一步就判完;不是同一个 Arc 时,lhs_op.as_ref() == rhs_op.as_ref() 逐个比较输入键,每个输入键又递归下去,没有记忆。图是 DAG,但这样比较是把 DAG 展开成树来走:y·y 的键有两个输入,都指向上一层的 y,n 层就是 2ⁿ 个叶子;svd3 每层三个输出各指一次 svd(y),是 3ⁿ。
谁在比较:tenferro 把每个值的元数据(dtype、形状)登记在一张全局表里,键就是 ValueKey(register_scoped_global_metadata_batch)。哈希只用缓存的指纹,是常数时间;指纹相同的键要再用 eq 确认。变换时 derivative_traces_from_frozen_program 为导数图登记元数据,导数图里引用了原图的值;释放时 GlobalMetadataScope 逐个注销,又要按键查表。两次查表都撞上已经登记过、结构相同的键,于是走进完整的递归比较。走到这里的两个键不共用 Arc(否则第一步就返回了),所以导数图里的键是重新构造出来的、结构相等的副本。这一点是从栈和代码推出来的,没有在 tenferro 里加打印核实。
computegraph 的 PR #3(「Optimize global graph key hashing」,2026-05-19 合并)之前,键类型(当时叫 GlobalValKey / GlobalOpKey)直接 #[derive(Clone, Debug, PartialEq, Eq, Hash)],运算键内联在值键里:哈希、比较、克隆都递归走整棵树。这个 PR 把运算键放进 Arc,在构造时算好一个指纹,Hash 只哈希指纹;文档注释写「maps keyed by recursively derived values can avoid repeatedly re-hashing the whole input tree. Equality still checks the full structure after the fingerprint prefilter」。同时加的测试 derived_keys_with_distinct_op_arcs_are_structurally_equal 断言两个不同 Arc、结构相同的键相等,这正是上面慢的那条路:指纹让查表找到桶,确认相等仍要走整棵树。computegraph-rs 没有打 tag,第一个发布到 crates.io 的 0.1.0(594cdce)包含这个提交,tenferro 0.8.0 用的就是它。
TRG 整张图
同样的事落在 TRG 上。χ = 16,截断数 [2, 4, 4, 16, ...],β 是标量占位符,整张图建好后 ad.grad:
let beta = TracedTensor::input_concrete_shape(DType::F64, &[])?;
let lnz = trg(&beta, &ks, None)?;
let g = ad.grad(&lnz, &beta)?; // 计时
// 编译、准备、执行 g;最后单独计时 drop(g)
eq, t_tr, t_lo, t_co, co = cost(make_trg(ks, lorentz_svd), b) # 同上的三段计时
# tenferro
steps=1 ks=[2] build 3.44 ms | ad.grad 4.78 ms | drop grad graph 0.23 ms | compile+prepare 4.93 ms | run 0.199 ms | ops value 71 grad 266 | grad=1.667309214024311
steps=2 ks=[2, 4] build 0.18 ms | ad.grad 5.56 ms | drop grad graph 3.91 ms | compile+prepare 5.40 ms | run 0.343 ms | ops value 116 grad 465 | grad=1.277612005080206
steps=3 ks=[2, 4, 4] build 0.20 ms | ad.grad 91.08 ms | drop grad graph 88.82 ms | compile+prepare 6.94 ms | run 0.524 ms | ops value 160 grad 665 | grad=1.023802944795145
steps=4 ks=[2, 4, 4, 16] build 0.31 ms | ad.grad 2125.22 ms | drop grad graph 2099.40 ms | compile+prepare 9.32 ms | run 1.230 ms | ops value 204 grad 863 | grad=0.844054073623855
exit=124
# JAX
trg steps= 1 grad jaxpr eqns 266 make_jaxpr(grad) 37.83 ms lower 23.28 ms compile 73.53 ms grad=1.667309214024311
trg steps= 4 grad jaxpr eqns 837 make_jaxpr(grad) 49.05 ms lower 63.09 ms compile 187.54 ms grad=0.844054073623855
trg steps= 6 grad jaxpr eqns 1239 make_jaxpr(grad) 64.64 ms lower 93.85 ms compile 261.77 ms grad=0.712432002551333
trg steps=20 grad jaxpr eqns 4053 make_jaxpr(grad) 212.64 ms lower 302.40 ms compile 481.34 ms grad=0.704498679529762
- tenferro 3 步到 4 步,变换乘了 23;5 步在 90 s 的上限内没有做完(
exit=124是timeout终止)。试验阶段 6 步跑了 3 分钟没有返回。 - 释放导数图又是一份同样的时间,4 步一共约 4.2 s。
- 导数程序线性增长(每步约 200 个算子,1 步时和 JAX 的 266 个方程正好相同),4 步的执行只要 1.2 ms。变换做完以后一切都便宜。
- JAX 20 步的追踪加变换 213 ms,编译 481 ms,和步数成正比。两边的梯度 1、2、4 步逐位相同,3 步差 1 ulp(
...145对...146)。
TRG 每一步里 T 被用了好几次(求范数、两种矩阵化),两次 SVD 的三个输出也都用上,所以底数比 svd3 还大。eager 的 backward 用的是同一个变换,整图 4 步每次约 7 秒,而且每个 β 都要重付(eager 还是 traced)。
mul 换成 y ← (y·y·y)/2,同一个 y 每层用三次。n = 12 时 ad.grad 是 23 ms,n = 16 时是多少?导数程序有几个算子?同一个 ad_blowup,release,单线程。
怎么避开
指数的底来自深度,所以办法是不让一次变换看见很深的图:
- 分段。 每种形状的一步建一个小程序,单独
ad.grad,步与步之间手工串反向。TRG 共 5 种步,全部程序的建图、变换、编译、准备一共 27 ms,20 步、所有 β 复用(分段)。这是本卷推荐的写法。 checkpoint。 把中间结果求值成叶子,下游的结构键从这个叶子重新开始,4 步的ad.grad从 2125 ms 降到 4 ms。但它的检查点链表自己按步数指数变长,5 步就在 8 MiB 的主线程栈上溢出(checkpoint)。- 检查一下自己的链。 不只是 SVD:任何「值用两次」的长链都会撞上。迭代 20 次以上、每次都复用上一轮结果的图,先用十几层量一次变换时间的比值,比值趋近 2 或 3 就别整图求导。
动脑筋答案
cube grad n=12 value ops 39 ad.grad 23.07 ms ratio 2.91 derivative ops 109 drop derivative 22.89 ms
cube grad n=13 value ops 42 ad.grad 69.38 ms ratio 3.01 derivative ops 118 drop derivative 68.33 ms
cube grad n=14 value ops 45 ad.grad 308.11 ms ratio 4.44 derivative ops 127 drop derivative 361.42 ms
cube grad n=15 value ops 48 ad.grad 1054.91 ms ratio 3.42 derivative ops 136 drop derivative 1116.39 ms
cube grad n=16 value ops 51 ad.grad 3218.53 ms ratio 3.05 derivative ops 145 drop derivative 3413.15 ms
cube grad: stopped after n=16 (transform > 1500 ms cap)
- 用三次,底数就是 3:n = 13 和 16 的比值都是 3.0。按 3⁴ = 81 估计 n = 16 约 1.9 s,实测 3.2 s;多出来的部分在 n = 14 那一步(比值 4.44),这次运行时机器的负载均值到了 45(另一个进程在跑满 CPU),单次的时间被拉长了。
- 导数程序 145 个算子,每层多 9 个,线性。
- 释放又是 3.4 s。和
svd3比:同样每层乘 3,cube里没有任何多输出运算。
小结
- JAX 的
grad变换和图的大小成正比;20 步 TRG 的追踪加变换 0.2 s。 - tenferro 0.8.0 的
ad.grad/ad.jvp在值被重复使用的链上随深度指数增长,底数是每层的使用次数:y·y是 2,SVD 三个输出都用是 3,TRG 一步约 20;导数程序的大小仍然线性。 - 释放导数图同样贵,计时时要算上。
- 时间耗在 computegraph 的
ValueKey::eq:不共用Arc的结构相等键被展开成树来逐个比较,查全局元数据表时触发。 - 整图 TRG 3 步 0.09 s、4 步 2.1 s,5 步 90 s 内没做完;要分段,每段的变换只看一步。