Rust in Everything
基于 tenferro-runtime 0.8.0

primitive AD 规则

主线位置:traced 主线和 eager 主线共用的底座。traced 主线的 y.grad(&x)、eager 主线的 loss.backward(&mut engine),最后都要对程序里的每个核心算子(primitive)套一条求导规则。本页讲这些规则写在哪、长什么样,以 mul、reduce_sum、dot_general 为例,再看 0.8.0 新加的 erf 和 sigmoid、softmax 这类组合运算在 AD 里是什么身份,最后用有限差分核对。规则怎么串成整张图的变换见 traced AD,einsum、线性代数这些运算族的规则见 semantic AD 与扩展。

flowchart TB
  comp["组合运算 sigmoid 等"] -->|分解成| op["核心算子 Mul Erf 等"]
  op --> reg["internal-ops 规则表"]
  op --> sem["semantic_transform"]
  reg -->|residual_mask| eager["eager 残差留存"]
  reg -->|"规则词汇"| linalg["linalg 适配层"]
  reg -.->|"cfg test"| tests["单元测试"]
  sem -->|linearize_core| jvp["jvp 导数程序"]
  sem -->|vjp_core| vjp["grad 与 vjp 导数程序"]

本页的源码在三个 crate 里:tenferro-internal-ops 的 src/ad/(规则表),tenferro-ad 的 src/semantic_transform.rs(变换实际调用的规则),tenferro-runtime 的 src/composite.rs(组合运算)。实验在 exp-tenferro8/ad-exp,prim_rules、traced_ad、nn_ad 三个程序,CPU(CpuEngine::serial()),debug,M2 Max、macOS 27、rustc 1.99.0。

两份规则,grad 走哪一份

上游的 docs/architecture/primitive-ad.md 写的是:「Core primitive AD rules are tenferro-owned. They live under tenferro-internal-ops/src/ad/ and are registered by PrimitiveOpKind.」这份文档在 v0.7.1 和 v0.8.0 之间一字未改。它描述的是一张真实存在的规则表,但 grad、vjp、jvp 并不调用它。核心算子的求导规则实际上写了两份:

tenferro-internal-ops/src/ad/tenferro-ad/src/semantic_transform.rs
改写的对象computegraph 的 GraphBuilder<StdTensorOp>语义程序 SemanticProgramBuilder(SSA)
入口linearize / transpose_rule,按 PrimitiveOpKind 查表linearize_core / vjp_core,对 CoreSemanticOp 做 match
反向怎么来先线性化,再转置线性化出来的算子直接对原算子写 VJP
规模(不含测试)registry.rs 加 8 个分类文件,6901 行semantic_transform.rs 加 4 个子模块,3466 行
生产代码里谁调用只有 primitive_residual_spec,供 eager 决定留哪些残差semantic_jvp / semantic_vjp,traced 和 eager 都走这里

在 tag 上搜这三个入口在规则目录之外的调用方,只有三处:

$ git grep -nE 'ad::linearize|ad::transpose_rule|primitive_residual_spec' v0.8.0 -- crates ':!crates/tenferro-internal-ops/src/ad'
v0.8.0:crates/tenferro-ad/src/semantic_transform.rs:647:        _ => tenferro_ops::ad::primitive_residual_spec(op).unwrap_or_default(),
v0.8.0:crates/tenferro-internal-ops/src/std_tensor_op.rs:487:        crate::ad::linearize(self, builder, primal_in, primal_out, tangent_in, ctx)
v0.8.0:crates/tenferro-internal-ops/src/std_tensor_op.rs:510:        crate::ad::transpose_rule(self, builder, cotangent_out, inputs.as_slice(), mode, ctx)

std_tensor_op.rs 那两处包在 #[cfg(all(test, feature = "autodiff"))] 里,只给单元测试用。规则表在生产里还有两个用途:它的 ResidualSpec 告诉 eager 端每个算子要留哪些张量(见 eager AD);它的类型 PrimitiveRuleBuilder、ShapeGuardContext 是 tenferro-linalg 旧规则的写法,linalg 通过一个适配层把它们接到语义程序上(见 semantic AD 与扩展)。

两份规则至今同步维护:0.8.0 加 erf 的提交 669f668c 在说明里写的是「both AD rule sets (d erf = 2/sqrt(pi) exp(-x^2))」,maximum / clamp 的修复(本页倒数第二节)也是两边各改一遍。下面各节先看规则表的写法(它把规则的形状讲得最清楚),再看 grad 实际跑的那一份。

📝NOTE
冷知识:规则表是怎么从主路径上下来的

