扩展算子
主线位置:traced 主线和 eager 主线在运算族上的入口。核心算子是封闭的一张表(见 computegraph),einsum、线性代数、FFT 都以
StdTensorOp::Extension(Arc<dyn ExtensionOp>)进程序:traced 下extension::apply往图里加一个扩展节点,eager 下apply_eager在调用方借出的 engine 上立即执行并记录;执行靠装进运行时的扩展模块,求导靠单独注册的规则集。本页讲外部 crate 怎么自己加一个这样的运算族;规则集的分派见 semantic AD 与扩展,einsum 这个最大的例子见 einsum。0.8.0 新加的sigmoid、softplus、softmax等不是扩展算子,是核心算子拼成的组合运算,见 nn-ops。
flowchart TB op["ExtensionOp 载荷"] -->|"extension apply"| tg["traced 程序"] op -->|apply_eager| eg["eager 立即执行"] mac["define_extension_runtime"] --> mod["ExtensionModule"] mod -->|install_extension_module| rt["Runtime 或 EagerRuntime"] tg -->|"prepare 和 run"| rt eg --> rt rt -->|"family 和 engine id"| ex["execute_in_session"] ex --> eng["借来的 CpuEngine"] rules["规则集"] -->|AdContext| ad["grad vjp jvp"] ad --> tg ad --> eg
代码在 tenferro-internal-ops/src/ext_op.rs(ExtensionOp)、tenferro-internal-extension-macros(两个宏)、tenferro-runtime/src/extension.rs(traced 入口和对外门面)、tenferro-runtime/src/runtime/(扩展引擎、模块、准备)、tenferro-ad/src/extension.rs(eager 入口)。仓库 ext/ 下的例子都是 publish = false,没有发布到 crates.io,本页只读它们的源码。实验在 exp-tenferro8/ops1-exp 的 softplus_ext(release,CpuEngine::serial())和 exp-tenferro8/ops1-cfail(两个编译失败的宏调用,debug),M2 Max、macOS 27、rustc 1.99.0。
三份东西,三个消费者
一个运算族由三份互相独立的东西组成:
| 是什么 | 谁提供 | 谁用 | |
|---|---|---|---|
| 载荷 | 实现 ExtensionOp 的类型:身份、输入输出个数、怎么推形状 | 运算族 crate | 建图(traced)、立即执行(eager)、缓存键、AD 变换 |
| 执行模块 | 一个 ExtensionModule,里面是某个 family id 在某个 engine id 上的扩展引擎 | 运算族 crate,通常由 define_extension_runtime! 生成 | Runtime / EagerRuntime 的准备和执行 |
| 求导规则 | SemanticExtensionRuleSet 里按 family id 登记的规则 | 运算族 crate 手写 | AdContext,以及用它构造的 EagerRuntime |
三者之间只靠 family id 这个字符串关联,没有全局注册表:载荷不需要先登记就能放进图里,执行模块和规则要使用者显式装上。漏装任何一个,都要到用的时候才报错(见下面的实验)。
外部 crate 不必依赖任何 tenferro-internal-*:tenferro_runtime::extension 是对外的门面,把 ExtensionOp、ExtensionShapeContext、ExtensionFamilyId、SymDim、副作用和别名声明、ExtensionModule、ExtensionCacheStore、ExtensionExecutionContext 以及宏 define_extension_runtime(再导出在这一行)一起再导出。宏生成的代码写的是 tenferro_runtime::…、tenferro_tensor::… 这样的绝对路径,所以使用宏的 crate 要以原名直接依赖这两个包。
ExtensionOp:身份、元数据、两项声明
ext_op.rs 在 v0.7.1 和 v0.8.0 之间没有改动。trait 的方法分几组:
| 方法 | 默认实现 | 作用 |
|---|---|---|
family_id、payload_hash、payload_eq、clone_arc、as_any | 无 | 身份。Arc<dyn ExtensionOp> 要满足 computegraph 对算子的 Hash + Eq + Clone;两个扩展节点 family id 相同且载荷相等才算同一个算子(去重、程序指纹、缓存都用它) |
input_count、output_count | 无 | 固定的元数 |
infer_output_meta | 无 | 由输入的 dtype 和符号形状(SymDim)推出输出的;可以经 ExtensionShapeContext::require_equal 留下形状约束,留到编译或运行时核对 |
semantic_effects、semantic_aliases | Undeclared | 有没有可观察的副作用、输出是不是输入的视图或别名 |
lower_to_standard_ops | Unsupported | 展开成标准算子;只有 tenferro-xla 调用 |
prune_outputs | None | 只用到部分输出时换成一个更小的算子(编译时用,见 编译) |
scalar_identity | None | 载荷带外部定义的标量类型时给出跨进程稳定的名字(ext/df64-proof 用它) |
trait 末尾只有一行注释「AD rules are registered separately in tenferro-ad」。
两项声明的默认值不能用。semantic_effects 默认返回 ExtensionEffectDeclaration::Undeclared,文档注释写的是「The compatibility default is deliberately Undeclared, not pure」;把 traced 图编成语义程序时,extension_effects 对它直接报错:
fn extension_effects(op: &dyn ExtensionOp) -> Result<Vec<Effect>, ProgramBuildError> {
let family = op.family_id();
let effects = match op.semantic_effects() {
ExtensionEffectDeclaration::Undeclared => {
return Err(ProgramBuildError::UndeclaredExtensionEffects { family })
}
ExtensionEffectDeclaration::Declared(effects) => effects,
};
// ...
别名声明紧接着是同样的写法。理由是语义程序要能被去重、重排、求导,一个没说清楚有没有副作用的算子做不了这些;声明了副作用的算子,求导时又会被 EffectfulExtension 拒绝(见 semantic AD 与扩展)。纯函数式的运算族就写 Declared(&[]) 和 AllFresh,einsum、tropical 都是这样。tenferro_runtime::extension::apply 文档里的例子 IdentityExt 没写这两项,它只演示 apply,拿去编译会报同样的错(见下面的实验)。
family id 的格式是 <命名空间>.<名字>.v<版本>,注册规则时会校验。派生宏 ExtensionFamilyId 生成一个 FAMILY_ID 常量;不写 name 时用类型名的 snake_case,所以下面实验里的 SoftplusOp 得到的是 rie-demo.softplus_op.v1。
执行:define_extension_runtime! 生成什么
运行时那一侧的接口有四层:ExtensionModule::configure 往 ExtensionModuleRegistrar 里登记扩展引擎和规划配置;ExtensionEngine 按 family id 和 engine id 认领算子,在准备阶段 prepare 出一个计划;计划里的 PreparedOperationExecutor 在执行阶段被调用。准备和执行的全过程见 执行与缓存。运算族真正要写的只有最后一步的那个函数,其余由宏 define_extension_runtime! 生成。einsum 的调用:
define_extension_runtime! {
runtime = EinsumRuntime,
family_id = EINSUM_EXTENSION_FAMILY_ID,
op_type = EinsumExtensionOp,
execute_in_session = execute_einsum_extension_reads_in_session,
session_supported = einsum_session_supported,
}
宏展开成四个 pub(crate) 类型:扩展引擎 EinsumRuntime<B>、模块 EinsumRuntimeModule<B>、准备好的算子 EinsumRuntimePreparedOperation<B>(都对 B: Engine 泛型,B 是具体的 engine 类型,例如 CpuEngine)和一个空的规划配置 EinsumRuntimePlanningConfig,外加一个公开函数 extension_module::<B>(engine_id)。各部分做的事:
- 扩展引擎的
prepare把请求里的&dyn ExtensionOp向下转型成op_type,克隆一份存进准备好的算子;转型失败是WrongOperationFamily(报错里的expected写死成CoreCapabilityKind::Elementwise)。准备不看形状,不做任何规划。它的context_identity是ExecutionContextIdentity::of::<B>():模块和 engine 的类型绑定在一起。 - 执行有两条路线,必须且只能给一条:
- 会话路线(文档称「the preferred one」):
execute_in_session加session_supported,前者签名是fn(&Op, &mut dyn Engine, &mut ExtensionCacheStore, &[TensorRead<'_>])。生成的execute把运行时传来的类型擦除的上下文向下转型回B,当成&mut dyn Engine交给这个函数;supports_session返回session_supported::<B>(&op),为真时 eager 和调度器直接走execute_in_session。 execute_reads:签名是fn<B: Engine + ?Sized>(&Op, &[TensorRead<'_>], &mut ExtensionExecutionContext<'_, B>),生成代码把 engine 和扩展缓存包成ExtensionExecutionContext再调它。
- 会话路线(文档称「the preferred one」):
- 0.7.1 的
execute参数已经删除,写上就是编译错误。
ops1-cfail 里两个故意写错的调用(debug):
== both_routes
error: supply either execute_reads or execute_in_session, not both
--> src/bin/both_routes.rs:33:19
|
33 | execute_reads = reads,
| ^^^^^
...
== legacy_execute
error: unsupported define_extension_runtime argument "execute"
--> src/bin/legacy_execute.rs:33:3
|
33 | execute = reads,
| ^^^^^^^
einsum、linalg、FFT 都用这个宏,三个都走会话路线;ext/tropical、ext/sparse 没用宏,原因见最后一节。
0.7.1 的宏要求 execute_reads,生成的代码把后端(当时的 CpuBackend)向下转型后包进 ExtensionExecutionContext<'_, B: TensorBackend> 交给扩展,扩展可以直接在这个「owner」上运行运算,einsum 自己的执行函数还得先 with_backend_session 开会话。PR #1944(2026-09-29 合并,首次发布于 v0.8.0)里的提交 8468debd 改了这一点,提交说明:「execute_reads is now optional, and a family that registers the session route (execute_in_session + session_supported) instead gets an owner entry that forms the session itself and passes the extension nothing but &mut dyn BackendSession. Supplying both routes is rejected, and omitting both is rejected.」同一个提交删掉了 einsum、linalg、FFT 的 owner 路线,理由是「No operation runs on the owner through the extension path any more」,这是后来把后端换成调用方持有的 engine(#2209,BackendSession 改名 Engine)的前提。旧参数 execute 从「接受但不用」改成直接拒绝,是 #2209 里的提交 fa466499。
接进 traced 和 eager
0.8.0 没有后端会话了,engine 由调用方持有,以 &mut 借给每次执行(见 engine 与 threadteam 池)。扩展模块因此只认两样东西:engine 的类型(宏的 B)和 engine 的 id(extension_module 的参数)。
traced:tenferro_runtime::extension::apply(op, &[&x]) 检查输入个数,往 TracedTensor 的图里追加一个 StdTensorOp::Extension 节点,调 infer_output_meta 得到输出的元数据,返回新的 TracedTensor。执行前要在 Runtime 上登记 engine 的描述、装模块,执行时把 engine 借进去:
let mut engine = CpuEngine::serial();
let mut builder = Runtime::builder();
builder.register_engine(runtime_engine_registration(&engine)?)?;
builder.install_extension_module(extension_module::<CpuEngine>(runtime_engine_id()?)?)?;
let runtime = builder.build()?;
let prepared = runtime.prepare(&program, &[], &[engine.target()])?;
let outputs = runtime.run(&mut [&mut engine], &prepared, &[])?;
runtime_engine_id() 是 CPU engine 固定的 id tenferro-cpu.default.v1,表示「这个 family 的算子在 CPU engine 上执行」;单线程和多线程的 CpuEngine 共用这个 id 和同一个模块。
eager:apply_eager(&mut engine, op, &[&x]) 在 with_eager_session 里先试 try_prepared_eager_extension:EagerRuntime 自己不持有 engine,第一次见到一种 engine 时登记它的描述(同一个 id 换成另一种 engine 类型会被拒绝),再从配置快照里找到这个 family 在该 engine id 上的执行器,准备、执行(supports_session 为真就走 execute_in_session),跳过建程序、编译那一套;找不到才退回编一个单算子程序执行。执行完之后按规则的 residual_mask 留残差、记进 semantic trace(见 eager AD)。模块要先用 EagerRuntime::install_extension_module 装上;同一个模块实例重复安装直接返回,不改配置。
einsum、linalg、FFT 不要求用户装:它们的 eager 入口走 #[doc(hidden)] 的 apply_eager_with_targeted_extension_in_session,第一次用到时按 engine 的种类(EagerExtensionBackendKind,0.8.0 只剩 Cpu 和开了 cuda 时的 Cuda)造模块并装上。外部 crate 用公开的 apply_eager 加显式安装即可。
求导:规则集照 semantic AD 与扩展 注册。按变换需要的角色:
| 变换 | 需要的规则 |
|---|---|
jvp | linearize |
grad / vjp / eager backward | primal_vjp;没有它时要 linearize 加 linear_transpose |
| 再求一阶 | 规则发出的那些算子自己的规则 |
最后一行是规则写法上的一个选择:规则发出的如果全是核心算子,高阶导数自动有;如果发出新的扩展算子(tropical 就是这样),那些算子也要有执行模块和规则。
实验:一个 softplus
softplus(x) = ln(1 + eˣ),导数是 sigmoid(x),而 sigmoid(x) = exp(x − softplus(x)):把前向的输出 y 留作残差,导数只要 Sub、Exp、Mul 三个核心算子。0.8.0 已经有内置的 softplus,它是组合运算:composite.rs 把它写成 max(x, 0) + log1p(exp(-|x|)) 的一串核心算子,求导走这些核心算子的规则。这里把同一个函数写成扩展算子,正好拿内置的结果对照:扩展算子是一个不透明的内核加一份手写规则,组合运算是一串图节点、不需要规则。完整代码在 ops1-exp/src/bin/softplus_ext.rs,只依赖公开的 tenferro-runtime、tenferro-tensor、tenferro-ad、tenferro-cpu。
载荷:
#[derive(Clone, Debug, ExtensionFamilyId)]
#[tenferro_extension(namespace = "rie-demo", version = 1)]
struct SoftplusOp;
const FAMILY_ID: &str = SoftplusOp::FAMILY_ID;
impl ExtensionOp for SoftplusOp {
fn family_id(&self) -> &'static str {
FAMILY_ID
}
fn payload_hash(&self, _hasher: &mut dyn Hasher) {}
fn payload_eq(&self, other: &dyn ExtensionOp) -> bool {
other.as_any().is::<Self>()
}
// clone_arc、as_any、input_count = 1、output_count = 1 省略
fn semantic_effects(&self) -> ExtensionEffectDeclaration<'_> {
ExtensionEffectDeclaration::Declared(&[])
}
fn semantic_aliases(&self) -> ExtensionAliasDeclaration<'_> {
ExtensionAliasDeclaration::AllFresh
}
fn infer_output_meta(
&self,
ctx: &mut ExtensionShapeContext<'_>,
) -> tenferro_tensor::Result<Vec<(DType, Vec<SymDim>)>> {
Ok(vec![(ctx.input_dtype(0)?, ctx.input_shape(0)?.to_vec())])
}
}
执行走会话路线:一个拿 &mut dyn Engine 的函数,一个说「哪种 engine 都行」的函数,一次宏调用。输入可能是视图,先让 engine 把它变成连续张量,再在主机上算(只处理 f64):
fn softplus_in_session(
_op: &SoftplusOp,
engine: &mut dyn Engine,
_caches: &mut ExtensionCacheStore,
inputs: &[TensorRead<'_>],
) -> tenferro_tensor::Result<Vec<Tensor>> {
let x = engine.to_contiguous_read(inputs[0].clone())?;
let xs = x.as_slice::<f64>()?;
// ln(1 + e^x) without overflow for large x
let ys: Vec<f64> = xs.iter().map(|&v| v.max(0.0) + (-v.abs()).exp().ln_1p()).collect();
Ok(vec![Tensor::from_vec_col_major(x.shape().to_vec(), ys)?])
}
fn softplus_supported<B: Engine + 'static>(_op: &SoftplusOp) -> bool {
true
}
define_extension_runtime! {
runtime = SoftplusRuntime,
family_id = SoftplusOp::FAMILY_ID,
op_type = SoftplusOp,
execute_in_session = softplus_in_session,
session_supported = softplus_supported,
}
求导规则:前向用 linearize,反向用 primal_vjp,不写 linear_transpose。residual_mask 声明要读第 0 个输入和第 0 个输出:
fn sigmoid(b: &mut SemanticProgramBuilder, x: ProgramValue, y: ProgramValue) -> Result<ProgramValue, SemanticAdError> {
let d = b.add_op(CoreSemanticOp::Sub, &[x, y])?[0];
Ok(b.add_op(CoreSemanticOp::Exp, &[d])?[0])
}
impl SemanticLinearizeRule for SoftplusAd {
// family_id 省略
fn linearize(
&self,
req: SemanticLinearizeRequest<'_>,
b: &mut SemanticProgramBuilder,
) -> Result<SemanticLinearizeResult, SemanticAdError> {
let AdValue::Value(dx) = req.tangent_inputs()[0] else {
return Ok(SemanticLinearizeResult::new([AdValue::Absent], []));
};
let s = sigmoid(b, req.primal_inputs()[0], req.primal_outputs()[0])?;
let dy = b.add_op(CoreSemanticOp::Mul, &[s, dx])?[0];
Ok(SemanticLinearizeResult::new([AdValue::Value(dy)], []))
}
}
impl SemanticPrimalVjpRule for SoftplusAd {
// family_id 省略
fn residual_mask(&self) -> ResidualSpec {
ResidualSpec::input(0).with_output(0)
}
fn primal_vjp(
&self,
req: SemanticPrimalVjpRequest<'_>,
b: &mut SemanticProgramBuilder,
) -> Result<Box<[AdValue]>, SemanticAdError> {
let AdValue::Value(ct) = req.cotangent_outputs()[0] else {
return Ok(vec![AdValue::Absent].into_boxed_slice());
};
if !req.active_inputs()[0] {
return Ok(vec![AdValue::Absent].into_boxed_slice());
}
let s = sigmoid(b, req.primal_input_value(0)?, req.primal_output_value(0)?)?;
let dx = b.add_op(CoreSemanticOp::Mul, &[s, ct])?[0];
Ok(vec![AdValue::Value(dx)].into_boxed_slice())
}
}
使用,x = [-2, -0.5, 0, 3]:
let rules = SemanticExtensionRuleSet::new()
.with_linearize(Arc::new(SoftplusAd))?
.with_primal_vjp(Arc::new(SoftplusAd))?;
let ad = AdContext::builder().with_semantic_extension_rules(rules)?.build()?;
let mut engine = CpuEngine::serial();
// eager
let eager = EagerRuntime::with_ad_context(&ad)?;
eager.install_extension_module(extension_module::<CpuEngine>(runtime_engine_id()?)?)?;
let x = eager.variable_from(Tensor::from_vec_col_major(vec![4], X.to_vec())?)?;
let y = apply_eager(&mut engine, Arc::new(SoftplusOp), &[&x])?.remove(0);
let loss = eager.with_eager_session(&mut engine, |s| s.reduce_sum(&y, None))?;
loss.backward(&mut engine)?;
// traced(rt 是装了同一个模块的 Runtime)
let xt = TracedTensor::from_vec_col_major(vec![4], X.to_vec())?;
let yt = tenferro_runtime::extension::apply(Arc::new(SoftplusOp), &[&xt])?.remove(0);
let g = ad.grad(&yt.reduce_sum(None)?, &xt)?;
let jvp = ad.jvp(&yt, &xt, &ones)?;
let h = ad.grad(&g.reduce_sum(None)?, &xt)?;
看一眼 primal_vjp 往程序里加了哪些算子,以及 y 是什么。
rules: SemanticExtensionRuleSet { linearize: ["rie-demo.softplus_op.v1"], linear_transpose: [], primal_vjp: ["rie-demo.softplus_op.v1"] }
expected sigmoid(x) = [0.11920292202211755, 0.3775406687981454, 0.5, 0.9525741268224334]
eager y = [0.1269280110429725, 0.4740769841801067, 0.6931471805599453, 3.048587351573742]
eager x.grad = [0.11920292202211753, 0.3775406687981454, 0.5, 0.9525741268224333]
builtin softplus y = [0.1269280110429725, 0.4740769841801067, 0.6931471805599453, 3.048587351573742]
builtin softplus x.grad = [0.11920292202211755, 0.37754066879814546, 0.5, 0.9525741268224333]
traced y = [0.1269280110429725, 0.4740769841801067, 0.6931471805599453, 3.048587351573742]
traced grad = [0.11920292202211753, 0.3775406687981454, 0.5, 0.9525741268224333]
traced jvp(ones) = [0.11920292202211753, 0.3775406687981454, 0.5, 0.9525741268224333]
...
grad program ops: ext:rie-demo.softplus_op.v1 Sub Exp BroadcastInDim Mul
eager 的 backward、traced 的 grad、traced 的 jvp 用的是同一份规则(前两者走 primal_vjp,后者走 linearize),三者逐位相同,和直接算的 sigmoid 差在最后一位(exp(x − y) 和 1/(1+e^{−x}) 的舍入不同)。内置 softplus(eager 会话上的 s.softplus(&x))的前向和扩展算子逐位相同(同一个公式),梯度在前两个元素的最后一位不同:它的梯度是沿 select、log1p、exp 的核心规则一步步求出来的,不是 exp(x − y)。梯度程序只有五个节点:前向的扩展算子(它的输出 y 是残差)、规则发出的 Sub、Exp、Mul,以及把 reduce_sum 的种子广播回 [4] 的 BroadcastInDim。
同一个程序里还试了几种漏装、写错:
undeclared effects: forward compile Err(graph_compile_semantic (Compile): runtime state failure: extension family "rie-demo.softplus_op.v1" has no semantic effect declaration)
undeclared effects: grad Err(graph_compile_semantic (Compile): runtime state failure: extension family "rie-demo.softplus_op.v1" has no semantic effect declaration)
linearize only: grad Err(unsupported vjp AD rule for rie-demo.softplus_op.v1)
bad family id: Err(MalformedFamilyId { family_id: "softplus" })
eager without module: Err(extension: invalid argument extension_module: missing extension module for op family_id "rie-demo.softplus_op.v1"; install an ExtensionModule on the Runtime)
traced without module: Err(Runtime::run (Execution): runtime state failure: missing extension family "rie-demo.softplus_op.v1")
apply_eager inside with_eager_session: Ok(())
TracedTensorAdExt::grad: Err(unsupported vjp AD rule for rie-demo.softplus_op.v1)
EagerRuntime::new() backward: Err("semantic_eager_vjp (GraphBuild): runtime state failure: semantic extension AD failed: semantic extension family \"rie-demo.softplus_op.v1\" has no Linearize AD rule")
- 不声明副作用(用默认实现):
apply能成功,连前向都编译不了。 - 只注册
linearize:grad报unsupported vjp AD rule,消息里不说缺的是linear_transpose;traced 把内部的MissingRule { role }映射成了不带角色的UnsupportedAdRule。 - family id 不合规:注册规则时就拒绝。
- 没装模块:eager 在
apply_eager时报,提示「install an ExtensionModule on the Runtime」,但 eager 下该调的是EagerRuntime::install_extension_module;traced 在prepare时就报,但错误里的操作名写的是Runtime::run。 - 在一个 engine 的
with_eager_session里,用另一个 engine 调apply_eager能执行。 TracedTensorAdExt::grad(loss.grad(&x))用的是空规则集,扩展算子一律求不了导,要用AdContext。EagerRuntime::new()同样没有规则集,backward报缺Linearize,要用EagerRuntime::with_ad_context。
例子:ext/tropical
热带半环把「乘、加」换成「加、取最大(或最小)」:max_k(a[i,k] + b[k,j])。ext/tropical(tenferro-ext-tropical 0.1.0,自成一个小工作区,不在主工作区的成员里)给它提供 traced 的 einsum 和求导。读它能看到这套机制在一个真实的、比 softplus 复杂的运算族上怎么用。
两种写法。 checked_tropical_dot_general_impl 用核心算子组合:两边 broadcast_in_dim_sym 到 [m, k, n],相加,再沿 k 取最大。全是核心算子,不需要模块也不需要规则,代价是中间张量是 m·k·n 的。*_fused 版本和 tropical_einsum 走扩展算子 TropicalEinsumOp(tropical_einsum_subscripts 里一次 extension::apply),不物化那个三维张量。
复用 einsum 的规划。 前向 tropical_einsum_subscripts_with_argmax 用 tenferro_einsum::ContractionTree::optimize 建树,要求只有一步、没有取对角和预归约,然后读 step_plan(0).gemm()(tenferro_einsum::lowering::GemmPlan)拿到 m、n、k 和目标轴序,自己在主机上做热带 GEMM,同时记下每个输出取到最大值的 k(argmax)。这就是 einsum 页说的「lowering.rs 的只读计划给外部 crate 用」。只支持两个输入、f32 / f64。
三个 family。 热带 GEMM 的导数只沿 argmax 那一项传:∂out[i,j]/∂a[i,k] 在 k 是赢家时为 1,否则为 0。这不能用核心算子表示,所以规则发出的是另外两个扩展算子,各有自己的 family id:
| family id | 载荷 | 规则 |
|---|---|---|
tenferro-ext-tropical.einsum.v1 | TropicalEinsumOp | linearize(发出 JVP 算子)、linear_transpose 和 primal_vjp(发出 VJP 算子) |
tenferro-ext-tropical.einsum_jvp.v1 | TropicalEinsumJvpOp(原输入加切向量) | linear_transpose、primal_vjp(对切向量输入,发出 VJP 算子) |
tenferro-ext-tropical.einsum_vjp.v1 | TropicalEinsumVjpOp(原输入加余切) | 无 |
注册在 tropical_semantic_ad_rules。JVP 和 VJP 算子执行时各自重算一遍前向拿 argmax,所以三个 family 都要有执行器。VJP 算子没有规则,梯度可以求,对梯度再求导就会缺规则;这和上面 softplus 的情形相反。
为什么不用宏。 define_extension_runtime! 的 family_id 是一个常量、op_type 是一个类型,一次调用对应一个 family。tropical 要三个 family,就手写了一个以 family id 为字段的扩展引擎 TropicalReferenceEngine:prepare 里先比对 family id,再检查载荷类型,不认识的返回 PrepareCapability::Unsupported;extension_modules 返回三个模块(不开 autodiff 时只有一个),使用者要逐个 install_extension_module。手写的执行器照搬宏的会话路线:supports_session 恒为真,execute_in_session 先让 engine 把输入变成连续张量,再在主机上算;execute 只是把擦除的上下文转回 &mut dyn Engine 后调它。extension_modules 的文档注释说规则在 tropical_ad_rules 里注册,实际的函数名是 tropical_semantic_ad_rules。
ext/ 下的其他例子
ext/sparse(tenferro-ext-sparse):COO 稀疏矩阵乘,结构和 tropical 一样,三个 family(tenferro-ext-sparse.matmul.v1及其_jvp、_vjp)、手写的扩展引擎、extension_modules返回多个模块;上游教程sparse-extension.md就是围绕它写的。它和 tropical 都是独立的嵌套工作区,根Cargo.toml把它们放在exclude里。ext/bf16-proof、ext/df64-proof、ext/scalar-consumer-algorithm、ext/scalar-consumer-application是主工作区的成员,同样publish = false,都在 v0.7.1 就已存在,0.8.0 只是改到 engine 上。它们证明的是「tenferro 没定义的标量类型能穿过同一套公开边界」:bf16-proof 用half::bf16做存储、视图、转换、逐元素运算和以 f32 累加的求和,外加一个 bf16 einsum 扩展(tenferro-bf16-proof.einsum.v1);df64-proof 定义一个两f64拼成的扩展精度标量Df64,经define_scalar_set!进标量集,用scalar_identity给出跨进程稳定的名字,一个 family(tenferro-df64-proof.df64_ops.v1)装了求和、QR 等运算和它们的规则;两个 scalar-consumer crate 把「算法」和「应用」拆开,算法只声明需要的能力(ScalarSupporttrait 里的qr、to_f64),应用决定绑定标准 f64 还是Df64。它们依赖仓库里的路径,本页没有构建,只读源码。ext/tenferro-cpu-tblis在 v0.8.0 只剩一个Cargo.lock,源码已删除;TBLIS 式的打包收缩现在在 tcontract 里(见 provider)。
动脑筋答案
traced d2 = [0.1049935854035065, 0.2350037122015945, 0.25, 0.045176659730912117]
expected s(1-s) = [0.1049935854035065, 0.2350037122015945, 0.25, 0.045176659730912]
- 不报错。一阶导数程序里是
Exp(Sub(x, y)) * 种子,其中y就是SoftplusOp的输出。对它再求导时,Sub、Exp、Mul用核心规则,y对x的导数又回到SoftplusOp自己的primal_vjp,规则集里有。 - 结果是
sigmoid'(x) = s(1 − s):d/dx exp(x − y) = exp(x − y)·(1 − y') = s(1 − s)。和直接按公式算的值一致到最后一位(expected那一行是{:?}打印的最短表示)。 - 如果规则里发出的是一个新的扩展算子(比如自己写一个
SigmoidOp),这一步就要那个算子也有规则,否则报缺规则;tropical 的 VJP 算子就是这种情况。
小结
- 一个运算族是三份独立的东西:
ExtensionOp载荷、执行模块、求导规则,靠 family id 关联,没有全局注册表;外部 crate 只需tenferro_runtime::extension这个门面,不必依赖内部 crate。 ExtensionOp的semantic_effects/semantic_aliases默认是Undeclared,用默认值的算子连前向都编译不了;纯函数式的运算族写Declared(&[])和AllFresh。lower_to_standard_ops只给 XLA 用。define_extension_runtime!按 engine 类型B生成扩展引擎、模块、准备好的算子和extension_module::<B>(engine_id);执行路线二选一:会话路线(execute_in_session+session_supported,einsum、linalg、FFT 都用它)或execute_reads,两个都给、都不给、写旧的execute都是编译错误(#1944)。- traced 用
extension::apply建节点,Runtime::builder()上register_engine加install_extension_module,run时借入 engine;eager 用EagerRuntime::install_extension_module加apply_eager(&mut engine, ..)。一份linearize+primal_vjp同时服务 eagerbackward、tracedgrad和jvp,规则只发核心算子时还自动有二阶导。 - 0.8.0 的内置
softplus是组合运算,不经过这套机制;同一个函数写成扩展算子,前向逐位相同,梯度差在最后一位。 - 漏装模块、漏声明副作用、规则角色不全都会报错,但时机不同(建图、编译、执行),traced 的缺规则错误不说缺哪个角色。