Rust in Everything
基于 tenferro-runtime 0.8.0

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」。

📝NOTE
冷知识:会话是 0.8.0 一次拆掉的

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)—
226.6 + 8.4 µs0.2 µs1.6 µs0.9 µs
440.0 + 18.2 µs0.4 µs2.2 µs1.0 µs
896.5 + 72.1 µs0.5 µs6.6 µs2.5 µs
12116.6 + 1437 µs0.8 µs14.0 µs3.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 runs Par::Seq there 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 2team 8team 12
add 1000 f640.710.711.121.171.17
neg 1000 f640.460.420.830.920.88
matmul 16×161.291.291.711.791.83
matmul 64×6413.9213.928.756.006.21
add 2^20 f64143147866956

(µ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 通常可以接受。
📝NOTE
没有蠢问题

问: 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 与融合。

💡TIP
打个比方:包车

Pool 是一家车行的一批司机(工作线程)。CpuEngine::new(pool) 不是每趟临时叫车,而是把整批司机包下来(into_team),合同期内他们只给你开车(预留的成员)。每次出门你开一张派车单(Exec 令牌),上面写着最多派几辆车(预算)。近的地方你自己走过去(不值得分派,就在调用线程上做完);远的地方才派车(team section)。派车单不能复印(不是 Clone),同一时刻只有一张有效(一个领头者)。合同结束(engine 被丢掉)司机回车行候着(进全进程缓存),下一个包同样数量司机的人直接用他们,不用重新招。

比方到这里为止:包下来的司机没活时会先在车里等一阵(自旋),这段等待照样算你的油钱(上面量到的 CPU 时间);而且车行只认「人数相同」的包车单,8 个司机的缓存不会分给要 4 个的人。

没有仲裁器

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 的名字下层的类型内容
CpuBatchStrategyBatchStrategyAuto(默认)、Sequential、Outer(每个问题一条通道、各自串行)、Inner(一条通道、问题内部用满预算)、Split { lanes }
CpuBatchThresholdsWorkModelAuto 用的工作量模型:本机类 muladds_per_ns = 27,min_items = 2 等
CpuBatchPolicyBatchPolicy策略加模型

生效的策略是:最内层的 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,Outer 0.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」。

❗IMPORTANT
动脑筋:把 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 都能改。

💬 关联讨论

发起讨论
加载中...