Rust in Everything
基于 tenferro-runtime 0.8.0

semantic AD 与扩展

主线位置:traced 主线和 eager 主线在运算族上的分岔,也是 einsum 线的最后一步(einsum 的 AD 规则)。primitive 规则 只覆盖核心算子;einsum、svd、inv、fft 在程序里是扩展算子(StdTensorOp::Extension),它们的求导规则由各自的 crate 提供,注册进一个规则集,再交给 AdContext 或 EagerRuntime。规则本身的数学见 einsum、线性代数、FFT;自己写一个扩展算子见 扩展算子。sigmoid、softmax 这些 0.8.0 新加的组合运算不是扩展算子,不需要规则集。

flowchart TB
  fam["linalg einsum fft"] -->|semantic_ad_rules| set["SemanticExtensionRuleSet"]
  set --> ctx["AdContext"]
  ctx -->|grad vjp jvp| tr["traced 变换"]
  ctx -->|with_ad_context| er["EagerRuntime"]
  er -->|backward| tr2["同一个变换"]
  tr --> disp{"按 family_id 查"}
  tr2 --> disp
  disp -->|"有 primal_vjp"| pv["直接 VJP"]
  disp -->|"否则"| lt["linearize 再 linear_transpose"]

代码在 tenferro-ad 的 semantic_extension.rs(规则 trait、规则集、校验)、extension.rs(扩展算子的 eager 执行与记录)、context.rs(AdContext),以及各运算族 crate 里的 semantic_ad_rules()。semantic_extension.rs 在 v0.7.1 和 v0.8.0 之间没有改动。实验在 exp-tenferro8/ad-exp:semantic_ad、einsum_bcast、einsum_bcast3,CPU,CpuEngine::serial(),debug,M2 Max、macOS 27、rustc 1.99.0。

执行和求导分开注册

一个扩展算子是实现了 ExtensionOp 的类型:它报告自己的 family id、输入输出个数、怎么推断输出形状。trait 里没有任何求导方法,定义的末尾只有一行注释(ext_op.rs):

    // AD rules are registered separately in `tenferro-ad`.

于是一个运算族要被用起来,得注册两次,各走各的路:

执行求导
运算族提供extension_module::<E>(engine_id),由宏 define_extension_runtime! 生成semantic_ad_rules(),返回一个 SemanticExtensionRuleSet
交给谁Runtime::builder().install_extension_module(..);eager 第一次在某种 engine 上用到时自动安装AdContext::builder().with_semantic_extension_rules(..);eager 要在构造时用 EagerRuntime::with_ad_context(&ad)
按什么找engine id 加 family idfamily id

tenferro-internal-extension-macros 只有两个宏:派生宏 ExtensionFamilyId 生成 FAMILY_ID 常量(namespace.name.vN),define_extension_runtime! 生成执行模块、准备引擎和准备好的算子。两个都不碰求导;扩展算子的 AD 注册是各运算族手写的 semantic_ad_rules()。

family id 在注册规则时校验,is_valid_family_id 要求 <crate>.<op>.v<数字> 三段:

    let Some((prefix, version)) = family_id.rsplit_once('.') else {
        return false;
    };
    let Some(version) = version.strip_prefix('v') else {
        return false;
    };
    let Some((crate_name, op_name)) = prefix.split_once('.') else {
        return false;
    };

三个运算族的 id 是 tenferro-linalg.linalg.v1、tenferro.einsum.v1、tenferro-fft.fft.v1。一个运算族只有一个 id:linalg 的 Cholesky、LU、solve、SVD、QR、eigh 等 28 种运算(LinalgOp 枚举的 28 个变体,和 v0.7.1 一样)都是同一个 LinalgExtensionOp 的不同载荷,规则拿到算子后自己再按载荷分派。inv 没有自己的变体,下面的实验里它同样落在这个 family 上。

三种规则,一个规则集

规则分三种角色(SemanticAdRuleRole),各一个 trait:

/// Definitional JVP rule for one extension family.
pub trait SemanticLinearizeRule: Debug + Send + Sync + 'static {
    /// Return the versioned extension family handled by this rule.
    fn family_id(&self) -> &'static str;

    // ...
    fn linearize(
        &self,
        request: SemanticLinearizeRequest<'_>,
        builder: &mut SemanticProgramBuilder,
    ) -> Result<SemanticLinearizeResult, SemanticAdError>;
}