PR #1453(「Execution engine provider U7-U8 semantic AD restart」,2026-07-26 合并,首次发布于 v0.3.0)之前,tenferro-internal-ops 依赖一个外部 AD 引擎 tidu(autodiff = ["dep:tidu"]),StdTensorOp 实现它的 Primitive trait,linearize / transpose_rule 两个方法就是转发到这张规则表,tidu 拿它们在 computegraph 的图上做扫描。这个 PR 里有一串提交「feat(ad): add semantic program jvp and vjp transforms」「feat(ad): linearize semantic core arithmetic」「feat(ad): transform semantic nonlinear reductions」……,在语义程序上把核心算子的规则重写了一遍;其中「Unification 7」一条写「Remove SemanticCompatDispatcher (tidu-semantic bridge for extension AD)」。合并之后 tidu 从依赖里消失,impl Primitive for StdTensorOp 只剩下测试里那个 jvp_rule / transpose_rule。规则表留了下来:它有自己的单元测试,后来又被 ResidualSpec 用上。

规则表的形状

PrimitiveAdRule 是每条规则要实现的 trait:

pub(crate) trait PrimitiveAdRule: Send + Sync {
    fn kind(&self) -> PrimitiveOpKind;

    /// Declare which primal inputs this rule's transpose reads as tensor
    /// residuals. Inputs not declared may only be accessed through metadata.
    fn residual_mask(&self) -> ResidualSpec;

    fn linearize(
        &self,
        op: &StdTensorOp,
        builder: &mut dyn PrimitiveRuleBuilder,
        primal_in: &[ValueKey<StdTensorOp>],
        primal_out: &[ValueKey<StdTensorOp>],
        tangent_in: &[Option<LocalValueId>],
        ctx: &mut ShapeGuardContext,
    ) -> ADRuleResult<Vec<Option<LocalValueId>>>;

    fn transpose_rule(
        &self,
        op: &StdTensorOp,
        builder: &mut dyn PrimitiveRuleBuilder,
        cotangent_out: &[Option<LocalValueId>],
        inputs: &[TransposeInputRef<'_>],
        mode: &OperationRole,
        ctx: &mut ShapeGuardContext,
    ) -> ADRuleResult<Vec<Option<LocalValueId>>>;
}

几个要点:

  • 规则不算数,只往图里加节点。 builder 只有一个方法 add_operation,返回新节点输出的 LocalValueId。primal 值以 ValueKey(图里的结构化身份)传进来,规则可以把它当输入接进新节点,但拿不到张量数据。形状和 dtype 从 ShapeGuardContext 查。
  • None 是符号零。 切向量(tangent)或余切向量(cotangent)槽位是 None,表示这条路上导数恒为零,规则直接返回 None,不造零张量。
  • 前向和反向是两件事。 linearize 产出 JVP 图;transpose_rule 只处理「已经线性化」的算子,它产出的节点角色是 OperationRole::Linearized { active_mask },掩码标出哪个输入是线性流、哪个是固定系数。反向模式 = 先 linearize 再对线性图转置(linearize-then-transpose),这是 JAX 的做法。

规则存在一张静态数组 PRIMITIVE_AD_RULES 里,下标就是 PrimitiveOpKind::as_index():

static PRIMITIVE_AD_RULES: [&'static dyn PrimitiveAdRule; PrimitiveOpKind::COUNT] = [
    &FunctionPrimitiveAdRule {
        kind: PrimitiveOpKind::Add,
        linearize: linearize_add,
        transpose_rule: transpose_add,
        residual_mask: ResidualSpec::none(),
    },
    // ...
    &FunctionPrimitiveAdRule {
        kind: PrimitiveOpKind::Erf,
        linearize: linearize_erf,
        transpose_rule: transpose_erf,
        residual_mask: ResidualSpec::input(0),
    },
    // ...
];

数组的长度写死成 PrimitiveOpKind::COUNT,tenferro-core-ops 的宏按变体个数算出它;v0.8.0 有 52 个条目,比 v0.7.1 多的一个就是 Erf。新增一个 primitive 而不写规则,这个数组字面量的元素个数就对不上,编译不过:erf 要进 primitive 集合,就得带上规则表里的这一条。顺序错了由 primitive_ad_rule 里的 debug_assert_eq!(rule.kind(), kind) 在调试构建里拦住。语义那一份没有这层保护:vjp_core 的 match 最后一支是 _ => return Err(unsupported_core(..)),漏写一个算子要到求导时才报错。

ResidualSpec:反向要留哪些张量

ResidualSpec 是两个 u64 位掩码,声明转置规则会把哪些 primal 输入 / 输出当作张量读(残差,residual);没声明的只能读形状和 dtype。反向要等前向结束才跑,残差就是前向得留到那时的数据,声明得越少越省内存。实验 prim_rules 第一段打印了九个算子的声明:

residual_spec Add        = Some(ResidualSpec { input_mask: 0, output_mask: 0 })
residual_spec Mul        = Some(ResidualSpec { input_mask: 18446744073709551615, output_mask: 0 })
residual_spec Exp        = Some(ResidualSpec { input_mask: 1, output_mask: 0 })
residual_spec Tanh       = Some(ResidualSpec { input_mask: 1, output_mask: 0 })
residual_spec Erf        = Some(ResidualSpec { input_mask: 1, output_mask: 0 })
residual_spec Maximum    = Some(ResidualSpec { input_mask: 18446744073709551615, output_mask: 0 })
residual_spec Clamp      = Some(ResidualSpec { input_mask: 18446744073709551615, output_mask: 0 })
residual_spec ReduceSum  = Some(ResidualSpec { input_mask: 1, output_mask: 0 })
residual_spec DotGeneral = Some(ResidualSpec { input_mask: 18446744073709551615, output_mask: 0 })

加法的导数不依赖任何值,什么都不留;乘法和 dot_general 的每一边都要用另一边的值(all_inputs(),掩码全 1);maximum、clamp 要比较各个输入才知道导数流向哪边,也全留;exp、tanh、erf 留输入。ReduceSum 的转置只需要输入的形状,却也声明了输入:输入形状在运行期才确定时(动态截断这类算子之后),广播回去要拿输入本身当形状来源,见下面的代码。

规则访问输入时经过 TransposeInputRef,它带着输入的序号和规则的掩码:

