Rust in Everything
基于 tenferro-runtime 0.8.0

traced AD

主线位置:traced 主线的中段。TracedTensor 上的运算只记图;本页从 y.grad(&x) 讲起:输出怎么变成一个语义程序,变换怎么按 primitive 规则 生成导数程序,导数程序怎么重新包成一个 TracedTensor,交给 编译 和 执行。整条主线连起来走一遍见 一次 grad。

sequenceDiagram
  participant U as 用户代码
  participant E as TracedTensorAdExt
  participant G as GraphCompiler
  participant S as semantic_vjp
  participant D as 导数 TracedTensor
  U->>E: loss.grad(x)
  E->>E: 种子 = 标量 1
  E->>G: compile_ad_source(loss)
  G-->>E: FrozenProgram
  E->>S: active_inputs 掩码
  S-->>E: SemanticAdProgram
  E->>D: 转回 computegraph 图
  D-->>U: dx 只是图
  U->>G: compile(dx)
  G-->>U: CompiledGraph
  U->>U: prepare 再 run

代码在 tenferro-ad 的 traced.rs(入口和包装)和 semantic_transform.rs(变换本身)。traced 这一侧在 0.7.1 到 0.8.0 之间改动很小(erf 的规则、maximum / clamp 掩码零的修复、无关 wrt 的错误类型,以及把图求值成叶子的 checkpoint 要显式传 engine):变换不碰 engine,engine 只在最后执行导数程序时才出现(runtime.prepare(&program, &[], &[engine.target()]) 再 runtime.run(&mut [&mut engine], ..),见 执行)。实验在 exp-tenferro8/ad-exp:traced_ad、ad_errors(debug)、traced_cache(release),CPU,CpuEngine::serial(),M2 Max、macOS 27、rustc 1.99.0。

两个入口,同一个变换

用户能调的有两组:扩展 trait TracedTensorAdExt(loss.grad(&x)、y.vjp(&x, &ct)、y.jvp(&x, &dx) 和各自的 _optional 版本),以及 AdContext 上同名的方法,外加 vjp_many / jvp_many。两组最后都进同一个函数,差别只在传进去的规则集和缓存:

impl TracedTensorAdExt for TracedTensor {
    fn grad(&self, wrt: &TracedTensor) -> Result<TracedTensor> {
        let rules = SemanticExtensionRuleSet::default();
        grad_with_optional_rules(self, wrt, &rules, None)
    }
    // ...
}

扩展 trait 每次新建一个空规则集,缓存传 None。空规则集意味着它只会对核心算子求导,碰到线性代数、FFT 这些扩展运算族就报 UnsupportedAdRule(见 semantic AD 与扩展);sigmoid、softmax 这类组合运算不受影响,它们在图里已经是核心算子(见 primitive AD 规则)。None 意味着同一个结构的图每次都要重新变换(本页最后一节量了它的代价)。

grad 本身就是种子为 1 的 vjp,grad_with_optional_rules:

    if output.rank != 0 {
        return Err(Error::NonScalarGrad {
            shape: error_shape_hint(output),
        });
    }

    let ones = ones_tensor(output.dtype, vec![])?;
    let seed = TracedTensor::from_tensor_concrete_shape(ones)?;
    let wrt_input_key = leaf_input_key(wrt)?;
    vjp_many_with_transform_and_cache(output, &[wrt], &seed, rules, "grad", ad_transform_cache)?
        .pop()
        .flatten()
        .ok_or_else(|| inactive_wrt_error("grad", &wrt_input_key))

三个前置条件对应三种错误。实验 ad_errors:

traced grad wrt intermediate: Err(Validation { op: "ad_support::leaf_input_key", phase: GraphBuild, source: InvalidArgument { argument: "tensor", message: "expected traced leaf input, got Derived { operation: OperationKey { operation: Mul, inputs: [Input(User { id: 0 }), Input(User { id: 0 })], role: Primary, fingerprint: 8852925308592293207 }, output_slot: 0 }" } })
traced grad of non-scalar:    Err(NonScalarGrad { shape: [3] })
traced grad, unrelated leaf:  Err(Validation { op: "grad", phase: GraphBuild, source: InvalidArgument { argument: "wrt", message: "grad is inactive for User { id: 2 }; use grad_optional to observe the inactive state" } })
traced grad_optional:         Ok(false)