/// Transpose rule for an extension viewed as a linear map.
pub trait SemanticLinearTransposeRule: Debug + Send + Sync + 'static {
    fn family_id(&self) -> &'static str;

    /// Declare which primal input/output indices this rule reads as tensor
    /// residuals. Indices not declared may only be accessed through metadata.
    fn residual_mask(&self) -> ResidualSpec;

    // ...
    fn linear_transpose(
        &self,
        request: SemanticLinearTransposeRequest<'_>,
        builder: &mut SemanticProgramBuilder,
    ) -> Result<Box<[AdValue]>, SemanticAdError>;
}

/// Optional direct VJP rule expressed against primal semantic values.
pub trait SemanticPrimalVjpRule: Debug + Send + Sync + 'static {
    // family_id、residual_mask 同上
    fn primal_vjp(
        &self,
        request: SemanticPrimalVjpRequest<'_>,
        builder: &mut SemanticProgramBuilder,
    ) -> Result<Box<[AdValue]>, SemanticAdError>;
}

和核心算子的规则一样,它们只往 SemanticProgramBuilder 里加算子,拿到的 primal 是程序里的值(ProgramValue),不是张量,也不碰 engine。linearize 是定义性的(JVP 和默认的反向都从它来);linear_transpose 把线性化的结果当作线性映射转置;primal_vjp 是可选的捷径,直接对原算子写反向。

SemanticExtensionRuleSet 是三张按 family id 索引的表,每张包在 Arc 里,register_* 时 Arc::make_mut,克隆一个规则集很便宜(写时复制)。各运算族的 semantic_ad_rules() 就是往一个空集里注册自己的规则,例如 linalg 的:

pub fn semantic_ad_rules() -> Result<SemanticExtensionRuleSet, SemanticExtensionRegistryError> {
    SemanticExtensionRuleSet::new()
        .with_linearize(Arc::new(LinalgAdRule))?
        .with_linear_transpose(Arc::new(LinalgAdRule))
}

实验 semantic_ad 第 1 段打印三个运算族的规则集,把 linalg 和 einsum 合并,再合并一次 linalg:

== 1. family rule sets
SemanticExtensionRuleSet { linearize: ["tenferro-linalg.linalg.v1"], linear_transpose: ["tenferro-linalg.linalg.v1"], primal_vjp: [] }
SemanticExtensionRuleSet { linearize: ["tenferro.einsum.v1"], linear_transpose: ["tenferro.einsum.v1"], primal_vjp: ["tenferro.einsum.v1"] }
SemanticExtensionRuleSet { linearize: ["tenferro-fft.fft.v1"], linear_transpose: ["tenferro-fft.fft.v1"], primal_vjp: ["tenferro-fft.fft.v1"] }
merged: SemanticExtensionRuleSet { linearize: ["tenferro-linalg.linalg.v1", "tenferro.einsum.v1"], linear_transpose: ["tenferro-linalg.linalg.v1", "tenferro.einsum.v1"], primal_vjp: ["tenferro.einsum.v1"] }
merge again -> Err("semantic extension AD Linearize rule for family \"tenferro-linalg.linalg.v1\" is already registered")
default (TracedTensorAdExt) rule set: SemanticExtensionRuleSet { linearize: [], linear_transpose: [], primal_vjp: [] }

einsum 和 FFT 三种规则都有;linalg 没有 primal_vjp,反向一律走「先线性化再转置」。同一个 family 的同一个角色只能注册一次,merge 先在副本上逐条注册,全部成功才替换自己,失败时原集合不变。TracedTensorAdExt 用的默认规则集是空的。

linalg 的 LinalgAdRule 不是直接写在语义程序上的。它原本是一条图级规则,用的是 primitive 规则表 那套 PrimitiveRuleBuilder / ShapeGuardContext;semantic.rs 里的 SemanticLinearizeRule 实现先建一个 LegacyInvocation(把程序值登记成图键、补上形状元数据),再用一个 SemanticRuleBuilder 把图级规则发出的节点翻译成语义算子。这是规则表在生产代码里的第二个用途。