    fn check_declared(&self, op: &str, use_case: &str) {
        debug_assert!(
            self.mask.declares_input(self.index),
            "transpose rule {op} accessed input {} as {use_case} but its residual mask does \
             not declare input {}; declare it in the rule's residual mask",
            self.index,
            self.index
        );
    }

这是 debug_assert!:只在调试构建和测试里检查,release 构建不查。扩展运算族那一层的同类检查是类型化错误,release 下也生效,见 semantic AD 与扩展。

eager 端用这张掩码决定每一步留哪些值,但对 exp 和 tanh 有一处覆盖,eager_core_residual_spec:

// These direct semantic VJPs consume outputs, unlike the lower-level linear
// transpose adapters, whose residual contract exposes only their inputs.
pub(crate) fn eager_core_residual_spec(
    op: &tenferro_ops::std_tensor_op::StdTensorOp,
) -> crate::semantic_extension::ResidualSpec {
    use tenferro_ops::std_tensor_op::StdTensorOp;
    match op {
        StdTensorOp::Exp | StdTensorOp::Tanh => crate::semantic_extension::ResidualSpec::output(0),
        _ => tenferro_ops::ad::primitive_residual_spec(op).unwrap_or_default(),
    }
}

规则表说 exp、tanh 留输入(input_mask: 1),实际跑的语义规则却用输出 exp(x)、1 - tanh² 当系数,所以 eager 改留输出。两份规则在这里露出不一致,由一个手写的 match 补上。erf 不在这个 match 里:它的语义规则用的是输入 x(系数 2/√π · exp(-x²)),和规则表的声明一致。

mul

规则表里的 linearize_mul:d(x·y) = dx·y + x·dy,哪边的切向量是 None 就少一项:

    if let Some(dx) = tangent_in[0] {
        let dx = convert_linear_to_dtype(builder, dx, lhs_dtype, output_dtype);
        let rhs = convert_fixed_ref_to_dtype(
            builder,
            ValueRef::External(primal_in[1].clone()),
            rhs_dtype,
            output_dtype,
        );
        let term = builder.add_operation(
            StdTensorOp::Mul,
            vec![ValueRef::Local(dx), rhs],
            OperationRole::Linearized {
                active_mask: vec![true, false],
            },
        );
        terms.push(term[0]);
    }
    // ... dy 一项对称
    match terms.as_slice() {
        [] => vec![None],
        [only] => vec![Some(*only)],
        [lhs, rhs] => {
            let sum = builder.add_operation(
                StdTensorOp::Add,
                vec![ValueRef::Local(*lhs), ValueRef::Local(*rhs)],
                OperationRole::Linearized {
                    active_mask: vec![true, true],
                },
            );
            vec![Some(sum[0])]
        }
        _ => unreachable!("mul linearization creates at most two terms"),
    }

convert_*_to_dtype 处理两边 dtype 不同时的提升(比如 f32 * f64)。线性化出来的 Mul 节点带 active_mask: [true, false]:第一个输入是切向量(线性流),第二个是 primal 系数。transpose_mul 只认这种节点:

