Rust in Everything
基于 tenferro-ad 0.8.0

grad、vjp、jvp

主线位置:变换主线的「grad / vjp / jvp」一步,函数是 f(x) = Σ sin(x)·x²,n = 1000。

版本:JAX 0.12.0.1(CPU,x64)、tenferro-rs 0.8.0(CPU,native,单线程)
flowchart TB
  y["y = f(x) 的图"] --> g["grad 种子 1"]
  y --> vj["vjp 给余切"]
  y --> jv["jvp 给切向量"]
  g --> d["导数的图"]
  vj --> d
  jv --> d
  d --> c["编译 准备"]
  c --> r["run 可换输入"]

jax.grad(f) 拿一个函数、还一个函数。tenferro 的变换拿一张图、还一张图:ad.grad(&y, &x) 的结果是一个 TracedTensor,和上一页的 y 一样,要 编译、准备、执行。变换本身怎么做(先把输出编成语义程序、倒着走一遍),见 traced AD。

实验代码:tenferro 一侧是 bin ad_api(release,CpuEngine::serial()),JAX 一侧是 ad_api.py。M2 Max、macOS 27、rustc 1.99.0;负载均值 4–6。输入 x_i = −2 + 4i/999,v_i = cos i,和全卷的变换主线相同。这组 x 关于 0 对称,f 本身约为 0,下面只看导数。

grad

g = jax.jit(jax.grad(f))(x)
print(jax.make_jaxpr(jax.grad(f))(x))
let ad = ad_context()?;            // AdContext,带 linalg 和 einsum 的规则
let x = TracedTensor::input_concrete_shape(DType::F64, &[N])?;
let y = f(&x)?;
let g = ad.grad(&y, &x)?;          // 也可以 use tenferro_ad::TracedTensorAdExt 后写 y.grad(&x)
let pg_prog = GraphCompiler::new().compile_with_input_specs(&g, &[(&x, DType::F64, &[N][..])])?;
# JAX
max|grad-exact|=4.441e-16 jit first 14691 us steady 10.6 us
{ lambda ; a:f64[1000]. let
    b:f64[1000] = sin a
    c:f64[1000] = cos a
    d:f64[1000] = integer_pow[y=2] a
    e:f64[1000] = mul 2.0:f64[] a
    f:f64[1000] = mul b d
    _:f64[] = reduce_sum[axes=(0,) out_sharding=None] f
    g:f64[1000] = broadcast_in_dim 1.0:f64[]
    h:f64[1000] = mul b g
    i:f64[1000] = mul g d
    j:f64[1000] = mul h e
    k:f64[1000] = mul i c
    l:f64[1000] = add_any j k
  in (l,) }
max|jax - tenferro|=4.441e-16, bitwise equal 761/1000
# tenferro
grad program inputs=2 ops=10: BroadcastInDim Sin Mul Mul Add Mul Mul Cos Mul Add
grad program: input index of x Some(0)
max|grad-exact|=4.441e-16  ext == ctx bitwise: true  steady 14.4 us
  • 结果。 两边对解析梯度 cos x · x² + 2x sin x 的误差都是 4.4e-16(梯度的绝对值最大约 3.2),1000 个元素里 761 个逐位相同,其余 239 个正好差一个 ulp。两份程序的算子序列不一样(上面 jaxpr 里先算 sin·1、1·x² 再各乘一次,tenferro 的是另一种排列),舍入跟着不同。
  • 导数程序自带前向。 jaxpr 里有 sin、x²,还有一行算出来没人用的 _ = reduce_sum(交给 XLA 删)。tenferro 的导数程序同样重算前向,但变换之后已经把用不到的 ReduceSum 剪掉了。
  • 种子是一个输入。 jaxpr 里的种子是字面量 1.0;tenferro 的程序有 2 个输入,x 是第 0 个,另一个是 grad 的种子,一个带默认值 1 的标量。run 只给 x 时种子用默认值。
  • 两个入口。 y.grad(&x)(TracedTensorAdExt)和 AdContext::grad 的结果逐位相同。差别在规则集和缓存,下面最后一节。
  • 时间。 JAX 的 jit(grad) 第一次 14.7 ms、稳态 10.6 µs;tenferro 稳态 14.4 µs。

