provider:native 与 BLAS
主线位置:直接计算主线的第 4 步。
a.matmul(&b, &mut engine)算出dot_general的配置,CpuEngine::dot_general_read从计划缓存里拿一个二元收缩计划,用 engine 的令牌执行它。本页讲这个计划背后是谁:默认的native构建是 cpueinsum、tcontract 和 t4a-faer,blas构建是 CBLAS;线性代数同样二选一。令牌和批处理策略见 engine 与 threadteam 池,逐元素运算不经过 provider,见逐元素、SIMD 与融合。
flowchart TB mm["dot_general"] --> cache["CpuPlanCache"] cache -->|native| nbp["cpueinsum BinaryPlan"] cache -->|blas| bbp["cpueinsum-blas BinaryPlan"] nbp --> plan["tcontract Plan"] plan --> faer["faer GEMM"] plan --> packed["打包驱动"] plan --> ewp["逐元素 pass"] bbp -->|"Auto 或 Sequential"| cblas["CBLAS gemm"] bbp -->|其余策略| nbp la["svd qr eigh"] -->|native| tl["tlinalg faer"] la -->|blas| tlb["tlinalg-blas LAPACK"] fp["full_piv_lu"] --> tl
tenferro-cpu 这一侧只有薄薄的适配:contraction.rs 的后半部分(计划缓存、fresh / initialized 两个执行函数)和 exec_session.rs 里的 dot_general 入口。下层是三个单独发布的仓库:tcontract-rs(tcontract、tcontract-exec 0.9,tcontract-kernel 0.3)、cpueinsum-rs(cpueinsum、cpueinsum-blas 0.9)、tlinalg-rs(tlinalg 0.8、tlinalg-blas 0.5)。实验在 exp-tenferro8/cpu-exp(native)和 exp-tenferro8/cpu-blas(default-features = false, features = ["blas-accelerate"],链接 Apple Accelerate),两边跑同一份源码,release,M2 Max(8 个性能核 + 4 个能效核)、macOS 27、rustc 1.99.0,uptime 负载 2.2 到 2.7。
两个 feature,只能选一个
tenferro-cpu 的 Cargo.toml 里,native = ["dep:faer"] 是默认,blas 打开 cpueinsum-blas、cblas-sys 和 lapack;blas-accelerate、blas-openblas、blas-mkl 在 blas 之上再经 blas-src / lapack-src 选一个库。provider 是编译期决定的,不能按 engine 切换。0.7.1 的 cpu-faer / cpu-blas 两个 feature 改名成了这两个(#2026)。
两个都开时,lib.rs 用 compile_error! 拒绝。但这条错误不是第一个出现的。只依赖 tenferro-cpu = { version = "=0.8.0", features = ["blas-accelerate"] }(忘了 default-features = false),cargo check 先报的是:
error[E0428]: the name `Scalar` is defined multiple times
--> .../tenferro-cpu-0.8.0/src/contraction.rs:74:1
|
70 | trait Scalar: PoolScalar + cpueinsum::Scalar {}
| -------------------------------------------- previous definition of the trait `Scalar` here
...
74 | trait Scalar: PoolScalar + cpueinsum_blas::BlasScalar {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `Scalar` redefined here
然后是一条 E0252(BinaryPlan 导入了两次),第三条才是:
error: native and blas are mutually exclusive; use --no-default-features for blas
--> .../tenferro-cpu-0.8.0/src/lib.rs:68:1
一共 9 个错误(还有 E0061、E0119、E0308、两个 E0599),could not compile tenferro-cpu (lib) due to 9 previous errors。看到 tenferro-cpu 内部的 Scalar 重复定义,先去检查依赖图里有没有哪个 crate 同时带来了 native 和 blas。
两个都不开时反而能编译。注释说这是为了「keeps any subset of the stack compilable」:数值路径返回 Unsupported。只依赖 tenferro-cpu = { version = "=0.8.0", default-features = false } 跑一次:
provider id: tenferro.cpu.none
add: Ok([2.0, 4.0, 6.0, 8.0])
matmul error: dot_general: unsupported operation: this build selects no CPU numerical backend; enable the `native` or `blas` feature
逐元素运算不需要 provider,照常工作;matmul 到运行时才报错。0.7.1 在这种配置下是编译错误。
哪个运算去哪
| 运算 | native | blas |
|---|---|---|
dot_general、matmul,einsum 的每个二元步 | cpueinsum BinaryPlan → tcontract Plan(faer、打包驱动或逐元素 pass) | cpueinsum-blas BinaryPlan:能直接交给 CBLAS 的布局走 ?gemm,否则走同一个 native 计划 |
| svd、qr、eigh、lu、solve、cholesky 等 | tlinalg(faer) | tlinalg-blas(LAPACK) |
full_piv_lu、full_piv_lu_solve | tlinalg(faer) | tlinalg(faer) |
| 逐元素、规约、结构、索引 | strided-rs | strided-rs |
exp、tanh 等 | elem-simd-math 加 strided-rs | 同左 |
整数和 Bool 的收缩两边都不支持,allocate_dot_output 里写着「CPU contraction providers support F32/F64/C32/C64; convert I64 to F64 before contraction」这样的提示,但 I64 的 dot_general_read 和 einsum 实际先撞上取标量 1 的那一步,报的是 ContractionScalar::one: dtype mismatch: expected I64, actual F32,期望和实际是反着写的,上面那条更有用的提示走不到。多于两个操作数的 einsum 怎么拆成二元步,在 einsum。
native:cpueinsum、tcontract、faer
execute_dot_allocated 对四种浮点 dtype 调 run_contraction,最终到 fresh:按两个操作数和输出的布局(形状、步长、偏移、是否共轭)组成键,从计划缓存拿一个 BinaryPlan,从缓冲池取一块未初始化的输出,执行,成功后 assume_init_recycled。eager 调用没有指令编号,eager_dot_cache_slot 把 dtype、两个形状、四组轴和共轭标志哈希成一个槽位;槽位只负责「放在哪」,命中前仍然比较完整的键,所以哈希碰撞只会多建一次计划,不会用错计划。release notes 说重复的 64×64 f64 eager matmul 因此从每次 84 次堆分配降到 0 次。
native 的 BinaryPlan 只包着一个 tcontract 的 Plan。Plan 在构造时按顺序选一种策略,模块注释列了规则:
- 显式要求了内核、分区、分块等,用打包驱动;
- 所有轴都是批轴(逐元素乘),用逐元素 pass;
- 不复制任何操作数就能折成一个带步长的批量 GEMM,交给 faer,除非每个批元的
m * n * k超过该 dtype 的FaerLimit(默认只有 c64 有上限,2^17); - 其余用打包驱动:TBLIS 式的驱动,把一般步长的张量直接打包进微内核的面板,不先转置(tcontract-kernel 里有 aarch64 NEON 和 x86 AVX2 / AVX-512 的微内核)。
普通的列主序 matmul 落在第 3 条,算的是 t4a-faer 的 GEMM。faer 用几个线程由令牌决定:team 令牌给 Par::Team,否则 Par::Seq(engine 与 threadteam 池)。转置过的、带奇怪步长的收缩落在第 4 条,不需要先物化成紧凑矩阵。
blas:能交给 CBLAS 的交给 CBLAS
BLAS 构建里,contraction.rs 的 BinaryPlan 换成 cpueinsum-blas 的 BinaryPlan,它同时持有一个 native 计划和一个可选的厂商计划:
pub struct BinaryPlan<T: BlasScalar> {
native: cpueinsum::BinaryPlan<T>,
vendor: Option<VendorPlan>,
}
厂商计划只在操作数的轴组能折成 BLAS 读得了的矩阵时存在。execute_slices_accum 在执行时再判断一次:
let vendor_route = crate::vendor_loop_route(exec);
let Some(plan) = self
.vendor
.as_ref()
.filter(|_| alpha != T::default() && vendor_route)
else {
return self
.native
.execute_slices_accum(exec, alpha, a, b, beta, source, d)
.map(ExecutionRoute::Native)
.map_err(|source| BlasError::Native { source });
};
vendor_loop_route 看的是 engine 的批处理策略:Auto 和 Sequential 走厂商路线,批维度上是一个「每个批元调一次 ?gemm」的串行循环;强制 Outer、Inner 或 Split 就走 native 计划,因为一个串行循环兑现不了这些策略。厂商调用在调用线程上发起(模块注释叫它「Coordinator-inline CBLAS execution」),之后 BLAS 库用自己的线程,engine 的池和令牌管不到它。上游 choosing-a-backend.md 说的就是这一点:Accelerate 的线程数归 VECLIB_MAXIMUM_THREADS 管,「tenferro cannot bound the provider's fan-out」。(同一份指南的后端表里还有一行 WebGPU,0.8.0 已经删了这个后端。)
cpueinsum-blas 里有一个 DEFAULT_MIN_MACS(2^15 次乘加),小于它的 GEMM 留给 tcontract;它只用在 N 元 einsum 计划的步骤后端 Blas 上。上面这个预备好的 BinaryPlan 没有这道门槛,64×64 的 matmul 在 BLAS 构建里也走 Accelerate(下文 3 µs 那一行)。
线性代数这边,tlinalg-blas 不接收令牌,没有 tenferro 的通道;batch_policy.rs 的模块注释说 BLAS 的线代通道对强制的 Outer、Inner、Split 在写任何东西之前返回 Unsupported,只接受 Auto 和 Sequential。
问: 我按指南写了 default-features = false, features = ["blas-accelerate"],为什么 cargo tree 里还有 faer?
答: 两条路都会把它带进来。第一条是 tenferro-linalg 的 blas feature 自己就打开了 dep:faer 和 dep:tlinalg,注释是「The blas build keeps tlinalg (faer) for complete-pivoting LU, which LAPACK does not provide in rank-revealing form」。第二条更底层:tcontract 的 Cargo.toml 里 faer 不是可选依赖,而 cpueinsum-blas 需要 cpueinsum 和 tcontract 做 native 备选。cpu-blas 里 cargo tree -i t4a-faer -e normal 的开头:
t4a-faer v0.27.0
├── tcontract v0.9.0
│ ├── cpueinsum v0.9.0
│ │ ├── cpueinsum-blas v0.9.0
│ │ │ ├── tenferro-cpu v0.8.0
...
├── tcontract-exec v0.9.0
...
├── tenferro-linalg v0.8.0 (*)
└── tlinalg v0.8.0 (*)
实测:matmul、SVD、QR
provider_bench 在两种构建里各跑一遍,每种构建分别用串行 engine 和 8 线程 engine(has_team 为真);输入是固定种子的 [-1, 1) 均匀随机数,1024 以上取 5 次最小值,其余 50 次。BLAS 构建另外在 VECLIB_MAXIMUM_THREADS=1 下跑了一次串行 engine。native 的输出(节选):
provider: tenferro.cpu.faer
== serial: num_threads 1 has_team false
matmul 1024: f64 44.516 ms ( 48.2 GFLOP/s) f32 22.073 ms ( 97.3 GFLOP/s)
matmul 2048: f64 356.404 ms ( 48.2 GFLOP/s) f32 173.435 ms ( 99.1 GFLOP/s)
512x512 f64: svd 64.896 ms qr 12.727 ms sigma_max 25.832193404581
...
== team: num_threads 8 has_team true
matmul 1024: f64 6.248 ms ( 343.7 GFLOP/s) f32 3.304 ms ( 649.9 GFLOP/s)
matmul 2048: f64 53.777 ms ( 319.5 GFLOP/s) f32 25.713 ms ( 668.1 GFLOP/s)
512x512 f64: svd 24.984 ms qr 3.905 ms sigma_max 25.832193404581
Accelerate:
provider: tenferro.cpu.blas
== serial: num_threads 1 has_team false
matmul 1024: f64 3.228 ms ( 665.2 GFLOP/s) f32 0.843 ms (2547.8 GFLOP/s)
matmul 2048: f64 28.540 ms ( 602.0 GFLOP/s) f32 6.922 ms (2481.8 GFLOP/s)
512x512 f64: svd 31.996 ms qr 5.594 ms sigma_max 25.832193404581
...
== team: num_threads 8 has_team true
matmul 2048: f64 28.321 ms ( 606.6 GFLOP/s) f32 7.188 ms (2390.1 GFLOP/s)
512x512 f64: svd 31.939 ms qr 5.507 ms sigma_max 25.832193404581
| matmul(ms) | native 串行 | native 8 线程 | Accelerate | Accelerate,vecLib 1 线程 |
|---|---|---|---|---|
| 256 f64 | 0.700 | 0.117 | 0.107 | 0.096 |
| 1024 f64 | 44.5 | 6.25 | 3.23 | 6.00 |
| 2048 f64 | 356 | 53.8 | 28.5 | 53.7 |
| 256 f32 | 0.378 | 0.078 | 0.031 | 0.026 |
| 1024 f32 | 22.1 | 3.30 | 0.843 | 1.57 |
| 2048 f32 | 173 | 25.7 | 6.92 | 13.4 |
| 512×512 f64 分解(ms) | native 串行 | native 8 线程 | Accelerate(任一 engine、任一 vecLib 设置) |
|---|---|---|---|
| SVD | 64.9 | 25.0 | 31.9 到 32.3 |
| QR | 12.7 | 3.9 | 5.5 到 5.6 |
- 大 matmul 上 Accelerate 遥遥领先:2048 的 f32 约 2.5 TFLOP/s,是 native 8 线程的 3.7 倍;f64 是 1.9 倍。tenferro 的池对它没有影响:BLAS 构建里串行 engine 和 8 线程 engine 的数字一样。把
VECLIB_MAXIMUM_THREADS设成 1,大 matmul 慢一半,小的(256)反而略快。 - 分解反过来:native 8 线程的 SVD 和 QR 都比 Accelerate 快,而且 Accelerate 的 SVD、QR 不受 vecLib 线程数影响,在这台机器上是单线程的数字;native 串行的 SVD 比 Accelerate 慢一倍。
- 两种构建的最大奇异值逐位相同(
25.832193404581),两个 provider 算的是同一个东西。 - native 串行的 matmul 在 256 以上稳定在每核约 48 GFLOP/s(f64)和 97 GFLOP/s(f32),8 线程是串行的 6.7 到 7.1 倍。
带批维度的 matmul 在 BLAS 构建里还取决于批处理策略(f64,毫秒,最小值):
== serial: num_threads 1 has_team false
batched 256 x 32^3 f64: Auto 0.099 Sequential 0.100 Outer 0.489 Inner 0.489 (min ms)
batched 64 x 64^3 f64: Auto 0.122 Sequential 0.122 Outer 0.817 Inner 0.814 (min ms)
batched 8 x 256^3 f64: Auto 0.711 Sequential 0.711 Outer 5.656 Inner 5.682 (min ms)
== team: num_threads 8 has_team true
batched 256 x 32^3 f64: Auto 0.097 Sequential 0.099 Outer 0.110 Inner 0.518 (min ms)
batched 64 x 64^3 f64: Auto 0.133 Sequential 0.134 Outer 0.150 Inner 0.234 (min ms)
batched 8 x 256^3 f64: Auto 0.711 Sequential 0.711 Outer 0.821 Inner 0.938 (min ms)
Auto 和 Sequential 是逐批元调 Accelerate;强制别的策略就换成 native 计划,在串行 engine 上慢 5 到 8 倍。在 native 构建里为 64 个 64³ 调好的 Outer,搬到 BLAS 构建里会把 Accelerate 关掉。
full_piv_lu 在 0.8.0 发布当天才统一PR #2320(「fix(linalg): one rank-revealing complete-pivoting LU for both CPU builds (#2319)」,首次进入 v0.8.0,提交日期就是发布当天)之前,BLAS 构建用 LAPACK 的 ?getc2 算全主元 LU。提交说明列了它和 native 的三处不同:「singular input was an error, a pivot below EPS * max was flagged singular and replaced by SMIN, and ties went to the last maximum」,而 faer 的版本能分解奇异矩阵、不扰动主元、取第一个最大值。修法是两种构建都用 tlinalg 的内核,删掉 ?getc2 适配,理由是「LAPACK has no rank-revealing complete-pivot LU, and the factorization is level-2 work」,并写明这是对早先 #1069 的部分回退。
现在两种构建对同一个秩为 2 的 3×3 矩阵(第三列等于前两列之和)给出逐位相同的 U,最后一个主元是舍入误差量级的 9.99e-16,不报错:
provider: tenferro.cpu.faer
full_piv_lu ok: U = [9.0, 0.0, 0.0, 3.0, -0.6666666666666667, 0.0, 6.0, 0.6666666666666665, 9.992007221626409e-16]
provider: tenferro.cpu.blas
full_piv_lu ok: U = [9.0, 0.0, 0.0, 3.0, -0.6666666666666667, 0.0, 6.0, 0.6666666666666665, 9.992007221626409e-16]
这也是上面那个问答里 tenferro-linalg 的 blas feature 打开 faer 的原因。
怎么选
- 默认的
native:线程完全由你交给 engine 的池决定,外层再并行也不会超订;分解在 8 线程时比 Accelerate 快。 blas-accelerate:大 GEMM 快 2 到 4 倍,代价是 GEMM 的线程不归 engine 管,外层又开多个 engine 时要自己限制VECLIB_MAXIMUM_THREADS;带批维度的收缩别强制Outer/Inner。- 两者都编进来只会得到一屏编译错误;
cpu_provider_id()(doc-hidden)返回tenferro.cpu.faer、tenferro.cpu.blas或tenferro.cpu.none,可以在程序里确认拿到的是哪一个。native 的标识里仍写着 faer,因为 GEMM 和线代最终是它。
CpuEngine::serial() 换成 8 线程的 team engine,2048×2048 的 f32 matmul 会快几倍?用上面的 provider_bench,看两段输出里 matmul 2048 的 f32 列。提示:厂商路线在哪个线程上发起,之后的并行归谁管?
动脑筋答案
== serial: num_threads 1 has_team false
matmul 2048: f64 28.540 ms ( 602.0 GFLOP/s) f32 6.922 ms (2481.8 GFLOP/s)
== team: num_threads 8 has_team true
matmul 2048: f64 28.321 ms ( 606.6 GFLOP/s) f32 7.188 ms (2390.1 GFLOP/s)
- 一点也不快:6.92 ms 对 7.19 ms,在噪声之内。
- 厂商计划在调用线程上直接调
cblas_sgemm,engine 的令牌和 team 都没用上;Accelerate 自己决定怎么并行。 - 真正能改变它的是 Accelerate 的设置:
VECLIB_MAXIMUM_THREADS=1时同一个 matmul 是 13.36 ms。
小结
native(默认)和blas编译期二选一;两个都开时compile_error!排在两个重复定义错误之后,两个都不开时能编译,收缩在运行时报Unsupported。- native 的收缩是 cpueinsum
BinaryPlan包 tcontractPlan:能零拷贝折成批量 GEMM 的给 faer,其余给 TBLIS 式打包驱动,纯批轴的走逐元素 pass。 - BLAS 的
BinaryPlan在布局合适、策略为Auto/Sequential时调?gemm,否则退回同一个 native 计划;厂商调用在调用线程上发起,线程归库自己。 - 线代 native 走 tlinalg(faer),BLAS 走 tlinalg-blas(LAPACK);
full_piv_lu两边都是 faer(#2320),BLAS 构建也一定带着 faer。 - 本机 Accelerate 的大 matmul 比 native 8 线程快 1.9 倍(f64)到 3.7 倍(f32),与 engine 宽度无关;512 的 SVD 和 QR 则是 native 8 线程更快。