engine 与 threadteam 池
主线位置:直接计算主线的第 3 步。调用方先建一个 engine(
CpuEngine::serial(),或者CpuEngine::new(Pool::builder().threads(n).build()?)),然后把&mut engine传给每一个运算:a.matmul(&b, &mut engine)。本页讲 engine 持有什么、建一个要多少钱、一个运算进来时它做哪几件事、线程从哪来。matmul进来之后交给哪个库,见 provider:native 与 BLAS;逐元素运算怎么并行、输出怎么回池,见逐元素、SIMD 与融合。
sequenceDiagram participant U as 调用方 participant P as threadteam Pool participant E as CpuEngine participant T as Team participant K as 内核 U->>P: Pool::builder().threads(8) U->>E: CpuEngine::new(pool) E->>T: pool.into_team(8) U->>E: a.matmul(b, &mut engine) E->>T: try_exec 铸令牌 T-->>E: Exec 令牌 E->>K: 令牌、缓冲池、计划缓存 K->>T: 值得并行才分派 K-->>U: 输出张量
代码在 tenferro-cpu/src/engine.rs(654 行,engine 本身)、contraction.rs(925 行,开头是线程放置 ExecutionResources)、exec_session.rs(1072 行,各运算族的 trait 实现;文件名是会话时代留下的)和 batch_policy.rs。线程池来自 threadteam 0.2。本页的实验在 exp-tenferro8/cpu-exp(依赖 crates.io 上的 tenferro 0.8.0,默认 native),全部 release,M2 Max(8 个性能核 + 4 个能效核)、macOS 27、rustc 1.99.0;同机还有别的写手在跑程序,表里的数字来自 uptime 负载 2.2 到 2.8 时的一轮,单位是微秒或毫秒,取最小值(括号里是中位数)。
engine 是一个值
后端 trait Engine 的文档注释说得很直接:「Operations take &mut of the engine, so one computation uses an engine at a time without any runtime lock. There is no separate session object.」CpuEngine 实现它,字段就是它持有的全部东西:
pub struct CpuEngine {
pub(crate) resources: ExecutionResources,
pub(crate) buffers: BufferPool,
pub(crate) plans: CpuPlanCache,
pub(crate) batch_policy: crate::CpuBatchPolicy,
pub(crate) allocation_domain: Option<Arc<dyn SharedTensorAllocationDomain>>,
runtime_clears: u64,
pub(crate) broken: bool,
/// Runtime engine id this engine runs prepared programs as.
pub(crate) runtime_engine_id: tenferro_runtime::EngineId,
/// Extension caches the runtime lends to extension executors during a run.
pub(crate) extension_caches: tenferro_runtime::ExtensionCacheStore,
}
resources:线程放置(没有池、一个线程的池、或者一个 team)、cpueinsum 的工作区ArenaProvider、N 元收缩的暂存区。buffers:缓冲池,运算的输出从这里取,用完回到这里(逐元素、SIMD 与融合)。plans:CpuPlanCache,二元收缩等的不可变计划。它是唯一可以在 engine 之间共享的部分:with_plan_cache(plans.clone())让几个 engine 读同一份计划,查找时持锁,计算时不持。batch_policy:批处理策略,下文。broken:上一个运算 panic 过。guard_unwind用catch_unwind包住每个运算,展开时把它置为true再继续展开;之后的运算都返回CpuEngineError::Broken(「the CPU engine is broken by an earlier panic; build a new engine」)。
所有运算都拿 &mut CpuEngine,所以同一时刻只有一个计算在用它,engine 内部不需要为「两个运算同时进来」加锁。CpuEngine 是 Send 也是 Sync(exp-tenferro8/cpu-exp 里的 send_check、sync_check 两个程序用 fn is_send<T: Send>() 这样的约束检查过),可以移到别的线程去用;想在 k 个线程上同时算,就建 k 个 engine。上游 API 迁移指南的原话是「The engine is a value, not a handle; hold one and borrow it with &mut」。
0.7.1 的 CPU 后端是 CpuBackend 句柄加 with_backend_session 会话:打开会话要向一个全进程的 ResourceArbiter 申请 CPU 集合的许可,再把闭包 install 进 tenferro 自己建的 rayon 池。PR #2209(「feat!: caller-owned execution engines and one prepare/run path (#2206, #2207)」,首次进入 v0.8.0)把这一套换成了本页的 engine。其中提交 1675a28b 的说明写的是「Replace CpuBackend, CpuContext, the resource arbiter, placement/topology/affinity, the engine registry, held sessions, phase lanes and child executions with one caller-held CpuEngine」,提交 b25ed7f2 删掉了 BackendSessionHost 和会话入口的线程局部守卫。
同一台机器上,把 0.7.1 的 CpuBackend::with_threads(1) 直接当会话传给每个运算(每个运算开一次会话),和 0.8.0 的 CpuEngine::serial() 比(cpu-old/per_op_old 与 cpu-exp/per_op,最小值):1000 个 f64 的 add 从 1.67 µs 降到 0.71 µs,neg 从 1.12 降到 0.46,reduce_sum 从 1.25 降到 0.62,exp 从 3.12 降到 1.96,16×16 的 matmul 从 2.08 降到 1.29,64×64 的 matmul 两边都是 13.9 µs。即使 0.7.1 一直开着同一个会话,add 也要 1.33 µs。release notes 写的是单线程「per-operation overhead fell by 15 to 40 percent」,用的是上游自己的基准;在这台机器上,小的逐元素运算降得更多,64×64 的 matmul 本身就占满了时间,看不出差别。0.7.1 的 12 线程后端每开一次会话要 7 到 19 µs,那条路已经不存在了。
构造:串行、单线程池、team
CpuEngine::serial() 没有池,每个运算都在调用线程上跑;CpuEngine::new(pool) 接管调用方建好的 threadteam::Pool(tenferro-cpu 把它重新导出成 tenferro_cpu::Pool)。tenferro 自己从不建线程:不读 RAYON_NUM_THREADS,不解析 CPU 集合,也不钉核(v0.8.0 的源码里已经没有 RAYON_NUM_THREADS 的读取,只剩两个 linalg 基准在检查它有没有被设置)。Pool 不是 Clone,new 按值拿走它,所以一个池不可能同时撑两个 engine。把同一个池传两次,rustc 报:
error[E0382]: use of moved value: `pool`
--> src/bin/pool_clone.rs:4:40
|
2 | let pool = tenferro_cpu::Pool::builder().threads(2).build().unwrap();
| ---- move occurs because `pool` has type `Pool`, which does not implement the `Copy` trait
3 | let a = tenferro_cpu::CpuEngine::new(pool);
| ---- value moved here
4 | let b = tenferro_cpu::CpuEngine::new(pool);
| ^^^^ value used here after move
池交进来之后变成三种放置之一,Placement:
enum Placement {
/// No pool: every operation runs on the calling thread.
Serial,
/// A one-worker pool: tokens have a budget of one and never dispatch.
Single(threadteam::Pool),
/// A pool of two or more workers, moved into a [`threadteam::Team`]: the
/// members stay reserved for the engine's lifetime, so every parallel
/// region (the strided kernels, tcontract batches, faer's `Par::Team`) is a
/// team section and no work goes through Rayon.
Team(threadteam::Team),
}
两个以上线程的池在 reserve_team 里调 pool.into_team(width):把除调用线程之外的 width - 1 个工作线程预留给这个 engine,直到 engine 被丢掉。池的默认获取超时是 2 ms(threadteam 的 DEFAULT_ACQUIRE_TIMEOUT),刚启动的线程或者繁忙的机器可能来不及响应,所以 reserve_team 失败后每 5 ms 重试一次,最多重试 10 秒;10 秒还拿不到,engine 就退成 Serial,has_team() 返回 false,num_threads() 报 1。注释给的理由是「a silently serial engine would ruin a measurement, which CpuEngine::has_team makes visible」,基准程序应该在构造后断言它。本页所有实验都断言了。
engine_cost 程序量构造和丢弃的代价。每个宽度先建一次(这个宽度第一次出现),再连续建 30 次:
CpuEngine::serial() + drop min 0.33 us median 0.38 us (n=200)
width 1 repeat (n=30): build pool min 0.2 med 0.2 us | CpuEngine::new min 0.2 med 0.2 us | drop min 0.6 med 0.7 us | process threads 2
width 8 first: build pool 96.5 us, CpuEngine::new 72.1 us, drop 76.5 us; has_team true, num_threads 8, process threads 16 -> 15
width 8 repeat (n=30): build pool min 0.5 med 0.6 us | CpuEngine::new min 6.6 med 8.1 us | drop min 2.5 med 3.0 us | process threads 13
width 12 first: build pool 116.6 us, CpuEngine::new 1436.8 us, drop 252.9 us; has_team true, num_threads 12, process threads 25 -> 13
width 12 repeat (n=30): build pool min 0.8 med 0.8 us | CpuEngine::new min 14.0 med 18.2 us | drop min 3.5 med 4.1 us | process threads 13
width 8 with stack_size(8 MiB): build+new 137.8 us, drop 104.7 us, process threads 13
| 宽度 | 第一次:建池 + new | 之后:建池 | 之后:new(预留 team) | 之后:drop |
|---|---|---|---|---|
| 串行 | — | — | 0.33 µs(含 drop) | — |
| 2 | 26.6 + 8.4 µs | 0.2 µs | 1.6 µs | 0.9 µs |
| 4 | 40.0 + 18.2 µs | 0.4 µs | 2.2 µs | 1.0 µs |
| 8 | 96.5 + 72.1 µs | 0.5 µs | 6.6 µs | 2.5 µs |
| 12 | 116.6 + 1437 µs | 0.8 µs | 14.0 µs | 3.5 µs |
- 「之后」的建池只要零点几微秒,因为 threadteam 0.2 的池在 drop 时不结束线程:默认设置的池把空闲工作线程放进一个按宽度索引的全进程缓存(
Drop for Pool调cache::put),下一个同宽度的默认池直接拿走(build_checked里的cache::take)。缓存最多留「可用核数和最宽的池两者取大」那么多线程,多出来的旧条目被 join,所以输出里进程的线程数稳定在 13(主线程加 12 个缓存线程)。 - 设置了
thread_name、on_start、qos或者非默认stack_size的池有专属线程,drop 时 join,不进缓存:stack_size(8 MiB)的 8 线程池每次建要 0.14 到 0.18 ms,丢要 0.09 到 0.1 ms。默认栈本身就是 16 MiB(DEFAULT_STACK_BYTES),注释说是为递归的 LU 留的。 new的主要代价是预留 team:每个成员要在自己的线程上确认一次(into_team_with末尾的acknowledge)。12 线程超过了性能核数,复用缓存时也要 14 到 18 µs,第一次是 1.4 ms。机器忙时这一步会慢很多:同机别的程序正在跑的第一轮里,12 线程的new中位数是 17.7 ms,最小 2.4 ms,也就是重试了好几次 5 ms 的暂停(之后几轮都回到了几十微秒)。
所以「建一次、一直用」仍然是对的,但理由变了:串行 engine 几乎不花钱;多线程 engine 在复用缓存线程时花的是几微秒的预留,第一次才是建线程的 0.1 ms 级代价。
一个运算进来时
每个运算都以同一个样子开始,例如 add_read 调 run_native_with_exec:
fn run_native_with_exec<R>(
&mut self,
op: impl FnOnce(threadteam::Exec<'_>, &mut BufferPool) -> crate::Result<R>,
) -> crate::Result<R> {
self.check_entry("CpuEngine")?;
let (exec, buffers, _, broken) = self.parts();
guard_unwind(buffers, broken, |buffers| op(exec.into_token(), buffers))
}
三件事:看 engine 有没有坏;从 resources 铸一个令牌,连同缓冲池借给内核;用 guard_unwind 包住。收缩走同样结构的 run_contraction,只是把整个 cpueinsum::Exec(令牌、工作区、批处理策略)交给 provider。铸令牌在 ExecutionResources::exec:
pub(crate) fn exec(&self, policy: crate::CpuBatchPolicy) -> Exec<'_> {
#[cfg_attr(not(feature = "ledger"), allow(unused_mut))]
let mut exec = match &self.placement {
Placement::Serial => Exec::serial_with_workspace(&self.workspace),
Placement::Single(pool) => Exec::from_token(
// INVARIANT: a width-one budget is non-zero.
threadteam::Exec::new(pool, 1).unwrap_or_else(|_| threadteam::Exec::serial()),
Some(&self.workspace),
cpueinsum::BatchPolicy::default(),
),
Placement::Team(team) => match self.lead(team) {
Some(token) => Exec::from_token(
token,
Some(&self.workspace),
cpueinsum::BatchPolicy::default(),
),
None => Exec::serial_with_workspace(&self.workspace),
},
};
// ...
exec.with_batch_policy(policy)
}
- 令牌的预算就是整个池的宽度,没有「这个运算用几个线程」的参数。用多少由下层根据工作量自己定(逐元素、SIMD 与融合讲这条规则),上限是这个预算。
- 一个线程的池(
Single)铸的令牌预算是 1,从不分派,和serial()实际上一样。has_team()对它返回false。 - team 的令牌由
lead拿:先锁一个记录「上一个领头线程」的Mutex,再team.try_exec(size)。一个 team 同时只能有一个领头者。另一个线程正在领头时(两个线程用自己的锁共享一个 engine),这里阻塞在team.exec上等它;如果领头的正是当前线程(运算里又调了运算),返回None,这次拿一个串行令牌,「so faer runsPar::Seqthere and nothing panics」。0.7.1 遇到这种重入是 panic。 - 令牌从调用线程出发。小运算的工作量不值得分派,整个运算就在调用线程上做完,不碰工作线程。
per_op 程序在不同的 engine 上反复做小运算:
== serial: has_team false, num_threads 1
add 1000 f64 min 0.71 us median 0.79 us
neg 1000 f64 min 0.46 us median 0.50 us
reduce_sum 1000 f64 min 0.62 us median 0.71 us
exp 1000 f64 min 1.96 us median 2.08 us
matmul 16x16 f64 min 1.29 us median 1.38 us
matmul 64x64 f64 min 13.92 us median 14.54 us
add 2^20 f64 min 143.38 us median 151.08 us
...
== team 8: has_team true, num_threads 8
add 1000 f64 min 1.17 us median 1.25 us
neg 1000 f64 min 0.92 us median 0.96 us
reduce_sum 1000 f64 min 1.17 us median 1.25 us
exp 1000 f64 min 2.50 us median 2.58 us
matmul 16x16 f64 min 1.79 us median 1.92 us
matmul 64x64 f64 min 6.00 us median 6.67 us
add 2^20 f64 min 68.75 us median 69.92 us
| 运算 | 串行 | 1 线程池 | team 2 | team 8 | team 12 |
|---|---|---|---|---|---|
add 1000 f64 | 0.71 | 0.71 | 1.12 | 1.17 | 1.17 |
neg 1000 f64 | 0.46 | 0.42 | 0.83 | 0.92 | 0.88 |
matmul 16×16 | 1.29 | 1.29 | 1.71 | 1.79 | 1.83 |
matmul 64×64 | 13.92 | 13.92 | 8.75 | 6.00 | 6.21 |
add 2^20 f64 | 143 | 147 | 86 | 69 | 56 |
(µs,最小值)
- 不值得分派的小运算,在 team engine 上比串行多约 0.45 µs,和宽度无关:这是
lead里的锁和try_exec,每个运算都要付。1 线程池和串行没有差别。 - 64×64 的 matmul 已经值得分派,8 线程比串行快 2.3 倍;2^20 个元素的
add快 2 到 2.6 倍(这是内存带宽受限的运算,多线程也只能到这里)。 - 一长串小运算放在串行 engine 上最便宜。需要并行的大运算和小运算混在一起时,用一个 team engine,小运算多付的 0.45 µs 通常可以接受。
问: 8 线程的 engine 做完一个运算,调用线程已经去 sleep 了,为什么进程还在吃 CPU?
答: 预留给 team 的成员线程不会立刻睡下。threadteam 0.2 的等待照搬 libgomp:没有活的成员先自旋一段(spin_polls,aarch64 上默认 2^21 次轮询),再让出几次,最后才睡,这样紧接着的下一个并行区域不用付唤醒的代价。idle_spin 程序在一次 2^22 个元素的 add 之后让调用线程睡 1 秒,看这 1 秒里进程消耗的 CPU 时间:
width 1: add 2^22 wall 9.78 ms, cpu during op 9.7 ms, cpu during the next 1000 ms of caller sleep 0.0 ms
width 8: add 2^22 wall 15.33 ms, cpu during op 50.2 ms, cpu during the next 1000 ms of caller sleep 239.9 ms
运算结束后的 1 秒里,7 个成员一共又转了约 240 ms 的 CPU(前几轮在 178 到 243 ms 之间),平均每个 30 多毫秒。和别的程序共用机器、又只是偶尔做一个大运算时,这部分要算进去;PoolBuilder::spin_polls 可以调小。
threadteam:池、team 和令牌
threadteam 的 README 第一句是「Splittable CPU placement token for the tensor4all CPU stack」。它是整个 CPU 栈里唯一启动任务的地方,strided-rs、tcontract、cpueinsum、tlinalg 和 tenferro 都只传令牌:
Pool:一组固定的工作线程。线程其实属于每个池私有的一个 rayon 池,但 rayon 的类型不出现在任何公开签名里(仓库里有个脚本专门检查cargo public-api的输出里没有rayon::)。Team:拥有一个池,并把其中width - 1个成员一直预留着。它铸出的令牌是普通的Exec,但「carries the team」:并行区域直接在预留的成员上开 team section,不经过 rayon 的任务队列。Exec:令牌,带一个预算(最多同时跑几个任务)。不是Clone也不是Copy,每个并行入口(fork、for_ranges、broadcast、team)都消耗它,所以两个运算不可能同时声称拥有全部预算。fork把预算分给子任务,子任务拿到子令牌。
faer 怎么并行也由令牌决定。tcontract-exec 的 with_faer_par:
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)
}
只有 team 令牌才给 faer 内部并行(t4a-faer 的 Par::Team 变体,包着 threadteam 的 TeamExec),其余一律 Par::Seq。这就是为什么只有带 team 的 engine 才并行:一个普通的多线程池如果没变成 team(预留失败),faer 也只会串行。
「值不值得分派」也由令牌回答。一个并行区域只有在估计的串行时间不少于 PAYS(3.0)倍的「每次分派代价 × 分派次数」时才并行。分派代价按主机类别查表(DispatchCosts::for_class):本机属于 aarch64-apple,team 令牌在宽度 2、4、6 时是 3.0、2.3、1.4 µs,宽度超过 6 按比例增长(8 时约 1.9 µs)。注释说这一行是在 M5 Max 上量的,不是本机。各个内核怎么用这条规则,见逐元素、SIMD 与融合。
Pool 是一家车行的一批司机(工作线程)。CpuEngine::new(pool) 不是每趟临时叫车,而是把整批司机包下来(into_team),合同期内他们只给你开车(预留的成员)。每次出门你开一张派车单(Exec 令牌),上面写着最多派几辆车(预算)。近的地方你自己走过去(不值得分派,就在调用线程上做完);远的地方才派车(team section)。派车单不能复印(不是 Clone),同一时刻只有一张有效(一个领头者)。合同结束(engine 被丢掉)司机回车行候着(进全进程缓存),下一个包同样数量司机的人直接用他们,不用重新招。
没有仲裁器
v0.8.0 的源码里已经搜不到 arbiter。上游的设计记录 execution-engine-ownership.md 写的是「There is no arbitration between engines. A caller that wants k concurrent computations holds k engines and runs them from k calling threads」。concurrent 程序让 k 个 OS 线程各建一个 engine,各做 40 次 512×512 的 f64 matmul:
engine width 1, 1 thread(s): 40 matmuls 512x512 f64 each, wall 221.8 ms, per-thread [215.5]
engine width 1, 2 thread(s): 40 matmuls 512x512 f64 each, wall 224.3 ms, per-thread [218.0, 217.6]
engine width 1, 4 thread(s): 40 matmuls 512x512 f64 each, wall 245.7 ms, per-thread [238.2, 239.1, 238.6, 238.4]
engine width 3, 1 thread(s): 40 matmuls 512x512 f64 each, wall 80.9 ms, per-thread [78.1]
engine width 3, 2 thread(s): 40 matmuls 512x512 f64 each, wall 87.0 ms, per-thread [83.2, 84.2]
engine width 3, 4 thread(s): 40 matmuls 512x512 f64 each, wall 166.9 ms, per-thread [164.4, 119.4, 146.6, 130.1]
四个串行 engine 在四个线程上真的并行了,墙钟时间只比一个线程多 11%。四个 3 线程 engine 一共要 12 个线程(含 4 个领头的调用线程),超过了 8 个性能核,就慢了一倍。没有仲裁器的代价正在这里:超订由调用方自己负责,tenferro 不会替你排队,也不会替你把 12 个线程降成 8 个。
批处理策略
带批维度的收缩(例如 [m, k, b] 乘 [k, n, b])和批量 LU 有两种并行法:把一批独立的小问题分给不同线程(每个问题串行),或者一个一个做、每个问题内部并行。tenferro-cpu 把这个选择叫 CpuBatchPolicy,三个名字都只是 tcontract-exec 类型的重新导出:
| tenferro 的名字 | 下层的类型 | 内容 |
|---|---|---|
CpuBatchStrategy | BatchStrategy | Auto(默认)、Sequential、Outer(每个问题一条通道、各自串行)、Inner(一条通道、问题内部用满预算)、Split { lanes } |
CpuBatchThresholds | WorkModel | Auto 用的工作量模型:本机类 muladds_per_ns = 27,min_items = 2 等 |
CpuBatchPolicy | BatchPolicy | 策略加模型 |
生效的策略是:最内层的 tenferro_cpu::with_batch_policy 作用域,否则是 engine 的默认(with_batch_policy 或 set_batch_policy 设置)。作用域函数返回时、出错时、panic 展开时都恢复原来的策略,没有任何全进程或线程局部的状态。强制的策略不会回退:下层做不到就报 StrategyUnavailable。Auto 的具体规则在下层库里,batch_policy.rs 的模块注释说「keeps that library's legacy rule until the upstream shared model lands」。
batch_policy 程序在 8 线程 engine 上对几种形状各跑四种策略(f64,毫秒,最小值;每种策略的结果和第一种逐位相同,否则行里会标 (!=)):
1024 x 16^3 f64: Auto 0.190 Sequential 0.447 Outer 0.102 Inner 0.448 (min ms, n=50)
256 x 32^3 f64: Auto 0.117 Sequential 0.514 Outer 0.110 Inner 0.506 (min ms, n=50)
64 x 64^3 f64: Auto 0.292 Sequential 0.818 Outer 0.150 Inner 0.246 (min ms, n=50)
8 x 256^3 f64: Auto 0.940 Sequential 5.654 Outer 0.820 Inner 0.934 (min ms, n=10)
4 x 512^3 f64: Auto 3.347 Sequential 21.849 Outer 5.739 Inner 3.344 (min ms, n=10)
- 小问题多(16³、32³)时
Inner和Sequential一样慢:一个 16³ 的 GEMM 内部不值得分派。Outer快 4 到 4.6 倍。 - 大问题少(4 个 512³)时反过来,
Inner比Outer快 1.7 倍:4 条通道只用得上 4 个线程。 Auto在两头都选对了,中间的 64 个 64³ 选得不好(0.29 ms,Outer0.15 ms)。1024 个 16³ 那一行,Auto在负载更高的前一轮是 0.102 ms,和Outer一样,两轮之间差了将近一倍,本页没有追下去。- 串行 engine 上四种策略没有差别(都在 0.42 到 0.49 ms、5.7 ms、21.3 到 21.6 ms 之间):预算只有 1。
形状固定、又在热循环里的批量运算,值得量一下再用 with_batch_policy 钉住策略。BLAS 构建里策略还决定走不走 BLAS,见 provider:native 与 BLAS。
缓冲池的上限
engine 的缓冲池默认最多留 100 MiB(DEFAULT_MAX_RETAINED_CAPACITY_BYTES)。set_buffer_pool_limit_bytes 同时限制缓冲池和收缩暂存区,设成 0 就不保留;reset_buffer_pool 清空两者。CpuEngine 的构造函数用 BufferPool::new(),它读环境变量 TENFERRO_BUFFER_POOL_MAX_RETAINED_BYTES:设成 0 跑 pool_recycle,engine 报 buffer_pool_limit_bytes 0,设成 12345 报 12345。函数的文档注释提醒了另一面:「Destinations larger than the retention limit cannot be recycled, so repeated calls allocate again and may pay first-touch costs」。这个代价在 macOS 上有多大,见逐元素、SIMD 与融合的最后一节。
上游文档有几处落后于代码。execution-engine-ownership.md 说直接调 faer 时「takes the width from engine.exec()?.into_token().external(..) and uses Par::rayon(width) within it」,而 tenferro-cpu/src/lib.rs 对 faer 重新导出的注释(#2290 之后)写的是用 token.team(..) 交给 faer Par::Team,「Par::rayon is never used」;同一份文档还列着 WebGPU engine。Engine trait 的注释仍说「a CPU engine owns a Rayon pool」。guard_unwind 的文档注释说展开时会「replenishes the pool with replacements for the buffers it lost」,函数体只置了 broken,注释写着「need no accounting repair」。
CpuEngine::serial() 换成 CpuEngine::new(Pool::builder().threads(8).build()?),对 1000 个 f64 做 add,每次调用会快还是慢?差多少?用上面的 per_op 程序,看 add 1000 f64 两行。提示:1000 个元素的 add 值不值得一次 team 分派?不分派时,team engine 在每个运算开头还多做了什么?
动脑筋答案
== serial: has_team false, num_threads 1
add 1000 f64 min 0.71 us median 0.79 us
== team 8: has_team true, num_threads 8
add 1000 f64 min 1.17 us median 1.25 us
- 慢,最小值从 0.71 µs 变成 1.17 µs,多约 0.46 µs。
- 1000 个 f64 的
add按aarch64-apple的估计(每元素 0.1 ns)只要约 0.1 µs,而这张表里一次 team 分派最便宜也要 1.4 µs,乘上 3 倍远超过它,所以两种 engine 都在调用线程上做完,算的部分一样。 - 多出来的是铸令牌:team engine 每个运算都要锁
last_leader那把Mutex再team.try_exec;串行 engine 只是造一个Exec::serial_with_workspace。
小结
CpuEngine是调用方持有的值,持有缓冲池、计划缓存、批处理策略和线程放置;运算都拿&mut engine,没有会话、没有会话守卫、没有全进程仲裁器。它是Send + Sync,并发就建多个 engine。- tenferro 不建线程:调用方建
threadteam::Pool,两个以上线程的池被变成预留成员的Team,预留失败重试 10 秒后退成串行,用has_team()检查。 - threadteam 0.2 把默认设置的池在 drop 时缓存起来,同宽度复用:之后建 8 线程 engine 约 7 µs,第一次约 0.17 ms;串行 engine 0.33 µs。
- 每个运算从 engine 铸一个预算为整个池的令牌;工作量不到 3 倍分派代价就留在调用线程上。team engine 上小运算多约 0.45 µs;预留的成员运算后会自旋一阵,8 线程时约 240 ms CPU。
- faer 只有拿到 team 令牌才并行(
Par::Team),否则Par::Seq。 - 批处理策略在 engine 上设默认、用
with_batch_policy局部覆盖;小问题多用Outer,大问题少用Inner,Auto中间形状会选错。 - 缓冲池默认上限 100 MiB,
set_buffer_pool_limit_bytes或TENFERRO_BUFFER_POOL_MAX_RETAINED_BYTES都能改。