变换里怎么分派

traced 的变换 遇到扩展算子时查规则集。前向模式只用 linearize;反向模式在 semantic_vjp_with_saved_outputs 里二选一:

            SemanticOpRef::Extension(op) => {
                if rules.lookup_primal_vjp(op.family_id()).is_some() {
                    rules.primal_vjp_operation(
                        operation,
                        &mapped_values(operation.inputs(), &values),
                        &mapped_values(operation.outputs(), &values),
                        &cotangent_outputs,
                        &active_operation_inputs,
                        &mut builder,
                    )?
                } else {
                    let inactive_tangents = vec![AdValue::Absent; operation.inputs().len()];
                    let active_operation_outputs: Vec<_> = cotangent_outputs
                        .iter()
                        .map(|value| matches!(value, AdValue::Value(_)))
                        .collect();
                    let linearized = rules.linearize_operation(
                        operation,
                        &mapped_values(operation.inputs(), &values),
                        &mapped_values(operation.outputs(), &values),
                        &inactive_tangents,
                        &active_operation_outputs,
                        &mut builder,
                    )?;
                    rules.linear_transpose_operation(
                        operation,
                        &mapped_values(operation.inputs(), &values),
                        &mapped_values(operation.outputs(), &values),
                        &cotangent_outputs,
                        &active_operation_inputs,
                        linearized.residuals(),
                        &mut builder,
                    )?
                }
            }

没有直接 VJP 时,先以「所有切向量都缺席」调一次 linearize:目的不是求切向量,而是让规则把反向要用的残差发进程序(SemanticLinearizeResult 的 residuals);active_operation_outputs 告诉规则哪些输出真的有余切。上游 ad-pipeline.md 举的例子是:调用方只用奇异值时,SVD 的规则应当只发奇异值那一支导数,不建奇异向量的那串运算。然后把残差交给 linear_transpose。

*_operation 这几个方法在调规则前后都做校验:extension_for_dispatch 拒绝核心算子和声明了可观察副作用(effects)的扩展算子(EffectfulExtension,副作用不能被隐式地重放或求导);请求和结果的长度要等于算子的输入输出个数(Arity);规则返回的值必须属于当前这个 builder(ForeignValue);查不到规则是 MissingRule。规则写错不会悄悄丢掉一条导数,而是变成一个类型化的错误。

残差访问要先声明

转置和直接 VJP 的请求不给原始的 primal 列表,只给带检查的访问器。primal_input_value 最后落到 checked_primal_value:

    if index >= values.len() {
        return Err(SemanticAdError::PrimalIndexOutOfBounds {
            family_id,
            kind,
            index,
            len: values.len(),
        });
    }
    let declared = match kind {
        PrimalValueKind::Input => residual_mask.declares_input(index),
        PrimalValueKind::Output => residual_mask.declares_output(index),
    };
    if !declared {
        return Err(SemanticAdError::UndeclaredResidualValue {
            family_id,
            kind,
            index,
        });
    }
    Ok(values[index])

规则的 residual_mask() 是唯一的依据:没声明的输入只能经 primal_input_meta 读形状和 dtype。这和核心规则表的 debug_assert! 不同,是 release 下也生效的类型化错误。这张掩码还有第二个读者:eager 记录 每个扩展算子时,finish_residuals 按同一个 residual_mask() 决定留哪些前向张量(有 primal_vjp 就用它的掩码,否则用 linear_transpose 的)。规则声明得少,eager 留得就少;规则想读一个没留的值,变换时就报错,而不是在执行时拿到一个不存在的张量。

📝NOTE
冷知识:检查过的访问器替换了原始切片

PR #1714(「Add downstream extension, AD, diagnostics, and BLAS-1 APIs」,2026-08-19 合并,首次发布于 v0.4.0)之前,SemanticLinearTransposeRequest 和 SemanticPrimalVjpRequest 有 pub const fn primal_inputs(self) -> &'a [ProgramValue] 和 primal_outputs,规则可以随手读任何一个 primal,residual_mask 只是一句声明。这个 PR 里的「feat: enforce semantic AD residual access」删掉了这两个方法,换成 primal_input_value(index) / primal_output_value(index)。设计文档 checked-semantic-ad-residual-access.md 写明了取舍:「Do not leave deprecated raw-value shims」「No panic/debug-assert enforcement」,越界检查先于掩码检查;SemanticLinearizeRequest 保留原始访问,因为线性化正是计算残差的阶段。迁移覆盖了 einsum、FFT、linalg 以及 ext/ 下的 sparse、tropical。

