Rust in Everything
基于 tenferro-runtime 0.8.0

einsum

主线位置:einsum 线的全程。下标字符串 → EinsumNotation / Subscripts(整数标签)→ 两个输入时试一次 dot_general 的捷径 → 否则建 ContractionTree(三个以上输入时按确定性贪心定顺序)→ 三个以上浮点输入在 CPU 上整条交给 cpueinsum,其余由 tenferro 自己逐对调 engine 的 dot_general_read → 结果张量;traced 下整个 einsum 是程序里的一个扩展算子,到运行时才规划和执行;求导走 einsum 自己的 semantic 规则。每个二元步最后落到哪个 provider 见 provider,规则怎么注册、怎么分派见 semantic AD 与扩展,扩展算子的执行模块见 扩展算子。

flowchart TB
  s["ij,jk->ik"] --> n["EinsumNotation"]
  n -->|"知道秩后展开"| sub["Subscripts"]
  sub -->|"两个输入"| fast["一次 dot_general"]
  sub -->|"其余"| tree["ContractionTree"]
  tree -->|"3+ 浮点 CPU"| ce["cpueinsum"]
  tree -->|"其余"| walk["binary_contract"]
  ce --> tc["tcontract 二元计划"]
  walk --> dg["engine dot_general"]
  tr["TraceContext einsum"] --> eop["EinsumExtensionOp"]
  eop -->|"运行时"| tree

代码在 tenferro-einsum/src/:syntax/、subscripts.rs、ellipsis.rs(下标),planning/、optimize.rs(规划),eager.rs、binary_dot.rs、concrete.rs、cpu_concrete.rs(具体张量的执行),traced.rs、extension.rs(traced 入口、扩展算子、AD 规则),eager_ad.rs(eager 入口),builder.rs(降到标准算子图)。N 元收缩的数值部分在单独发布的 cpueinsum 0.9.0(124aa12d),规划器 omeco 0.2.6(6c83996f)只在开了退火时才用。实验 crate 是 exp-tenferro8/ops1-exp(plan_network、binary_fast、einsum_route、einsum_nary、einsum_lower),全部 release,CPU(native provider),M2 Max、macOS 27、rustc 1.99.0。没有特别说明时 engine 是 CpuEngine::serial();跑计时时机器上有其他写手的负载,uptime 的 1 分钟负载在 4.4 到 7.4 之间。

下标:三种写法,一种整数表示

所有入口最后都落到同一个结构:每个输入一组 u32 标签,加输出一组。字符串里的每个字符直接取 Unicode 码位当标签,只有 ,、-、>、(、)、空格是保留字符(char_to_label),所以汉字也能当标签,下面的网格实验就是这么写的。三种写法:

类型写法什么时候用
Subscripts"ij,jk->ik",不接受 (、.规划器和执行器吃的就是它
EinsumNotation允许每项一个 ...所有字符串入口先解析成它,知道输入的秩以后再展开
NestedEinsum"(ij,jk),kl->il",括号指定收缩顺序只能经 EinsumOptimize::Nested 交给 traced 入口

箭头必须写、括号和省略号不能同时用,这几条在 怎么读这一卷 里已经逐条跑过。省略号展开在 resolve_einsum_notation:先算每项省略号盖住几个轴,从 u32::MAX 往下挑没被占用的整数当这些轴的标签(fresh_labels),按右对齐展开;然后逐个标签合并尺寸,规则是「相等或者其中一个是 1」(merge_broadcast_size)。下面报错里的 label 4294967295 就是省略号盖住的第一个轴。

这一步合并的是所有标签,不只是省略号展开出来的那些。三个入口对长度 1 广播的态度因此不同:具体张量的 einsum 什么都接受;eager 入口在写了省略号、或者发现同一标签一边是 1 另一边不是(requires_broadcast)时打开广播;traced 入口只看有没有写省略号,没写时 infer_output_meta 对同一标签的两个尺寸调 require_equal。实验 einsum_nary 用 a[2,1]、b[3,2]:

2 ops, j is 1 vs 3           ij,jk->ik              ok shape=[2, 2] first=Ok(6.0)
traced 2 ops, j is 1 vs 3    ij,jk->ik              Err(compile: extension family "tenferro.einsum.v1" shape constraint at instruction None failed: Const(1) (1) Equal Const(3) (3))
traced 2 ops, batch 1 vs 4   ...ij,...jk->...ik     ok shape=[4, 2, 2] first=61

同一个式子,直接计算算得出来,traced 在编译时拒绝;写成省略号的批维广播,traced 也接受。上游工作日志 2026-08-17-einsum-ellipsis.md 写的意图是「Carry an internal broadcast permission bit on semantic extension payloads so existing symbolic equality guards are not weakened for ordinary integer-label einsum」,traced 这边按意图做了,具体张量这边的解析没有区分。三个以上输入时广播还有另一个问题,见下面的「执行」一节。

两个输入:不建树

具体张量的字符串入口 eager_einsum_subscripts_on_session 第一件事是 try_binary_dot_shortcut:两个输入、dtype 相同,而且整个式子恰好是一次按原顺序的 dot_general、结果的轴序就是输出的轴序时,直接调 engine 的 dot_general_read,不建 ContractionTree。binary_dot_shortcut_plan 的注释列了不走捷径的情形:重复标签(对角)、只在一边求和的标签、没有收缩标签(外积)、输出轴序不是「左自由、右自由、批」、收缩或批维尺寸不等(长度 1 广播)、dtype 不同,以及需要交换两个操作数的情形(「swapping would change the kernel call and could change the bits」)。ConcreteEinsumPlan::prepare 也在准备时算好这份捷径计划,execute 时直接用。

这是 PR #2325 加的,release notes 写的是 16×16 ij,jk->ik「went from 12.9 to 4.5 us through the string API and from 4.9 to 2.9 us through a prepared plan」,走不了捷径的情形「pay about 1 to 2 us for the check」。实验 binary_fast,16×16 f64,每项 20000 次取平均,7 轮取最小,跑了两遍:

ij,jk->ik    string   2.17 us   prepared   1.41 us
ij,jk->ki    string   7.92 us   prepared   3.40 us
ij,kj->ik    string   2.20 us   prepared   1.39 us
ik,kj->ij    string   2.13 us   prepared   1.39 us
ContractionTree::optimize(ij,jk->ik) alone   2.73 us
Subscripts::parse alone   0.13 us
engine.dot_general_read directly   1.39 us
string == dot_general bitwise: true

(第二遍各项相差不到 0.15 µs。)准备好的计划和直接调 dot_general_read 一样快,字符串入口多出的 0.75 µs 是解析和展开下标。ij,jk->ki 的输出是转置,走树:单是 ContractionTree::optimize 就要 2.7 µs,比整个捷径调用还贵,这就是要绕开它的原因。

规划:ContractionTree 与确定性贪心

ContractionTree 记的是一串两两收缩:(left, right) 引用原输入(0..n)或之前步骤的中间结果(n + 步号)。自动规划的入口 optimize_with_options:

        options.validate()?;
        let input_count = subscripts.inputs.len();
        if input_count <= 1 {
            return Self::from_pairs(subscripts, shapes, &[]);
        }
        if input_count == 2 {
            return Self::from_pairs(subscripts, shapes, &[(0, 1)]);
        }

        let size_dict = build_size_dict(subscripts, shapes, None)?;
        // Without annealing TreeSA returns its greedy initializer unchanged,
        // and omeco 0.2.6's greedy breaks ties in per-process `HashMap` order
        // (#1963), so the deterministic greedy plans this case directly.
        let pairs = if !options.anneals() {
            optimize_self_greedy_pairs(subscripts, &size_dict)?
        } else if let Some(omeco_pairs) = optimize_omeco_pairs(subscripts, &size_dict, options)? {
            omeco_pairs
        } else {
            optimize_self_greedy_pairs(subscripts, &size_dict)?
        };
        Self::from_pairs(subscripts, shapes, &pairs)

ContractionOptimizerOptions 的默认值仍是 betas: Vec::new()、ntrials: 1、niters: 0;anneals() 要求 niters > 0 且 betas 非空,所以默认参数下根本不调 omeco,而是 tenferro 自己的 optimize_self_greedy_pairs。它的文档注释说明规则:每步在共享至少一个标签的操作数对里,挑结果最小的一对(结果的尺寸只算之后还有人要的标签);代价打平时取 (left, right) 最小的一对;没有任何一对共享标签时,取编号最小的两个做外积。这就是 omeco tree_greedy 在 alpha = 0、temperature = 0 时的规则,只是顶点和候选都放在 BTreeMap / BTreeSet 里,每步只给新中间结果的邻居重新打分。

所以结果只取决于下标和形状。ContractionTree::optimize 的文档仍说它找的是「the pairwise contraction sequence that minimizes total operation count」,实际每步按结果大小选,既不看运算次数,也不保证总和最少;同一段文档紧接着写的「The path is deterministic」是对的。traced 入口的 EinsumOptimize::default() 把 score 换成 ScoreFunction::time_optimized()(default_auto_options),不退火时 score 不参与,结果一样。

实验 plan_network repeat 在一个进程里对 4×4 网格(键长 8,收缩成标量)调 6 次 ContractionTree::optimize,再调 3 次一个很短的退火(betas = [1, 5]、niters = 2),最后用 1 个和 8 个线程的 engine 各做 3 次一次性 einsum,打印结果的位模式;整个程序跑 3 个进程:

-- process 1
grid 4x4 bond 8 default: flops=5.624e6 pairs=[(0, 1), (2, 3), (8, 12), (11, 15)]
...
grid 4x4 bond 8 TreeSA(2x2x1): flops=1.721e6 pairs=[(11, 15), (14, 16), (10, 17), (7, 3)]
grid 4x4 bond 8 TreeSA(2x2x1): flops=9.322e6 pairs=[(0, 1), (2, 3), (16, 17), (4, 5)]
grid 4x4 bond 8 TreeSA(2x2x1): flops=4.671e7 pairs=[(8, 12), (4, 16), (13, 17), (18, 9)]
grid 4x4 bond 8 one-shot einsum, 1 thread(s): value bits ["3ebf949cd6797eac", "3ebf949cd6797eac", "3ebf949cd6797eac"]
grid 4x4 bond 8 one-shot einsum, 8 thread(s): value bits ["3ebf949cd6797eac", "3ebf949cd6797eac", "3ebf949cd6797eac"]
-- process 2
...
grid 4x4 bond 8 TreeSA(2x2x1): flops=5.882e6 pairs=[(14, 15), (10, 16), (3, 2), (7, 11)]
grid 4x4 bond 8 TreeSA(2x2x1): flops=1.950e6 pairs=[(14, 15), (8, 12), (13, 17), (9, 18)]
grid 4x4 bond 8 TreeSA(2x2x1): flops=3.789e6 pairs=[(7, 3), (2, 16), (6, 17), (0, 1)]
...

(flops 是每一步「两个操作数所有标签尺寸之积」的和,pairs 只印前 4 步。)默认计划 18 次全部是同一棵树,代价 5.624e6,3 个进程、两种线程数下结果逐位相同。退火的计划每次都不一样,同一进程里也会变,代价从 1.72e6 到 4.67e7,最差的比贪心还贵 8 倍:omeco 的 TreeSA 给每次试验的随机数生成器用固定种子(trial_idx + 42),但它的贪心初始树仍按 HashMap 的顺序打破平局,每个新 HashMap 的种子都不同。ContractionOptimizerOptions 的文档只说退火的路径「not guaranteed to agree across processes」,实际同一进程里也不保证。

📝NOTE
冷知识:默认路径曾经每次都可能不一样

PR #2014(2026-10-06 合并,首次发布于 v0.8.0)里的提交 96b89e08 解决的是 issue #1963。提交说明:「omeco 0.2.6's greedy initializer breaks equal-cost ties in HashMap iteration order, which is seeded per process, so the same spec produced different contraction paths (and different rounding) in different processes. Without annealing TreeSA returns that initializer unchanged, so these options now run the local greedy planner」,并写明「Annealed paths still come from omeco TreeSA」。之前默认参数同样是 0 次迭代,TreeSA 原样返回 omeco 的贪心树,各边尺寸都相同的网络里打平很常见,同一个 einsum 调用的代价和末位舍入会随进程变化。修法没有改 omeco,而是在 tenferro 里按 omeco 的规则重写一份按编号打破平局的贪心;代码注释写明要等「an omeco release carries the fix」。release notes 也提醒:「Where earlier releases broke ties randomly, the chosen path may differ from a previous run」。

显式指定顺序

traced 入口的 einsum_with 收一个 EinsumOptimize:Auto(options)(默认)、False、Nested(NestedEinsum)、Path(JAX 式的位置路径)、Tree(ContractionTree)。它在建图时只被解析成一个与形状无关的「计划规格」EinsumPlanSpec,存进扩展算子的载荷;真正的树等运行时知道形状才建(resolve_plan_spec)。具体张量的 einsum、ConcreteEinsumPlan 和 eager 的 einsum 都没有这个参数,只用默认值。Tree 变体的文档说两个输入的树「may bypass the extension path and lower directly to dot_general」,traced 入口的代码里没有这个分支,Tree 一律存成固定的收缩对进扩展算子。

False 的文档注释是「No optimization: contract operands left-to-right」,实现是:

        EinsumPlanSpec::LeftToRight => {
            let n = subscripts.inputs.len();
            if n <= 1 {
                ContractionTree::from_pairs(subscripts, shapes, &[])
            } else {
                let path: Vec<(usize, usize)> = (0..n - 1).map(|_| (0, 1)).collect();
                let pairs = jax_path_to_v1_pairs(&path, n)?;
                ContractionTree::from_pairs(subscripts, shapes, &pairs)
            }
        }

JAX 路径的语义是「每步收缩当前列表里的这两个位置,结果追加到末尾」(jax_path_to_v1_pairs)。

❗IMPORTANT
动脑筋:七个输入 "ij,jk,kl,lm,mn,no,o->i" 用 EinsumOptimize::False,第 4 步收缩的是哪两个操作数?它们有没有共享的标签?按上面的规则手推一遍位置列表。

实验:一条矩阵链和一个网格

plan_network 对两个网络各比四种计划:默认(贪心)、EinsumOptimize::False、真正的从左到右((0,1)、(n,2)、(n+1,3)……经 Tree 传入)、omeco 的 TreeSA 默认参数(betas 0.01 到 15、步长 0.05,10 次试验,每温度 50 次迭代)。执行都是 traced 程序:第一次 prepare + run 单独计,之后对同一个 prepared 程序 run 20 次取最小值;最后两行是具体张量的 ConcreteEinsumPlan 和一次性 einsum。f64,CpuEngine::serial()。

第一个是六个 256×256 矩阵乘一个长 256 的向量。从左到右先做五次矩阵乘,最后才乘向量;反过来从向量开始,每步都是矩阵乘向量:

== ij,jk,kl,lm,mn,no,o->i, n=256 (7 tensors)
  default (greedy)       flops=3.932e5 peak=2.560e2 first=3.815ms min-of-20=0.078ms value=-1.242054e5 pairs=[(5, 6), (4, 7), (3, 8), (2, 9), (1, 10), (0, 11)]
  EinsumOptimize::False  flops=1.007e8 peak=1.678e7 first=28.398ms min-of-20=20.417ms value=-1.242054e5 pairs=[(0, 1), (2, 3), (4, 5), (6, 7), (8, 9), (10, 11)]
  true left-to-right     flops=8.395e7 peak=6.554e4 first=3.696ms min-of-20=3.509ms value=-1.242054e5 pairs=[(0, 1), (7, 2), (8, 3), (9, 4), (10, 5), (11, 6)]
  TreeSA 10x300x50       flops=3.932e5 peak=2.560e2 first=0.166ms min-of-20=0.077ms value=-1.242054e5 pairs=[(6, 5), (7, 4), (8, 3), (9, 2), (10, 1), (11, 0)]
  concrete ConcreteEinsumPlan: prepare=23.3us execute min-of-20=0.059ms value=-1.242054e5
  concrete one-shot [..].einsum min-of-20=0.095ms

贪心一眼就找到了「从向量开始」,代价是从左到右的 1/214,时间快 45 倍。TreeSA 找到的是同一棵树(只是左右对调),多花的退火时间换不来任何东西。False 比真正的从左到右还贵:它的 peak 是 1.678e7 个元素,即一个 256×256×256 的外积(见动脑筋答案),比默认计划慢 260 倍。

第二个是 4×4 网格,键长 8,收缩成标量:

== 4x4 grid, bond 8, -> scalar (16 tensors)
  default (greedy)       flops=5.624e6 peak=3.277e4 first=1.635ms min-of-20=0.650ms value=1.882346e-6
  EinsumOptimize::False  flops=3.031e8 peak=1.678e7 first=75.970ms min-of-20=64.869ms value=1.882346e-6
  true left-to-right     flops=9.577e6 peak=3.277e4 first=1.206ms min-of-20=0.951ms value=1.882346e-6
  TreeSA 10x300x50       flops=1.720e6 peak=4.096e3 first=1.421ms min-of-20=0.246ms value=1.882346e-6
  concrete ConcreteEinsumPlan: prepare=123.2us execute min-of-20=0.561ms value=1.882346e-6
  concrete one-shot [..].einsum min-of-20=0.754ms

(pairs 省略。)贪心只比从左到右好一点,TreeSA 的代价是它的 1/3、peak 是 1/8,时间是 0.38 倍。贪心每步只看下一对的输出大小,网格上它会先在几个角各自长出大的中间张量。EinsumOptimize::False 慢 100 倍。用 8 个线程的 engine(CpuEngine::new(Pool::builder().threads(8).build()?))再跑一遍,计划不变;从左到右的链从 3.51 ms 降到 0.58 ms,False 的网格从 64.9 ms 降到 14.6 ms,默认计划的链仍是 0.078 ms(每步只是矩阵乘向量,没有可分的活)。四种计划的结果一致到打印精度。

规划本身的时间(ContractionTree::optimize,5 次取最小;链的键长 8,网格的键长 2):

  chain n=   4: 21.1us
  chain n=  16: 86.0us
  chain n=  64: 434.0us
  chain n= 256: 2877.3us
  grid 4x4 (16 tensors): 117.3us
  grid 8x8 (64 tensors): 939.5us
  grid 12x12 (144 tensors): 3065.1us
  TreeSA grid 3x3: 24.8ms
  TreeSA grid 4x4: 47.6ms
  TreeSA grid 6x6: 112.6ms

贪心每个张量 5 到 21 µs(含 from_pairs 预先算好每步的计划);TreeSA 的默认参数要几十到上百毫秒,10 次试验在 omeco 自己用的 rayon 全局池上并行,不经过 engine 的 threadteam 池。对几个张量的小收缩,规划已经和收缩本身同一个量级:上面网格的 ConcreteEinsumPlan 准备 123 µs,执行 561 µs。重复执行同一形状,先 prepare 一次。

执行:tenferro 做什么,cpueinsum 做什么

0.8.0 的 CPU 上,一次 einsum 的工作分在几处:

步骤谁做
解析下标、展开省略号、合并尺寸tenferro-einsum
两个输入的捷径(一次 dot_general)tenferro-einsum 判断,engine 的 dot_general_read 执行(cpueinsum BinaryPlan → tcontract,见 provider)
收缩顺序tenferro-einsum(贪心、TreeSA 或用户给的路径)
三个以上浮点输入:每个中间结果保留哪些标签、尽早求和、中间结果放在哪、每个二元步的 tcontract 计划cpueinsum EinsumPlan
其余情形:取对角、预归约、广播、逐对 dot_general 或外积、最后转置tenferro-einsum 的 binary_contract

cpueinsum 的 README 把边界说得很直接:「Deliberately not included: contraction-order search, string notation ("ij,jk->ik"), and GPUs. Those belong to the caller.」它吃的是 i64 标签和 SSA 形式的顺序。

三个以上输入:cpueinsum

execute_cached 在这几种情况下返回 None,让调用方走 tenferro 自己的执行器:输入少于 3 个、第一个输入的 dtype 不是 F32 / F64 / C32 / C64、有输入不在 CPU 主机上、engine 不是 CpuEngine。否则它在 engine 的 with_contraction_exec 里拿到 cpueinsum 的 Exec(令牌、工作区、批处理策略,见 engine 与 threadteam 池),在 run_into 里把树翻译成 cpueinsum 的输入:

        let order: Vec<_> = tree
            .steps
            .iter()
            .map(|step| [step.left, step.right])
            .collect();
        let spec = EinsumSpec::new(&label_refs, &output, &order)
            .map_err(|error| map_lower_einsum_error("CPU einsum", error))?;

传过去的只有每步的 [left, right],tenferro 树里每步的中间标签、预归约、取对角计划都不用。cpueinsum 的 EinsumPlan 自己重算:每个中间结果只保留输出要的、或者后面还有操作数用到的标签,其余在这一步就求和掉;所有中间结果放在一块可复用的暂存区里,死掉的位置按首次适配复用;每个二元步是一个 tcontract 计划。ConcreteEinsumPlan 带一个只放一份数值计划的缓存(按输入、输出的形状和步长匹配),实验 einsum_route 对每个式子执行两次再读 cpu_cache_stats:

ij,jk->ik      F64: cpu cache entries=0 hits=0 misses=0 run=Ok(())
ij,jk->ki      F64: cpu cache entries=0 hits=0 misses=0 run=Ok(())
ij,jk,kl->il   F64: cpu cache entries=1 hits=1 misses=1 run=Ok(())
ij,jk,kl->il   I64: cpu cache entries=0 hits=0 misses=0 run=Err("ContractionScalar::one: dtype mismatch: expected I64, actual F32")

两个输入不碰这个缓存(走捷径或 tenferro 的执行器,二元计划缓存在 engine 里);三个浮点输入第一次未命中、第二次命中。一次性的 [..].einsum 不带这个缓存,每次都重新规划顺序、新建数值计划,所以上面网格的一次性调用比准备好的计划慢 0.19 ms。

整数没有 CPU 收缩 provider。I64 退回 tenferro 的执行器,第一次 dot_general_read 就失败,报错来自 ContractionScalar::one:它在 I64 时报 DTypeMismatch { expected: I64, actual: F32 },把「不支持的 dtype」写成了「期望 I64」。tenferro-cpu 里写着「convert I64 to F64 before contraction」的那条提示在 allocate_dot_output 里,排在 ContractionScalar::one 之后,这条路上走不到。

cpueinsum 要求同一个标签在所有操作数上长度相同(ShapeError::Extent),输出标签不能重复(SpecError::OutputRepeated)。tenferro 把原始输入直接交给它,不先做长度 1 广播;tenferro 自己的执行器在第一步 broadcast_to_tree_sizes 里把每个输入广播到树记录的尺寸,cpueinsum 这条路没有对应的一步。einsum_nary 的其余几行(debug 和 release 输出相同):

3 ops, j is 1 vs 3           ij,jk,kl->il           Err(CPU einsum: backend failure: label 106 has extents 1 and 3)
3 ops, no broadcast          ij,jk,kl->il           ok shape=[2, 2] first=Ok(120.0)
3 ops, repeated label        iij,jk,kl->il          ok shape=[2, 2] first=Ok(204.0)
1 op, i->ii output           i->ii                  ok shape=[3, 3] first=Ok(1.0)
2 ops, i->ii output          i,i->ii                Err(eager_einsum: invalid argument configuration: cannot map label occurrences [105, 105] into [105])
3 ops, i->ii output          i,i,i->ii              Err(CPU einsum: backend failure: output label 105 is repeated)
3 ops, batch 1 vs 4 (...)    ...ij,...jk,...kl->...il Err(CPU einsum: backend failure: label 4294967295 has extents 1 and 4)
traced 3 ops, batch 1 vs 4   ...ij,...jk,...kl->...il Err(prepare/run: CPU einsum: backend failure: label 4294967295 has extents 1 and 4)
  • 两操作数时同样的 j 广播能算(第一节),三操作数就被 cpueinsum 拒绝,标签 106 是 j。写成省略号的批维广播也一样,traced 下要到 prepare / run 才报。eager 的同一个三操作数式子报同样的错,见 semantic AD 与扩展。
  • 输入里的重复标签(iij)cpueinsum 能处理,结果 204 和手算一致。
  • 输出里的重复标签(把向量放到对角线上)只有一个输入时能算:i->ii 走单输入的分支,两个输入时 tenferro 的执行器报 cannot map label occurrences,三个输入时 cpueinsum 拒绝。lib.rs 的文档只举了单输入的 "i->ii"。

其余情形:tenferro 的执行器

不归 cpueinsum 的(两个输入走不了捷径、整数、CUDA)由 eager.rs 的执行器逐步调 binary_contract:

  1. 两边标签都不重复、每个标签要么被收缩要么留在输出时,try_build_binary_dot_plan 直接给出一个 DotGeneralConfig,一次 dot_general_read,必要时再转置。
  2. 否则先把只在一边出现、又不需要保留的标签 reduce_sum 掉(预归约),再把标签分成批、收缩、左自由、右自由四类。有收缩标签就 dot_general_read;没有就是外积,两边 broadcast_in_dim 后逐元素乘。
  3. 只有最后一步把结果转置成用户要的标签顺序,中间结果保持 dot_general 的输出顺序。

重复标签在进树之前就用取对角处理掉。dot_general 的输出顺序是「左自由、右自由、批」,批维在最右,这是列主序的约定(见 列主序与视图)。所以 "bij,bjk->bik" 比 "ijb,jkb->ikb" 多一次转置。实验 einsum_lower 把 traced einsum 的扩展算子用 lower_to_standard_ops 展开成标准算子打印出来(展开由 build_einsum_graph 完成,它和 eager.rs 是同一套规则的两份实现):

== bij,bjk->bik [[6, 2, 3], [6, 3, 4]]
  0: DotGeneral { config: DotGeneralConfig { lhs_contracting_dims: [2], rhs_contracting_dims: [1], lhs_batch_dims: [0], rhs_batch_dims: [0] } }
  1: Transpose { perm: [2, 0, 1] }
== ijb,jkb->ikb [[2, 3, 6], [3, 4, 6]]
  0: DotGeneral { config: DotGeneralConfig { lhs_contracting_dims: [1], rhs_contracting_dims: [0], lhs_batch_dims: [2], rhs_batch_dims: [2] } }
== iij,jk->ik [[2, 2, 3], [3, 4]]
  0: ExtractDiag { axis_a: 0, axis_b: 1 }
  1: DotGeneral { config: DotGeneralConfig { lhs_contracting_dims: [1], rhs_contracting_dims: [0], lhs_batch_dims: [], rhs_batch_dims: [] } }
== ij,kl->ijkl [[2, 3], [4, 5]]
  0: BroadcastInDim { shape: [...], dims: [0, 1] }
  1: BroadcastInDim { shape: [...], dims: [2, 3] }
  2: Mul

traced

TraceContextEinsumExt 的 einsum 往语义程序里加一个 EinsumExtensionOp,载荷是整数下标、计划规格、广播许可位,不含树。两个输入的 ij,jk->ik 也是这样,捷径只在具体张量和 eager 入口上:

semantic op: Extension(tenferro.einsum.v1)  EinsumExtensionOp { subscripts: EinsumSubscripts { inputs: [[105, 106], [106, 107], [107, 108]], output: [105, 108] }, plan_spec: Auto(ContractionOptimizerOptions { betas: [], ntrials: 1, niters: 0, score: ScoreFunction { tc_weight: 1.0, sc_weight: 0.0, rw_weight: 0.0, sc_target: inf } }), output_shape_hint: None, allow_broadcast: false }

TracedTensor 上没有 einsum 方法(TracedTensorEinsumExt 只有 tensordot),traced einsum 要经 TraceContext。运行时执行这个算子靠 tenferro_einsum::extension_module::<CpuEngine>(runtime_engine_id()?) 装进 Runtime 的模块,它由 define_extension_runtime! 的会话路线生成(见 扩展算子)。执行函数 execute_einsum_extension_session_reads 拿到具体形状,经 cached_runtime_tree 按「下标 + 形状 + 计划规格」在 engine 的扩展缓存里查树,没有就规划一次,然后调的是同一个 eager_einsum_exec_read,三个以上浮点输入同样进 cpueinsum。所以 traced 的第一次运行(含 prepare 和规划)在上面的实验里是 0.2 到 4 ms,之后和具体张量的执行同一个量级(链:0.078 ms 对 0.059 ms)。没装模块时,即使所有形状都是常数也跑不了:

== traced einsum on a runtime without the einsum module
Err(Runtime::run (Execution): runtime state failure: missing extension family "tenferro.einsum.v1")
with module: ok [2, 2] [12.0, 12.0, 12.0, 12.0]
📝NOTE
没有蠢问题

问: EinsumExtensionOp 实现了 lower_to_standard_ops,能降成上面那串 DotGeneral、Transpose,为什么 CPU 运行时还要装 einsum 模块?

答: 因为 CPU 运行时从不调用它。v0.8.0 整个仓库里调 lower_to_standard_ops 的只有 tenferro-xla 的 lowering/program.rs;trait 的文档把它定位成给「peer lowerers such as XLA that cannot execute extension runtimes」用的。CPU 运行时把 einsum 当一个整体的扩展算子交给模块,三个以上输入时再整条交给 cpueinsum;降成逐对的 DotGeneral 反而用不上 cpueinsum 的中间结果复用。上游设计文档 einsum.md 的表格说全是常数形状时「optimize the contraction tree at graph build time and lower into ordinary graph ops where possible」,和 v0.8.0 的代码不符:建图时不规划,执行时也不降成普通图算子。

lowering.rs 这个模块名容易让人以为降低在这里。它只有只读视图:PairwiseStepPlan 和 GemmPlan 把 ContractionTree 预先算好的每步计划(取对角、预归约、m、n、k、批维)暴露出去。tenferro 自己的执行器不读这些计划,v0.8.0 里唯一的用户是 ext/tropical,它用 GemmPlan 写热带半环的 GEMM(见 扩展算子)。

eager

eager 的 einsum 在 0.8.0 是借来的会话上的方法:EagerSessionEinsumExt,写在 rt.with_eager_session(&mut engine, |s| s.einsum(&[&a, &b], "ij,jk->ik")) 里,取代了 0.7.1 自己开会话的 EagerEinsumExt(#1947)。einsum_subscripts_with_broadcast 依次试三条路:两个输入能写成一次 dot_general 的,调 EagerSession::dot_general;不需要广播时,try_expand_eager_einsum 用 build_einsum_graph 把收缩展开成核心算子程序(按下标和形状缓存),逐个当作 eager 运算执行;只有需要长度 1 广播时才建 EinsumExtensionOp,按 engine 的种类现场造模块装上。前两条路记录的是核心算子,求导用核心规则,不需要 einsum 的规则集;第三条用 einsum 自己的规则。这三条路的求导实验在 semantic AD 与扩展。0.7.1 里由环境变量 TENFERRO_EAGER_WHOLE_PROGRAM 打开的另一条路已经删掉(#1946)。

AD 规则

semantic_ad_rules 注册了三种角色,用的是同一个 EinsumAdRule。extension.rs 从 v0.7.1 到 v0.8.0 只删了经旧后端会话执行的入口,规则本身没变。

前向(linearize)用的是 einsum 对每个输入都是线性的:对每个有切向量的输入,把它换成切向量、其余输入不变,各做一次同样的 einsum,再把这些项加起来。

反向的 primal_vjp 和 linear_transpose 都调 semantic_einsum_vjp,残差掩码是所有输入。对第 active_idx 个输入,余切是另一个 einsum:输入是输出的余切加上其余输入(复数时取共轭),输出标签是这个输入的标签里「在输出或其他输入里出现过」的那些:

        let vjp_output_labels: Vec<u32> = input_labels[active_idx]
            .iter()
            .copied()
            .filter(|label| available_labels.contains(label))
            .collect();
        // ...
        let mut input_cotangent = builder.add_extension(Arc::new(vjp_op), &vjp_inputs)?[0];
        if vjp_output_labels != input_labels[active_idx] {
            input_cotangent = semantic_broadcast_einsum_vjp(
                builder,
                input_cotangent,
                &vjp_output_labels,
                &input_labels[active_idx],
                primal_input_shapes[active_idx].clone(),
            )?;
        }
        result.push(AdValue::Value(input_cotangent));

只在这个输入里出现、前向被求和掉的标签,导数沿它是常数,semantic_broadcast_einsum_vjp 用 BroadcastInDim 补回来;重复标签用 ExtractDiag 再 EmbedDiag 投影到对角线上。反向的 einsum 本身也是一个 EinsumExtensionOp,计划规格从原算子继承(显式路径会改写成以余切为第一个输入的等价路径),所以反向同样在运行时规划。

长度 1 广播时梯度形状错

semantic AD 与扩展 在 0.8.0 上重跑了 a[2,1]、"ij,jk->ik":前向对,backward 和函数式 grad 给出的梯度都是 [2, 3],有限差分是 [21, 21],写成省略号 ...j,jk->...k 也一样。错的梯度正是「把 a 当成已经广播后的张量」时的梯度,有限差分给出的是它沿被广播的轴求和:5 + 7 + 9 = 21。

原因在上面那段代码。以 a 为例,j 也出现在 b 里,所以 vjp_output_labels 就是 [i, j],等于 a 自己的标签,if 不成立,什么都不补。反向 einsum "ik,jk->ij" 的 j 从 b 那里取到尺寸 3,结果就是 [2, 3]。规则只看标签,不看形状:primal_input_shapes 就在手边,却只用来给 BroadcastInDim 当目标形状;前向允许 j 在 a 里是 1、在 b 里是 3,反向没有对应地「在 a 的尺寸为 1、而余切尺寸大于 1 的轴上求和并保持维数」。修法就是在载荷的 allow_broadcast 为真时补上这一步(ReduceSum 这些轴再 Reshape 回原形状)。三个以上输入时,这个问题被前向的 cpueinsum 报错先挡住了。

📝NOTE
冷知识:广播是随省略号一起加进来的,反向测试没有用到广播

PR #1705(「feat: add einsum ellipsis notation」,2026-08-17,首次发布于 v0.4.0)新增了 ellipsis.rs、EinsumNotation、扩展算子载荷里的 allow_broadcast 位,以及 eager 入口里的 requires_broadcast。之前 einsum 要求同一标签的尺寸严格相等,不存在「前向广播、反向要求和」的问题。这个 PR 的前向测试覆盖了广播([2,2,3] 配 [1,3,2]),反向测试的两个输入批维都是 2,没有长度 1 的轴,semantic_einsum_vjp 在这个 PR 里也没有改动广播求和的逻辑。

动脑筋答案

位置列表从 [0, 1, 2, 3, 4, 5, 6] 开始,每步取前两个、结果追加到末尾:

步当前列表收缩结果编号
10 1 2 3 4 5 6A·B7
22 3 4 5 6 7C·D8
34 5 6 7 8E·F9
46 7 8 9v(标签 o)和 7(标签 ik)10

实验打印的 EinsumOptimize::False 的 pairs=[(0, 1), (2, 3), (4, 5), (6, 7), (8, 9), (10, 11)] 就是这张表。第 4 步把向量 v[o] 和 AB[i,k] 放在一起,两者没有共享标签,是外积。七个 f64 输入走 cpueinsum,它给这一步的结果保留 i(输出要)、k(CD 还要)、o(EF 还要),得到一个 [i, k, o] 的 256×256×256 张量,即实验里 peak=1.678e7 的来源,单线程 20.4 ms 里大部分花在这一步。所以 False 在四个以上输入时不是「从左到右」,而是两两配对,再对结果两两配对;对链式的网络,它会把不相邻的两段放在一起。需要真正从左到右时,用 EinsumOptimize::Path 每步写 (0, 当前长度 - 1)(写成 [(0, 1), (0, 1), ..] 正是 False),或者直接给一个 Tree。

小结

  • 字符串解析成 EinsumNotation,知道秩后展开成整数标签;省略号的轴从 u32::MAX 往下分配标签。具体张量对任何标签都允许长度 1 广播,eager 在需要时打开,traced 只在写了 ... 时允许,否则编译时报形状约束失败。
  • 两个输入、恰好是一次原顺序 dot_general 的式子不建树(#2325):16×16 字符串入口 2.2 µs,准备好的计划 1.4 µs,和直接调 dot_general_read 一样,结果逐位相同。
  • 三个以上输入的默认计划是 tenferro 自己的确定性贪心(#2014),不调 omeco;同一输入在任何进程里都是同一棵树、同样的位。只有开了退火才用 omeco 的 TreeSA,它的结果每次调用都可能不同。网格上 TreeSA 的代价是贪心的 1/3,但规划要几十毫秒;EinsumOptimize::False 在四个以上输入时是两两配对,可能产生外积。
  • CPU 上三个以上 F32 / F64 / C32 / C64 输入整条交给 cpueinsum:tenferro 只给顺序,中间标签、暂存区、每步的 tcontract 计划都是 cpueinsum 的。它不做长度 1 广播、不接受重复的输出标签,所以这些式子两操作数能算、三操作数报错。
  • traced 下整个 einsum 是一个扩展算子,运行时按形状规划并缓存树,CPU 上从不降成普通图算子;lowering.rs 只是给外部 crate 的只读计划。
  • AD:前向按多重线性逐项求和,反向每个输入一个 einsum。前向允许长度 1 广播,反向没有沿广播轴求和,梯度是广播后的形状,0.8.0 上仍然如此。

💬 关联讨论

发起讨论
加载中...