非标量输出两边都拒绝,规则一样:

# JAX
TypeError: Gradient only defined for scalar-output functions. Output had shape: (1000,). To get the gradient, reduce the output to a scalar with output.sum(), or use jax.jacobian to differentiate a function with a non-scalar output.
# tenferro
Err: grad requires a scalar output, got shape [1000]
  NonScalarGrad { shape: [1000] }

tenferro 的 grad 还要求 wrt 是叶子,不能对中间结果求导,见 traced AD 的三种前置错误。JAX 的 grad 只对函数参数求导,这条限制换个说法而已。tenferro 没有 jax.jacobian、jacfwd、jacrev、vmap(AdContext 的求导方法只有 grad、jvp、vjp,它们的 _optional、_many 版本,以及直接给出导数程序的 jvp_program、vjp_program),要雅可比矩阵就按列或按行循环,见 高阶导数。

vjp

jax.vjp 返回前向的值和一个闭包,余切是闭包的参数。tenferro 的 AdContext::vjp 在建图时就要余切:

y, pullback = jax.vjp(h, x)          # h(x) = sin(x) * x**2,向量输出
(ct,) = pullback(v)
jax.jit(lambda x, u: jax.vjp(h, x)[1](u)[0])(x, v)
let ct = TracedTensor::from_vec_col_major(vec![N], vs.clone())?;   // 带数据
let hx = h(&x)?;
let vj = ad.vjp(&hx, &x, &ct)?;
let ct_ph = TracedTensor::input_concrete_shape(DType::F64, &[N])?; // 占位符
ad.vjp(&hx, &x, &ct_ph)
# JAX
max|vjp - g*v|=8.882e-16
jit(vjp) with the cotangent as an argument: first 11171 us steady 10.9 us
# tenferro
vjp program inputs=2 ops=9: Sin Mul Mul Add Mul Mul Cos Mul Add
max|vjp - g*v|=8.882e-16
vjp with placeholder cotangent: Err: vjp (GraphBuild): invalid argument cotangent: vjp cotangent must have concrete tensor data

余切不能是占位符。vjp_many_optional_impl 一开始就查它带不带数据:余切要作为绑定挂进导数图,不能是一个没有数据的输入。

❗IMPORTANT
动脑筋:vjp 程序有 2 个输入。run 时传 [x, 全 1 向量],得到什么?

报错(余切已经烧进程序了),还是 v ⊙ g,还是别的?

jvp

jit_jvp = jax.jit(lambda x, t: jax.jvp(f, (x,), (t,))[1])
jit_jvp(x, v); jit_jvp(x, 2 * v)
let vc = TracedTensor::from_vec_col_major(vec![N], vs.clone())?;
let j = ad.jvp(&y, &x, &vc)?;
let pj_prog = GraphCompiler::new().compile_with_input_specs(&j, &[(&x, DType::F64, &[N][..])])?;
run1(&rt, &mut eng, &pj, &[&xt]);          // 切向量用默认值 v
run1(&rt, &mut eng, &pj, &[&xt, &v2t]);    // 两个输入都给:切向量换成 2v
ad.jvp(&y, &x, &v_placeholder)             // 占位符
# JAX
jvp=1.879649926237039 |.-exact|=4.885e-15 jit first 17463 us steady 9.8 us
same jit, tangent 2v: 3.759299852474078 (42 us, no retrace: cache size 1)
# tenferro
jvp transform 58.5 us; program inputs=2 ops=10: Cos Mul Mul Mul Mul Add Sin Mul Add ReduceSum
jvp=1.879649926237050 |.-exact|=8.216e-15 steady 14.0 us
jvp program run with [x, 2v]: 3.759299852474099; steady 13.8 us
jvp with placeholder tangent: Err: jvp (GraphBuild): invalid argument tangent: jvp tangent must have concrete tensor data