同一组规则,traced 和 eager 共用

规则集通过 AdContextBuilder::with_semantic_extension_rules 进入 AdContext(内部就是一次 merge,所以同样拒绝重复注册),AdContext 的 grad / vjp / jvp 把它传给变换。eager 那边,EagerRuntime::with_ad_context 克隆同一个规则集,并共享 AdContext 的变换缓存:

    pub fn with_ad_context(ad: &AdContext) -> Result<Arc<Self>> {
        Ok(Arc::new(Self::with_rules_and_cache(
            ad.semantic_extension_rules().clone(),
            ad.ad_transform_cache(),
        )?))
    }

它不再带后端参数(0.7.1 叫 with_cpu_backend_and_ad_context(backend, &ad)):eager 运行时不持有 engine,规则集也和 engine 无关(见 eager AD)。eager 的 backward 最后调用的是 AdContext::vjp_program,和 traced 的 grad 是同一个 semantic_vjp。所以一个运算族只写一套规则,两种模式都能用。规则集是构造时定下的:EagerRuntime 没有事后追加规则的方法。

实验 semantic_ad 第 2、3 段,A = [[4, 1], [1, 3]],损失是 sum(inv(A)),traced 和 eager 各算一次:

let (a, l) = inv_loss(&A)?;                      // traced: a.inv()?.reduce_sum(None)
match l.grad(&a) { /* 打印 Ok 或 Err */ }        // TracedTensorAdExt:空规则集
let ad = AdContext::builder().with_semantic_extension_rules(all.clone())?.build()?;
let g = ad.grad(&l, &a)?;                        // 带 linalg + einsum 规则
// eager:同一个 ad 交给 EagerRuntime
let with = EagerRuntime::with_ad_context(&ad)?;
let y = with.variable_from(Tensor::from_vec_col_major(vec![2, 2], A.to_vec())?)?;
let ly = with.with_eager_session(&mut engine, |s| {
  let i = s.inv(&y)?;                            // EagerSessionLinalgExt
  s.reduce_sum(&i, None)
})?;
ly.backward(&mut engine)?;
== 2. traced: inv(A) summed, d/dA
TracedTensorAdExt::grad: Err(unsupported vjp AD rule for tenferro-linalg.linalg.v1)
  UnsupportedAdRule { transform: "vjp", op: "tenferro-linalg.linalg.v1" }
AdContext(linalg) grad = [-0.033057851239669415, -0.04958677685950412, -0.049586776859504134, -0.07438016528925619]
finite difference      = [-0.033057851234197244, -0.049586776851295866, -0.04958677690680702, -0.07438016533245495]
max err = 4.730e-11
== 3. eager: same op, runtime without / with the rule set
...
with AdContext(linalg+einsum): y.grad = Some([-0.033057851239669415, -0.04958677685950412, -0.049586776859504134, -0.07438016528925619])

traced 的执行要在 Runtime 上装 linalg 的执行模块(install_extension_module),求导要在 AdContext 里放 linalg 的规则,两件事缺一不可。带规则时梯度和中心差分差 4.7e-11,eager 的结果与 traced 逐位相同:同一条规则、同一个变换。

❗IMPORTANT
动脑筋:同一个 sum(inv(A)),用 EagerRuntime::new() 建的变量调 backward(&mut engine),会怎样?如果报错,报的是缺哪个角色的规则?

new() 的规则集是空的。再看一眼上面反向分派的代码:linalg 没有 primal_vjp。

einsum 在 eager 下多半不走扩展算子

EagerRuntime::new() 能对 einsum 求导吗?实验第 4 段在两种运行时上各算一次三操作数的 "ij,jk,kl->il" 和两操作数的 "ij,jk->ik"(eager einsum 是 session 上的 EagerSessionEinsumExt::einsum):

