Eigen3实战:C++矩阵运算、MATLAB迁移与VC编译避坑

发布时间:2026/10/27 3:53:14
Eigen3实战:C++矩阵运算、MATLAB迁移与VC编译避坑

简介:Eigen3.zip 面向使用 C++ 进行矩阵运算与数值算法开发的工程人员,提供在 CMake3 与 VS2017 环境下编译整理的 Eigen3 共享文件,主要解决 Windows 平台缺少现成 include 与 lib、VS 工程链接配置繁琐的问题,可用于算法验证、课程实验及中小型 C++ 项目。压缩包共含 482 个文件,以 424 个 h 头文件为主体,并配有 4 个 cmake 配置文件及 Core、Sparse、SVD、Geometry、Tensor、FFT 等模块相关文件,覆盖稠密与稀疏矩阵、线性方程求解、特征值、几何变换等常用能力;整体约 1.45MB,目录按头文件与构建配置组织,便于按模块查找。已有 302 人学习,说明其对需要快速接入 Eigen 的开发者具有一定参考价值。借助包内头文件和链接库,读者可减少自行编译与依赖排查时间,将矩阵运算、稀疏线性代数、几何变换等能力接入现有 VC 工程,并利用 cmake 配置完成工程集成,适合希望省去重复构建、专注算法实现的开发者。

1. Eigen3 到手先别急着 include:一份头文件库到底能替掉多少手写矩阵代码

有个做图像处理的同行拿着一个 zip 过来,问这东西和 MATLAB 里那套矩阵语法到底差多远,能不能直接丢进 VC 工程里用。文件名叫 Eigen3.zip,解压出来没有 lib、没有 dll、没有 bin,只有一层层的.h和Eigen/src/—— Eigen3 是个纯头文件的 C++ 模板库,矩阵、向量、稠密/稀疏分解、几何变换都在里面,算法层从 LU 到 SVD 到特征值一条龙。它的价值不在于“又一个数学库”,而在于你写 C++ 时不用再手搓三重循环和三重指针,且语法能贴着 MATLAB 的手感走。适合三类人:正在把 MATLAB 算法往 C++ 迁的、被自研矩阵类坑过的、以及想在 VC 工程里加线性代数但不想引第三方二进制依赖的。

2. 从 zip 到可编译:目录结构、头文件路径与第一个矩阵乘法

拿到包的第一步不是写代码,是把目录认全。Eigen 的接入方式直接决定了后面编译、链接、发布会不会翻车,这一章先把路径打通,再落一个能跑的例子。

2.1 解压后先认目录:Eigen/ 与 unsupported/ 的边界

解压完你会看到两个顶层目录:Eigen/和unsupported/。前者是核心,Eigen/Dense一个头就能把矩阵、向量、常用分解全拉进来;后者是社区模块,多项式求根、样条、自动微分、张量这些都在里面,接口稳定性弱一档,用之前要有心理准备。

关键点在于这个包没有编译产物,所有东西都是模板,在你的编译单元里实例化。这意味着三件事:编译期开销会体现在你的工程上,而不是库里;ABI 对齐、编译选项这些必须由你统一;版本升级就是换头文件,不存在二进制兼容问题。

常见做法是把整包放到工程的third_party/下,然后把third_party/eigen3这一个目录加进 include 路径。注意是加包含Eigen/的那一层,不是加Eigen/本身。加错层的典型现象是#include <Eigen/Dense>报找不到,而#include <Dense>反而能找到——这时候别怀疑包坏了,回去看-I指向哪里。

提示:不要把unsupported/里的头文件路径单独拆出来加-I,它内部是相对unsupported/写的引用,拆开会连环报错。

2.2 三种接入方式,按工程形态选

第一种,裸编译加-I,适合验证和小 demo:

g++ -std=c++17 -O2 -I./third_party/eigen3 verify.cpp -o verify

-std=c++17建议直接上,Eigen 的很多对齐和constexpr行为在 C++17 下更省心;-O2别省,模板库在-O0下跑矩阵乘法会慢到让你误以为库有问题。