wrt 必须是叶子(图的输入),对中间结果 h = x * x 求导在建图阶段就被拒绝;输出必须是标量;wrt 和输出无关时,grad 报 inactive_wrt_error 造的 Validation 错误,grad_optional 返回 None。最后一种在 0.7.1 是 Error::Internal("grad output is inactive for User { id: 2 }"),按 Internal 去 match 的代码升级后会落空(修复见 primitive AD 规则 里 #1961 那个框)。

输出先编成程序,再变换

vjp_many_optional_impl 是反向模式的主体:

    let requested_keys = wrts
        .iter()
        .map(|wrt| leaf_input_key(wrt))
        .collect::<Result<Vec<_>>>()?;
    let mut compiler = GraphCompiler::new();
    let source = compile_ad_source(&mut compiler, output)?;
    let source_indices: Vec<_> = requested_keys
        .iter()
        .map(|key| source.input_key_index(key))
        .collect();
    let mut active_inputs = vec![false; source.input_count()];
    for source_index in source_indices.iter().flatten().copied() {
        active_inputs[source_index] = true;
    }
    if !active_inputs.iter().any(|active| *active) {
        return Ok(vec![None; wrts.len()]);
    }

    let active_outputs = vec![true; source.output_count()];
    let derivative = semantic_vjp_with_cache(
        source.frozen_program(),
        &active_inputs,
        &active_outputs,
        rules,
        ad_transform_cache,
    )?;

变换不直接在 computegraph 的图上做。它先用一个新的 GraphCompiler 把输出连同它依赖的整张图编成 CompiledGraph,取出里面的 FrozenProgram:一个 SSA 形式的语义程序,输入有序,每个值带 dtype 和形状元数据,叶子的数据作为绑定(bindings)挂在程序上。wrt 叶子的输入键映射成程序输入的下标,拼成活跃掩码 active_inputs。一个叶子不在程序里(和输出无关)时,它的下标是 None,对应的结果也是 None。

semantic_vjp:倒着走一遍程序

semantic_vjp_with_saved_outputs 先把源程序原样导入一个新的 SemanticProgramBuilder,再给每个活跃输出加一个余切种子输入,然后倒序遍历源程序的算子:

    for operation in operations.into_iter().rev() {
        let cotangent_outputs: Vec<_> = operation
            .outputs()
            .iter()
            .map(|value| {
                cotangents
                    .get(value)
                    .copied()
                    .map_or(AdValue::Absent, AdValue::Value)
            })
            .collect();
        if cotangent_outputs
            .iter()
            .all(|value| matches!(value, AdValue::Absent))
        {
            continue;
        }
        let active_operation_inputs: Vec<_> = operation
            .inputs()
            .iter()
            .map(|value| forward_active.contains(value))
            .collect();
        if active_operation_inputs.iter().all(|active| !active) {
            continue;
        }
        let cotangent_inputs = match operation.op() {
            SemanticOpRef::Extension(op) => {
                // ... 扩展运算族:查规则集
            }
            SemanticOpRef::Core(op) => vjp_core(
                op,
                &mapped_values(operation.inputs(), &values),
                &mapped_values(operation.outputs(), &values),
                &cotangent_outputs,
                &active_operation_inputs,
                &mut builder,
            )?,
            // ...
        };
        for (source, cotangent) in operation.inputs().iter().copied().zip(cotangent_inputs) {
            if let AdValue::Value(cotangent) = cotangent {
                accumulate_cotangent(&mut builder, &mut cotangents, source, cotangent)?;
            }
        }
    }
  • 两次剪枝。 输出没有余切的算子跳过(它不通向被求导的输出);输入都不「前向活跃」的算子也跳过(它不依赖任何 wrt)。forward_active 由 requested_input_reachability 从活跃输入顺着程序正向传一遍得到。
  • 程序是 SSA,没有拓扑排序。 语义程序的算子本来就按定义先后排列,倒序遍历就是逆拓扑序,不需要 candle 那样先做一遍拓扑排序(见 backward 与 GradStore)。
  • 累加也是程序里的节点。 一个值被用了几次,accumulate_cotangent 就往程序里加几个 CoreSemanticOp::Add,执行时才真的相加。

最后 finish_derivative 只把活跃输入的余切设为输出,再做两遍清理:prune_dead_derivative_operations 从输入和输出重新导入一次,没被输出用到的算子(比如前向最后那个 ReduceSum)就丢掉;cancel_double_neg_derivative_operations 把相邻的两个 Neg 抵消。返回的 SemanticAdProgram 记下两张映射:源程序第几个输出的种子在导数程序的第几个输入,源程序第几个输入的余切在导数程序的第几个输出。

再包成 TracedTensor

导数程序还不能直接返回给用户,用户手里要的是一个能继续参与运算、能再求导的 TracedTensor。derivative_traces_from_frozen_program 把语义程序逐个算子搬回一张 computegraph 图,关键在输入键:

    for (input_index, input) in frozen.program.inputs().iter().copied().enumerate() {
        let key = if input_index < source.input_keys().len() {
            source.input_keys()[input_index].clone()
        } else {
            allocate_input_key()
        };
        let local = builder.add_input(key.clone());
        value_map.insert(input, local);
        input_keys.push(key);
    }

前面那些输入(源程序的 primal 输入)沿用原叶子的输入键,只有种子拿新键。种子的数据和叶子的数据一起放进 inputs_map。所以导数 dx 和 x 在同一个键空间里:对 dx 再求一次关于 x 的导数时,leaf_input_key(x) 仍然能在 dx 的图里找到对应的输入。高阶导数就是这么来的。返回的 TracedTensor 的 data 是 None:它只是图,求值要自己 compile,再 prepare、run。

导数程序有多大

实验 traced_ad 第 1 段,L(A, B) = sum((A·B) ⊙ (A·B)),A 是 2×3,B 是 3×2,打印各个程序编译后的输入数和算子序列:

== 1. program sizes
L          inputs=2 ops= 3: DotGeneral Mul ReduceSum
dL/dA      inputs=3 ops= 5: BroadcastInDim DotGeneral Mul Add DotGeneral
vjp_many A inputs=3 ops= 5: BroadcastInDim DotGeneral Mul Add DotGeneral
compile_many(dA, dB) ops=6: BroadcastInDim DotGeneral Mul Add DotGeneral DotGeneral
raw vjp_program(A) inputs=3 ops=6: DotGeneral BroadcastInDim Mul Mul Add DotGeneral
  • 导数程序自带前向。 dL/dA 里第一个 DotGeneral 重算 C = A·B:Mul 的反向要用 C,而导数程序是独立执行的,前向的结果不会被保存下来给它用。输入从 2 个变成 3 个,多出来的是种子。
  • 变换的原始输出比编译后多一个算子。 raw vjp_program 是直接调 AdContext::vjp_program 拿到的变换结果,C ⊙ C 的两个操作数是同一个值,vjp_core 给两边各发一个 Mul(ct, C),再 Add;两个 Mul 完全相同,编译后合成了一个。
  • 两个梯度一起编译时共用前向。 dA 和 dB 分开编译各 5 个算子,compile_many 一起编译只有 6 个:重算的 C、广播的种子、Mul、Add 都只算一次,多出来的只是 B 那一边的 DotGeneral。

这和 eager AD 不同:eager 的前向已经执行过,反向程序会把前向留下的残差当输入,而不是重算。

jvp:正着走一遍

前向模式在 semantic_jvp 里,结构和反向对称:给每个活跃输入加一个切向量输入,正序遍历,核心算子走 linearize_core,扩展算子走规则集的 linearize_operation。入口 jvp_many_optional_impl 有两条反向没有的检查:

        if !seen.insert(key.clone()) {
            return Err(Error::invalid_argument(
                "jvp",
                ErrorPhase::GraphBuild,
                "wrt_tangents",
                "wrt_tangents contains duplicate wrt leaves",
            ));
        }
        let tangent_data = tangent.attached_value().cloned().ok_or_else(|| {
            Error::invalid_argument(
                "jvp",
                ErrorPhase::GraphBuild,
                "tangent",
                "jvp tangent must have concrete tensor data",
            )
        })?;

jvp_many 把所有方向的贡献加成一个方向导数,同一个叶子给两个切向量有歧义,直接拒绝;切向量必须带数据(反向的余切种子也一样,vjp_many_optional_impl 开头就查「vjp cotangent must have concrete tensor data」),因为它会作为绑定挂进导数图的 inputs_map,不能是一个等运行时再喂的占位输入。实验第 3 段的方向导数和中心差分、和 ⟨grad, dA⟩ 都对得上(数字见 primitive AD 规则)。

多个输入:一次变换

AdContext::vjp_many(&l, &[&a, &b], &ct) 对两个叶子一次求出两个余切。上游的设计文档 multi-input-traced-ad.md 写的要求是「Compile output once」「Call semantic_jvp_with_cache or semantic_vjp_with_cache exactly once」,代码就是上面那段:所有 wrt 合成一个 active_inputs 掩码,变换一次。重复的 wrt 在结果里重复出现(output_slots 去重后再展开),无关的叶子是 None。实验里 vjp_many 的 A 那一份和单独 grad 的结果逐位相同,编译出的程序也一样(上面的 vjp_many A 一行)。

单独对 a、b 各调一次 grad,会编译两次源程序、变换两次;vjp_many 编译一次、变换一次,再把两个导数一起 compile_many,前向重算也只做一次。

高阶导数:jvp(grad(f))

因为导数仍是 TracedTensor,而且沿用原叶子的输入键,高阶导数就是变换的组合。上游测试 hvp.rs 主要用前向套反向(forward-over-reverse):先 grad 再 jvp,得到 Hessian-向量积(HVP),和 JAX 的标准写法一样。实验第 4、5 段:

// f(x) = sum(tanh(M.x)); x is 3x1
let f = f_tanh(&m, &x)?;
let g = f.grad(&x)?;
let hv = g.jvp(&x, &v)?;
// 对照:在 x ± 1e-5·v 处各求一次梯度,中心差分

let cube = (&(&s * &s)? * &s)?; // s = 2.5
let d1 = cube.grad(&s)?;
let d2 = d1.grad(&s)?;
let d3 = d2.grad(&s)?;
f          inputs=2 ops= 3: DotGeneral Tanh ReduceSum
grad f     inputs=3 ops= 9: BroadcastInDim Constant BroadcastInDim DotGeneral Tanh Mul Sub Mul DotGeneral
hvp     = [-0.9930593881003207, 1.1987671592011429, -0.2074128493294373]
hvp_fd  = [-0.993059387921491, 1.1987671584723358, -0.20741284927350276]
max |hvp - fd| = 7.288e-10
== 5. second derivative of x^3 at 2.5 by grad(grad)
f' = [18.75], f'' = [15.0], f''' = [6.0]
x^3        inputs=1 ops= 2: Mul Mul
f'         inputs=2 ops= 6: Mul Mul Mul Mul Add Add
f''        inputs=3 ops=10: Add Mul Mul Mul Mul Add Mul Mul Add Add
f'''       inputs=3 ops=10: Add Mul Mul Mul Add Mul Add Mul Mul Add

HVP 与梯度的有限差分差 7.3e-10。grad f 里的 Constant 和 Sub 来自 tanh 的反向系数 1 - tanh²(vjp_core 里 Tanh 一支用 one_like 造常数 1)。grad 套三次得到 x³ 在 2.5 处的三阶导数 6,二阶导数 15 = 6 × 2.5。在这个例子里程序没有随阶数变大:f'' 和 f''' 都是 10 个算子。eager 那边同样能这样组合,见 eager AD。

❗IMPORTANT
动脑筋:HVP 程序 hv 编译出来有几个输入?比 grad f 多几个算子?

grad f 有 3 个输入(M、x、种子)、9 个算子。想一想 jvp 给程序加了什么输入,tanh 的反向系数 1 - tanh² 再线性化一次会多出什么。

变换的代价和缓存

TracedTensorAdExt 不缓存,AdContext 有一个有界的变换缓存(默认上限 128 条、64 MiB,transform_cache.rs)。缓存键是 SemanticAdTransformCacheKey:

pub(crate) struct SemanticAdTransformCacheKey {
    kind: SemanticAdTransformKind,
    input_fingerprint: SemanticFingerprint,
    input_metadata: Box<[ProgramValueMetadata]>,
    active_inputs: Box<[bool]>,
    active_outputs: Box<[bool]>,
}

键里只有程序的结构指纹、输入元数据和两张掩码,没有数据,也没有 engine:变换的结果与在哪台设备上执行无关。命中时 with_input_prefix_bindings_from(source) 把这一次的叶子数据重新绑上去。所以数据变了、结构没变的图(训练循环里每一步都是这种)能命中。实验 traced_cache(release)每轮新建一个结构相同、数据不同的图:32×32 的 W,五层 matmul + tanh,平方和;每轮先调 loss.grad(&w),再调 ad.grad(&loss, &w),再编译后者;50 轮。连跑三次,负载均值约 7,三次的中位数相差不到 3 µs,下面是第三次:

TracedTensorAdExt::grad  first    370.8us  later min   156.0us median   175.3us
AdContext::grad          first    261.7us  later min    77.5us median    86.0us
compile(grad)            first     95.0us  later min    67.7us median    75.5us
AdContextCacheStats { ad_transforms: CacheStats { entries: 1, retained_bytes: 1155, hits: 49, misses: 1, evictions: 0, clears: 0 } }

第一轮两者都明显更慢,含首次运行的开销(三次里第一轮的 TracedTensorAdExt::grad 在 344 到 795 µs 之间跳动)。之后两者的差就是变换本身:不缓存的中位数约 175 µs,命中缓存约 86 µs。命中时仍要做两步:compile_ad_source 把源图编成程序(拿指纹当键),以及把导数程序转回 TracedTensor。49 次命中、1 次未命中,缓存只占一条、1155 字节:缓存的是程序,不是张量。这些时间都在建图阶段,和执行无关;执行的开销见 执行。

📝NOTE
冷知识:变换缓存为什么挂在 AdContext 上

PR #1312(「Finish AD transform cache and DCE」,2026-07-06 合并,首次发布于 v0.3.0)之前,每次 grad / vjp / jvp 都从头做一遍变换。这个 PR 的几个提交依次是「Add shared AD transform cache API」「Share AD transform cache with eager runtimes」「Cache traced AD transforms through AdContext」「Prune unused multi-output AD operation slots」。缓存的所有者定成了 AdContext,而不是一个全局表:上游 ad-pipeline.md 的「Cache Ownership」一节写「direct traced helper methods remain stateless unless the caller supplies an AdContext」,并要求缓存条目「must not retain tensor buffers」。当时的变换还跑在外部 AD 引擎上;三周后 PR #1453 换成语义程序变换,缓存键随之换成上面这个语义指纹,所有权的规定没变。

动脑筋答案

grad f     inputs=3 ops= 9: BroadcastInDim Constant BroadcastInDim DotGeneral Tanh Mul Sub Mul DotGeneral
hvp        inputs=4 ops=14: DotGeneral Constant BroadcastInDim DotGeneral Tanh Mul Sub Mul Mul Add Neg BroadcastInDim Mul DotGeneral
  • 4 个输入:M、x、grad 的种子,加上 jvp 的切向量 v。种子在 grad 时作为绑定进了 inputs_map,到 HVP 里仍是输入,不会被折成常量。
  • 多 5 个算子:DotGeneral 从 2 个变成 3 个,多的是 M·v,即 M·x 对 x 的线性化;Mul 从 2 个变成 4 个,又多了一个 Add 和一个 Neg,它们来自系数 1 - tanh² 的线性化 -(dt·t + t·dt)(两个相同的 Mul 编译后合成一个)。原 grad f 里「种子 × 系数」的那个 Mul 只服务于一阶梯度,HVP 用不到,被剪掉。前向的 M·x、tanh 只算一次,被两条链共用。
  • 反向套反向也能求 HVP:上游 hvp.rs 的 hvp_ror_quadratic_form 写的是 grad(dot(grad, v), x),和前向套反向的结果对照。

小结

  • TracedTensorAdExt 和 AdContext 走同一个变换:前者用空规则集、不缓存;后者带运算族规则和有界缓存。grad 是种子为 1 的 vjp,要求输出是标量、wrt 是叶子;wrt 与输出无关时报 Validation(0.7.1 是 Internal)。
  • 变换的对象是语义程序:先 compile_ad_source 把输出编成 FrozenProgram,wrt 变成活跃掩码,semantic_vjp 倒序遍历 SSA 程序、累加也写成 Add 节点,最后剪掉死代码、抵消双重 Neg。变换不碰 engine。
  • 导数程序被搬回 computegraph 图,沿用原叶子的输入键,所以导数还能再求导;jvp(grad(f)) 的 HVP 与有限差分差 7.3e-10,grad 套三次得到三阶导数。
  • 导数程序是独立执行的,自带前向重算;多个梯度用 vjp_many 一次变换、compile_many 一起编译,前向只算一次。
  • 变换缓存按结构指纹和掩码做键,不含数据和 engine;结构相同的图命中后变换从约 175 µs 降到约 86 µs。

💬 关联讨论

发起讨论
加载中...