    let active_mask = match mode {
        OperationRole::Linearized { active_mask } => active_mask,
        OperationRole::Primary => return Ok(vec![None, None]),
    };
    // ...
    if active_mask[0] {
        let rhs = inputs[1].fixed_value("mul", 1)?;
        let rhs_conj = conjugate_primal_if_complex(builder, rhs, ctx)?;
        let rhs_conj = convert_fixed_ref_to_dtype(builder, rhs_conj, rhs_dtype, output_dtype);
        let out = builder.add_operation(
            StdTensorOp::Mul,
            vec![rhs_conj, ValueRef::Local(ct)],
            OperationRole::Linearized {
                active_mask: vec![false, true],
            },
        );
        // ...
    }

一个线性化后的 Mul 是「系数 × 线性流」,它的转置是「共轭系数 × 余切」。复数时取共轭,对应 tenferro 用实内积定义的伴随(见上游 docs/guides/complex-ad.md)。实验直接调用这两个公开函数(tenferro_ops::ad::linearize / transpose_rule),x1、x2 是两个 [3] 的 primal,打印规则往一张空图里加了什么:

== linearize mul, both tangents: returned [Some(4)]
  op0: Mul            inputs=["L0", "x2"] role=Linearized { active_mask: [true, false] }
  op1: Mul            inputs=["x1", "L1"] role=Linearized { active_mask: [false, true] }
  op2: Add            inputs=["L2", "L3"] role=Linearized { active_mask: [true, true] }
== transpose mul, active_mask [true,false]: returned [Some(1), None]
  op0: Mul            inputs=["x2", "L0"] role=Linearized { active_mask: [false, true] }
== transpose mul, role Primary: returned [None, None]

L0、L1 是两个切向量输入,L2–L4 是新节点的输出。对一个 Primary 角色的 Mul(原程序里的乘法)调转置,什么也不返回:规则表的反向只能经过 linearize 一步。

grad 实际跑的是 vjp_core 里的这一支,直接对原乘法写 VJP,一步到位:

        CoreSemanticOp::Mul => {
            let rhs_coefficient = conjugate_if_complex(builder, primal_inputs[1])?;
            let lhs_coefficient = conjugate_if_complex(builder, primal_inputs[0])?;
            let lhs = multiply_ad_value(builder, cotangent, rhs_coefficient)?;
            let rhs = multiply_ad_value(builder, cotangent, lhs_coefficient)?;
            vec![
                normalize_ad_value(builder, lhs, active_inputs[0], primal_inputs[0])?,
                normalize_ad_value(builder, rhs, active_inputs[1], primal_inputs[1])?,
            ]
        }

AdValue::Absent 在这一层扮演 None 的角色;normalize_ad_value 把不活跃的输入置成 Absent,把余切调整回输入的形状和 dtype。这一层不再先线性化,于是可以直接取前向输出当系数,比如 exp 的系数就是 exp(x) 本身,这正是上一节 eager_core_residual_spec 那段注释说的「These direct semantic VJPs consume outputs」。

📝NOTE
没有蠢问题

问: 既然两份规则公式一样,为什么不删掉一份?

答: 规则表现在还有生产用途:它的 residual_mask 是 eager 留存残差的依据,它的类型是 linalg 旧规则的写法。语义规则在 CoreSemanticOp 上做 match,没有 ResidualSpec 字段,所以 eager 端要从规则表借掩码,exp 这种两边对不上的情形就得靠 eager_core_residual_spec 手工覆盖。删掉哪一份,都要先把这两件事搬到另一份里。代价是每加一个 primitive(比如 erf)、每修一个规则 bug,都要改两遍。

reduce_sum

求和是线性的,linearize_reduce_sum 把同一个算子原样作用在切向量上(op.clone()):

    match tangent_in[0] {
        Some(dx) => {
            let out = builder.add_operation(
                op.clone(),
                vec![ValueRef::Local(dx)],
                OperationRole::Linearized {
                    active_mask: vec![true],
                },
            );
            vec![Some(out[0])]
        }
        None => vec![None],
    }

它的转置 transpose_reduce_sum_input 把余切广播回输入的形状,被求和掉的轴就是广播时新加的轴:

            let input_key = ValueRef::External(input.key().clone());
            let input_rank = ctx.rank_of(&input_key)?;
            let kept_dims = kept_dims(input_rank, axes);
            // ... 全部轴都被求和时,先 reshape 成标量
            let (shape, shape_sources) = input.shape_operand(input_rank, 1, ctx)?;
            let (op_inputs, active_mask) =
                linearized_inputs_with_inactive_shape_sources(cotangent, shape_sources);
            let out = builder.add_operation(
                StdTensorOp::BroadcastInDim {
                    shape,
                    dims: kept_dims,
                },
                op_inputs,
                OperationRole::Linearized { active_mask },
            );

shape_operand 先看元数据里有没有确切形状;有,就把形状写成常量表达式;没有(输入来自 DynamicTruncate、PadToMatch 这类运行期才知道大小的算子),就把输入本身接成 BroadcastInDim 的第二个输入,形状写成 InputDim { input_idx: 1, axis },运行时再读。这就是 ReduceSum 要声明输入为残差的原因。实验对 [2, 3] 沿轴 0 求和的转置:

== transpose reduce_sum axes [0] of [2,3]: returned [Some(1)]
  op0: BroadcastInDim { shape: [Const(2), Const(3)], dims: [1] } role=Linearized { active_mask: [true] }

形状已知,shape 是两个常量,没有接形状来源;dims: [1] 表示余切(长度 3)对应输出的第 1 轴。传进去的输入是 Linear { primal: None },规则只读了它的元数据,没有触发「linear-only 值不能当张量用」的错误。语义层的 vjp_core 里,ReduceSum 一支做的是同一件事:broadcast_ad_value_in_dim_to_shape 广播,再用 truncate_ad_value_to_dynamic_axes 处理动态轴。

dot_general

dot_general 是 tenferro 的通用收缩:两个操作数各列出收缩轴和批轴,剩下的是自由轴,输出按「批轴、左自由轴、右自由轴」排列。matmul 就是左 [1] 与右 [0] 收缩、没有批轴的特例。

linearize_dot_general 和乘法同构:d(A·B) = dA·B + A·dB,两项各是一个配置不变的 DotGeneral,再相加。难点在转置:G 是输出的余切,要从 G 收缩出与 A 同形的东西。transpose_dot_general 对每个活跃的一边算一份新配置,transpose_plan_for_lhs 是左边那份:

    let new_config = DotGeneralConfig {
        lhs_contracting_dims: ct_rhs_free_positions,
        rhs_contracting_dims: rhs_free.into(),
        lhs_batch_dims: (lhs_free.len() + rhs_free.len()..output_rank).collect(),
        rhs_batch_dims: config.rhs_batch_dims.clone(),
    };
    Ok((
        new_config,
        output_rank,
        rhs_rank,
        permutation_to_original_order(lhs_rank, &result_order),
    ))

G 里属于 B 的自由轴,和 B 自己的自由轴收缩掉;批轴照旧对齐。收缩的结果轴序是「A 的自由轴、A 的收缩轴、批轴」,和 A 原来的轴序不一定相同,所以最后按 permutation_to_original_order 补一个 Transpose(add_transpose_if_needed 在排列恰好是恒等时省掉它)。实验对 A[2,3]·B[3,4] 求两边的转置:

== transpose dot_general [2,3].[3,4]: returned [Some(1), Some(2)]
  op0: DotGeneral { config: DotGeneralConfig { lhs_contracting_dims: [1], rhs_contracting_dims: [1], lhs_batch_dims: [], rhs_batch_dims: [] } }
  op1: DotGeneral { config: DotGeneralConfig { lhs_contracting_dims: [0], rhs_contracting_dims: [0], lhs_batch_dims: [], rhs_batch_dims: [] } }

第一个是 G[2,4] 的第 1 轴与 B[3,4] 的第 1 轴收缩,得到 [2,3],即 G·Bᵀ;第二个是 A[2,3] 的第 0 轴与 G 的第 0 轴收缩,得到 [3,4],即 Aᵀ·G。两边的轴序都已经对,没有多出 Transpose。转置没有被写成一个独立的 Transpose 节点再乘,而是折进了收缩配置:后端拿到的仍是一次 dot_general。语义层的 dot_general_vjp 有自己的一份 dot_general_transpose_plan_for_lhs / _for_rhs,逻辑与此相同。

erf 是 primitive,sigmoid 不是

0.8.0 新加了一批神经网络常用的运算(运算本身、数值边界策略和融合内核见 神经网络运算)。对 AD 来说它们分成两类。

erf 是新的核心算子。 它有自己的 PrimitiveOpKind、规则表里的一条(上面数组里的 Erf)和语义规则里的一支。analytic_unary_coefficient 里 Erf 的系数:

        CoreSemanticOp::Erf => {
            // d erf(x) = 2/sqrt(pi) * exp(-x^2)
            let square = builder.add_op(CoreSemanticOp::Mul, &[primal_input, primal_input])?[0];
            let negated = builder.add_op(CoreSemanticOp::Neg, &[square])?[0];
            let gaussian = builder.add_op(CoreSemanticOp::Exp, &[negated])?[0];
            let scale = real_float_constant_like(
                builder,
                primal_input,
                std::f64::consts::FRAC_2_SQRT_PI,
                role,
            )?;
            builder.add_op(CoreSemanticOp::Mul, &[scale, gaussian])?[0]
        }

规则表那一份发出的节点是同一个公式,实验 prim_rules 对一个 [3] 的输入调 linearize(&StdTensorOp::Erf, ..):

== linearize erf: returned [Some(7)]
  op0: Constant       inputs=[] role=Primary
  op1: BroadcastInDim inputs=["L1", "x7"] role=Primary
  op2: Mul            inputs=["x7", "x7"] role=Linearized { active_mask: [false, false] }
  op3: Neg            inputs=["L3"] role=Linearized { active_mask: [false] }
  op4: Exp            inputs=["L4"] role=Linearized { active_mask: [false] }
  op5: Mul            inputs=["L2", "L5"] role=Linearized { active_mask: [false, false] }
  op6: Mul            inputs=["L6", "L0"] role=Linearized { active_mask: [false, true] }

常数 2/√π 先广播成 x 的形状(BroadcastInDim 的第二个输入 x7 是形状来源),x²、取负、exp、乘常数都是掩码全 false 的系数计算,只有最后一个 Mul 碰到切向量 L0。

sigmoid、silu、softplus、gelu、gelu_tanh、softmax、log_softmax、layer_norm、rms_norm 等是组合运算。 它们在 tenferro-runtime/src/composite.rs 里各写一次,写在一个小的 primitive 词汇 CompositeOps 上;traced、eager、concrete 三个面各实现一遍这个 trait。模块注释写明了 AD 怎么来:「AD of the traced and eager forms follows from the primitives' rules.」两份规则里都没有 Sigmoid 或 Softmax,求导就是对分解出来的 Exp、Select、Div、ReduceMax 等逐个套规则。例如 gelu:

pub fn gelu<O: CompositeOps>(ops: &mut O, x: &O::Value) -> Result<O::Value, O::Error> {
    let dtype = ops.dtype(x);
    require_real_float("gelu", dtype)?;
    let shape = ops.shape(x)?;
    let scaled = scale_by(ops, x, std::f64::consts::FRAC_1_SQRT_2, &shape)?;
    let erf = ops.unary(CompositeUnary::Erf, &scaled)?;
    drop(scaled);
    let one = splat(ops, dtype, 1.0, &shape)?;
    let gate = ops.binary(CompositeBinary::Add, &one, &erf)?;
    drop((one, erf));
    let gated = ops.binary(CompositeBinary::Mul, x, &gate)?;
    drop(gate);
    scale_by(ops, &gated, 0.5, &shape)
}

精确的 gelu 是 x/2 · (1 + erf(x/√2)):有了 erf 和它的导数规则,gelu 就不需要自己的规则。实验 nn_ad 第 1 段把各运算的前向程序和 sum(w ⊙ f(x)) 对 x 的梯度程序编出来(x、w 都是长度 4),节选:

erf        inputs=1 ops= 1: Erf
d erf      inputs=3 ops= 9: BroadcastInDim Mul Constant BroadcastInDim Mul Neg Exp Mul Mul
sigmoid    inputs=1 ops= 9: Neg Compare Constant BroadcastInDim Select Exp Select Add Div
d sigmoid  inputs=3 ops=24: Neg Compare Constant BroadcastInDim BroadcastInDim Mul Constant BroadcastInDim Select Exp Select Mul Add Mul Div Neg Div Select Add Mul Select Select Neg Add
gelu       inputs=1 ops=11: Constant BroadcastInDim Constant BroadcastInDim Mul Erf Add Mul Constant BroadcastInDim Mul
d gelu     inputs=3 ops=23: BroadcastInDim Mul Constant BroadcastInDim Mul Constant BroadcastInDim Constant BroadcastInDim Mul Erf Add Mul Mul Constant BroadcastInDim Mul Neg Exp Mul Mul Mul Add
softmax    inputs=1 ops=11: ReduceMax Constant Maximum BroadcastInDim Sub Exp ReduceSum Constant Maximum BroadcastInDim Div
d softmax  inputs=3 ops=45: BroadcastInDim Mul ReduceMax Constant Maximum BroadcastInDim Sub Exp ReduceSum Constant Maximum BroadcastInDim Div Compare Compare Mul Mul Div Neg ReduceSum Constant Select Constant Add Div Select BroadcastInDim Add Mul BroadcastInDim Compare Convert ReduceSum BroadcastInDim Div Compare Compare Neg ReduceSum Select Div Select BroadcastInDim Mul Add
layer_norm inputs=1 ops=12: ReduceSum Constant Mul BroadcastInDim Sub ReduceSumSquares Mul Constant Add Rsqrt BroadcastInDim Mul
d layer_norm inputs=3 ops=30: BroadcastInDim Mul ReduceSum Constant Mul BroadcastInDim Sub ReduceSumSquares Mul Constant Add Rsqrt BroadcastInDim Mul Mul ReduceSum Neg Add Div Mul Mul BroadcastInDim Mul Add Add Neg ReduceSum Mul BroadcastInDim Add
  • 编译出来的程序里没有组合运算的名字。 sigmoid 在程序里是 9 个核心算子:Compare、Select 实现「x > 0 时取 1/(1+e),否则取 e/(1+e),e = exp(-|x|)」这个不溢出的写法。
  • 组合运算的反向比手写规则大。 softmax 前向 11 个算子,梯度程序 45 个:ReduceMax 的反向要用 Compare 标出最大值的位置、Convert 成数再求和得到并列个数、按个数平分;给最大值和分母兜底、让全被屏蔽的切片不出 NaN 的两个 Maximum 也各要一套掩码。而 softmax 的数学导数本身与减去的最大值无关,手写一条 y ⊙ (g - sum(g ⊙ y)) 只要几个算子。这是「定义一次、三个面共用」换来的代价。
  • 梯度程序自带前向重算,和 traced AD 里讲的一样,梯度程序前面那一段就是重算的前向。

第 2 段对九个运算各做一次中心差分(ε = 1e-6),并在 eager 上用被跟踪的变量 backward 一次:

erf         max|traced-fd|=3.1e-11 max|eager-traced|=0.0e0 grad=[0.03567908676708881, -1.084134787104863, 2.256758334191025, 0.2974928931287344]
sigmoid     max|traced-fd|=4.7e-11 max|eager-traced|=0.0e0 grad=[0.04474393562109985, -0.24751657271185992, 0.5, 0.10695484826014721]
silu        max|traced-fd|=2.8e-11 max|eager-traced|=0.0e0 grad=[-0.012388246289742881, -0.4006626881451501, 1.0, 0.430551119171924]
softplus    max|traced-fd|=1.0e-10 max|eager-traced|=0.0e0 grad=[0.054727657141906895, -0.4501660026875221, 1.0, 0.34498724056380625]
gelu        max|traced-fd|=3.0e-11 max|eager-traced|=0.0e0 grad=[-0.03824075766899386, -0.34253175176580575, 1.0, 0.5099489218128948]
gelu_tanh   max|traced-fd|=2.6e-11 max|eager-traced|=0.0e0 grad=[-0.03831323794542993, -0.34254185080430055, 1.0, 0.5097918655286516]
softmax     max|traced-fd|=4.2e-11 max|eager-traced|=0.0e0 grad=[-0.013242187672278883, -0.29800356572608855, 0.3390214906371875, -0.027775737238820097]
log_softmax max|traced-fd|=3.6e-10 max|eager-traced|=0.0e0 grad=[0.20588318064150288, -1.345342531639056, 1.5781976793460415, -0.43873832834848847]
layer_norm  max|traced-fd|=7.9e-11 max|eager-traced|=0.0e0 grad=[0.13252838602003836, -1.762111528752393, 1.8216079804071303, -0.19202483767477602]

x = [-1.5, -0.2, 0, 0.8],w = [0.3, -1, 2, 0.5]。第三个分量 x = 0 正好落在 sigmoid / softplus 那个 Select 的分界上:sigmoid 的梯度是 2 × 1/4 = 0.5,silu、softplus、gelu 都是 2 × 1/2 = 1,分段写法在分界点上的导数是精确的。eager 和 traced 的结果逐位相同:两边记下的是同一串 primitive,走同一个变换。eager 不跟踪梯度时会走一条融合内核的捷径,那一条不产生 AD 记录,见 eager AD。

maximum、clamp:被屏蔽的那一边

maximum(x, y) 的导数流向较大的那一边,并列时两边各一半。语义规则 balanced_extrema_contribution 对每一边做一次:

    let AdValue::Value(active) = active else {
        return Ok(AdValue::Absent);
    };
    let zero = zero_constant_like(builder, active, role)?;
    let selected = builder.add_op(CoreSemanticOp::Select, &[self_eq_output, active, zero])?[0];
    let one = one_like(builder, active, role)?;
    let two = builder.add_op(CoreSemanticOp::Add, &[one, one])?[0];
    let half = builder.add_op(CoreSemanticOp::Div, &[selected, two])?[0];
    Ok(AdValue::Value(
        builder.add_op(CoreSemanticOp::Select, &[other_eq_output, half, selected])?[0],
    ))

self_eq_output 是「这一边等于输出」的掩码:等于就拿余切,不等于就拿 zero;另一边也等于输出(并列)时再除以 2。clamp 的 clamp_vjp 结构相同,五张比较掩码分给输入、下界、上界。关键是那个 zero:zero_constant_like 的注释写「A zero synthesized as x - x or x + (-x) evaluates to NaN when x is non-finite」,所以它发一个真正的 Constant 零再广播。实验 nn_ad 第 4 段让余切本身非有限:loss = sum(1 / maximum(x, lo)),x = [0, 0.5],lo = [-1, 1],第一个位置取的是 x = 0,1/y 在那里的余切是 -inf:

traced d/dx  = [-inf, 0.0]
traced d/dlo = [0.0, -1.0]
clamp d/dlo  = [0.0, -1.0], d/dhi = [0.0, 0.0]
eager  x.grad = [-inf, 0.0], lo.grad = [0.0, -1.0]
tie: d/dx and d/dlo of sum(maximum(x, lo)) at x = lo = [1, 1]
  [0.5, 0.5] [0.5, 0.5]

-inf 只流向被选中的 x,被屏蔽的 lo 在那个位置拿到 0;第二个位置取的是 lo = 1,d/dlo = -1/1² = -1。clamp(x, lo, [3, 3]) 的下界梯度相同,上界从没被选中,全是 0。并列时两边各 0.5。

📝NOTE
冷知识:屏蔽位置上的零曾经从余切里减出来

PR #1961(2026-10-03 合并,首次发布于 v0.8.0)之前,maximum / minimum / clamp 的掩码规则从余切本身造零:语义规则里是 builder.add_op(CoreSemanticOp::Sub, &[active, active]),select_ad_values 等辅助函数里是 x + (-x)。提交 755ae1d3 的说明写:「For a legitimate non-finite seed (for example differentiating through 1/y) that identity evaluates to NaN at the masked-out position instead of 0.0.」按旧写法,上面实验里 lo 第一个位置的「零」就是 -inf - (-inf)。修复在「both the StdTensorOp primitive rules and the semantic AD transform」里都换成 dtype 对应的 Constant 零,又是两份规则各改一遍。同一个 PR 的另一个提交 98319e01 把「wrt 与输出无关」从 Error::Internal 改成了 Validation 错误(见 traced AD)。

用有限差分核对

规则对不对,和数值导数比最直接。实验 traced_ad 用一个同时含 mul、reduce_sum、dot_general 的损失(f64):

// L(A, B) = sum((A.B) * (A.B)); A is 2x3, B is 3x2 (column-major data)
fn loss(a: &TracedTensor, b: &TracedTensor) -> R<TracedTensor> {
  let c = a.matmul(b)?;
  Ok((&c * &c)?.reduce_sum(None)?)
}
// 对 A、B 的每个元素:用 ±1e-6 扰动后的数据重建图、编译、prepare、run,中心差分,
// 与 l.grad(&a) 和 AdContext::vjp_many(&l, &[&a, &b], ..) 的结果比
grad A      = [5.07, -6.91, 4.24, -4.995, -15.719999999999999, 18.86]
vjp_many A  = [5.07, -6.91, 4.24, -4.995, -15.719999999999999, 18.86]
vjp_many B  = [10.75, 10.0625, -16.125, -4.35, -5.499999999999999, 6.524999999999999]
max |grad - fd| A = 2.162e-9, B = 2.313e-9
== 3. jvp vs directional difference
jvp_A      inputs=3 ops= 5: DotGeneral DotGeneral Mul Add ReduceSum
jvp = -13.575000000000, fd = -13.575000000543, <grad, dA> = -13.575000000000

12 个梯度分量的最大误差 2.3e-9,在 ε = 1e-6 中心差分的截断和舍入误差范围内。前向模式的 jvp 与方向差分差 5.4e-10,与 ⟨grad, dA⟩ 完全相同:前向和反向两条规则对得上。jvp_A 那一行是 JVP 程序:一个 DotGeneral 重算 primal C = A·B,一个是 dA·B,Mul 与 Add 来自 C ⊙ C 的线性化(dC·C + C·dC 两项相同,编译后合成了一个 Mul 再加自己),最后 ReduceSum。上游的正确性依据是同样的思路:docs/architecture/ad-pipeline.md 的「Golden Invariants」把数值 oracle 或有限差分覆盖列为 AD 规则的正确性门槛。

小结

