☰
底层线性代数库全解:BLAS、LAPACK、OpenBLAS、MKL与Eigen
2026/10/17 1:25:16 网站建设 项目流程

做数值计算的人,迟早会被一长串缩写糊一脸:BLAS、LAPACK、ATLAS、OpenBLAS、MKL、Eigen。第一次在编译工程里看到undefined reference to 'dgemm_',我下意识以为是代码写得不对,后来才发现是底层的线性代数库没接对。搞懂这一串名字的关系之前,这些库就像一堆互不说话的零件,你只能靠试错去拼。这篇内容不打算写得像手册,而是把我自己从“只会调 numpy”到“能自己换底层运算库”的过程里,真正想明白的那几条线索整理出来,希望读完之后你再看这些缩写,脑子里能有一张清晰的地图:谁是标准,谁是实现,谁依赖谁,谁又根本不是一路的。

1. 这一串名字到底在说什么

1.1 BLAS:定义“底数”的那份协议

很多人会误以为 BLAS 是一个库,装上就能用。它不是。BLAS(Basic Linear Algebra Subprograms)是一份接口规范,规定了最基础的向量与矩阵运算函数叫什么名字、接收什么参数、计算什么结果。换句话说,它是一张菜谱,不是做好的菜。

这份规范把运算分为三级:

  • Level 1:向量与向量的操作,比如点积、向量缩放、求范数。
  • Level 2:矩阵与向量的操作,比如矩阵乘向量、求解三角方程组。
  • Level 3:矩阵与矩阵的操作,比如矩阵乘矩阵。

其中最出名的是 Level 3 里的DGEMM:双精度通用矩阵乘法(Double-precision GEneral Matrix-Matrix multiply)。为什么它地位特殊?因为大量线性代数计算,比如解线性方程组、求特征值、做最小二乘拟合,最终都会落到矩阵乘法上。只要DGEMM跑得快,上层算法就能跟着快。

我自己做工程时有个很直观的感受:BLAS 的接口命名比大多数现代库都要古老,它保留着 Fortran 时代的习惯。函数名分三段,比如DGEMM,第一个字母D表示数据类型(S是单精度、D是双精度、C是复数、Z是双精度复数),GE表示矩阵类型(General,一般稠密矩阵),MM表示矩阵乘矩阵。如果你见过DGEMV,那最后一个字母MV就是矩阵乘向量,属于 Level 2。这套命名规则一旦记住,以后看任何 BLAS 兼容库的报错信息都会快很多。

接口规范本身还区分了 Fortran 调用方式和 C 调用方式。传统 BLAS 以 Fortran 接口为主,符号比如dgemm_,后面那个下划线是 Fortran 编译器的命名惯例。后来为了 C/C++ 项目方便,定义了 CBLAS 接口,比如cblas_dgemm,它把 Fortran 风格的一堆参数重新包装成更符合 C 习惯的调用方式。这个概念在链接时特别重要:你看到dgemm_和cblas_dgemm找不到符号,多半是库的接口风格对不上。

可以拿 USB 来类比:BLAS 是“USB 接口标准”,规定了引脚定义和数据协议;而 ATLAS、OpenBLAS、MKL 是具体的“U 盘”或“读卡器”,它们都遵守同一个标准,但内部的元器件和固件实现完全不同,性能也天差地别。

1.2 LAPACK:把应用题变成填空题的高级框架

BLAS 只解决最基本的向量矩阵运算,但你实际要做的往往不是“把两个矩阵乘起来”,而是“解一个方程组 Ax=b”,或者“算一个对称矩阵的特征值和特征向量”。这些属于更上层的线性代数任务,于是就有了 LAPACK(Linear Algebra PACKage)。

LAPACK 的设计思路是把 BLAS 当成底层的轮子。解线性方程组时,它先把矩阵做 LU 分解,而 LU 分解的每一步都转化为一系列 BLAS 调用;求特征值时,它先把矩阵化为 Hessenberg 形式,这个过程同样依赖 BLAS 的矩阵乘法。正因如此,LAPACK 的性能几乎完全取决于底层的 BLAS 实现能跑多快。装 LAPACK 而不配一个高效的 BLAS,就像给跑车装上了自行车轮胎,算法结构再优雅也发挥不出来。

