Rust in Everything
基于 tenferro-runtime 0.8.0

扩展算子

主线位置: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_aliasesUndeclared有没有可观察的副作用、输出是不是输入的视图或别名
lower_to_standard_opsUnsupported展开成标准算子;只有 tenferro-xla 调用
prune_outputsNone只用到部分输出时换成一个更小的算子(编译时用,见 编译)
scalar_identityNone载荷带外部定义的标量类型时给出跨进程稳定的名字(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 再调它。
  • 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 没用宏,原因见最后一节。

📝NOTE
冷知识:执行函数原来拿到的是后端本身

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 与扩展 注册。按变换需要的角色:

变换需要的规则
jvplinearize
grad / vjp / eager backwardprimal_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)?;
❗IMPORTANT
动脑筋:最后一行对梯度再求一次梯度。规则集里只有 softplus 自己的两条规则,这一步会报缺规则吗?如果不报,结果是什么?

看一眼 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.v1TropicalEinsumOplinearize(发出 JVP 算子)、linear_transpose 和 primal_vjp(发出 VJP 算子)
tenferro-ext-tropical.einsum_jvp.v1TropicalEinsumJvpOp(原输入加切向量)linear_transpose、primal_vjp(对切向量输入,发出 VJP 算子)
tenferro-ext-tropical.einsum_vjp.v1TropicalEinsumVjpOp(原输入加余切)无

注册在 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 把「算法」和「应用」拆开,算法只声明需要的能力(ScalarSupport trait 里的 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 同时服务 eager backward、traced grad 和 jvp,规则只发核心算子时还自动有二阶导。
  • 0.8.0 的内置 softplus 是组合运算,不经过这套机制;同一个函数写成扩展算子,前向逐位相同,梯度差在最后一位。
  • 漏装模块、漏声明副作用、规则角色不全都会报错,但时机不同(建图、编译、执行),traced 的缺规则错误不说缺哪个角色。

💬 关联讨论

发起讨论
加载中...