  • 核心算子的求导规则写了两份:tenferro-internal-ops/src/ad/ 的规则表(图级 linearize / transpose_rule,长度为 COUNT 的静态数组,v0.8.0 有 52 条),和 tenferro-ad 的 linearize_core / vjp_core。grad、vjp、jvp 只走后者;上游架构文档说规则在 internal-ops,与代码不符。
  • 规则只往图或程序里加节点,不碰张量数据;None / Absent 是符号零。规则表的反向是先线性化再转置,转置只认 Linearized 节点;语义层直接写 VJP。
  • ResidualSpec 声明反向要留的张量:加法不留,乘法、dot_general、maximum、clamp 全留,exp / tanh / erf 留输入;eager 对 exp / tanh 改成留输出。
  • erf 是 0.8.0 新增的 primitive,两份规则各有一条;sigmoid、gelu、softmax、layer_norm 等是组合运算,没有自己的规则,求导就是对分解出的 primitive 逐个套规则;九个运算的梯度与有限差分差 1e-10 量级,softmax 的梯度程序有 45 个算子。
  • maximum / clamp 的掩码零是真正的 Constant,非有限的余切不会在屏蔽位置变成 NaN(#1961);mul、reduce_sum、dot_general 组成的损失梯度与中心差分最大差 2.3e-9。

💬 关联讨论

发起讨论
加载中...