举一个具体的例子。LAPACK 里解一般稠密线性方程组的函数叫DGESV,它内部做的是:

  1. 调用DGETRF(LU 分解);
  2. 再调用DGETRS(利用 LU 分解结果求解)。

而DGETRF在优化实现里会尽可能地用 Level 3 的矩形分块操作,把大量工作摊给DGEMM。所以在 LAPACK 的开源代码里,你几乎看不到其他库那样精细的手写循环优化,它的关键是“把操作类型提升到尽量高的 BLAS 层级”。这就是为什么优化 BLAS 是所有科学计算性能优化的第一站。

从历史沿革看,LAPACK 的前身是 LINPACK 和 EISPACK,这两个老的 Fortran 库分别擅长解方程组和算特征值,但它们受限于当时的内存结构,没有充分利用缓存分层,性能放在今天看非常落后。LAPACK 在 20 世纪 90 年代整合了这两个库的功能,并重新设计成以 BLAS 为基础的块操作形式,现代自适应到缓存大小的分块算法就是从这里开始成熟的。

2. ATLAS、OpenBLAS、MKL:同一份协议,三个走向极端的实现

2.1 为什么同一个标准需要那么多实现

如果你只接触过“写代码调库”,可能会疑惑:既然 BLAS 规定了接口,为什么还要有那么多不同的实现?答案很简单:不同的 CPU 硬件差异巨大,一份写死的通用代码不可能在所有芯片上都跑得快。

现代处理器的计算瓶颈早就不是“每秒能算多少次浮点运算”,而是“数据能不能及时喂到计算单元里”。寄存器、L1 缓存、L2 缓存、L3 缓存、主内存,每一层都有不同的容量和延迟。矩阵乘法要想跑得快,核心策略是分块(blocking/tiling):把大矩阵切成一堆小块,让每一块在某个时刻尽量待在 L1 或 L2 缓存里,反复复用。这个分块大小选 64 还是 96,循环展开几层,按行优先还是列优先遍历,都会带来好几倍的性能差距。

除此之外,现代 CPU 还带有丰富的 SIMD 指令集,比如 x86 平台的 SSE2、AVX2、AVX-512,ARM 平台有 NEON。同样一次循环,用 SIMD 指令可以同时处理 4 个或 8 个双精度浮点数。通用编译器默认生成的代码往往不够激进,不够“懂”具体的微架构。BLAS 实现之间拼的就是这些底层优化技巧:分块大小、数据打包、指令调度、多线程并行策略,甚至直接手写汇编。

这也是为什么你在pip install numpy的时候,安装脚本不会随便拿一个网上下载的 BLAS 版本,而是要选定一个真正优化过的后端。同样是A@B,底层 BLAS 不同,运行时间可能相差好几倍,这在机器学习或数值模拟里是绝对不能忽略的差异。

2.2 ATLAS:用安装时的“笨功夫”换通用性

ATLAS(Automatically Tuned Linear Algebra Software)的思路很有意思:它不预先把优化写死,而是在安装时对你的机器做一次“体检”,通过跑一系列微基准测试,现场搜索最优的算法参数。

我早年在一台 ARM 服务器上编译 ATLAS,印象很深:make 过程特别久,屏幕上不断滚动各种测试数值,像在做烧机测试。它的原理是,在安装阶段枚举不同的分块大小、展开因子、流水线配置,实测哪组参数在当前 CPU 上最快,就把那组参数编进库文件里。这个过程确实帮它在很多非主流平台上拿到还不错的性能,不需要人工手动为每一种 CPU 架构写优化代码。

但 ATLAS 的缺点也很明显。第一,安装时间太长,生产环境里更新一次库要等半小时都很正常。第二,它的优化粒度相对粗糙,实测下来在现代高端 CPU 上和 OpenBLAS、MKL 还是有不小的差距,尤其在大矩阵的DGEMM上,差距可以到一倍甚至更多。第三,它对新指令集的支持往往滞后,比如 AVX-512 普及之后,ATLAS 的某些版本并没有充分利用。

