Rust in Everything
基于 tenferro-runtime 0.8.0

一次 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 与线程池。

📝NOTE
冷知识:会话是在 0.8.0 一次删掉的,不是逐步弃用

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 = 32n = 256n = 1024
A,w = 1一个 engine,循环里连续 matmul2.868542092
A,w = 23.337021447
A,w = 43.420511953
A,w = 83.41196348
A,w = 123.420010874
B每次 CpuEngine::serial() 再 matmul6.070442186
C每次新建 12 线程默认池和 engine,用完 drop26.3(中位 464)2288612
C2同 C,但池设了 stack_size(8 << 20)296.552711533
faer Par::Seq直接调 faer,Mat 先建好1.669141876
faer Par::Team(12)用 12 线程 engine 的令牌开 team1.818710304

(三列 c[0,0] 和 faer 直接算的打印到 12 位小数相同。)

几件事从表里能读出来:

  • 大矩阵上时间全在 faer 里。 n = 1024 时 w = 1 和 faer Par::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。两次运行各取最小值,单位微秒:

nnative w = 1native w = 8Accelerate w = 1Accelerate w = 8
322.73.41.31.9
25668511895105
102442225636830783139

结果到 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。

❗IMPORTANT
动脑筋:C 组每次新建一个 12 线程的默认 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 按令牌给 faer Par::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。

💬 关联讨论

发起讨论
加载中...