Rust in Everything
基于 tenferro-runtime 0.8.0

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 在这种配置下是编译错误。

哪个运算去哪

运算nativeblas
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_solvetlinalg(faer)tlinalg(faer)
逐元素、规约、结构、索引strided-rsstrided-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 在构造时按顺序选一种策略,模块注释列了规则:

  1. 显式要求了内核、分区、分块等,用打包驱动;
  2. 所有轴都是批轴(逐元素乘),用逐元素 pass;
  3. 不复制任何操作数就能折成一个带步长的批量 GEMM,交给 faer,除非每个批元的 m * n * k 超过该 dtype 的 FaerLimit(默认只有 c64 有上限,2^17);
  4. 其余用打包驱动: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。

📝NOTE
没有蠢问题

问: 我按指南写了 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 (*)
所以 BLAS 构建不能用来去掉 faer,它只是把 GEMM 和大部分 LAPACK 式分解换给了厂商库。

实测: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 线程AccelerateAccelerate,vecLib 1 线程
256 f640.7000.1170.1070.096
1024 f6444.56.253.236.00
2048 f6435653.828.553.7
256 f320.3780.0780.0310.026
1024 f3222.13.300.8431.57
2048 f3217325.76.9213.4
512×512 f64 分解(ms)native 串行native 8 线程Accelerate(任一 engine、任一 vecLib 设置)
SVD64.925.031.9 到 32.3
QR12.73.95.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 关掉。

📝NOTE
冷知识: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 和线代最终是它。
❗IMPORTANT
动脑筋:BLAS(Accelerate)构建里,把 engine 从 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 包 tcontract Plan:能零拷贝折成批量 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 线程更快。

💬 关联讨论

发起讨论
加载中...