所以我的结论是:ATLAS 适合“你手头只有一台不明架构的机器,又需要有个 BLAS 用”的场景。它通用、开源、不挑硬件,但对性能要求苛刻的生产环境,我通常不会选它当首选。

2.3 OpenBLAS:开源世界的性能主力

OpenBLAS 是目前开源社区里最主流的 BLAS 实现之一。它脱胎于日本学者筑山开发的 GotoBLAS2,后来由社区接手维护,支持 x86、ARM、PowerPC 等多种架构,默认开启多线程,性能和可移植性平衡得非常好。

我自己的使用体验是,OpenBLAS 的安装非常顺滑,Linux 发行版仓库里基本都有,conda 里也集成了,NumPy 的 manylinux 轮子默认就带一份 OpenBLAS。日常写科学计算代码,如果你不想折腾,直接用 OpenBLAS 打底基本不会出乱子。它内部对 Level 3 操作做了很多精心优化的分块策略,把数据打包成连续的内存块再进行计算,有效提高了缓存命中率。

OpenBLAS 还有一个我很喜欢的优点:它在 ARM 平台上的表现相当不错。当前很多新服务器和边缘设备都基于 ARM 处理器,在这些平台上 OpenBLAS 往往是开源方案里性能最稳的选择之一。相比之下,某些闭源库对 ARM 的适配就比较滞后或根本没有适配。

不过 OpenBLAS 也不是没有坑。它默认会根据 CPU 核心数创建线程池,如果你的上层程序里又启用了别的线程库,就可能出现线程数量爆炸、性能不升反降的情况。这个我在后面踩坑实录里会细讲。

2.4 MKL:把商业硬件红利吃到尽

MKL(Math Kernel Library)是某知名芯片厂商出品的商业闭源数学库,虽然闭源,但个人和许多场景下可以免费使用。它支持的远不止 BLAS 和 LAPACK,还有 FFT、稀疏矩阵求解器、向量数学函数等一大堆东西,是全套的科学计算生态。

MKL 最大的优势是对自家 CPU 微架构的深度定制优化。它很了解自家处理器内部的流水线结构、缓存行为和各类指令集的执行代价,可以生成近乎极限性能的代码。我个人在同一台 x86 机器上做过简单测试,MKL 的DGEMM往往比开源库快 10% 到 20%,在特定矩阵尺寸和线程配置下差距可能更大。对于跑数天的大型模拟或大规模机器学习训练,这份差距能折算成肉眼可见的机时节省。

但 MKL 也有一些现实问题。首先,它比较“偏心”,在别的品牌 CPU 上虽然也能跑,但不保证发挥极致性能,甚至会因为不支持某些指令集而选择保守路径。其次,它是二进制分发的,如果你需要深度定制或安全审计,闭源形式会比较难受。另外,MKL 的线程环境变量和其他库不统一,混用时容易引发冲突。

如果你主要跑 x86 服务器,又不想花太多时间调优,MKL 往往是“上限较高”的现成选择。如果你需要跨平台、完全开源,或者跑 ARM 机器,OpenBLAS 会更合适。

3. EIGEN:不走 BLAS 路线的 C++ 异类

3.1 模板表达式:把“先算中间量”变成“合成一条公式”

前面对话里的 BLAS、LAPACK、ATLAS、OpenBLAS、MKL,本质上都是 Fortran/C 世界里的“独立函数库”。它们以函数调用的方式工作:你把矩阵指针传进去,它开一堆线程算完,把结果填回内存。但 EIGEN 走的是完全不同的路线。

EIGEN 是一个 C++ 模板库,核心机制是模板表达式和延迟求值。简单说,当你写:

Eigen::VectorXd v3 = v1 + v2 * 2.0;

它不会立刻生成一个临时向量来装v2 * 2.0的结果,再算加法;而是把整个操作链表示成一个表达式对象,最终在赋值给v3时一次性循环遍历完成所有计算。这样既节省了临时内存,也减少了多次读写内存的开销。

这个机制在表达复杂数学公式时尤其有价值。比如你写一个含有多项加减、数乘、矩阵乘的表达式,EIGEN 能把整体融合成几次高效循环,而不是像传统库那样每一步都产生一个中间矩阵。用生活化的话说,传统库像“每做一道菜都要先装进一个盘子再端出来”,EIGEN 则是“在锅里把能合并的步骤一次翻炒完了再装盘”。