== 4. eager einsum, 3 operands and 2 operands
EagerRuntime::new(): a.grad = Some([77.0, 77.0, 97.0, 97.0, 117.0, 117.0])
EagerRuntime::new(): 2-operand a.grad = Some([15.0, 15.0, 19.0, 19.0, 23.0, 23.0])
with AdContext: a.grad = Some([77.0, 77.0, 97.0, 97.0, 117.0, 117.0])
with AdContext: 2-operand a.grad = Some([15.0, 15.0, 19.0, 19.0, 23.0, 23.0])

没有规则集也能求导,因为 eager 的 einsum 在多数情况下根本不产生扩展算子。einsum_subscripts_with_broadcast 先试两条捷径:两个操作数能直接写成一次 dot_general 的(try_direct_binary_dot_general),就调 dot_general;不需要广播的,try_expand_eager_einsum 把整个收缩展开成一串核心算子逐个执行。核心算子的规则总在,所以这两条路不需要 einsum 的规则。只有 requires_broadcast 发现同一个下标一边长度为 1、另一边不是 1 时,才建 EinsumExtensionOp,这时才要规则集。

这条扩展路径在 0.8.0 上有两个问题。实验 einsum_bcast 用 a 形状 [2, 1]、b 形状 [3, 2],j 一边是 1、一边是 3,前向的 loss = 63:

EagerRuntime::new() ij,jk->ik loss=[63.0] backward: Err("semantic_eager_vjp (GraphBuild): runtime state failure: semantic extension AD failed: semantic extension family \"tenferro.einsum.v1\" has no Linearize AD rule")
== ij,jk->ik
  eager tracked: out.shape=[2, 2]
  a.grad shape=[2, 3] value=[5.0, 5.0, 7.0, 7.0, 9.0, 9.0]
  rt.grad shape=[2, 3] value=[5.0, 5.0, 7.0, 7.0, 9.0, 9.0]
  finite difference = [21.000000000270802, 21.000000000270802]
  eager untracked: out.shape=[2, 2] value=[6.0, 12.0, 15.0, 30.0]
  traced: Err(extension family "tenferro.einsum.v1" shape constraint at instruction None failed: Const(1) (1) Equal Const(3) (3))
  concrete: shape=[2, 2] value=[6.0, 12.0, 15.0, 30.0]
...
== ...j,jk->...k
  eager tracked: out.shape=[2, 2]
  a.grad shape=[2, 3] value=[5.0, 5.0, 7.0, 7.0, 9.0, 9.0]
  rt.grad shape=[2, 3] value=[5.0, 5.0, 7.0, 7.0, 9.0, 9.0]
  finite difference = [21.000000000270802, 21.000000000270802]
  eager untracked: out.shape=[2, 2] value=[6.0, 12.0, 15.0, 30.0]
  traced: shape=[2, 2] value=[6.0, 12.0, 15.0, 30.0]
  concrete: shape=[2, 2] value=[6.0, 12.0, 15.0, 30.0]
  • 梯度形状错,和 v0.7.1 完全一样。 前向是对的,但 backward 和函数式 grad 都返回了广播之后的 [2, 3] 梯度,没有沿被广播的轴求和回 [2, 1];正确值是有限差分给出的 [21, 21](5 + 7 + 9)。写成显式省略号 ...j,jk->...k 也一样。einsum 的 VJP 规则在广播情形下的问题,细节留给 einsum 页。
  • 三个面对广播的态度不一致。 eager 和 concrete 接受 ij,jk->ik 里的长度 1 广播,traced 不写省略号时在形状约束上拒绝,写了省略号才接受。

第二个问题在三操作数上。v0.7.1 时 eager 的 [&a, &b, &c].einsum("ij,jk,kl->il")(同样的 [2, 1] 广播)前向能算、只是梯度形状错;0.8.0 前向就失败,concrete 也一样(实验 einsum_bcast3,debug 和 release 相同):

concrete: Err(CPU einsum: backend failure: label 106 has extents 1 and 3)
eager: Err(CPU einsum: backend failure: label 106 has extents 1 and 3)
  Runtime(TensorRuntime(BackendSource { op: "CPU einsum", source: Shape(Extent { label: 106, first: 1, second: 3 }) }))

