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 id | family 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 留得就少;规则想读一个没留的值,变换时就报错,而不是在执行时拿到一个不存在的张量。
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 逐位相同:同一条规则、同一个变换。
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 拒绝。