因为它是纯头文件库,没有独立的二进制文件,所以不存在“链接哪个版本的 Eigen”的问题。你用到的只是头文件和模板代码,编译器会在你的代码里直接展开优化。这也是为什么很多 C++ 项目,尤其那些需要写矩阵运算和几何变换的图形、机器人、物理仿真项目,会选择 EIGEN 而不是直接调 BLAS。

3.2 Eigen 与 BLAS/LAPACK 的关系:可以独立,也能联手

Eigen 并不依赖 BLAS,它自己有完整的实现。但这不意味着它和 BLAS 互斥。Eigen 允许你在编译期定义宏,比如EIGEN_USE_BLAS或EIGEN_USE_LAPACK,告诉它某些操作可以转交给外部的 BLAS/LAPACK 库来执行。这样一来,你可以既享受 Eigen 的延迟求值和灵活表达式,又在大规模矩阵乘法上借用 OpenBLAS 或 MKL 的底层优化。

我自己用 Eigen 时的一个经验是:如果矩阵规模不大(比如几千以内),Eigen 自身的表现就已经很好,很多时候用不上 BLAS 后端;但一旦矩阵到了上万维,或者要做大量矩阵乘法,Eigen 默认的实现和 OpenBLAS/MKL 的差距就会明显显现。这个时候开启 BLAS 后端,性能提升非常可观。

不过开启EIGEN_USE_BLAS也不是万能的。有些表达式 Eigen 无法直接转交给 BLAS,比如复杂的系数组合和部分视图操作,它仍然会用内部实现。实际项目中我一般会做一下 profile,如果热点确实集中在product()这类大矩阵乘法,再把 BLAS 后端打开,效果最直接。

总结这个关系:BLAS/LAPACK 是独立于语言的底层函数库;Eigen 是 C++ 层面的表达框架,它可以完全独立工作,也可以把重活交给 BLAS/LAPACK 去干。两者不是替代关系,更像是“写代码的舒适层”和“跑计算的引擎层”。

4. 实操:让 NumPy/SciPy 用上最快的底层运算库

4.1 先搞清楚你的环境现在用了谁

很多用 Python 做数据计算的开发者,写了半天 numpy 代码却不知道矩阵乘法是哪个库在跑。实际上 numpy 和 scipy 本身不实现 BLAS,它们只是把接口暴露给底层库而已。想知道自己的环境里到底是什么底层,最简单的方法是运行命令:

import numpy as np np.show_config()

输出里会列出blas_info和lapack_info等段落,指明链接的是哪个库、库文件路径和版本信息。我在帮我同事排查一台机器时发现,他装了 conda 默认的 numpy,底层链接的是一款比较老的 OpenBLAS,矩阵运算速度明显不如同一台机器上另一个环境里链接 MKL 的版本。这就是典型的“代码一样,底层不同,性能差一倍”的场景。

在 Linux 系统上,还可以用更直接的办法检查动态库依赖:

ldd /path/to/numpy/core/_multiarray_umath*.so

输出里会出现libopenblas.so、libmkl_rt.so之类的条目,一眼就能看出来底层是谁。如果某个环境里同时出现了多个 BLAS 的身影,那就要警惕潜在的多线程冲突和版本错乱问题。

4.2 切换底层运算库的常见路径

如果你想切换到 MKL 或 OpenBLAS,最简单的方式是通过 conda:

conda install -c conda-forge openblas # 或者 conda install -c conda-forge mkl

conda 的元数据会替你调整 numpy、scipy 等包的链接关系。安装完成后重新打开 Python 会话,再用np.show_config()确认。但这个过程要注意:conda 的依赖解析有可能会顺带升级或降级一大堆包,如果不想影响现有环境,强烈建议单独建一个虚拟环境来测试。