切向量同样必须带数据(jvp_many_optional_impl)。但「带数据」只是建图时的要求:它在导数程序里是第 1 个输入,带着默认值 v。run 时把两个输入都给,切向量就换成了 2v,不用重新变换、编译、准备,相当于 jit(jvp) 把切向量当参数。规则和上一页的闭包常量一样:输入个数等于程序的输入个数时,默认值全被忽略。

要注意位置。切向量在导数图里拿的是新分配的输入键,不是 vc 自己的键,input_key_index 查不到它;这里只能从「x 是第 0 个、一共 2 个」推出它是第 1 个。

数值上,jvp 是 1000 项的和,两边对 ⟨g, v⟩ 的精确值各差 4.9e-15 和 8.2e-15,彼此差 1.1e-14(值约 1.9):求和顺序不同。

value_and_grad

jax.value_and_grad 一次给出值和梯度,jit 之后是一个可执行文件。tenferro 把两个输出放进一个程序要用 compile_many,而它没有输入说明的版本,compile_with_input_specs 只收一个输出:

GraphCompiler::new().compile_many(&[&y, &g])                 // x 是占位符
let xd = TracedTensor::from_tensor_concrete_shape(Tensor::from_vec_col_major(vec![N], xs.clone())?)?;
let yd = f(&xd)?;                                            // x 是带数据的叶子
let gd = ad.grad(&yd, &xd)?;
let pm_prog = GraphCompiler::new().compile_many(&[&yd, &gd])?;
run_all(&rt, &mut eng, &pm, &[]);              // 默认值
run_all(&rt, &mut eng, &pm, &[&x2]);           // 换 x
run_all(&rt, &mut eng, &pm, &[&x2, &seed]);    // 换 x,种子照写 1
# JAX
value=-0.000000000000028 max|grad-exact|=4.441e-16 jit first 18840 us steady 12.5 us
# tenferro
compile_many with a placeholder: Err: placeholder User { id: 0 } has no runtime input binding
compile_many(value, grad) with a data leaf: inputs=2 ops=12: Sin Mul Mul ReduceSum BroadcastInDim Mul Mul Add Mul Cos Mul Add  (x index Some(0))
defaults: value=0.000000000000048 max|grad-exact|=4.441e-16 steady 16.3 us
run with [x+0.5]: Err: Runtime::run (Execution): invalid argument inputs: expected 2 inputs, received 1
run with [x+0.5, seed=1]: value=855.495821415973751 max|grad-exact|=1.332e-15

办法是让 x 也成为带数据的叶子:compile_many 用它的数据做默认值,12 个算子里前向的 Sin、Mul 只算一次,值和梯度共用。代价是换 x 时只给 x 不行,要连种子一起给。

📝NOTE
没有蠢问题

问: grad 程序只给 x 能跑,这个 value_and_grad 程序为什么只给 x 就报 expected 2 inputs, received 1?

答: 两个程序的输入都是 [x, 种子],区别在 x 有没有默认值。resolve_input_reads 在输入个数少于程序输入时,只把给的张量依次填进没有默认值的输入。grad 程序里 x 是占位符,正好吃掉一个;这里 x 和种子都有默认值,没有空位可填,多出来的那一个就成了错误。所以要么一个不给,要么全给。

多个输入:argnums 与 vjp_many

f2 = lambda x, w: jnp.sum(jnp.sin(x) * w * x)
gx, gw = jax.grad(f2, argnums=(0, 1))(x, v)
let w = TracedTensor::input_concrete_shape(DType::F64, &[N])?;
let y2 = (&(&x.sin()? * &w)? * &x)?.reduce_sum(None)?;
let one = TracedTensor::from_vec_col_major(vec![], vec![1.0_f64])?;
let gs = ad.vjp_many(&y2, &[&x, &w], &one)?;          // Vec<Option<TracedTensor>>
// 每个梯度各自 compile_with_input_specs,规格先写 [x, w]
# JAX
d/dx max|.-exact|=2.220e-16  d/dw max|.-exact|=0.000e+00
# tenferro
d/dx: ops=9 max|.-exact|=4.441e-16
d/dw: specs [x, w]: Err: GraphCompiler::compile_with_input_specs (Compile): invalid argument bindings: binding 1 declares a placeholder the output does not depend on; remove it from the bindings
d/dw: ops=4 max|.-exact|=2.220e-16