第二种,CMake 的find_package,适合多模块工程:

cmake_minimum_required(VERSION 3.16) project(eigen_demo CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 指向解压目录,里面能找到 Eigen3Config.cmake list(APPEND CMAKE_PREFIX_PATH "${CMAKE_SOURCE_DIR}/third_party/eigen3") find_package(Eigen3 REQUIRED NO_MODULE) add_executable(demo main.cpp) target_link_libraries(demo PRIVATE Eigen3::Eigen)

NO_MODULE是让它走 config 模式而不是去系统里瞎找;Eigen3::Eigen是导入目标,它自带 include 目录和编译定义,别自己再手写一遍include_directories,两套混用容易出重复路径。如果你的包是老版本没带 config 文件,就退回include_directories(${CMAKE_SOURCE_DIR}/third_party/eigen3)。

第三种,vendoring,把Eigen/目录整个拷进自己源码树。好处是发布时零外部依赖,坏处是升级要手动 diff。我一般在对外交付的项目里用这种,内部工具链用第二种。

2.3 第一个可运行例子:3x3 求逆与残差校验

先别上大矩阵,用一个小到能手算的方阵把链路走通:

// verify.cpp #include <Eigen/Dense> #include <iostream> #include <iomanip> int main() { // 对称正定矩阵,方便后面换求解器做对比 Eigen::Matrix3d A; A << 4.0, -2.0, 1.0, -2.0, 4.0, -2.0, 1.0, -2.0, 3.0; Eigen::Vector3d b(1.0, 2.0, 3.0); // partialPivLu:带部分主元选取的 LU,一般方阵的默认选择 Eigen::Vector3d x = A.partialPivLu().solve(b); // 残差是判断解是否可信的第一指标,比看 x 的值靠谱 double res = (A * x - b).norm(); // 条件数粗估:||A|| * ||A^-1||,量级过大说明矩阵病态 double cond = A.norm() * A.inverse().norm(); std::cout << std::setprecision(12); std::cout << "x = " << x.transpose() << "\n"; std::cout << "residual = " << res << "\n"; std::cout << "cond = " << cond << "\n"; return 0; }

A << 4.0, -2.0, ...是逗号初始化器,按行填充,这一点和 MATLAB 的字面量顺序一致,但和内存布局不一致——后面讲存储顺序时会说清这个差异为什么会让新手翻车。partialPivLu()返回一个分解对象,.solve(b)才真正求解,两个动作分开写的好处是同一个分解可以反复解不同的右端项。

(A * x - b).norm()是二范数,残差在 1e-16 量级说明解正常;如果残差在 1e-3 以上,先别怀疑库,去看矩阵是不是本身接近奇异。A.inverse()这里只是用来估条件数,生产代码里不要真的求逆再乘,那是数值上的忌讳。

跑通之后,把A换成Eigen::MatrixXd::Random(200, 200),把b换成随机向量,看耗时。这一步是给自己一个基准手感:几百阶的稠密矩阵在普通机器上解一次应该是毫秒级,如果你测出几百毫秒,八成是编译选项没开优化。

3. 矩阵与算法的核心接口:Map、Block、表达式模板怎么选

跑通 hello world 只是开始,Eigen 真正决定性能的是三件事:内存怎么排、外部数据怎么零拷贝接进来、线性方程组该用哪个分解。这三件事选错,代码能跑但结果和速度都会让你怀疑人生。

3.1 存储顺序:ColMajor 与 RowMajor 的性能差异

Eigen 默认是列主序(ColMajor),和 Fortran、BLAS、LAPACK 一致。MATLAB 也是列主序,所以从 MATLAB 迁过来的数值逻辑通常不需要改,但如果你是从 C 二维数组或者图像行优先缓冲区接数据,就会踩到第一个坑。

// 3 行 4 列,列主序:内存里先存完整的第一列 Eigen::Matrix<double, 3, 4> A; // A.data()[0..2] 是第 0 列,A.data()[3..5] 是第 1 列 // 3 行 4 列,行主序:内存里先存完整的第一行 Eigen::Matrix<double, 3, 4, Eigen::RowMajor> B; // B.data()[0..3] 是第 0 行

选哪个不看你顺眼,看数据从哪来。图像处理里一行一行扫的缓冲区,用RowMajor接进来,遍历时缓存命中率高;做矩阵分解、解方程、跟 BLAS 对接,用默认的ColMajor。混合使用不是不行,但每做一次A * B,Eigen 内部要处理布局转换,小矩阵无所谓,大矩阵就是白送的性能损失。

一个具体的判断方法:把矩阵按你最常用的遍历方式走一遍,for (i) for (j) sum += M(i,j)是行优先遍历,for (j) for (i)是列优先遍历。让遍历顺序和存储顺序一致,这是零成本的优化。

3.2 零拷贝接入外部内存:Map 的三种写法

已有的double*缓冲区不想拷一遍,用Eigen::Map。它不拥有内存,只做视图,改它等于改原缓冲区。

#include <Eigen/Dense> // 已有缓冲区:按行存了 3x4 的数据 double raw[12] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }; // 声明成行主序,和缓冲区布局对齐,零转换 Eigen::Map<Eigen::Matrix<double, 3, 4, Eigen::RowMajor>> M(raw); // M(1, 2) 就是 raw[1 * 4 + 2] = 7 // 只读视图:防止下游代码误改 Eigen::Map<const Eigen::Matrix<double, 3, 4, Eigen::RowMajor>> Mc(raw); // 动态尺寸视图:长度运行时才知道 Eigen::Map<Eigen::VectorXd> v(raw, 12);

Map的模板参数必须显式写布局,因为它没法从裸指针推断。写错布局不会编译报错,只会让M(1,2)读到错误位置,这种 bug 最难查——数值看起来“差不多对”,但就是和参考实现差一点。

const版本值得养成习惯,尤其是当这个Map要往下传好几层函数时,加const能挡住一批手滑的原地写入。

注意:Map不管理生命周期,原缓冲区释放后这个视图就是野指针,别把它存进比缓冲区活得更久的对象里。

3.3 解线性方程组:LU、QR 还是 LLT

这是选型里最容易被糊弄过去的一节。很多人一律A.inverse() * b,能出结果,但慢且不稳。正确的做法是先看矩阵结构:

分解适用矩阵相对开销稳定性说明
llt()对称正定最低不检查正定性,失败时结果无意义
ldlt()对称半正定/不定低比 llt 稳,可处理零特征值
partialPivLu()一般方阵中默认首选,带行主元
fullPivLu()秩亏、需判秩较高行列都选主元,可暴露秩
householderQr()长方阵、最小二乘较高数值稳定
colPivHouseholderQr()可能秩亏的长方阵更高带列主元,能判秩
jacobiSvd()任意,最稳最高病态问题和伪逆的兜底

选型理由一句话:结构信息比矩阵大小更决定算法。你明确知道是对称正定,用llt()能省掉一半计算量;不确定就用partialPivLu();矩阵可能秩亏或者要做最小二乘,走 QR;实在不知道怎么办,SVD。

Eigen::MatrixXd A = Eigen::MatrixXd::Random(500, 500); Eigen::VectorXd b = Eigen::VectorXd::Random(500); // 对称正定场景(先对称化构造,仅作演示) Eigen::MatrixXd S = A.transpose() * A; Eigen::VectorXd x1 = S.llt().solve(b); // 一般方阵 Eigen::VectorXd x2 = A.partialPivLu().solve(b); // 需要判秩或做最小二乘 Eigen::VectorXd x3 = A.colPivHouseholderQr().solve(b);

同一份A,三种分解给出的x在小数点后十几位才可能不同,但耗时能差出几倍。批量求解时把分解对象提出来复用,比如Eigen::PartialPivLU<Eigen::MatrixXd> lu(A);之后循环里只调lu.solve(bi),这是最直接的提速手段。

colPivHouseholderQr()还有个额外能力:rank()和isInvertible(),遇到上游数据脏、矩阵实际秩亏的情况,这两个接口能让你在解出天文数字之前就发现问题。

4. 从 MATLAB 迁到 Eigen:语法对照与批量改写

绝大多数人拿到 Eigen 是因为手上有一堆.m文件要落地成 C++。这一章给的是逐条对照和迁移中最容易产生数值差异的地方,照着改能省掉大量来回对数的功夫。

4.1 逐行对照表:A\b、.*、size、reshape

MATLABEigen说明
A'A.transpose()实数矩阵等价
A'(复数)A.adjoint()共轭转置,别用 transpose
A\bA.partialPivLu().solve(b)方阵;长方阵用 QR
A.*BA.cwiseProduct(B)逐元素乘
A./BA.cwiseQuotient(B)逐元素除
A.^2A.array().square()逐元素平方
size(A,1)A.rows()行数
numel(A)A.size()元素总数
zeros(n,m)Eigen::MatrixXd::Zero(n,m)初始化
ones(n,m)Eigen::MatrixXd::Ones(n,m)初始化
eye(n)Eigen::MatrixXd::Identity(n,n)单位阵
A(2,3)A(1,2)索引从 0 开始
A(:,3)A.col(2)取列
A(2,:)A.row(1)取行
A(2:5,:)A.middleRows(1,4)起始索引 + 行数
sum(A)A.colwise().sum()按列求和
max(A(:))A.maxCoeff()全局最大

索引偏移是最容易出错的:MATLAB 的A(2,3)是第 2 行第 3 列,Eigen 的A(1,2)才是同一个元素。迁移时把每个下标减 1 是机械动作,但凡是循环边界、切片、end关键字的位置,都要单独核一遍。

注意A.array()之后的世界是逐元素的,A.matrix()切回来。A.array().square()这种写法在信号处理、损失函数里到处都是,别写成A * A——那是矩阵乘。

4.2 数据桥接:csv 读入与矩阵落地

算法迁完之后,输入输出还得对上。最常见的中间格式是 csv,写个小的读写函数比引第三方解析库划算:

#include <Eigen/Dense> #include <fstream> #include <sstream> #include <vector> #include <string> // 读 csv 为动态矩阵;空行跳过,按逗号切分 Eigen::MatrixXd loadCsv(const std::string& path) { std::ifstream in(path); if (!in) throw std::runtime_error("open failed: " + path); std::vector<std::vector<double>> rows; std::string line; while (std::getline(in, line)) { if (line.empty()) continue; std::stringstream ss(line); std::string cell; std::vector<double> row; while (std::getline(ss, cell, ',')) row.push_back(std::stod(cell)); rows.push_back(std::move(row)); } Eigen::MatrixXd M(rows.size(), rows.front().size()); for (size_t i = 0; i < rows.size(); ++i) for (size_t j = 0; j < rows[i].size(); ++j) M(i, j) = rows[i][j]; return M; }

M(i, j)逐个赋值在列主序矩阵上是行优先访问,缓存不友好。大文件请换成先建RowMajor矩阵、读完再M = Mrow转一次,或者直接M.transpose()后按列填。这个细节在几百乘几百的矩阵上无所谓,上万维时会明显拖慢。

写出更简单,Eigen::IOFormat配std::ofstream就够:

Eigen::IOFormat csvFmt(Eigen::FullPrecision, Eigen::DontAlignCols, ",", "\n"); std::ofstream out("result.csv"); out << M.format(csvFmt);

FullPrecision别省。默认精度只保留 6 位有效数字,导出的结果再喂给下一环节,误差会被放大,这类问题排查起来特别像玄学。

4.3 数值差异从哪来:列主序、条件数与浮点累加

同样一份算法,MATLAB 跑出来和 C++ 跑出来在 1e-12 上有差异是正常的,在 1e-3 上有差异就不正常。差异来源通常三个:

一是累加顺序。MATLAB 的sum和 Eigen 的colwise().sum()都可能做向量化重排,浮点加法不满足结合律,顺序一变结果就变。要严格复现,就手动写顺序循环。

二是条件数。MATLAB 的A\b内部会根据矩阵结构自动选求解器,你手动写partialPivLu()未必选了同一个。同一个病态矩阵,不同分解的结果可以有量级差异,这不是谁错了,是问题本身没有唯一稳定解。

三是隐式类型转换。Eigen::MatrixXi和Eigen::MatrixXd混用会触发逐元素转换,整数除法截断这种老问题会在这里重现。所有参与数值计算的矩阵,从一开始就定义成double,别用int存中间结果。

提示:迁移验证的做法是拿同一组输入分别跑 MATLAB 和 C++,输出 csv 逐元素做差,看最大绝对误差和相对误差两个指标,别只看平均值。

5. Windows + VC 环境的避坑:编译、对齐与链接

同样的代码在 Linux 下 g++ 一次过,到 VC 上各种报错,这是 Eigen 用户里最常见的血泪经验。这一章按现象、原因、解决三段式,把四类高频问题摆清楚。

5.1 现象:编译到一半报 C1128 或堆栈溢出

原因:Eigen 是重度模板库,一个包含大量矩阵运算的.cpp会生成巨量符号,MSVC 的默认对象文件段数上限扛不住;同时深递归的模板实例化会吃光编译器栈。

解决:加/bigobj提高对象文件的段上限,加/MP开多进程编译(每个源文件一个进程,编译时间能砍掉一半以上),模板递归过深时加/Zm200放宽预编译头内存限制。

cl /std:c++17 /O2 /EHsc /bigobj /MP /I third_party\eigen3 main.cpp

/EHsc别漏,异常处理模型不匹配会在某些标准库交互处报奇怪的警告。如果你是 CMake 工程,用target_compile_options(demo PRIVATE /bigobj /MP)加,别全局改。

还有一个隐藏项:如果你开了/fp:fast,Eigen 的部分归约结果会和你预期不同,数值敏感的项目建议用/fp:precise。

5.2 现象:程序在_mm_load_pd或_mm256_load_pd处崩溃,调用栈指向某个类的构造函数

原因:Eigen 对固定尺寸、且大小是 16 字节整数倍的向量化类型(如Vector4d、Matrix4d、Vector2d之外的Vector4f)要求 16 或 32 字节对齐。把这些类型作为类的成员、或者用new分配、或者塞进std::vector,对齐要求可能不被满足,访问时就是非法指令。

解决分三步。第一,C++17 起new已经能处理过对齐类型,std::vector配合标准分配器也基本没问题,但 MSVC 的老版本工具集仍有边界情况,所以:

class Tracker { public: // C++17 之前必须写;现在写上也无害,属于双保险 EIGEN_MAKE_ALIGNED_OPERATOR_NEW Eigen::Vector4d state; // 32 字节,需要对齐 Eigen::Matrix4d cov; // 128 字节,需要对齐 };

第二,如果这个类要放进std::vector,用std::vector<Tracker, Eigen::aligned_allocator<Tracker>>,别用默认分配器。

第三,实在不想管对齐,把固定尺寸成员换成动态尺寸,或者用带 MaxSize 的写法把数据放到堆上:

// 栈上固定上限、实际尺寸运行时决定,回避对齐约束 Eigen::Matrix<double, Eigen::Dynamic, Eigen::Dynamic, 0, 4, 4> small;

代价是失去一部分编译期优化,换来的是不再有对齐崩溃。我一般在对性能不敏感的配置类、状态类上用这种,热路径上还是老实加宏。

5.3 现象:链接期报 LNK2019,符号在某个.obj里找不到

原因:Eigen 是头文件库,理论上不该有链接错误,出现基本是两个情况——把unsupported/里某个需要额外源文件的模块当纯头文件用了,或者工程里同时存在两个不同路径下的 Eigen 副本,导致同名类型在多个编译单元里被当成不同类型。

解决:先搜工程里有没有第二份Eigen/Core,find一下Dense文件名,重复的删掉。然后确认unsupported/模块的使用方式,有些模块(比如部分稀疏求解器)确实需要额外编译单元,看它目录下的说明。

还有一种隐蔽情况:预编译头里 include 了 Eigen,而某个.cpp又用了不同宏定义(比如一个开了EIGEN_DONT_VECTORIZE,一个没开),两边的类型布局不一致,链接期就出问题。宏定义要么全局统一,要么别放预编译头。

5.4 现象:改一行代码编译三分钟

原因:模板实例化是按编译单元走的,一个.cpp里用到的所有矩阵类型组合都要现算一遍。你在头文件里写了一个模板函数,被 50 个源文件包含,就等于实例化 50 次。

解决:把重模板代码收敛到少量.cpp里,对外只暴露非模板接口。具体做法是定义一个struct MatrixOps,内部用 Eigen,头文件里只声明Eigen::MatrixXd compute(const Eigen::MatrixXd&)这类具体类型签名,实现放.cpp。调用方拿不到模板,编译时间立刻下来。

另一招是预编译头,把Eigen/Dense放进pch.h,MSVC 下/Yu配/Yc能显著减少重复解析。注意预编译头里的宏定义要和所有源文件一致,否则就是上一节说的链接问题。

6. 验证与压测:怎么确认这份库在你的工程里没跑偏

接入完成、代码编译通过、结果看着也对,这时候还不能算完。真正要确认的是两件事:数值上有没有隐藏的精度退化,性能上有没有被动态内存分配拖住。

数值验证我固定用三件套。第一,残差:(A * x - b).norm() / b.norm(),相对残差控制在 1e-12 以内算通过。第二,条件数:用A.norm() * A.inverse().norm()粗估,超过 1e12 就说明这个方程组本身不可靠,换 SVD 兜底。第三,和参考实现对拍:拿同一组数据在另一套环境里跑一遍,逐元素做差,看最大绝对误差。

性能这块有个黑匣子级别的技巧:Eigen 支持在运行时禁止堆分配,任何隐式临时变量都会立刻断言。这个开关需要宏开启:

#define EIGEN_RUNTIME_NO_MALLOC #include <Eigen/Dense> #include <iostream> int main() { Eigen::MatrixXd A = Eigen::MatrixXd::Random(64, 64); Eigen::VectorXd b = Eigen::VectorXd::Random(64); // 分解阶段允许分配,把临时量一次吃完 Eigen::PartialPivLU<Eigen::MatrixXd> lu(A); Eigen::VectorXd x(64); x.setZero(); // 之后禁止任何堆分配,谁偷偷 new 谁就断言 Eigen::internal::set_is_malloc_allowed(false); x = lu.solve(b); Eigen::internal::set_is_malloc_allowed(true); std::cout << "residual = " << (A * x - b).norm() << "\n"; return 0; }

set_is_malloc_allowed(false)之后,一旦某行代码触发了内部临时矩阵分配,程序会在断言处停下,调用栈直接指到那一行。常见触发点是A = B * C这种会生成临时量的表达式,改成A.noalias() = B * C就能消掉;x = lu.solve(b)里如果x尺寸不对也会分配,所以提前resize好。

这个检查我建议在性能敏感路径上常开,Release 模式下用NDEBUG关掉断言即可,代码不用改。它抓出来的问题往往是那种“跑得慢但说不清哪里慢”的典型场景,比 profiler 更直接。

压测的做法简单粗暴:MatrixXd::Random(256, 256)做 100 次乘法,用std::chrono::high_resolution_clock计时,累加结果写进volatile防止被优化掉,然后对比开/O2和/Od的差距。开优化后 256 阶乘法单次应该在毫秒以内,如果你的数字大一个量级,先回去查是不是漏了-O2或者矩阵被声明成了RowMajor却在做列方向运算。

从那以后我每次把 Eigen 接进新工程,都强制走一遍这个开关 malloc 的检查,再配一次小矩阵残差对拍,两分钟的事,能挡掉后面几天的排查。这份 zip 的价值也就在这——它是头文件,你怎么用、用哪部分、走不走向量化,全在你手里,跑通上面这些步骤,它就能安安稳稳待在你的third_party/里。希望帮到你。

本文还有配套的精品资源,点击获取