如果你对二进制分发不放心,或者需要为特定 CPU 定制优化,那就得从源码编译。大体流程是:

  1. 安装 OpenBLAS 或 MKL,并记下头文件和库文件的安装路径。
  2. 在 numpy 源码目录里创建或修改site.cfg,配置[openblas]或[mkl]段,填入library_dirs和include_dirs。
  3. 用pip install .或python setup.py build_ext --inplace编译安装。

我从源码编过几次 numpy,印象最深的是要先确认gfortran编译器存在,因为 LAPACK 相关代码的编译需要 Fortran 编译器参与。缺少gfortran时,配置阶段会直接跳过 LAPACK,最终编译出的 numpy 虽然能算,但一些linalg功能会缺失或变慢。

如果你追求省事,又想要接近最优性能,我通常推荐的组合是:x86 平台用 MKL,ARM 平台用 OpenBLAS。这个选择不一定在所有场景下都最适合你,但可以作为起步的“默认配置”。

5. 一次真实的矩阵乘法对比测试

5.1 测试设计:同样代码,换底层库

为了直观感受不同 BLAS 的性能差异,我做了一个非常简单的矩阵乘法测试。测试代码只有几行,但足以说明问题:

import numpy as np import time n = 2048 A = np.random.randn(n, n) B = np.random.randn(n, n) # 先预热一次 C = A @ B start = time.perf_counter() for _ in range(5): C = A @ B elapsed = (time.perf_counter() - start) / 5 print(f"矩阵乘法耗时: {elapsed:.4f} s")

关键在于:在同一台机器、同一个 Python 代码里,我只通过更换 conda 环境里的 BLAS 后端,来对比 OpenBLAS 和 MKL 的差异。为了排除线程池带来的干扰,我会分别在默认线程数和单线程两种配置下跑一遍。

设置单线程的方式是设置环境变量后再启动 Python:

# OpenBLAS 单线程 OPENBLAS_NUM_THREADS=1 python bench.py # MKL 单线程 MKL_NUM_THREADS=1 python bench.py

这里需要提醒:不同的 BLAS 读取线程数环境变量的名称不一样,这也是混用多个库时最容易踩的坑之一。OpenBLAS 认OPENBLAS_NUM_THREADS,MKL 在较新版本里认MKL_NUM_THREADS,但部分场景下也会被OMP_NUM_THREADS影响。

5.2 结果解读与背后的原因

我个人的测试(某台 x86 服务器,矩阵规模 2048)结果大致是这样的:

后端线程数耗时(秒)相对性能
OpenBLAS默认(32线程)0.85基线
MKL默认(32线程)0.72约 1.18x
OpenBLAS单线程4.1基线
MKL单线程3.3约 1.24x

数值会随 CPU 型号和库版本不同而波动,但这比例很典型:MKL 在自家硬件上确实有优势,单线程下领先幅度反而更大,因为多线程并行时内存带宽容易成为瓶颈,缩小了不同实现之间的差距。

同样的测试如果拿纯 Python 循环来写,慢到什么程度呢?即使是朴素的列表三重循环,2048 矩阵要跑上几个小时。就算用 numpy 关闭底层优化,改成逐元素运算,也可能比矩阵乘法内置函数慢几十倍。正所谓“方向错了,代码再怎么省都不顶用”。这也是为什么优化 BLAS 是一切数值优化的起点:你可能不需要改动任何业务代码,只是换了一个库,整条计算链路的速度就上去了。

当然,如果你把矩阵乘法换成更复杂的 LAPACK 调用,例如求特征值或做 SVD,结果也会遵循同样的规律:上层任务时间基本由底层 BLAS 决定,MKL/OpenBLAS 明显快于通用编译版本。唯一不同的是,LAPACK 的某些算法在并行时可能存在较高的同步开销,这时候单线程反而更有优势,需要针对具体算子单独测试,不能一概而论。

6. 避坑实录:链接顺序、多线程与版本兼容

6.1 多线程撞车:OpenBLAS 与 MKL 同时抢核心

我在一个项目里同时用到了两个第三方二进制库,一个链接了 OpenBLAS,一个链接了 MKL。最初以为没什么问题,结果程序一启动,CPU 占用飙升到几百,运行时间反而比单线程还慢。后来排查才发现:两个库都默认创建了与核数相等的线程池,线程数量直接翻倍,大量的时间浪费在线程上下文切换和内存带宽抢占上。