label 106 是字符 j 的码值。报错是 cpueinsum 0.9.0 的形状错误 ShapeError::Extent:0.8.0 的 CPU einsum 换成了单独发布的 cpueinsum(见 provider),它要求同一个标签在各操作数上长度相同,不做长度 1 广播。同样的广播在两操作数时能算,三操作数时整条收缩交给了 cpueinsum。

一个没有实现的桥

tenferro-internal-ops 里还留着一个隐藏的 trait ExtensionAdDispatcher,注释说「tenferro-ad owns the sole implementation」,它是图级规则表遇到扩展算子时转发给语义规则集的桥。上游 semantic-ad.md 也写「a small context-owned bridge still lets the core graph sweep call semantic extension rules」。在 v0.8.0 的仓库里仍然搜不到任何 impl ExtensionAdDispatcher for,ShapeGuardContext::with_extension_ad_dispatcher 也没有调用方:图级扫描已经不在主路径上(见 primitive AD 规则),这座桥只剩定义。

动脑筋答案

实验 semantic_ad 第 3 段:

EagerRuntime::new(): forward loss = [0.4545454545454545]
EagerRuntime::new(): backward Err(semantic_eager_vjp (GraphBuild): runtime state failure: semantic extension AD failed: semantic extension family "tenferro-linalg.linalg.v1" has no Linearize AD rule)
  RuntimeStateSource { op: "semantic_eager_vjp", phase: GraphBuild, source: Extension(MissingRule { family_id: "tenferro-linalg.linalg.v1", role: Linearize }) }
functional grad: Err(semantic_eager_vjp (GraphBuild): runtime state failure: semantic extension AD failed: semantic extension family "tenferro-linalg.linalg.v1" has no Linearize AD rule)
  • 报错,缺的是 Linearize,不是 PrimalVjp 或 LinearTranspose:反向分派先查 primal_vjp,查不到就退回「先线性化再转置」,第一步 linearize_operation 就找不到规则。
  • 前向的 s.inv(&x) 照常算出了结果(sum(inv(A)) = 5/11):规则集只在求导时才查。
  • 同一个缺规则,traced 报 Error::UnsupportedAdRule { transform: "vjp", .. },eager 报的是包着 MissingRule 的 Error::RuntimeStateSource。traced 的 semantic_transform_error 把 MissingRule 映射成 UnsupportedAdRule,eager 的 semantic_eager_vjp_many 直接用 runtime_state_source 包装。EagerTensor::backward 的文档写的是「[Error::UnsupportedAdRule] when a graph operation lacks a reverse rule」,按这个文档去 match 错误类型会落空。这一处和 v0.7.1 完全相同。

小结

  • 扩展算子的 trait 里没有求导方法。运算族注册两次:执行模块(define_extension_runtime! 生成,装进 Runtime),求导规则(手写的 semantic_ad_rules(),放进 AdContext);扩展宏只管 family id 和执行,不管 AD。组合运算(sigmoid、softmax 等)不是扩展算子,不需要规则集。
  • 规则分 linearize、linear_transpose、可选的 primal_vjp 三种,按 <crate>.<op>.v<N> 形式的 family id 存进写时复制的 SemanticExtensionRuleSet;同一角色重复注册报错。einsum、FFT 三种都有,linalg 只有前两种,且是图级旧规则加一层适配。
  • 反向分派:有 primal_vjp 就直接用,否则先以全缺席的切向量 linearize 发出残差,再 linear_transpose。请求只给检查过的 primal 访问器,没在 residual_mask 里声明的值读不到;同一张掩码决定 eager 留哪些张量。
  • AdContext 把规则集交给 traced 变换,EagerRuntime::with_ad_context 把同一组规则和变换缓存交给 eager;sum(inv(A)) 的梯度两边逐位相同,与有限差分差 4.7e-11。没有规则时 traced 报 UnsupportedAdRule,eager 报 RuntimeStateSource。
  • eager einsum 多数情况走 dot_general 或展开成核心算子,不需要规则;需要长度 1 广播时才走扩展算子,此时两操作数的梯度形状仍然错([2, 3] 而非 [2, 1]),三操作数在 0.8.0 上连前向都被 CPU provider 拒绝。

💬 关联讨论

发起讨论
加载中...