一次 matmul
主线位置:直接计算主线,从头到尾。本页用一个
TypedTensor<f64>的矩阵乘把这条线走一遍:engine 从哪来、运算在哪个线程上跑、matmul经过哪几层到达 faer,结果怎么读回;再量一量「engine 只建一次」这条约定值多少,以及换成 Apple Accelerate 会怎样。
sequenceDiagram participant U as 用户代码 participant E as CpuEngine participant C as dot_fresh participant P as cpueinsum 计划 participant T as tcontract 计划 participant F as faer U->>E: matmul 即 dot_general_read E->>E: 检查 engine 铸放置令牌 E->>C: run_contraction C->>C: 取缓存的计划 分配输出 C->>P: execute_uninit_slices P->>T: 已选好 faer 策略 T->>F: with_faer_par 再 matmul F-->>C: 写满未初始化输出 C-->>U: TypedTensor U->>U: host_data 借出切片
程序
和 README 的 Quickstart A 一样的写法,只留 matmul(one_matmul,完整程序还带计时):
use tenferro_cpu::CpuEngine;
use tenferro_runtime::prelude::*;
let a = TypedTensor::<f64>::from_vec_col_major(vec![2, 2], vec![1.0, 2.0, 3.0, 4.0])?;
let b = TypedTensor::<f64>::from_vec_col_major(vec![2, 2], vec![1.0, 0.0, 0.0, 1.0])?;
let mut engine = CpuEngine::serial();
let c = a.matmul(&b, &mut engine)?;
println!("{:?}", c.host_data()?);
没有会话,也没有闭包:engine 是调用方手里的一个值,matmul 以 &mut 借它一下。要多线程就自己建池交给 engine:CpuEngine::new(Pool::builder().threads(8).build()?),Pool 是 tenferro_cpu 再导出的 threadteam 池。tenferro 不读 RAYON_NUM_THREADS,也不替你建池。
逐层走
在 faer 的 matmul_with_conj 上下断点(debug 构建,lldb,串行 engine,2×2),bt 的前 27 帧,去掉了地址、泛型参数和参数列表:
* thread #1, name = 'main', queue = 'com.apple.main-thread', stop reason = breakpoint 1.2
* frame #0: faer::linalg::matmul::matmul_with_conj::<f64, ...>(..., beta=Replace, ...) at mod.rs:2036:16
frame #1: <f64 as tcontract::api::scalar::FaerGemm>::matmul(...) at scalar.rs:176:17
frame #2: <tcontract::strategy::faer::FaerPlan>::item::<f64>(...) at faer.rs:316:13
frame #3: <tcontract::strategy::faer::FaerPlan>::run::<f64>::{closure#0}(...) at faer.rs:245:22
frame #4: <tcontract::strategy::faer::FaerPlan>::run::<f64>::{closure#3}(...) at faer.rs:289:21
frame #5: tcontract_exec::faer::with_faer_par::<(), ...>(...) at faer.rs:44:5
frame #6: <tcontract::strategy::faer::FaerPlan>::run::<f64>(...) at faer.rs:287:13
frame #7: <tcontract::plan::Plan<f64>>::run_storage_in(...) at mod.rs:959:24
frame #8: <tcontract::plan::Plan<f64>>::run_storage(...) at mod.rs:849:23
frame #9: <tcontract::plan::Plan<f64>>::execute_uninit_slices(...) at execution.rs:306:18
frame #10: <cpueinsum::prepared::BinaryPlan<f64>>::execute_uninit_slices(...) at prepared.rs:467:14
frame #11: tenferro_cpu::contraction::fresh::<f64>(...) at contraction.rs:702:31
frame #12: tenferro_cpu::contraction::dot_fresh(...) at contraction.rs:747:17
frame #13: <tenferro_cpu::engine::CpuEngine>::execute_dot_allocated::{closure#0}(...) at exec_session.rs:534:17
frame #14: <tenferro_cpu::engine::CpuEngine>::run_contraction::<...>::{closure#0}(...) at exec_session.rs:228:13
frame #15: tenferro_cpu::engine::guard_unwind::<...>::{closure#0} at engine.rs:620:68
...
frame #21: tenferro_cpu::engine::guard_unwind::<...>(...) at engine.rs:620:11
frame #22: <tenferro_cpu::engine::CpuEngine>::run_contraction::<...>(...) at exec_session.rs:227:9
frame #23: <tenferro_cpu::engine::CpuEngine>::execute_dot_allocated(...) at exec_session.rs:533:25
frame #24: <tenferro_cpu::engine::CpuEngine as tenferro_tensor::backend::TensorDot>::dot_general_read(...) at exec_session.rs:575:14
frame #25: <TypedTensor<f64> as tenferro_runtime::typed_session_ext::TypedTensorSessionOpsExt<f64>>::matmul(...) at typed_tensor.rs:187:27
frame #26: one_matmul::main at one_matmul.rs:71:17
(帧 16–20 是 catch_unwind 的几层标准库包装。)断点停在 thread #1,主线程。从下往上读,分成三段。
第一段(帧 21–26):matmul 变成 engine 上的 dot_general。 用户调用的 matmul 来自 tenferro-runtime 给 TypedTensor 加的扩展 trait:
fn matmul(&self, rhs: &TypedTensor<T>, session: &mut dyn Engine) -> Result<TypedTensor<T>> {
let config = matmul_config_for_shapes("matmul", self.shape(), rhs.shape())?;
let out = session.dot_general_read(T::tensor_read(self), T::tensor_read(rhs), &config)?;
into_typed_result("matmul", out)
}
(TypedTensorSessionOpsExt::matmul。)参数名还叫 session,类型已经是 &mut dyn Engine。后端只认识 dot_general:收缩哪几维、哪几维是批维,由 matmul_config_for_shapes 算成一个 DotGeneralConfig;形状对不上在这里就返回 Err。CpuEngine::dot_general_read 先用 dtype、两边形状、四组轴和共轭标志算一个哈希,作为 engine 计划缓存里的槽位(eager_dot_cache_slot),再进 execute_dot_allocated:检查两边 dtype、算出输出形状,只有 f32 / f64 / c32 / c64 走收缩,整数和布尔最后报 Unsupported,消息里提示先转成 F64。
run_contraction 是 engine 和 provider 的接缝:
fn run_contraction<R>(
&mut self,
op: impl FnOnce(&mut cpueinsum::Exec<'_>, &mut BufferPool, &CpuPlanCache) -> crate::Result<R>,
) -> crate::Result<R> {
self.check_entry("dot_general")?;
let _retention = self
.resources
.retention_guard(self.buffers.max_retained_capacity_bytes());
let mut exec = self.resources.exec(self.batch_policy);
let plans = &self.plans;
guard_unwind(&mut self.buffers, &mut self.broken, |buffers| {
op(&mut exec, buffers, plans)
})
}
check_entry 拒绝一个已经坏掉的 engine;resources.exec 铸一个放置令牌,串行 engine 是预算为 1 的令牌,多线程 engine 是覆盖整个 team 的令牌;guard_unwind 用 catch_unwind 包住整个运算,panic 时把 engine 标成 broken 再继续展开,之后这个 engine 上的运算一律返回 CpuEngineError::Broken。这里没有许可、没有队列,也没有把闭包送到别的线程:&mut 借用已经保证一个 engine 同一时刻只做一件事。
第二段(帧 10–14):从 engine 的缓存取计划,交给 cpueinsum。 contraction::fresh 用上面的槽位向 CpuPlanCache 要一个 BinaryPlan(槽位只负责路由,命中前还要比对完整的键),从缓冲池里拿一块未初始化的输出,调 cpueinsum 的 BinaryPlan::execute_uninit_slices,传下去的是两个输入的主机切片和偏移,不拷贝。成功之后才 assume_init_recycled,源码注释写着安全前提:「BinaryPlan succeeded on MaybeUninit storage and proves complete physical coverage.」
第三段(帧 0–9):tcontract 和 faer。 cpueinsum 的二元计划底下是一个 tcontract 的 Plan。tcontract 在 Plan::build 时就选好了策略:这个收缩不用拷贝任何操作数就能并成一次 GEMM,f64 又没有设 FaerLimit,所以选 faer(另外两种是 tcontract 自己的打包驱动和逐元素,见 provider)。执行时 FaerPlan::run 经 tcontract-exec 的 with_faer_par 决定 faer 的并行方式:
pub fn with_faer_par<R: Send>(
mut token: threadteam::Exec<'_>,
f: impl FnOnce(faer::Par<'_>) -> R + Send,
) -> R {
if token.budget().get() > 1 && token.carries_team() {
// A carried team is handed over without acquisition, so `team` never
// refuses it.
return token
.team(|team| f(faer::Par::Team(team)))
.expect("a carried team is never refused");
}
f(faer::Par::Seq)
}
串行 engine 的令牌预算是 1,faer 拿到 Par::Seq,在调用线程上算完。多线程 engine 的令牌也不是原样交下来的:FaerPlan::run 先按每项的乘加数估算该用几个线程,把令牌 limit 到这个数再调 with_faer_par。帧 0 的 beta=Replace 是 β = 0:faer 不读目标,直接覆盖,这就是输出可以先不初始化的原因。
换成 4 线程的 engine、256×256 再停一次,栈顶多出两帧 threadteam:
* thread #1, name = 'main', queue = 'com.apple.main-thread', stop reason = breakpoint 1.2
frame #5: tcontract_exec::faer::with_faer_par::<...>::{closure#0}(...) at faer.rs:41:26
frame #6: <threadteam::exec::Exec>::team_with::<...>
frame #7: <threadteam::exec::Exec>::team::<...>
frame #8: tcontract_exec::faer::with_faer_par::<...>(...) at faer.rs:41:14
还是 thread #1:调用线程当 team 的领头,faer 拿到 Par::Team,在 engine 预留的 team 成员上分活。多线程不再意味着「把整个运算送进池里」,只是 provider 决定分活时才用到 team。
这一路上 tenferro 自己的函数只剩六七个,其余是 cpueinsum、tcontract、tcontract-exec 各自的一层。每一层回答一个问题:engine 能不能用、令牌给多宽(tenferro-cpu);哪个计划(cpueinsum 和 engine 的计划缓存);哪种策略(tcontract);faer 串行还是 team(tcontract-exec)。和 candle 的四个函数(见 candle 卷的一次 matmul)比还是多,但多出来的都是可以单独替换的 provider。engine 的令牌、team 和批处理策略见 CpuEngine 与线程池。
0.7.1 的 matmul 收的是 &mut dyn BackendSession,要先 backend.with_backend_session(|s| ..):CpuBackend 向一个全局的 ResourceArbiter 取许可,再把整个闭包送进自己的 rayon 池去跑,所以闭包和返回值都得是 Send。#2209「feat!: caller-owned execution engines and one prepare/run path (#2206, #2207)」(2026-10-20 合并,首次发布于 v0.8.0)把这套换成了今天的 engine。随它加入的设计记录 execution-engine-ownership.md 把新模型概括成三句:「the caller owns the computation ...; the caller owns the execution resources (pools, streams, buffers, scratch, plans tied to device handles) through an engine it keeps; and execution is one run of a prepared program over borrowed engines, or an operation called directly on one borrowed engine.」它的「What was removed」一节开头就是「The session model is gone, not deprecated」,表里写着 BackendSession、BackendSessionHost、with_backend_session* 由「Engine operations on &mut engine」取代,「admission permit, reentry/contention errors, ResourceArbiter」由「&mut borrow」取代。上面 run_contraction 里只剩一个 check_entry,就是这个取舍的结果。release notes 说单线程下每个运算的固定开销因此降了 15% 到 40%(原文是 lazy dot_general 从 4.69 µs 到 3.97 µs),那是上游的测量,本页的计时见下一节。
读回
host_data 借出主机上的 &[T],不拷贝。结果张量由调用方拥有,但它记得自己从哪个缓冲池来:输出是 assume_init_recycled 出来的,存储里留着一个指回 engine 缓冲池的弱引用,张量 drop 时 Vec 就回到池里。下面 A 组每次都丢掉结果,跑完 12 线程那一组,buffer_pool_stats 是 BufferPoolStats { buffers: 1, capacity_bytes: 8192 }(n = 32,正好一个 32×32×8 字节的输出);n = 256、1024 时是 524288、8388608 字节。engine 先被 drop 的话,弱引用失效,结果的内存照常释放。存储归谁、怎么借,见存储与所有权。
计时
同一对 n×n 的 f64 矩阵(sin 填充),release,负载 2.2 左右。w 是 engine 的线程数(w = 1 是 CpuEngine::serial(),其余是 CpuEngine::new(Pool::builder().threads(w).build()?),构造后 has_team() 都是 true)。每组取多次里的最小值(n = 32 各 200 次,n = 256 各 100 次,n = 1024 各 20 次),单位微秒:
| 组 | 做法 | n = 32 | n = 256 | n = 1024 |
|---|---|---|---|---|
A,w = 1 | 一个 engine,循环里连续 matmul | 2.8 | 685 | 42092 |
A,w = 2 | 3.3 | 370 | 21447 | |
A,w = 4 | 3.4 | 205 | 11953 | |
A,w = 8 | 3.4 | 119 | 6348 | |
A,w = 12 | 3.4 | 200 | 10874 | |
| B | 每次 CpuEngine::serial() 再 matmul | 6.0 | 704 | 42186 |
| C | 每次新建 12 线程默认池和 engine,用完 drop | 26.3(中位 464) | 228 | 8612 |
| C2 | 同 C,但池设了 stack_size(8 << 20) | 296.5 | 527 | 11533 |
faer Par::Seq | 直接调 faer,Mat 先建好 | 1.6 | 691 | 41876 |
faer Par::Team(12) | 用 12 线程 engine 的令牌开 team | 1.8 | 187 | 10304 |
(三列 c[0,0] 和 faer 直接算的打印到 12 位小数相同。)
几件事从表里能读出来:
- 大矩阵上时间全在 faer 里。 n = 1024 时
w = 1和 faerPar::Seq都是 42 ms。 - 小矩阵上每次调用有一微秒多的固定开销。 n = 32 时 faer 1.6 µs,串行 engine 2.8 µs:多出来的是上面那几层、计划缓存查找和输出分配。多线程 engine 在 n = 32 时只多 0.5 µs:
FaerPlan::run先按每项的乘加数做一次batch_decision,再把令牌limit到决定的线程数,这么小的活只给 1,faer 拿到的是Par::Seq(4 线程 engine 上 n = 2 和 n = 32 各停一次,栈里都没有 threadteam 的帧),team 根本没有动。 - 8 线程比 12 线程快。 n = 256 时 119 µs 对 200 µs,n = 1024 时 6.3 ms 对 10.9 ms,faer 直接用 12 线程的 team 也是 10.3 ms。M2 Max 有 8 个性能核、4 个能效核,12 线程的 team 要等能效核上的成员。threadteam 不替你挑核,宽度由调用方定。
- 每次新建串行 engine 多 3 µs 左右(B 比 A):丢掉了缓冲池和计划缓存,每次都要重建计划、重新分配输出。这就是 README 说的「per-call construction discards the buffer pool」。
- 每次新建多线程 engine,代价取决于池能不能从缓存里拿。 C 组的最小值只有 26 µs,C2 是 297 µs。区别只在
stack_size:动脑筋里细说。
单看构造本身(one_matmul cost,release,每种 50 次,同一进程里先测「第一次」):
serial() first 24.3 us threads=1 team=false
serial() x50 min 0.2 us median 0.2 us
new(pool 2) first build 30.5 us build+engine 44.7 us team=true
x50 build+engine min 1.9 us median 2.8 us
...
new(pool 12) first build 116.1 us build+engine 1462.3 us team=true
x50 build min 0.8 us median 0.8 us
x50 build+engine min 14.8 us median 20.9 us
x50 drop min 3.3 us median 4.2 us
dedicated pool 12 (stack 8 MiB) x20 build min 97.1 us median 117.8 us
dedicated pool 12 (stack 8 MiB) x20 build+engine min 183.2 us median 265.2 us
串行 engine 只是几个空容器,0.2 µs。多线程 engine 分两步:Pool::build 起线程(进程里第一次 12 线程 116 µs),ExecutionResources::new 再把池换成一个预留好全部成员的 team(reserve_team,被拒绝时每 5 ms 重试,最多 10 秒,之后退化成串行,has_team() 会报 false)。第一次 12 线程的总数在七次运行里从 0.25 ms 到 2.2 ms 不等,后面那 50 次因为池来自缓存,build 不到 1 µs,加上预留 team 也只要十几微秒。
换成 Accelerate
同一个 matmul 用 blas-accelerate 构建(intro-blas:tenferro-cpu、-ad、-einsum、-linalg 都 default-features = false 并开 blas-accelerate),release,mm_cmp。两次运行各取最小值,单位微秒:
| n | native w = 1 | native w = 8 | Accelerate w = 1 | Accelerate w = 8 |
|---|---|---|---|---|
| 32 | 2.7 | 3.4 | 1.3 | 1.9 |
| 256 | 685 | 118 | 95 | 105 |
| 1024 | 42225 | 6368 | 3078 | 3139 |
结果到 10 位小数相同。Accelerate 版的路不同:在 cblas_dgemm 上停下来,栈是 CpuEngine::dot_general_read → contraction::dot_fresh → cpueinsum_blas::prepared::BinaryPlan::execute_uninit_slices → VendorPlan::run_vendor → cpueinsum_blas::gemms → libBLAS.dylib 的 cblas_dgemm,没有经过 tcontract 和 faer。engine 的宽度对它几乎没有影响:1024 时单线程 engine 就是 3.1 ms,比 8 线程的 faer 快一倍,比单线程的 faer 快 13 倍;Accelerate 自己决定怎么算,tenferro 的令牌管不到它。两条路的细节和取舍见 provider。
Pool 加 engine,n = 32 的 matmul 最快 26 µs;C2 组只是在 Pool::builder() 上多写了一句 .stack_size(8 << 20),最快要多久?为什么?先猜,再看文末。动脑筋答案
296.5 µs,是 C 组的 11 倍(one_matmul 32 200,release,各 200 次 / 20 次取最小值):
C new(pool 12) per call min 26.3 us median 463.7 us
C2 dedicated pool 12 per call min 296.5 us median 355.8 us
- threadteam 0.2 的
Pool文档把池分成两种:默认设置的池是「shared」,drop 时「keeps the idle workers in a process-wide cache, keyed by width」,下一个同宽度的默认池直接拿走这些线程;带了thread_name、on_start、stack_size或qos的池是「dedicated」,drop 时「shuts them down and joins them」。判断就在build_checked里:shared要求self.stack_size == DEFAULT_STACK_BYTES,默认值是 16 MiB。 - 所以 C2 每次都要起 12 个新线程、drop 时再 join 掉,C 只是从缓存里拿回同一组线程再预留成 team。上面单测构造的输出也是这个比例:缓存命中时 build 0.8 µs,专用池 97 µs。
- C 组的中位数在两次运行里差了十几倍(另一次是 35.5 µs)。最小值说明缓存命中时能有多快;中位数说明每次重建 engine 仍然要等 12 个成员都被预留上,在有别的负载的机器上会抖。本页没有再拆这部分。
- 结论和 README 一样,理由更具体:engine(连同它的池)建一次,复用到底。不得不频繁新建时,用默认设置的池,或者干脆用
CpuEngine::serial()。
小结
- engine 是调用方持有的值,运算以
&mut借它,从调用线程开始执行;没有会话、许可和闭包,panic 会把 engine 标成 broken。 matmul是dot_general的特例:engine 从计划缓存取 cpueinsum 的二元计划,tcontract 在建计划时选了 faer 策略,with_faer_par按令牌给 faerPar::Seq或Par::Team,faer 用 β = 0 写满未初始化的输出。- 大矩阵上时间全在 faer 里;小矩阵上每次调用多一微秒多;8 线程比 12 线程快,能效核拖慢 team。
- 每次新建串行 engine 多约 3 µs(丢掉缓冲池和计划缓存);每次新建 12 线程 engine,默认池最快 26 µs,专用池约 0.3 ms,差别来自 threadteam 的池缓存。
- 结果张量
drop时回到 engine 的缓冲池。换成 Accelerate,matmul 绕过 tcontract 和 faer 直达cblas_dgemm,n = 1024 时单线程 3.1 ms。