写一个迷你 einsum
主线位置:把 einsum 线自己走一遍。解析
"ij,jk,kl->il"(对应Subscripts::parse)→ 选两两收缩的顺序(对应ContractionTree::optimize)→ 每一步把标签分成四类、两边各置换一次、做一批矩阵乘(tenferro 在 CPU 上把顺序交给 cpueinsum,每一步是一个 tcontract 计划)→ 排成输出要求的轴序。每一步都拿嵌套循环和 tenferro 核对;einsum 线最后一步的 AD 规则 mini 没有,和 tenferro 多出来的其他东西一起放在最后对照。
flowchart TB str["下标字符串"] --> parse["parse"] parse --> pre["预先求和"] parse --> plan["greedy_plan"] pre --> exec["execute"] plan --> exec exec --> split["Split 四类标签"] split --> perm["两边 permute"] perm --> mm["batch 个 matmul"] mm --> next["下一步"] next --> split next --> out["排成输出轴序"]
mini 是一个没有任何依赖的库 mini-einsum。tenferro 和 omeco 只出现在 [dev-dependencies] 里,只有 tests/tenferro_compare.rs 和 examples/ 用到:
[dependencies]
# 只有对照测试 tests/tenferro_compare.rs 用 tenferro 和 omeco;库本身没有依赖
[dev-dependencies]
tenferro-einsum = "=0.8.0"
tenferro-cpu = "=0.8.0"
tenferro-tensor = "=0.8.0"
omeco = "=0.2.6"
omeco 是 tenferro-einsum 0.8.0 自己的依赖(要求 0.2.4 起,解析到 0.2.6),测试直接用它做穷举搜索、核对代价。实验 crate 是 exp-tenferro8/site-mini-einsum,平台 M2 Max、macOS 27、rustc 1.99.0。除了「快慢」一节和「顺序和末位」一节是 release,其余都是 debug 构建。库里没有 unwrap / expect,出错一律返回 Result;测试里随便用。
范围
| 文件 | 做什么 | 代码行(不含空行和注释) |
|---|---|---|
src/parse.rs | Equation:解析下标、从形状得到标签长度、算出每个输入要预先求和掉哪些标签 | 70 |
src/plan.rs | Plan、Step:贪心规划、按给定顺序建计划、四类标签的划分、每一步的代价 | 105 |
src/contract.rs | Tensor(列主序 Vec<f64> 加形状)、置换、求和、两两收缩、execute、einsum | 152 |
src/lib.rs | Error、导出 | 12 |
tests/common/mod.rs | 随机数、嵌套循环的参照实现 | 64 |
tests/naive.rs | 13 个下标和参照实现比;换收缩顺序;长度为 0 的维;报错 | 80 |
tests/tenferro_compare.rs | 7 个下标和 tenferro 比数值;4 个网络比规划代价 | 96 |
examples/timing.rs | release 计时:mini 两种顺序、tenferro 8 线程和串行 | 55 |
examples/dialect.rs | 几个 mini 不收的下标交给 tenferro | 17 |
examples/plan_bits.rs | 同一个 einsum 的默认规划、退火规划、结果各重复多次,数有几种 | 65 |
examples/greedy_cost.rs | 打印贪心计划的每一步 | 10 |
库一共 339 行代码,加上空行和注释 413 行。
三个源文件对应 tenferro 的三块:parse.rs 是 tenferro-einsum 的 syntax/,plan.rs 是 planning/tree.rs,contract.rs 在 CPU 上对应 cpueinsum 和它下面的 tcontract。tenferro-einsum 的 cpu_concrete.rs 第一行就写明分工:「Notation and order search stay in tenferro.」tenferro 在这三块之外还有三种入口(直接计算、traced、eager)、AD 规则、扩展模块,mini 一样都没有。
解析下标
/// 解析后的下标:每个输入一串标签,加输出标签。
#[derive(Debug, Clone, PartialEq)]
pub struct Equation {
pub inputs: Vec<Vec<char>>,
pub output: Vec<char>,
}
/// 解析 `"ij,jk,kl->il"`。必须有且只有一个 `->`;标签只能是 ASCII 字母;
/// 不支持省略号,也不支持同一个输入里重复的标签(对角线)。
pub fn parse(notation: &str) -> Result<Equation> {
let Some((lhs, rhs)) = notation.split_once("->") else {
return err(format!("missing '->' in {notation:?}"));
};
if rhs.contains("->") {
return err(format!("more than one '->' in {notation:?}"));
}
let inputs = lhs.split(',').map(labels).collect::<Result<Vec<_>>>()?;
let output = labels(rhs)?;
// ... 同一输入里重复的标签、输出里重复的标签、输出里有而输入里没有的标签,都报错
Ok(Equation { inputs, output })
}
三个取舍:
- 箭头必须写。numpy 允许省略
->,按「只出现一次的标签按字母序排」推出输出。tenferro 也要求写:split_and_validate_notation按->切开,不是正好两段就报错。 - 不支持省略号。
...代表「若干个前导轴」,展开它要先知道每个输入的秩,tenferro 在拿到输入之后才做(resolve_einsum_notation)。mini 的解析不看输入,所以不做。 - 不支持对角线。
"ii->i"里同一个输入的两个轴共用一个标签,意思是只取两个下标相等的元素A[i, i]。这要在收缩之前多一个「取对角」的步骤,步长是两个轴的步长之和;tenferro 的步骤计划里专门有一个compute_diag_plan,cpueinsum 那一侧则由 tcontract 处理(「A label repeated withinaorbselects a diagonal」)。mini 直接报错,这样后面每一步都可以假设「一个标签在一个操作数里至多出现一次」。
标签用 char。tenferro 的标签是 u32,由字符转来:char_to_label 把任何非保留字符转成 c as u32,所以 ×、÷ 这类 opt_einsum 在字母用完时生成的标签也能用。mini 只认 ASCII 字母。
Equation::sizes 从输入形状得到每个标签的长度,秩不对、同一个标签两处长度不同都报错。mini 不做广播:tenferro 允许长度为 1 的维和别的长度配对,这条路上的问题见 semantic AD 与扩展 和 einsum;mini 一律当成长度不一致。
先把只出现一次的标签求和掉
"ij,jk->k" 里 i 只在第一个输入里出现,输出里也没有。它和别的输入无关,收缩之前先把它求和掉,矩阵就从 [i, j] 变成向量 [j],后面那次乘法小了 i 倍:
/// 每个输入在收缩之前先求和掉的标签:只在这一个输入里出现、输出里也没有。
/// 返回每个输入剩下的标签。
pub(crate) fn reduced_inputs(&self) -> Vec<Vec<char>> {
let count = |label: &char| self.inputs.iter().filter(|op| op.contains(label)).count();
self
.inputs
.iter()
.map(|op| op.iter().copied().filter(|l| self.output.contains(l) || count(l) > 1).collect())
.collect()
}
只在开头做一次就够了。中间结果只留下「输出或者别的操作数还要用」的标签,所以收缩到后面不会再冒出只属于一个操作数的标签。tenferro 的步骤计划是每一步各做一次(compute_reduce_plan),上游的 contraction-pipeline.md 把这类标签叫「pre-reduced」。
求和本身复用置换:把要留的轴排到前面,列主序下数据就是一个「留下的 × 求和的」矩阵,按列加起来:
fn sum_except(self, keep: &[char]) -> Labeled {
let (kept, dropped): (Vec<char>, Vec<char>) = self.labels.iter().partition(|l| keep.contains(l));
if dropped.is_empty() {
return self;
}
let rows = self.product(&kept);
let shape = kept.iter().map(|&l| self.size(l)).collect();
let moved = self.arrange(&[&kept[..], &dropped].concat());
let mut data = vec![0.0; rows];
if rows > 0 {
for column in moved.data.chunks(rows) {
data.iter_mut().zip(column).for_each(|(acc, x)| *acc += x);
}
}
Labeled { labels: kept, tensor: Tensor { shape, data } }
}
rows > 0 的判断不能省:chunks(0) 会 panic。
两两收缩:置换、reshape、matmul
一步收缩两个操作数 a、b。两边的标签分成四类,keep 是这一步之后还要用到的标签(在输出里,或者在别的还没收缩的操作数里):
/// 两个操作数一起出现的标签分成四类,决定置换之后的布局:
/// 左边 `[left, contracted, batch]`,右边 `[contracted, right, batch]`。
pub(crate) struct Split {
pub left: Vec<char>,
pub contracted: Vec<char>,
pub right: Vec<char>,
pub batch: Vec<char>,
}
impl Split {
pub(crate) fn new(a: &[char], b: &[char], keep: &[char]) -> Split {
let (shared, left): (Vec<char>, Vec<char>) = a.iter().partition(|l| b.contains(l));
let (batch, contracted) = shared.into_iter().partition(|l| keep.contains(l));
let right = b.iter().copied().filter(|l| !a.contains(l)).collect();
Split { left, contracted, right, batch }
}
pub(crate) fn result_labels(&self) -> Vec<char> {
[&self.left[..], &self.right, &self.batch].concat()
}
}
left:只在a里(预先求和之后,只在一边的标签一定还要用)。right:只在b里。contracted:两边都有,之后不再用,这一步求和掉。batch:两边都有,之后还要用,例如"bij,bjk->bik"的b,或者"ij,ij->ij"的i、j。
tenferro 的 classify_modes 是同样的四类(batch、lo、ro、sum),每类按标签在 a(ro 按 b)里第一次出现的顺序排。
分好类,两边各置换一次:a 排成 [left, contracted, batch],b 排成 [contracted, right, batch]。列主序下第一个轴变得最快,所以 a 的数据就是 batch 个连续的 m × k 矩阵(m 是 left 各轴长度之积,k 是 contracted 的),b 是 batch 个 k × n 矩阵。reshape 不移动数据,同一个 Vec 换一种方式数下标而已。结果是 batch 个 m × n,轴序 [left, right, batch]:
fn contract_pair(a: &Labeled, b: &Labeled, labels: &[char], split: &Split) -> Labeled {
let m = a.product(&split.left);
let k = a.product(&split.contracted);
let n = b.product(&split.right);
let batch = a.product(&split.batch);
let lhs = a.arrange(&[&split.left[..], &split.contracted, &split.batch].concat());
let rhs = b.arrange(&[&split.contracted[..], &split.right, &split.batch].concat());
let mut out = vec![0.0; m * n * batch];
if m * n * k > 0 {
let blocks = out.chunks_mut(m * n).zip(lhs.data.chunks(m * k)).zip(rhs.data.chunks(k * n));
for ((c, a), b) in blocks {
matmul(a, b, c, m, k);
}
}
// ... 由 labels 得到结果形状
}
/// 列主序 `c[m x n] += a[m x k] * b[k x n]`。最内层沿 `a` 和 `c` 的一列走,都是连续内存。
fn matmul(a: &[f64], b: &[f64], c: &mut [f64], m: usize, k: usize) {
for (c_col, b_col) in c.chunks_mut(m).zip(b.chunks(k)) {
for (a_col, &b_pj) in a.chunks(m).zip(b_col) {
c_col.iter_mut().zip(a_col).for_each(|(c, &a)| *c += a * b_pj);
}
}
}
batch 放在最后,是因为列主序下最后一个轴变得最慢,每个批的切片才是一整块连续内存。行主序的库(numpy、PyTorch)把批放在最前面,是同一个道理反过来。外积("i,j->ij")的 contracted 是空的,k = 1;逐元素乘("ij,ij->ij")的 left、right 都是空的,m = n = 1,变成 batch 个 1 × 1 的乘法。没有特殊情况。
tenferro 的 ContractionTree 建好之后,每一步仍会算一份 GemmPlan,记的就是这套布局:
/// Target subscript order for A: [lo..., sum..., batch...].
pub(crate) target_a: Vec<u32>,
/// Target subscript order for B: [sum..., ro..., batch...].
pub(crate) target_b: Vec<u32>,
/// Shape of GEMM output: [m, n, batch...].
pub(crate) c_gemm_shape: Vec<usize>,
但在 CPU 上真正执行的不是它。直接计算的 einsum 把树里的顺序交给 cpueinsum(cpu_concrete.rs 里把 tree.steps 转成 EinsumSpec 的 order),cpueinsum 的 crate 文档说它的每一步是「one tcontract plan (packed TBLIS-style driver, faer on a copy-free GEMM fusion, or an elementwise pass)」,所有中间结果共用一块可复用的暂存区。tcontract 直接读带步长的视图,能不搬数据就不搬(列主序与视图、provider:native 与 BLAS);mini 的 arrange 在不需要置换时克隆,需要时总是拷一份。GemmPlan 这套步骤计划经 ContractionTree::step_plan 以只读的 lowering 视图公开给别的后端。
置换本身是一个「里程表」:按输出的顺序一个个元素写,多重下标像里程表一样进位,输入里的偏移量跟着加减步长:
/// 新的第 `a` 轴是原来的第 `perm[a]` 轴。按输出顺序走,
/// 用一个里程表式的多重下标在输入里跳。
fn permute(&self, perm: &[usize]) -> Tensor {
let strides = col_major_strides(&self.shape);
let shape: Vec<usize> = perm.iter().map(|&p| self.shape[p]).collect();
let steps: Vec<usize> = perm.iter().map(|&p| strides[p]).collect();
let len = self.data.len();
let mut data = Vec::with_capacity(len);
let mut index = vec![0; shape.len()];
let mut offset = 0;
for _ in 0..len {
data.push(self.data[offset]);
for axis in 0..shape.len() {
index[axis] += 1;
offset += steps[axis];
if index[axis] < shape[axis] {
break;
}
offset -= steps[axis] * shape[axis];
index[axis] = 0;
}
}
Tensor { shape, data }
}
贪心地选顺序
收缩顺序不影响结果(只影响浮点舍入),但影响代价,而且可以差好几个数量级。mini 给一步定义的代价是乘加次数:m · k · n · batch,也就是两边标签并集里各标签长度之积。整个计划的代价是各步之和。
/// 贪心:每一步在所有还活着的操作数对里挑乘加次数最少的一对,相同时取先遇到的。
pub fn greedy_plan(eq: &Equation, sizes: &HashMap<char, usize>) -> Result<Plan> {
let mut builder = Builder::new(eq);
while builder.live.len() > 1 {
let mut best: Option<(u128, usize, usize)> = None;
for (i, &a) in builder.live.iter().enumerate() {
for &b in &builder.live[i + 1..] {
let cost = step_cost(&builder.ops[a], &builder.ops[b], sizes)?;
if best.is_none_or(|(c, _, _)| cost < c) {
best = Some((cost, a, b));
}
}
}
let Some((_, a, b)) = best else { break };
builder.push(a, b, sizes)?;
}
Ok(builder.finish())
}
builder.push 把 a、b 从活着的列表里删掉,用 Split 算出结果的标签,把结果追加成新的操作数。编号和 tenferro 的 ContractionTree 一样:输入是 0..n,第 s 步的结果是 n + s,所以 tenferro 的 step_pair 给出的顺序可以原样交给 mini 的 Plan::from_pairs,用同一个代价模型算。每一步看所有的对,n 个输入一共 O(n³) 次代价计算;遇到相等的代价取先遇到的那对,同样的输入永远得到同样的计划。
这个代价和 omeco 的「时间复杂度」是同一个量。omeco 的 nested_complexity 对每个节点取「这一步出现过的所有标签」的长度之积(取 log2),各步用 log2sumexp2 加起来,flops() 再取回 2^tc。下文的测试对同一个顺序同时打印两边的数,相等。
和嵌套循环对一对
参照实现不分步,不置换:枚举所有标签的每一种取值组合,把各输入对应的元素乘起来,加到输出对应的位置。三十几行,慢,但没有可以写错的地方。tests/naive.rs 给每个标签一个不同的长度(2 到 5),置换错了形状就对不上。cargo test --offline --test naive -- --nocapture --test-threads=1:
running 4 tests
test any_order_gives_same_result ... [(0, 1), (5, 2), (6, 3), (7, 4)] cost 189 diff 1.2e-15
[(3, 4), (2, 5), (1, 6), (0, 7)] cost 198 diff 1.5e-15
[(0, 4), (1, 2), (5, 6), (3, 7)] cost 258 diff 1.6e-15
ok
test errors ... ij,jk Error("missing '->' in \"ij,jk\"")
ii->i Error("label 'i' repeats in input 0 (diagonal is not supported)")
...ij->ij Error("ellipsis '...' is not supported")
ij->ik Error("output label 'k' appears in no input")
ij,jk->ik Error("label 'j': size 2 vs 3 (input 1)")
ij,jk->ik Error("2 inputs in equation, 1 tensors given")
ok
test matches_naive_reference ... ij,jk->ik shape [3, 4] plan [(0, 1)] max |mini - naive| = 0.0e0
ij,jk,kl->il shape [3, 3] plan [(0, 1), (2, 3)] max |mini - naive| = 1.1e-16
bij,bjk->bik shape [3, 3, 4] plan [(0, 1)] max |mini - naive| = 0.0e0
ij,ij->ij shape [3, 2] plan [(0, 1)] max |mini - naive| = 0.0e0
i,j->ij shape [3, 2] plan [(0, 1)] max |mini - naive| = 0.0e0
i,i-> shape [] plan [(0, 1)] max |mini - naive| = 0.0e0
ij->ji shape [2, 3] plan [] max |mini - naive| = 0.0e0
ijk->kj shape [4, 2] plan [] max |mini - naive| = 0.0e0
ij,jk->k shape [4] plan [(0, 1)] max |mini - naive| = 1.1e-16
ab,bc,cd,da-> shape [] plan [(0, 1), (2, 3), (4, 5)] max |mini - naive| = 8.9e-16
abc,cd,dbe->ae shape [2, 2] plan [(0, 1), (2, 3)] max |mini - naive| = 8.9e-16
ijk,jl,klm,mn,in-> shape [] plan [(3, 4), (1, 5), (0, 2), (6, 7)] max |mini - naive| = 1.0e-15
pqr,rs,st,tq,pu->us shape [5, 4] plan [(2, 3), (1, 5), (0, 6), (4, 7)] max |mini - naive| = 1.4e-16
ok
test zero_length_dimension ... [2,0] x [0,3] -> [2, 3] [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
- 13 个下标覆盖了矩阵乘、矩阵链、批、逐元素、外积、点积、纯置换、置换加求和、预先求和、环(
ab,bc,cd,da->是四个矩阵乘积的迹)、多输入多输出。差别都在 1e-15 以内,来自加法顺序:参照实现按「所有标签的组合」一个个加,mini 按矩阵乘的列加。 ij->ji、ijk->kj的计划是空的:一个输入不需要收缩,预先求和掉i(第二个),再排成输出的轴序。any_order_gives_same_result对同一个五输入网络手工给三种顺序,代价 189、198、258,结果相同。贪心选的是[(3, 4), (1, 5), (0, 2), (6, 7)],examples/greedy_cost.rs打印它每一步的代价:
(3, 4) -> ['m', 'i'] cost 30
(1, 5) -> ['j', 'l', 'm', 'i'] cost 36
(0, 2) -> ['i', 'j', 'l', 'm'] cost 144
(6, 7) -> [] cost 36
total 246
贪心的 246 比手工的第一种(189)还高:前两步各挑了最便宜的,第三步只剩 ijk 和 klm 两个大张量相乘。下文在更大的网络上还会看到这种短视。
[2, 0] × [0, 3]得到全 0 的[2, 3]:k = 0时求和是空和。matmul前的m * n * k > 0判断挡住了chunks(0)。
和 tenferro 对数值
tests/tenferro_compare.rs 把同样的随机输入交给 tenferro 的直接计算入口:
fn tenferro_einsum(notation: &str, inputs: &[&Tensor]) -> Vec<f64> {
let typed: Vec<TypedTensor<f64>> = inputs
.iter()
.map(|t| TypedTensor::<f64>::from_vec_col_major(t.shape().to_vec(), t.data().to_vec()).unwrap())
.collect();
let refs: Vec<&TypedTensor<f64>> = typed.iter().collect();
let mut engine = CpuEngine::serial();
let out = refs.as_slice().einsum(notation, &mut engine).unwrap();
out.as_slice().unwrap().to_vec()
}
两边都是列主序,数据原样拷过去,不用转置。engine 由调用方持有、按 &mut 借给每次运算(engine 与 threadteam 池)。
test values_match_tenferro ... ij,jk->ik max |mini - tenferro| = 0.0e0
bij,bjk->bik max |mini - tenferro| = 0.0e0
ij,jk->k max |mini - tenferro| = 2.8e-17
ijk->kj max |mini - tenferro| = 0.0e0
ab,bc,cd,da-> max |mini - tenferro| = 0.0e0
ijk,jl,klm,mn,in-> max |mini - tenferro| = 2.2e-16
pqr,rs,st,tq,pu->us max |mini - tenferro| = 4.4e-16
ok
[&TypedTensor<f64>] 上的 TypedTensorEinsumExt 的 einsum 解析下标后进 typed_einsum_subscripts:两个输入、能写成一次 dot_general 时直接走捷径,否则每次调用现建一个 ConcreteEinsumPlan,其中 plan_subscripts 调 ContractionTree::optimize。也就是说,直接计算的 einsum 和 mini 一样,每次调用都重新规划一遍。
规划代价:mini 的贪心和 tenferro
tenferro 的规划在 ContractionTree::optimize_with_options:一个输入不规划,两个输入直接 (0, 1),三个及以上看选项。默认的 ContractionOptimizerOptions 是空的温度表、1 次试验、0 次迭代:
impl Default for ContractionOptimizerOptions {
fn default() -> Self {
Self {
betas: Vec::new(),
ntrials: 1,
niters: 0,
score: ScoreFunction::default(),
}
}
}
不退火时,tenferro 不调 omeco,而是跑自己的 optimize_self_greedy_pairs;只有温度表非空且迭代次数大于 0 时才交给 omeco 的 TreeSA。自己的贪心规则写在它的文档注释里:
/// Each step contracts the connected pair (sharing at least one label) whose
/// result is smallest, the size counting every label still needed after the
/// pair: by another operand or by the output. Ties go to the smallest
/// `(left, right)` operand index pair, where an intermediate takes the next
/// index after all existing operands. When no remaining operands share a
/// label, the two smallest indices form an outer product.
它和 mini 有两处不同:只考虑有公共标签的对(没有相连的对了才做外积),mini 看所有的对;代价是结果的元素个数,mini 是乘加次数。注释接着说这是 omeco 的 tree_greedy 在 alpha = 0、temperature = 0 时的规则(omeco 的 greedy_loss 是 size(output) - α * (size(input1) + size(input2)))。ContractionTree::optimize 的文档仍说它「determine[s] the pairwise contraction sequence that minimizes total operation count」,实际每一步按结果大小选,不直接看运算次数。traced 入口的默认值 default_auto_options 换成了 ScoreFunction::time_optimized(),上游 einsum.md 称之为「time-optimized automatic planning」;但它也是 0 次迭代,走的还是同一个贪心,评分用不上。
测试比四种顺序,都换算成 mini 的代价(总乘加次数):
- mini greedy:上面的
greedy_plan。 - tenferro default:
ContractionTree::optimize,直接计算的 einsum 用的就是它。 - tenferro TreeSA:
optimize_with_options,温度表和 omeco 的TreeSA::fast()相同(100 个温度、每个 20 次迭代),10 次试验,评分ScoreFunction::time_optimized()。 - omeco exhaustive:直接调 omeco 的
optimize_exhaustive,动态规划求总运算次数最少的树,当作最优解;括号里是 omeco 自己算的flops(),用来核对两边的代价模型。
四个网络:mps6、mps8 是两个矩阵乘积态(MPS)的内积,3 个和 4 个格点,物理维 2、键维 32;grid6 是 2 × 3 的闭合格点,所有键长 8;chain8 是 8 个矩阵的链,维度在 2 到 70 之间交替。cargo test --offline --test tenferro_compare -- --nocapture --test-threads=1:
running 2 tests
test plan_cost_vs_tenferro ... mps6: pa,aqb,br,pc,cqd,dr->
mini greedy 137216 [(0, 3), (2, 5), (1, 6), (4, 7), (8, 9)]
tenferro default 8712 [(0, 1), (2, 6), (3, 4), (5, 8), (7, 9)]
tenferro TreeSA 8712 [(3, 4), (5, 6), (1, 2), (8, 0), (7, 9)]
omeco exhaustive 8712 [(0, 1), (6, 2), (3, 4), (8, 5), (7, 9)] (omeco flops 8712)
mps8: pa,aqb,brc,cs,pw,wqx,xry,ys->
mini greedy 267264 [(0, 4), (3, 7), (1, 8), (2, 9), (5, 10), (6, 11), (12, 13)]
tenferro default 17424 [(0, 1), (2, 3), (8, 9), (4, 5), (6, 7), (11, 12), (10, 13)]
tenferro TreeSA 17424 [(5, 4), (7, 6), (8, 9), (3, 2), (0, 1), (11, 12), (10, 13)]
omeco exhaustive 17424 [(0, 1), (2, 3), (8, 9), (4, 5), (6, 7), (11, 12), (10, 13)] (omeco flops 17424)
grid6: ad,abe,bf,dg,egh,fh->
mini greedy 9728 [(0, 3), (2, 5), (1, 6), (4, 7), (8, 9)]
tenferro default 9280 [(0, 3), (2, 5), (1, 6), (4, 8), (7, 9)]
tenferro TreeSA 9280 [(3, 0), (6, 4), (7, 1), (8, 5), (2, 9)]
omeco exhaustive 9280 [(0, 3), (6, 4), (7, 1), (8, 5), (9, 2)] (omeco flops 9280)
chain8: ab,bc,cd,de,ef,fg,gh,hi->ai
mini greedy 2740 [(0, 1), (2, 8), (3, 9), (4, 10), (5, 11), (6, 7), (12, 13)]
tenferro default 2820 [(0, 1), (6, 7), (2, 3), (8, 10), (4, 5), (9, 12), (11, 13)]
tenferro TreeSA 2656 [(2, 3), (7, 6), (5, 9), (4, 10), (8, 11), (1, 0), (12, 13)]
omeco exhaustive 2656 [(0, 1), (2, 3), (6, 7), (5, 10), (4, 11), (9, 12), (8, 13)] (omeco flops 2656)
ok
| 网络 | mini 贪心 | tenferro 默认 | TreeSA 退火 | 最优 |
|---|---|---|---|---|
mps6 | 137216(15.7 倍) | 8712 | 8712 | 8712 |
mps8 | 267264(15.3 倍) | 17424 | 17424 | 17424 |
grid6 | 9728(1.05 倍) | 9280 | 9280 | 9280 |
chain8 | 2740(1.03 倍) | 2820(1.06 倍) | 2656 | 2656 |
- 最后一列的括号和 mini 算的数相等:同一个顺序,mini 的「各步乘加次数之和」和 omeco 的
flops()是同一个数。 - MPS 上 mini 差了 15 倍多。
mps6的第一步,mini 看到pa和pc(两个态的第一个格点)只共享长度 2 的物理指标p,乘加 2 · 32 · 32 = 2048 次,是所有对里最便宜的;pa和aqb要 2 · 32 · 2 · 32 = 4096 次。可前者的结果是 32 × 32 的ac,后者只有 2 · 2 · 32 = 128 个元素。第三步aqb碰上ac,四个标签a q b c,32 · 2 · 32 · 32 = 65536 次,一步就是最优总代价的 7.5 倍。按结果大小选的 tenferro 从pa、aqb开始,沿着链「拉拉链」,每一步的中间结果都小。 - 矩阵链上 mini 反而比 tenferro 默认好一点(2740 对 2820),两者都不是最优。贪心只看一步,换哪个代价函数都有输的网络。
- tenferro 的退火在四个网络上都找到了最优解,代价是 10 次试验 × 2000 次迭代。直接计算和 eager 的 einsum 没有参数可以换优化器:
ConcreteEinsumPlan和 eager 的EagerSessionEinsumExt都只用默认值,EinsumOptimize只能经 traced 的einsum_with传进去。具体张量想要退火的顺序,可以像这个测试一样单独调optimize_with_options,再把顺序交给 traced 程序。 - 再看「TreeSA」那几行的顺序:多跑几次测试,这几行的顺序会变,代价不变;「tenferro default」那几行不变。下一节专门看这件事。
PR #574(「Optimize CPU einsum execution paths」,首次发布于 v0.1.0)之前,ContractionTree::optimize 自己写着一个贪心。这个 PR 带的计划文档 2026-03-27-treesa-only-planner-plan.md 写了目标:「Replace the hard-coded greedy contraction planner with a TreeSA-based public planner API whose default is greedy-initialized TreeSA with zero annealing iterations」,原来的贪心改名 optimize_self_greedy_pairs,只在 omeco 返回 None 时兜底。于是默认路径来自 omeco 0.2.6 的贪心,而它按 HashMap 的遍历顺序打破平局。0.8.0 的提交 96b89e08(随 PR #2014 合入,修 issue #1963,首次发布于 v0.8.0)把方向倒了回来,提交说明是:「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, rewritten as omeco's greedy rule (connected pairs, smallest intermediate, alpha = 0) with index-ordered ties and incremental re-scoring.」这就是上面 optimize_with_options 里 !options.anneals() 那个分支;文档注释说等 omeco 发布带修复的版本再换回去。
顺序和末位:默认固定,退火不固定
mini 的贪心遇到相等的代价取先遇到的那对,同样的输入永远是同一个计划。tenferro 0.8.0 的默认贪心也是这样:候选对从 BTreeMap / BTreeSet 里按编号取出,放进以 (代价, left, right) 排序的 BinaryHeap,平局取编号最小的一对,不依赖哈希顺序。退火的路径仍来自 omeco,它的贪心初始化靠 IncidenceList 的 vertices() 决定入队顺序,而那是一个 std::collections::HashMap。
examples/plan_bits.rs 对 mps6(输入固定)做四件事:调 100 次 ContractionTree::optimize;用和上面测试相同的退火参数调 20 次 optimize_with_options;在 CpuEngine::serial() 上直接调 100 次 einsum(串行是为了排除多线程的累加顺序);准备一次 ConcreteEinsumPlan、执行 100 次。每种顺序都用 mini 的代价模型算总乘加次数。release,连跑 5 个进程,第一个进程的输出:
== process 1
default plan, 100 calls: 1 distinct
[(0, 1), (2, 6), (3, 4), (5, 8), (7, 9)] cost 8712 x100
annealed plan, 20 calls: 4 distinct
[(0, 1), (2, 6), (4, 5), (8, 3), (7, 9)] cost 8712 x10
[(2, 1), (0, 6), (4, 3), (8, 5), (7, 9)] cost 8712 x4
[(3, 4), (5, 6), (1, 2), (8, 0), (7, 9)] cost 8712 x3
[(5, 4), (3, 6), (1, 0), (8, 2), (7, 9)] cost 8712 x3
einsum(), 100 calls: 1 distinct
-2.91600766218305019e0 bits c00753fbd33fb164 x100
prepared plan, 100 runs: 1 distinct results
- 5 个进程的默认路径都是
[(0, 1), (2, 6), (3, 4), (5, 8), (7, 9)],每个进程 100 次都一样;einsum的结果在 5 个进程、500 次调用里逐位相同(c00753fbd33fb164)。准备好的计划执行 100 次也逐位相同。 - 退火路径在每个进程里 20 次就出现 4 种,5 个进程都是这 4 种,各自的次数不同(例如另一个进程是 6 / 6 / 6 / 2)。总代价都是最优的 8712,差别只在平局怎么打破。
- 不只是跨进程:同一个进程里,20 次退火就有 4 种路径。标准库的
RandomState每新建一个就换一组键(同一线程里在上一组上加 1),omeco 每次规划都新建HashMap,所以每次的遍历顺序都可能不同。上游回归测试tests/integration/deterministic_path.rs的注释说这种规划器「agrees with itself inside one process and only diverges across processes」,和这里的观察不符;那个测试只检查不退火的路径,结论不受影响。
所以要逐位复现(回归测试拿结果做哈希、两次运行比较 checkpoint),默认路径现在就可以;用了退火(只有 traced 的 EinsumOptimize::Auto 带非空温度表时),要么把规划结果存下来用 EinsumOptimize::Path 或带括号的写法固定,要么接受末位的差别。
快慢
examples/timing.rs,release,每项 5 次取最小。两个 einsum:512 × 512 × 512 的矩阵乘;mps8 把键维放大到 128。mini 跑两次,一次用自己的贪心,一次用 tenferro 规划出的顺序(Plan::from_pairs),只比内核不比顺序;tenferro 跑一个 8 线程的 engine 和 CpuEngine::serial()。连跑三次,开始时负载 3.4,下面是第一次:
ij,jk->ik
mini, greedy plan 19.49 ms cost 134217728 max diff 2.7e-15
mini, tenferro's plan 19.51 ms cost 134217728 max diff 2.7e-15
tenferro, 8 threads 0.88 ms
tenferro, serial 5.59 ms
pa,aqb,brc,cs,pw,wqx,xry,ys->
mini, greedy plan 2.80 ms cost 16859136 max diff 1.7e-12
mini, tenferro's plan 0.30 ms cost 266256 max diff 1.4e-13
tenferro, 8 threads 0.10 ms
tenferro, serial 0.09 ms
另外两次 mini 和串行的各项相差不到 3%,8 线程的矩阵乘是 0.94 和 1.17 ms。
- 内核:同样 1.34 亿次乘加,mini 的三重循环 19.5 ms(约 6.9 G 次乘加每秒),tenferro 串行 5.6 ms,8 线程 0.88 ms。两个输入的 einsum 走
dot_general捷径,交给 tcontract(provider:native 与 BLAS);串行差的 3.5 倍来自缓存分块、打包和 SIMD 微内核。 - 顺序:
mps8上 mini 的贪心代价是 tenferro 顺序的 63 倍,时间只差 9.3 倍(2.80 对 0.30 ms)。tenferro 的顺序每一步都是小矩阵,时间花在置换和分配上,不在乘加上。代价模型只数乘加,不数搬数据,这是它和真实时间之间的差距。 mps8这样的小运算,8 线程 engine 和串行一样快:每一步都小,下层按工作量判断后留在调用线程上算(engine 与 threadteam 池)。用 tenferro 的顺序,mini 比 tenferro 串行慢 3.3 倍。
只改 greedy_plan 里算 cost 的那一行:用 Split 算出结果的标签,再取它们的长度之积。mini 看所有的对,tenferro 只看相连的对,这一点不改。
tenferro 有而这里没有的
| tenferro 有 | mini 的做法 | 讲它的页 |
|---|---|---|
规划器:默认是确定的贪心(按结果大小、只看相连的对);traced 入口可以经 ContractionOptimizerOptions 换成 omeco 的 TreeSA(温度表、多次试验、按时间 / 空间 / 读写加权的 ScoreFunction);omeco 另有穷举和切片,tenferro 没有接 | 一个贪心,代价只数乘加 | einsum |
计划复用:ConcreteEinsumPlan 准备一次、执行多次,CPU 上还缓存 cpueinsum 的数值计划;traced 程序把规划策略存进扩展算子的负载 | 每次调用重新规划 | einsum、编译 |
AD 规则:semantic_ad_rules 注册 einsum 的线性化、转置和 VJP,traced 和 eager 共用 | 只有前向 | semantic AD 与扩展、einsum |
三种入口:直接计算的 TypedTensorEinsumExt;traced 的 TraceContextEinsumExt(运行时要装 extension_module);eager 的 EagerSessionEinsumExt | 一个 einsum 函数 | traced 张量、eager AD |
下标方言:...、取对角 ii->i 和嵌入对角 i->ii、带括号的顺序(NestedEinsum::parse,只在 traced 的 einsum_with 里)、任意 Unicode 标签、长度 1 的广播 | ASCII 字母,显式箭头,其余报错 | einsum |
视图与写入:TypedTensorReadEinsumExt 直接读带步长的视图,*IntoExt 写进调用方给的输出 | 输入先克隆,每步置换都拷贝 | 列主序与视图 |
| 执行:cpueinsum 按顺序调 tcontract,每步是打包的分块内核、faer GEMM 或逐元素一遍,可以在 engine 的 threadteam 池上并行;BLAS 构建换成 CBLAS | 三重循环,单线程 | provider:native 与 BLAS、engine 与 threadteam 池 |
| 中间结果:cpueinsum 的所有中间结果共用一块可复用的暂存区,输出进 engine 的缓冲池 | 每步新分配 Vec | 逐元素、SIMD 与融合 |
dtype:f32、f64、复数,Tensor 擦除类型 | 只有 f64 | 数据模型 |
| GPU:CUDA engine(本机没有,只读源码) | 没有 | CUDA |
examples/dialect.rs 把几个 mini 不收的下标交给 tenferro 的直接计算入口(输入是 [[1, 3], [2, 4]],列主序存 [1, 2, 3, 4]):
ii->i
mini Err(Error("label 'i' repeats in input 0 (diagonal is not supported)"))
tenferro Ok([1.0, 4.0])
ii->
mini Err(Error("label 'i' repeats in input 0 (diagonal is not supported)"))
tenferro Ok([5.0])
...ij->ji
mini Err(Error("ellipsis '...' is not supported"))
tenferro Ok([1.0, 3.0, 2.0, 4.0])
ij,jk
mini Err(Error("missing '->' in \"ij,jk\""))
tenferro Err(invalid einsum subscripts: einsum notation must contain exactly one '->', got: ij,jk)
(ij,jk),kl->il
mini Err(Error("invalid label '('"))
tenferro Err(invalid einsum subscripts: EinsumNotation::parse does not accept parentheses; use NestedEinsum::parse for parenthesized contraction order)
取对角、迹、省略号 tenferro 都收;不写箭头两边都拒绝;括号在直接计算入口也被拒绝,错误信息指向 NestedEinsum::parse。
mini 的结果和 tenferro 差在 1e-16 量级,规划在 MPS 上差 15 倍、在矩阵链上反而略好,内核差 3.5 到 22 倍。和 burn、candle 的动手页一样,追不上的是「工程」:可调的规划器、可复用的计划、带步长的视图、多线程的收缩内核、AD 规则和 GPU。
动脑筋答案
只改 greedy_plan 里的一行(实验 crate exp-tenferro8/site-mini-outsize,其余文件相同):
// 动脑筋:代价换成结果的元素个数
let keep: Vec<char> = builder.output.iter().chain(builder.live.iter().filter(|&&x| x != a && x != b).flat_map(|&x| builder.ops[x].iter())).copied().collect();
let cost = step_cost(&Split::new(&builder.ops[a], &builder.ops[b], &keep).result_labels(), &[], sizes)?;
cargo test --offline --test tenferro_compare plan_cost -- --nocapture:
running 1 test
mps6: pa,aqb,br,pc,cqd,dr->
mini greedy 8712 [(0, 1), (2, 6), (3, 4), (5, 8), (7, 9)]
tenferro default 8712 [(0, 1), (2, 6), (3, 4), (5, 8), (7, 9)]
tenferro TreeSA 8712 [(2, 1), (0, 6), (4, 3), (8, 5), (7, 9)]
omeco exhaustive 8712 [(0, 1), (6, 2), (3, 4), (8, 5), (7, 9)] (omeco flops 8712)
mps8: pa,aqb,brc,cs,pw,wqx,xry,ys->
mini greedy 17424 [(0, 1), (2, 3), (8, 9), (4, 5), (6, 7), (11, 12), (10, 13)]
tenferro default 17424 [(0, 1), (2, 3), (8, 9), (4, 5), (6, 7), (11, 12), (10, 13)]
tenferro TreeSA 17424 [(6, 7), (4, 5), (8, 9), (0, 1), (3, 2), (11, 12), (10, 13)]
omeco exhaustive 17424 [(0, 1), (2, 3), (8, 9), (4, 5), (6, 7), (11, 12), (10, 13)] (omeco flops 17424)
grid6: ad,abe,bf,dg,egh,fh->
mini greedy 9280 [(0, 3), (2, 5), (1, 6), (4, 8), (7, 9)]
tenferro default 9280 [(0, 3), (2, 5), (1, 6), (4, 8), (7, 9)]
tenferro TreeSA 9280 [(5, 2), (6, 4), (7, 1), (8, 5), (3, 9)]
omeco exhaustive 9280 [(0, 3), (6, 4), (7, 1), (8, 5), (9, 2)] (omeco flops 9280)
chain8: ab,bc,cd,de,ef,fg,gh,hi->ai
mini greedy 2820 [(0, 1), (6, 7), (2, 3), (8, 10), (4, 5), (9, 12), (11, 13)]
tenferro default 2820 [(0, 1), (6, 7), (2, 3), (8, 10), (4, 5), (9, 12), (11, 13)]
tenferro TreeSA 2656 [(2, 3), (7, 6), (5, 9), (4, 10), (8, 11), (1, 0), (12, 13)]
omeco exhaustive 2656 [(0, 1), (2, 3), (6, 7), (5, 10), (4, 11), (9, 12), (8, 13)] (omeco flops 2656)
test plan_cost_vs_tenferro ... ok
| 网络 | 按乘加次数 | 按结果大小 | tenferro 默认 |
|---|---|---|---|
mps6 | 137216 | 8712 | 8712 |
mps8 | 267264 | 17424 | 17424 |
grid6 | 9728 | 9280 | 9280 |
chain8 | 2740 | 2820 | 2820 |
- 四个网络上,改过的 mini 和 tenferro 默认不只代价一样,顺序也逐对相同。两边的规则只差「看所有的对还是只看相连的对」和平局怎么打破:mini 取先遇到的(编号小的在前),tenferro 取
(left, right)最小的,在这四个网络上每一步都是同一对,mini 每一步选中的也都是相连的对。 - MPS 上一下回到最优:结果小的那一步,下一步碰到的张量也小。张量网络里中间结果的大小决定了后面各步的规模;omeco(移植自 Julia 的 OMEinsumContractionOrders.jl)的贪心默认就按大小选,tenferro 照搬了这条规则。
- 矩阵链上反而变差(2740 → 2820)。哪个贪心都不保证最优;要最优就退火或穷举,代价是规划时间。
- TreeSA 那几行的顺序又和正文里不同,代价相同,就是上一节说的退火路径不固定。
小结
- einsum 的骨架是四步:解析下标,预先求和只在一个输入里出现的标签,选两两收缩的顺序,每一步把标签分成左、右、收缩、批四类,置换成
[left, contracted, batch]和[contracted, right, batch],做batch个矩阵乘。列主序下批放最后,每个批是一块连续内存;tenferro 的classify_modes和GemmPlan是同一套布局,CPU 上的执行则交给 cpueinsum 和 tcontract。 - mini 库 339 行,13 个下标和嵌套循环差在 1e-15 以内,7 个下标和 tenferro 差在 5e-16 以内。不支持省略号、对角线、广播,这三样 tenferro 都支持。
- mini 按这一步的乘加次数贪心,在 MPS 内积上比最优差 15 倍多;换成按结果大小贪心就和 tenferro 0.8.0 的默认规划逐对相同,回到最优,但在矩阵链上变差。退火要经
ContractionOptimizerOptions,只有 traced 入口传得进去。 - tenferro 0.8.0 的默认规划不再交给 omeco,而是自己的确定性贪心:同一输入在 5 个进程、500 次调用里路径和结果逐位相同。退火路径仍来自 omeco 的贪心初始化,同一进程里 20 次就有 4 种。
- 速度上,512³ 矩阵乘 mini 19.5 ms,tenferro 串行 5.6 ms、8 线程 0.88 ms;小网络上规划的影响大于内核,乘加次数的代价模型也漏掉了搬数据的开销。