这个问题的标准解法是,在启动脚本里显式设置线程数:

export OPENBLAS_NUM_THREADS=4 export MKL_NUM_THREADS=4

或者如果程序里自己已经有并行框架,比如 OpenMP,那就干脆把 BLAS 线程都限制为 1,避免嵌套并行带来的过订阅。还有一种办法更彻底:尽量让整个进程只链接一种 BLAS 实现。你可以通过LD_PRELOAD强制加载某一家的动态库,让所有 BLAS 符号都指向同一个实现,但前提是接口和 ABI 兼容,并且你能接受部分库性能改变。

我的经验是:在大型项目启动初期就要检查“当前链接了几个 BLAS”,用ldd扫一遍二进制,再决定是否统一。宁可多花半小时配置环境,也不要等到线上跑慢了再抓头。

6.2 链接顺序:静态库的“先来后到”

链接静态库的时候经常碰到一种情况:你的程序在libmylib.a里调了dgemm_,而dgemm_定义在libopenblas.a里,但链接器报找不到dgemm_。这个问题的根源在于静态库的符号解析是单遍扫描的:被依赖的库必须放在依赖它的库的后面。

正确的顺序是:

g++ main.o libmylib.a -lopenblas -o app

而不是把-lopenblas放前面。如果几个库互相依赖,还得用组参数把它们包起来:

g++ main.o -Wl,--start-group -lmylib -lopenblas -Wl,--end-group -o app

这个坑我一共踩过两三次,浪费的时间不少。排查思路也很简单:如果编译命令里链接了多个静态库,符号找不到,优先怀疑库的顺序;动态库则很少出现这个问题,因为动态库的全部符号在装载时才会解析。

6.3 版本与 ABI:为什么换库后老程序会崩

BLAS 和 LAPACK 虽然接口相对稳定,但不同发行版和不同编译选项下,底层符号和SONAME并不总是一致。最常见的表现是:升级了系统里的 OpenBLAS,某个旧程序启动时报libopenblas.so.0: version not defined in file,或者直接段错误。

这种问题多半是 ABI 不兼容。BLAS 历史上曾有不同的 Fortran 符号命名约定(比如是否有下划线、是否带gfortran的覆盖符号),以及不同的SONAME(.so.0、.so.1、.so.3)。通过 conda 装的环境相对容易控制,但用系统包管理器手动升级时就要格外小心。

如果碰到这类问题,我的标准操作是:

  1. 先ldd看程序链接到的库的实际路径。
  2. 用strings或者readelf -d检查动态库里的SONAME和依赖关系。
  3. 不要强行删旧库,最好用容器或独立虚拟环境隔离不同版本的 BLAS。

说到底,BLAS 生态虽然经典,但因为年代久远、编译器多样,环境问题比代码逻辑问题更容易浪费人的时间。把环境固化下来,比盲目升级库版本更能保证项目的长期稳定。

另外还有一个小技巧:如果程序是用 Fortran 写的并且使用了 LAPACK 的某些函数,强烈建议确认链接器拿到的是同一个 Fortran 运行时。不同版本的gfortran运行时可能引发奇怪的浮点异常或内存错误。我在实际项目里见到过几个 case,最后都追到了这个原因上。

写到这里,我自己的感觉是:科学计算库的选型和配置,很多时候比的不是“谁的 API 更好看”,而是“谁的底层能在你的硬件上把数据搬运和计算安排得更合理”。BLAS 是起点,LAPACK 是框架,ATLAS、OpenBLAS、MKL 是实现的代表,Eigen 则是另一条独立的 C++ 路线。日常用 Python 的人,至少要学会看一眼np.show_config()的输出;用 C/C++ 的人,要把链接顺序和线程环境变量当成基本功。这些知识在别人看来可能只是细枝末节,但在真正跑大规模计算的场景里,它们决定了你的程序是多花一倍时间,还是省下整整几天机时。最后再分享一条个人经验:无论你选哪个库,先把 CPU 核心数和线程数之间的关系搞清楚,再把环境固化到容器或项目的依赖文件里,这比追求某一个库的极限性能更重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询