自动微分
图与运行时 讲了 traced 程序怎么建、怎么编译、怎么在 engine 上执行。这一部分讲 tenferro-ad:在那之上怎么求导。tenferro 有两种用法,JAX 式的 traced(y.grad(&x) 返回一张新图)和 PyTorch 式的 eager(loss.backward(&mut engine) 之后 x.grad())。两者表面差别很大,底下是同一件事:把计算写成一个语义程序(SSA),对程序做一次 JVP 或 VJP 变换,得到导数程序,再编译执行。eager 只是在每次运算时顺手把这个程序记下来。变换本身不碰 engine,engine 只在执行导数程序时由调用方借进来。
代码集中在 tenferro-ad/src/:traced.rs、context.rs(traced 入口和 AdContext)、semantic_transform.rs(变换和核心算子的规则)、eager.rs、eager_ops.rs、eager/residuals.rs、eager/composite.rs(eager)、semantic_extension.rs、extension.rs(运算族的规则和扩展算子)。tenferro-internal-ops/src/ad/ 里还有一张图级的规则表,grad 不走它,第一页讲为什么。
- primitive AD 规则:核心算子的规则写了两份,
tenferro-internal-ops按PrimitiveOpKind登记的图级linearize/transpose_rule,和grad实际调用的linearize_core/vjp_core;ResidualSpec声明反向要留哪些张量;新的erf是 primitive,两份规则各一条,sigmoid、gelu、softmax、layer_norm是组合运算,按分解求导;maximum/clamp的掩码零不再被非有限余切污染成 NaN;梯度与有限差分差 2.3e-9。 - traced AD:
grad是种子为 1 的vjp;输出先编成FrozenProgram,semantic_vjp倒序遍历,导数程序沿用原叶子的输入键再包成TracedTensor,所以能再求导(HVP 与有限差分差 7.3e-10);导数程序自带前向重算;vjp_many一次变换求多个梯度;AdContext的变换缓存按结构指纹命中,变换从约 175 µs 降到约 86 µs。 - eager AD:
EagerRuntime不持有 engine,运算写在with_eager_session(&mut engine, |s| ...)的回调里,立即执行,同时往 semantic trace 追加节点、按ResidualSpec留住残差;组合运算只在不跟踪时走融合内核;backward对所有活着的梯度槽做一次语义 VJP,残差当输入不重算前向,梯度累加进槽位,可以调两次;值记录表在训练循环里不再增长。 - semantic AD 与扩展:einsum、线性代数、FFT 的规则按 family id 注册进
SemanticExtensionRuleSet,经AdContext交给 traced、经EagerRuntime::with_ad_context交给 eager;反向优先用primal_vjp,否则先线性化再转置;没注册时两边报的错误类型不同;eager einsum 在需要长度 1 广播时梯度形状仍然错,三操作数时前向就被 CPU provider 拒绝。
flowchart TB tt["TracedTensor 运算"] --> prog["语义程序 FrozenProgram"] et["EagerSession 运算"] -->|立即执行并记录| trace["semantic trace"] trace --> prog prog --> xf["semantic_jvp 或 semantic_vjp"] core["核心算子规则"] --> xf ext["运算族规则集"] -->|AdContext| xf xf --> dp["导数程序"] dp -->|traced| back["包回 TracedTensor"] dp -->|eager| run["在 engine 上执行 累加进梯度槽"]
第一页讲规则,后三页按「traced 怎么用规则、eager 怎么用规则、运算族的规则从哪来」排列。和 burn、candle 的反向在行为上的差别(能不能 backward 两次、梯度存在哪),eager 页各用一句话对照(burn 的 backward、candle 的 backward),完整的对照在 设计取舍。