纯Rust线性代数库faer:矩阵分解与高性能数值计算实践
第一次在一个纯 Rust 项目里被要求做矩阵分解的时候我盯着需求单愣了几秒系统里不能引入任何 C/Fortran 编译产物交叉编译要能一把过但活儿又是实打实的数值计算。那会儿我的默认方案还是找个 BLAS/LAPACK 的绑定包一层结果一查依赖链链接器、系统库版本、目标平台 ABI全是坑。后来把视线转到 Rust高性能线性代数库faer 上才算是把这条路走通了。faer 是一个纯Rust实现的计算引擎覆盖稠密矩阵、稀疏矩阵、各种矩阵分解、线性方程组求解、最小二乘、特征值、奇异值分解这些常规数值计算需求不需要在系统里预装任何外部数学库cargo build 出来的东西就是完整的。这篇文章写给两类人一类是正在做 Rust 数值/图形/机器学习相关项目被外部数学库的链接问题折腾过的工程师另一类是想搞清楚一个纯 Rust 线性代数库到底靠什么把性能撑起来的技术好奇者。下面我按为什么这么设计、内部怎么实现、自己怎么跑通、踩过哪些坑的顺序把这几件事摊开讲。1. 先把定位说清楚faer到底解决的是哪一类问题1.1 传统线性代数栈的历史包袱过去三十年高性能线性代数的基本盘是 BLAS 和 LAPACK 这两层前者管矩阵乘法这类基础运算后者管分解和求解。它们的性能无可挑剔手写汇编、分块、缓存预取、多线程调度都做到了极致。但这套栈有个绕不开的特征——它是 Fortran 血统后来虽然有 C 实现OpenBLAS、MKL 之类接口约定依然是传裸指针 前导维度 列主序这一套。这套约定在 C/C 世界里运行得很好但搬到一个强调所有权、生命周期和内存安全的语言里就变成了持续的摩擦点。你得写 unsafe 去调 FFI得手工保证传入的指针在调用期间有效得自己处理返回错误码得为每个目标平台准备一份预编译库。交叉编译到非主流架构的时候经常卡在这个平台没有现成的 BLAS 二进制上。我在一个嵌入式风格的场景里就吃过这个亏目标平台没有官方预编译包自己编 OpenBLAS 又要处理工具链和浮点 ABI 的差异折腾掉的时间远超写业务代码。这也是我后来认真去看纯 Rust 方案的根本原因不是为了纯而纯而是想让构建链路少一层外部依赖。1.2 纯Rust实现真正值钱的地方很多人对纯 Rust 实现的第一反应是性能肯定打不过 BLAS。这个判断在五年前基本成立但放在今天的 faer 上就不太对了——它在不少基准里已经能贴着手写汇编的库跑差距在一倍以内甚至更小而对绝大多数业务场景来说这个量级完全够用。纯 Rust 实现的价值主要体现在四个地方。第一是构建确定性cargo build就能产出完整可执行文件没有动态库搜索路径没有 -lopenblas 找不到的问题交叉编译到 aarch64、wasm 甚至某些裸机环境理论上都只需要 rustc 支持。第二是内存安全边界清晰矩阵视图切片的生命周期由编译器保证不会出现分解完之后原始数据被释放了但视图还在用这类只有在半夜跑长任务才会暴露的问题。第三是泛型和类型系统的红利同一个算法可以对 f32/f64/复数类型工作编译期就能消掉分支而不是靠运行时的类型标记。第四是可调试性出问题的时候你能一路单步走进算法内部而不是对着一个 .so 文件干瞪眼。当然代价也是客观存在的生态成熟度不如老牌库某些极端场景超大矩阵、特殊硬件指令集的调优深度还差一点文档和示例相对少。这些我在后面会具体说。1.3 什么场景该选它什么场景别硬上我整理了一张对照表是我自己在几个项目里做技术选型时的判断依据你可以直接拿去参考场景特征建议理由需要交叉编译到冷门平台优先选无外部动态库依赖构建链路短矩阵规模在几百到几千阶很合适性能足够API 顺手需要在 wasm 里跑数值计算优先选纯 Rust 编译到 wasm 体验远好于 FFI超大规模稠密矩阵万阶以上追求极限需评估与深度调优的原生库仍有差距团队完全不想碰 unsafe推荐绝大多数路径不需要 unsafe需要极成熟的稀疏直接法生态谨慎稀疏模块相对较新先验证功能覆盖有一点要说明这张表是基于我遇到的场景总结的不是绝对结论。同一个库在不同问题规模上的表现可能差出一个数量级动手前跑一个和你业务同规模的基准比看任何人的表格都靠谱。2. 架构拆解性能是怎么一点一点抠出来的2.1 内存布局与矩阵视图的取舍线性代数库的性能一半以上取决于内存访问模式。faer 默认采用列主序存储这个选择和 BLAS/LAPACK 的传统保持一致好处是和外部世界的约定对齐将来要跟别的库交换数据时心智负担小坏处是对习惯了行主序比如从 C 数组或某些图像库过来的人来说第一次写from_fn的时候容易把行列搞反。真正有意思的是它的视图设计。矩阵类型分为拥有数据的Mat和借用数据的MatRef/MatMut后两者带步长信息可以表示从大矩阵里切出来的一块而不复制。这个设计在数值计算里非常关键——比如做分块分解时算法需要反复对子块操作如果每次都复制光是内存带宽就吃光了。有了带步长的视图算法内部可以对任意子块做原地运算且生命周期检查保证你不会在视图存活期间动原始数据。我踩过一个相关的坑早期我以为切片操作会产生新矩阵写了一堆let block a.submatrix(i, j, r, c).to_owned()结果在大矩阵上 GC 压力其实是分配压力巨大性能掉了一半。后来改成直接持MatRef做原地运算分配次数从几千次降到个位数。这类细节在文档里不会强调但它是纯 Rust 方案能不能跑出性能的分水岭。2.2 分块与缓存为什么小矩阵反而难写好矩阵乘法的朴素三重循环是 O(n³)但真正的性能差异来自缓存。现代 CPU 的 L1 缓存通常只有几十 KB一个 256×256 的 f64 矩阵就有 512KB塞不进去。所以高性能实现都要做分块把大矩阵切成能塞进 L1/L2 的小块在小块内做密集运算块与块之间搬运数据。faer 的矩阵乘法内核走的是这条路外层按缓存层级分块内层用 SIMD 微内核处理微内核的尺寸是根据目标架构的向量寄存器和 FMA 单元数量选的。比如在支持 AVX2 的 x86 上一个 f64 向量是 4 个元素微内核常见尺寸是 8×4 或 16×4 这种到 aarch64 的 NEON 上向量宽度变成 2微内核尺寸要重算。这里有个容易被忽略的事实小矩阵的性能反而更难做。大矩阵的时间被稳态的分块循环主导分块调好了就稳而 8×8、16×16 这种规模函数调用开销、分支预测、内核启动成本占比极高写不好就是大矩阵很快小矩阵一塌糊涂。我在一个每步都要解 4×4 小系统的仿真项目里验证过这件事最终的方案是给小于阈值比如 32 阶的矩阵走专用的小尺寸路径超过阈值才进分块内核。这个阈值在不同硬件上不一样得实测。2.3 SIMD与多线程的调度策略SIMD 这块Rust 的现状是稳定版可以用std::arch里的 intrinsics但要按目标特性做条件编译或者开target-cpunative让编译器自己向量化。faer 的做法是在编译期根据目标特性选择不同的内核实现运行时不需要做指令集探测分支——这对性能是好事代价是你编译出的二进制绑定了编译时的目标特性。实操建议本地开发和跑基准可以开RUSTFLAGS-C target-cpunative发布到通用机器上时老老实实按基线特性编译或者明确指定-C target-featureavx2,fma并接受它不能跑在老 CPU 上。我见过有人把 native 编出来的二进制扔到老服务器上直接 illegal instruction 挂掉排查了半天。多线程方面faer 的并行是按任务粒度决定的。矩阵乘法这种天然可并行的操作会把输出矩阵切成若干块分给不同线程而分解算法里有些步骤本质上是串行的比如带主元的 LU每一步选主元都依赖前一步的结果并行度就受限。库一般提供全局的并行度设置入口可以设成串行、自动、或者指定线程数。在容器环境里要特别小心库读到的核心数可能是宿主机的而不是容器的 CPU limit结果开出一堆线程互相抢占性能反而比单线程差。我的习惯是在服务启动时显式设置并行度环境变量和实际分配对齐。3. 算法层分解家族与数值稳定性3.1 LU、Cholesky、QR 到底该选哪个这三个是最常用的分解新手最容易犯的错是无脑用 LU。实际选择取决于矩阵的性质LU 分解最通用的方阵解法。带部分主元partial pivoting的版本能处理绝大多数非奇异矩阵数值稳定性满足常规需求。带全主元full pivoting的版本更稳但选主元的开销大得多一般只在病态到离谱的矩阵上才值得。Cholesky 分解只适用于对称正定矩阵但速度是 LU 的两倍左右内存占用也少一半。如果你的问题是协方差矩阵、正规方程、有限元刚度矩阵这类天然对称正定的优先用它。判断错了会直接失败所以不要觉得应该是正定就用最好加个校验或者准备回退路径。QR 分解适用于非方阵、或者数值条件不太好但又不至于完全病态的情况。它的稳定性比 LU 好代价是常数更大。最小二乘问题的标准解法就是 QR。我的经验法则是先看矩阵是不是对称正定是就 Cholesky不是但行数等于列数用带部分主元的 LU行数不等于列数直接 QR 走最小二乘路径。这套顺序能覆盖九成以上的实际场景。3.2 SVD和特征值最贵的两个家伙SVD 和特征值分解是计算成本最高的两个操作通常比 LU 贵一个数量级甚至更多。它们的实现路径也复杂得多一般先做 Householder 变换把矩阵化归成双对角SVD或者 Hessenberg 形式一般特征值再用迭代法收敛。对于对称矩阵的特征值问题算法会走到三对角化再 QR 迭代这条路稳定性和速度都不错。对于非对称矩阵特征值可能是复数所以返回值通常是复数类型——这一点很多第一次用的人会懵明明输入全是实数输出出来一对共轭复数。实用建议能用对称路径就别用一般路径。如果你的矩阵在数学上是对称的哪怕是浮点误差导致的微小不对称构造时用自伴self-adjoint视图走对称算法速度能快一到两倍。另外如果你只需要最大的几个特征值别做完整分解看看有没有截断的迭代方法可用——完整分解是 O(n³)而只要前 k 个的话有 O(n²k) 的路子。SVD 有个额外好处是数值稳定性极强条件数再差的矩阵它都能给出有意义的答案。所以在做病态最小二乘、求伪逆、做低秩近似时哪怕贵一点也值得用 SVD 而不是 QR。3.3 稀疏与稠密的边界在哪稀疏矩阵的核心是只存非零元用压缩列CSC或者压缩行CSR格式。稀疏矩阵乘法的性能和稠密完全是两套逻辑稠密看缓存和向量化稀疏看的是非零元分布是否规整、索引数组能不能顺序访问。判断该用稀疏还是稠密别只看非零元比例。一个常见的经验阈值是稀疏度超过 90%即稠密存储里有九成以上是零时稀疏格式的内存优势才明显但真正的决定因素是矩阵乘法的填充fill-in——稀疏 LU 分解过程中会产生大量新的非零元一个看起来极稀疏的矩阵分解完之后可能变得相当稠密。我在一个图相关的项目里验证过原始矩阵 10 万阶、非零元占比 0.01%看着很适合稀疏格式但一做 Cholesky 分解填充让非零元数量涨了两个数量级最后的内存占用和计算时间反而不如直接重排reorder后再分解。稀疏求解器的重排策略比如最小度排序、嵌套剖分对性能影响能有好几倍这块不是调参能解决的得看算法本身。4. 上手实操从空目录到跑通第一个求解4.1 工程初始化与特性开关依赖配置这件事我建议一开始就写清楚默认特性别等上线了才发现拉进来一堆不需要的东西[package] name faer-playground version 0.1.0 edition 2021 [dependencies] faer { version 0.20, default-features false, features [std, rayon] } [profile.release] opt-level 3 lto thin codegen-units 1 panic abort几个参数的理由default-features false配合显式开启能保证依赖行为可控不会因为上游改了默认值就悄悄变rayon特性打开多线程支持如果你的目标环境是单核或者不希望引入线程池可以关掉换成串行lto thin是性能和编译时间的平衡点fat编译慢但通常再快几个百分点看你能不能忍codegen-units 1让优化器有全局视野对数值内核的效果比一般业务代码更明显。注意faer 的版本号和小版本之间的 API 有过变动尤其解算器相关的方法名。下面的代码是按 0.20 前后的写法给的你实际跑之前打开 docs.rs 对一眼签名别硬抄。4.2 矩阵构造与视图几个必知的写法构造矩阵有三条常用路径各有适用场合use faer::prelude::*; use faer::mat; // 路径一字面量宏适合小矩阵、测试用例 let a mat![ [4.0, 1.0, 2.0], [1.0, 5.0, 3.0], [2.0, 3.0, 6.0], ]; // 路径二从函数生成适合有解析表达式的场景 let n 1000; let h 1.0 / (n as f64 1.0); let tri Mat::f64::from_fn(n, n, |i, j| { if i j { 2.0 / h } else if (i as isize - j as isize).abs() 1 { -1.0 / h } else { 0.0 } }); // 路径三从已有切片零拷贝视图适合和外部队冲区对接 let raw: Vecf64 vec![1.0; 12]; // 列主序12 个元素 4 行 3 列 let v MatRef::from_column_major_slice(raw, 4, 3);路径三是最容易被忽略但最有价值的。当你从别的模块拿到一块Vecf64或者[f64]时from_column_major_slice能直接包成视图而不复制代价是你要自己保证行数乘列数是切片长度。我见过有人在debug_assert阶段没加校验release 编译后维度对不上算出来的结果只是看起来合理这种 bug 极难定位。另外要记住mat!宏的语法是行优先书写的——你按视觉上的行写进去内部会转成列主序存储。这个转换是自动的但如果你之后用a[i][j]之类的索引方式混着用一定要确认索引语义和你的数学直觉一致。4.3 解方程、最小二乘与分解复用解一个Ax b的最短路径use faer::prelude::*; use faer::mat; let a mat![ [4.0, 1.0, 2.0], [1.0, 5.0, 3.0], [2.0, 3.0, 6.0], ]; let b mat![[1.0], [2.0], [3.0]]; // 一次性求解 let lu a.partial_piv_lu(); let x lu.solve(b);这里有个性能上的关键点分解和求解要分开。如果你有多个右端项或者需要在时间步循环里反复解同一个系数矩阵的系统一定要把分解结果存下来复用。我见过的最典型的性能事故是在一个有限元后处理脚本里每次迭代都重新构造 LU一千次迭代跑了两分钟把分解提到循环外面之后变成三秒。// 正确姿势分解一次求解多次 let lu a.partial_piv_lu(); for rhs in right_hand_sides { let x lu.solve(rhs); // ... 使用 x }如果矩阵是对称正定的换 Cholesky 会明显更快而且失败时能拿到明确错误match a.cholesky() { Ok(chol) { let x chol.solve(b); } Err(_) { // 不是正定回退到 LU let x a.partial_piv_lu().solve(b); } }非方阵的最小二乘用 QR// a 是 m x nm n let qr a.qr(); let x qr.solve_lstsq(b);这里我要补一句原理QR 解最小二乘之所以比构造正规方程 AᵀAx Aᵀb 再解更靠谱是因为正规方程会把条件数平方。原始矩阵条件数 1e6正规方程那边就变成 1e12f64 的 53 位有效数字直接损失一半。所以除非矩阵条件数极好否则别走正规方程这条路。SVD 和特征值的典型写法// SVDA U * S * V^T let svd a.svd(); let u svd.u(); let s svd.s(); let v svd.v(); // 对称矩阵的特征值 let eig a.self_adjoint_eigen(); let values eig.S(); // 特征值实对称情况下是实数 let vectors eig.U(); // 一般矩阵的特征值结果为复数 let eig_general a.eigen();具体的方法名和返回类型在不同版本间有过调整尤其是特征值那部分返回的是自伴分解还是复分解一定要看当前版本的文档。我在一个项目里升级小版本后就遇到过返回值类型变了编译器报错倒还好最怕的是类型没变但语义变了——所以升级数值库之后一定要跑一遍结果对比别只看能不能编过。4.4 基准怎么测才不算白测自己写基准的时候有几个坑特别常见use std::time::Instant; // 反面教材只测一次还包含分配 let start Instant::now(); let x a.partial_piv_lu().solve(b); println!(耗时 {:?}, start.elapsed());这段代码测出来的数字毫无参考价值单次运行受冷启动、缓存未预热、系统抖动影响极大分配和解算混在一起你根本不知道时间花在哪。正确做法是用criterion至少跑几十次取统计量并且把数据准备放在计时循环外面use criterion::{criterion_group, criterion_main, Criterion, BenchmarkId}; fn bench_lu(c: mut Criterion) { let mut group c.benchmark_group(lu_solve); for n in [64usize, 128, 256, 512] { let a Mat::f64::from_fn(n, n, |i, j| { if i j { 4.0 } else { 1.0 / ((i j 1) as f64) } }); let b Mat::f64::from_fn(n, 1, |i, _| (i as f64) 1.0); group.bench_with_input(BenchmarkId::from_parameter(n), n, |bench, _| { bench.iter(|| a.partial_piv_lu().solve(b)); }); } group.finish(); } criterion_group!(benches, bench_lu); criterion_main!(benches);还有一点测性能必须用 release 编译。cargo bench 默认走 bench profile接近 release但如果用 cargo test 跑 #[bench] 之外的裸计时记得加--release。我见过有人拿着 debug 模式的数据下结论说这个库比另一边慢十倍实际上 release 下面差距小到可以忽略。5. 踩坑实录这些问题我基本都遇到过一次以上5.1 编译与特性相关的坑最高频的一个多线程开了但没生效。faer 的并行需要 rayon 特性而特性开了之后还需要在运行时确认并行度设置。有些环境里线程池初始化失败会静默回退到串行你看到的现象就是明明 32 核CPU 占用只有 100%。第二个坑是交叉编译时的目标特性。默认编译出来的二进制不含 AVX2 这类指令性能可能只有 native 编译的一半。发布给通用平台的版本我一般显式指定一个保守但覆盖广的特性集比如-C target-featuresse4.2之类的具体取哪些要看你的最低支持硬件。第三个坑来自lto和codegen-units的组合。这两个一起开会让编译时间暴涨CI 上容易超时。我的做法是 CI 用快速配置跑测试只在打 tag 的流水线上开完整优化。5.2 数值精度与维度错误数值这块的典型问题有两类。一类是条件数过高导致的精度崩塌。f64 大约有 16 位十进制有效数字条件数 1e12 的矩阵解出来基本只剩 4 位可信。这种时候换算法LU 换 SVD比调参数有用得多因为问题出在数学层面不是实现层面。另一类是维度语义搞反。from_fn(rows, cols, f)里的闭包参数是 (i, j)但真正在内存里连续的方向是列。当你的闭包里有类似按 i 做累加的逻辑时访问模式可能是跳跃的性能会莫名其妙地差。我一般的做法是开#[cfg(debug_assertions)]加几条assert_eq!校验维度上线前靠这个抓 bug。还有一个隐蔽的对称矩阵当作一般矩阵处理。数学上对称浮点存储上可能因为构造过程的舍入而略有不对称走一般分解路径不仅慢还可能得到复数特征值虚部是 1e-18 量级的噪声。用自伴视图走对称算法能规避这个问题。5.3 性能不及预期怎么查我整理了一张速查表基本都是按这个顺序排查的现象可能原因排查动作单线程跑满多核闲置并行特性未开或线程数被限制检查 feature 和并行度设置小矩阵很慢走了大矩阵分块路径检查是否有小尺寸快路径必要时手写特化大矩阵比预期慢一个数量级未开目标指令集优化对比 native 编译与默认编译的耗时内存占用暴涨分解产生填充检查是否该重排或改用迭代法结果精度异常条件数过高计算条件数考虑换 SVD 或做缩放首次调用特别慢线程池/内核初始化预热一次别把首次耗时算进基准容器里性能忽高忽低线程数与 CPU quota 不匹配显式设置并行度对齐分配核数我个人的经验是九成的性能问题最后都归结到三个来源一是编译配置没对debug 模式、没开优化、没开指令集二是算法选错了该用 Cholesky 的地方用了一般的 LU该复用分解的地方每次都重算三是数据布局和访问模式不匹配按行推理、按列存储缓存命中率极低。真正需要深挖到库内部实现的情况很少所以排查顺序一定要从上往下别一上来就怀疑库不行。一条我觉得最值钱的建议在动手优化之前先花半小时确认你的问题是计算密集还是内存密集。用一个规模翻倍的输入跑一遍如果耗时增长接近 8 倍立方增长是计算瓶颈优先换算法如果接近 2 倍线性说明瓶颈在数据搬运这时候折腾 SIMD 意义不大该考虑的是减少复制、复用缓冲区、换存储格式。最后分享一个我自己一直在用的小习惯给每个数值计算模块准备一组金标准数据——固定输入、固定期望输出、容差范围改动代码或者升级依赖之后跑一遍对比。数值库的 bug 最恶心的不是崩溃而是结果偏了一点点还看不出来。有了这层校验升级的时候心里会踏实很多也不用每次改动都手工验算一遍。