vjp_many 对应 argnums:一次变换,返回每个 wrt 的梯度(和输出无关的是 None)。种子这里显式给了标量 1。

∂f2/∂w = sin(x)·x 不含 w,所以 d/dw 这张图里没有 w,规格里写上 w 就被拒绝,只能写 [x]。JAX 的 jit 不在乎某个参数用没用到。在 tenferro 里,同一组参数的几个导数可能要配不同的输入列表;这条检查是 0.8.0 加的,之前多声明的占位符会被悄悄丢掉、把张量绑到别的输入上,来历见 TracedTensor 里的冷知识框。

AdContext 和 TracedTensorAdExt

JAX 的求导规则都内置在原语里。tenferro 的核心算子(逐元素、规约、dot_general 等)的规则在 tenferro-ad 里,线性代数、einsum、FFT 这些运算族的规则在各自的 crate 里,要注册进 AdContext 才能用:

let mut rules = tenferro_linalg::semantic_ad_rules()?;
rules.merge(tenferro_einsum::semantic_ad_rules()?)?;
let ad = AdContext::builder().with_semantic_extension_rules(rules)?.build()?;

let (_, s, _) = a.svd()?;            // a 是 2x2 的 [[4, 1], [1, 3]]
let ls = s.reduce_sum(None)?;
ls.grad(&a)                          // TracedTensorAdExt
ad.grad(&ls, &a)                     // AdContext
TracedTensorAdExt::grad: Err: unsupported vjp AD rule for tenferro-linalg.linalg.v1
AdContext::grad: [1.0000000000000007, 5.551115123125783e-17, 1.1102230246251565e-16, 1.0000000000000002]

TracedTensorAdExt 每次调用用一个空规则集、不缓存;AdContext 带规则,还有一个按结构缓存的变换缓存。报错和修法见 unsupported vjp AD rule。对称正定矩阵的奇异值之和是迹,梯度是单位矩阵,结果和它差 7e-16。迁移 JAX 代码时,全程用一个 AdContext 最省事。

动脑筋答案

vjp program run with [x, ones]: max|.-grad|=4.441e-16
  • 得到的是 f 的梯度 g,和解析值差 4.4e-16:传两个输入时,余切的默认值 v 被全 1 向量顶替,h 的 vjp 乘全 1 就是 Σ h = f 的梯度。
  • 余切和切向量一样,「必须带数据」只限制建图,不限制运行。
  • 反过来,这也说明 grad 程序里那个种子就是余切:grad 是种子为 1 的 vjp(traced AD)。

小结

  • jax.grad / vjp / jvp 返回函数;tenferro 返回导数的 TracedTensor,自己编译执行。导数程序自带前向重算,两边都一样。
  • 种子、余切、切向量建图时必须带数据,在程序里是带默认值的输入;run 时给全所有输入就能换掉它们,相当于 jit(jvp) 把切向量当参数。切向量的位置要靠输入个数推断。
  • 梯度两边对精确值都是 4.4e-16,761 / 1000 个元素逐位相同,其余差一个 ulp。
  • value_and_grad 要 compile_many,它不收输入说明,x 只能做成带数据的叶子,换 x 时连种子一起给。
  • 多个 wrt 用 vjp_many;不用某个参数的导数,编译时不能声明那个占位符。
  • 线性代数等运算族的求导要 AdContext 加各 crate 的 semantic_ad_rules();TracedTensorAdExt 只认核心算子。

💬 关联讨论

发起讨论
加载中...