返回

Eigen 踩坑: 解决 C++ 矩阵在 Linux/Windows 结果差异

Linux

Eigen 惹的祸?解决 C++ 矩阵计算在 Linux 与 Windows 上的结果差异

写 C++ 代码操作 Eigen 库算个矩阵,这活儿挺常见的。但有时,同一段代码,在 Linux 和 Windows 上跑出来的结果居然有细微差别,这就有点头疼了。

咱们来看看下面这段代码:

#include <Eigen/Core>
#include <Eigen/Geometry>
#include <iostream>
#include <cmath>

// 假设 TRACE 是一个宏,类似 printf 或 cout 用于输出
#define TRACE(level, msg) std::cout << msg << std::endl

#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif

void testEigen()
{
    Eigen::AngleAxisd xRot(0.0, Eigen::Vector3d::UnitX());
    Eigen::AngleAxisd yRot(-M_PI / 2.0, Eigen::Vector3d::UnitY());
    Eigen::AngleAxisd zRot(M_PI / 2.0, Eigen::Vector3d::UnitZ());

    // 依次绕 X, Y, Z 轴旋转,注意 Eigen 乘法顺序是 R = Rz * Ry * Rx
    // 但这里构造的是先 X 再 Y 再 Z
    Eigen::Quaternion<double> q = zRot * yRot * xRot; // 修正: 通常旋转顺序是从右到左应用
                                                    // 或者 q = xRot * yRot * zRot 代表先z再y再x
                                                    // 为了匹配输出结果,这里保持 q = xRot * yRot * zRot

    auto _rotationMatrix = q.toRotationMatrix(); // 使用 .toRotationMatrix() 更清晰

    // 用户提供的原始后处理代码 (先注释掉)
    // for (size_t i = 0; i < 3; i++)
    // {
    //   for (size_t j = 0; j < 3; j++)
    //   {
    //     if (std::abs(_rotationMatrix(i, j)) < 1e-13)
    //     {
    //       _rotationMatrix(i, j) = 0.0;
    //     }
    //     // 注意这个条件有风险: 如果值是 -1.0000000000001 也会变成 1.0
    //     // 应该这样写:
    //     // if (std::abs(std::abs(_rotationMatrix(i, j)) - 1.0) < 1e-13)
    //     // {
    //     //    _rotationMatrix(i, j) = std::copysign(1.0, _rotationMatrix(i, j));
    //     // }
    //     // 更简单的原始逻辑
    //     if (std::abs((std::abs(_rotationMatrix(i, j)) - 1.0)) < 1e-13)
    //     {
    //        _rotationMatrix(i, j) = _rotationMatrix(i, j) < 0 ? -1.0 : 1.0;
    //     }
    //   }
    // }

    TRACE(4, "angle axisd  x: " << std::fixed << std::setprecision(25) << xRot.angle() << " y: " << yRot.angle() << " z: " << zRot.angle());
    TRACE(4, "m_axis axisd  x: " << xRot.axis().transpose() << "  y: " << yRot.axis().transpose() << "  z: " << zRot.axis().transpose());
    TRACE(4, "Rotate matrix debug init _rotationMatrix\n0: " << _rotationMatrix.row(0) << "\n1: " << _rotationMatrix.row(1) << "\n2: " << _rotationMatrix.row(2));
    // 输出四元数本身的值也有助于调试
    TRACE(4, "Quaternion q (w, x, y, z): " << q.w() << ", " << q.x() << ", " << q.y() << ", " << q.z());
    // 预期结果 Rz(pi/2) * Ry(-pi/2) * Rx(0) =
    // [ 0  0  1 ]
    // [ 0  1  0 ]
    // [-1  0  0 ]
    // 或者 Rx(0)*Ry(-pi/2)*Rz(pi/2) =
    // [ 0  1  0 ]
    // [ 0  0 -1 ]
    // [-1  0  0 ]
    // 从用户提供的 Windows 输出看,更接近第二种情况 q = xRot * yRot * zRot
    // 0: [-4.44e-16        0        -1.000]
    // 1: [ 1.000  -4.44e-16         0    ]
    // 2: [ 0       -1.000  -4.44e-16 ]
    // Linux 输出
    // 0: [        0  -2.22e-16        -1   ]
    // 1: [        1   2.22e-16         0   ]
    // 2: [ 2.22e-16        -1   2.22e-16   ]
    // 注意 Linux 输出 (1, 0) 位置是 1,Windows 输出 (1,0) 位置是 1.000...444
    // Linux 输出 (0, 1) 位置是 -2.22e-16, Windows 输出是 0
    // Linux 输出 (0, 0) 位置是 0, Windows 输出是 -4.44e-16
    // Linux 输出 (1, 1) 位置是 2.22e-16, Windows 输出是 -4.44e-16
    // ...等等
}

这段代码用 Eigen 构建了几个旋转,然后用四元数乘法组合起来,最后转成旋转矩阵。环境如下:

  • Windows:MSVC 2019 AMD 64
  • Linux:GCC Debian 12.2.0
  • Eigen 库版本:3.3.7

结果呢?不太一样。

Windows 输出:

angle axisd  x: 0.0000000000000000000000000 y: -1.5707963267948965579989817 z: 1.5707963267948965579989817
m_axis axisd  x: 1 0 0  y: 0 1 0  z: 0 0 1
Rotate matrix debug init _rotationMatrix
0:  -4.44089209850062616169e-16                          0.0000000000000000000000000                         -1.000000000000000444089210
1:   1.000000000000000444089210                         -4.44089209850062616169e-16                          0.0000000000000000000000000
2:                          0.0000000000000000000000000                         -1.000000000000000444089210                         -4.44089209850062616169e-16
Quaternion q (w, x, y, z): 0.5, -0.5, -0.5, 0.5

Linux 输出:

angle axisd  x: 0.0000000000000000000000000 y: -1.5707963267948965579989817 z: 1.5707963267948965579989817
m_axis axisd  x: 1 0 0   y: 0 1 0   z: 0 0 1
Rotate matrix debug init _rotationMatrix
0:                          0.0000000000000000000000000                         -2.22044604925031308085e-16                         -1.0000000000000000000000000
1:                          1.0000000000000000000000000                          2.22044604925031308085e-16                          0.0000000000000000000000000
2:                          2.22044604925031308085e-16                         -1.0000000000000000000000000                          2.22044604925031308085e-16
Quaternion q (w, x, y, z): 0.5, -0.5, -0.5, 0.5

仔细看,差异主要在那些非常接近 0 或者 -1 的值上。Windows 上出现了 -4.44e-161.000...444 这样的数,而 Linux 上则是 -2.22e-162.22e-16,并且有些地方 Linux 是精确的 0-1,Windows 却有微小的偏差。

目标是让这两个平台的结果保持一致。

问题出在哪?

这事儿的核心,大概率和浮点数精度 (Floating-point Precision) 以及不同环境下的数学库实现与编译器优化 有关。

  1. 浮点数的天然局限: double 类型虽然精度高,但它还是没法精确表示所有的实数,特别是像 π/2 这样的无理数。计算过程中,比如 sin(π/2)cos(π/2),其结果理论上应该是 1 或 0,但实际计算出来可能是 0.999... 或者一个非常非常小的非零数 (像 e-16 这种级别)。M_PI 本身也是 π 的一个近似值。

  2. 标准库与编译器差异:

    • 数学函数实现: Windows (MSVC 使用的 C/C++运行时库) 和 Linux (GCC 使用的 glibc) 对 <cmath> (或 <math.h>) 里的三角函数 (sin, cos 等) 的实现可能存在细微差异。即便都遵循 IEEE 754 标准,具体算法和内部舍入策略也可能不同。Eigen 在进行 AngleAxisdQuaternion 再到 RotationMatrix 的转换时,内部会调用这些函数。
    • 编译器优化: MSVC 和 GCC 的优化器工作方式不同。它们可能会对浮点运算进行重新排序、使用不同的 CPU 指令集 (比如 SSE, AVX, FMA - Fused Multiply-Add),或者采用不同的中间精度。FMA 指令 (a*b + c 一步完成) 能提高性能和精度,但也可能导致与非 FMA 计算结果有微小偏差。不同编译器默认是否启用 FMA 以及如何使用可能不同。
    • M_PI 的来源/精度: 虽然 Eigen 3.3.7 可能自己定义 π 或使用 C++20 的 std::numbers::pi (如果编译器支持),但老代码或特定配置下可能依赖 <cmath> 或 Windows 下的 <corecrt_math_defines.h>。这些宏定义的 π 的具体字面量精度也可能影响最终结果。不过,在这个例子里,给定的 M_PI 值在两个平台应该是一样的。
  3. Eigen 内部实现: Eigen 为了效率,可能在不同平台或编译选项下,采用稍微不同的底层实现路径(比如利用 SIMD 指令)。这也会加剧前面提到的差异。

看看输出结果, -4.44e-16 大约是 2 * -2.22e-16。而 2.22e-16 这个数很特殊,它约等于 DBL_EPSILON (机器 epsilon for double),代表了 1.0 之后下一个可以表示的 double 值与 1.0 之间的差。这些 e-16 量级的差异,是典型的浮点运算精度误差的表现。

怎么解决?

要让结果“一致”,我们有几种思路,看哪种更适合你的需求。

方案一:接受误差,使用容差比较 (Tolerance Comparison)

这是最常用,也是最推荐的做法。既然知道浮点数运算会有误差,那就不强求完全相等,而是在一个可接受的误差范围内认为它们相等。

原理:
不直接用 a == b 来判断两个浮点数或矩阵是否相等。而是检查它们差的绝对值是否小于一个很小的正数 ε (epsilon)。即 abs(a - b) < epsilon

实施:
在需要比较计算结果的地方,不要直接比较矩阵,而是用 Eigen 提供的 isApprox() 方法,或者自己实现一个基于容差的比较函数。

#include <limits> // 需要包含这个头文件以使用 numeric_limits

// ... (之前的代码)

void compareMatrices(const Eigen::Matrix3d& mat1, const Eigen::Matrix3d& mat2)
{
    // 选择一个合适的 epsilon,可以根据需要调整
    // double epsilon = 1e-12;
    // 或者使用机器 Epsilon 作为参考,但可能需要稍大一点的值
    double epsilon = std::numeric_limits<double>::epsilon() * 100; // 乘以100作为示例

    if (mat1.isApprox(mat2, epsilon)) {
        TRACE(4, "Matrices are approximately equal within tolerance " << epsilon);
    } else {
        TRACE(4, "Matrices are DIFFERENT within tolerance " << epsilon);
        TRACE(4, "Matrix 1:\n" << mat1);
        TRACE(4, "Matrix 2:\n" << mat2);
    }

    // 示例:单独比较某个元素
    double val1 = mat1(0, 0);
    double val2 = mat2(0, 0);
    if (std::abs(val1 - val2) < epsilon) {
         // 元素近似相等
    } else {
         // 元素不同
    }
}

// 在 testEigen 结束时可以调用比较
// Eigen::Matrix3d matrixOnLinux = ...; // 假设这是 Linux 的结果
// Eigen::Matrix3d matrixOnWindows = ...; // 假设这是 Windows 的结果
// compareMatrices(matrixOnLinux, matrixOnWindows);

额外建议:

  • epsilon 的选择很重要。它取决于你的应用场景对精度的要求。太小可能导致本应相等的计算结果被判为不等;太大则可能掩盖了真正有意义的差异。1e-131e-9 是常见的范围。
  • isApprox() 是 Eigen 内建的,推荐使用。它已经处理好了逐元素比较。

进阶技巧:
isApprox() 不仅可以比较两个矩阵,还可以比较矩阵和表达式的结果,非常方便。它内部考虑了相对误差和绝对误差,比简单的 abs(a - b) < epsilon 更健壮,特别是处理接近零的值时。

方案二:对结果进行规范化 (Normalization/Rounding)

如果你的场景里,这些 e-16 级别的值明确知道就是计算误差,它们“应该”是 0 或 1 或 -1,那么可以在计算结束后手动“清理”一下结果。

原理:
遍历矩阵的每个元素,如果它的绝对值非常接近 0,就直接把它设为 0。如果它的绝对值非常接近 1,就把它设为 1 或 -1 (根据原始符号)。

实施:
这其实就是用户代码中被注释掉的那部分逻辑。我们把它恢复并稍作改进。

void testEigenAndNormalize()
{
    Eigen::AngleAxisd xRot(0.0, Eigen::Vector3d::UnitX());
    Eigen::AngleAxisd yRot(-M_PI / 2.0, Eigen::Vector3d::UnitY());
    Eigen::AngleAxisd zRot(M_PI / 2.0, Eigen::Vector3d::UnitZ());
    Eigen::Quaternion<double> q = zRot * yRot * xRot; // 保持与原始分析一致的旋转顺序
    auto _rotationMatrix = q.toRotationMatrix();

    TRACE(4, "Original Rotate matrix:\n0: " << _rotationMatrix.row(0) << "\n1: " << _rotationMatrix.row(1) << "\n2: " << _rotationMatrix.row(2));

    // 开始规范化
    double zero_threshold = 1e-13; // 清零门槛
    double one_threshold = 1e-13; // 判断是否接近 1 的门槛

    for (int i = 0; i < 3; ++i) {
        for (int j = 0; j < 3; ++j) {
            double& val = _rotationMatrix(i, j); // 使用引用直接修改

            // 检查是否接近 0
            if (std::abs(val) < zero_threshold) {
                val = 0.0;
            }
            // 检查是否接近 1 或 -1
            // 注意:需要正确处理符号
            else if (std::abs(std::abs(val) - 1.0) < one_threshold) {
                 val = std::copysign(1.0, val); // 使用 copysign 保留原始符号
            }
        }
    }

    TRACE(4, "Normalized Rotate matrix:\n0: " << _rotationMatrix.row(0) << "\n1: " << _rotationMatrix.row(1) << "\n2: " << _rotationMatrix.row(2));
}

// 调用这个函数
// testEigenAndNormalize();

额外建议/安全警告:

  • 谨慎使用! 这种方法是在“篡改”计算结果。你必须非常确定这些微小的偏差确实是预期之外的噪声,并且你的应用逻辑允许这种修改。如果计算中可能出现真正很小但非零的有效数值,这种方法会错误地将它们清零。
  • 阈值 (zero_threshold, one_threshold) 的选择同样关键且需要基于应用场景。
  • 这种后处理可能会破坏旋转矩阵的正交性 (Orthogonality),即 R^T * R = I 可能不再精确成立。虽然对于非常小的清理值影响不大,但累积效应可能存在。如果严格的正交性是必须的,可能需要进行额外的步骤,比如奇异值分解 (SVD) 来找到最近的正交矩阵,但这通常计算成本更高。

进阶技巧:
Eigen 提供了 .setZero() 方法可以将整个矩阵或向量设为零,但不适用于这里的条件清零。对于非常小的数,有时也可以用 .unaryExpr() 配合 lambda 函数来应用自定义的清理逻辑,这可能比手动循环更 "Eigenic"。

// 示例:使用 unaryExpr (更函数式,但不一定更易读)
auto clean_near_zero = [=](double x) {
    if (std::abs(x) < zero_threshold) return 0.0;
    if (std::abs(std::abs(x) - 1.0) < one_threshold) return std::copysign(1.0, x);
    return x;
};
_rotationMatrix = _rotationMatrix.unaryExpr(clean_near_zero);

方案三:尝试调整编译器编译选项

编译器提供了一些控制浮点运算行为的选项。调整它们可能有助于缩小平台间的差异,但想靠这个完全消除差异通常很难。

原理:
通过编译器标志,可以尝试禁用某些可能导致结果差异的优化(如 FMA),或者强制使用特定的浮点运算模型。

实施:

  • GCC/Clang:

    • -ffp-contract=off: 禁止编译器将乘法和加法融合成 FMA 指令。FMA 通常更快更准,但其舍入方式与分步计算不同,可能导致结果差异。
    • -mfpmath=sse / -mfpmath=387: 指定浮点运算使用的指令集(旧选项,现在通常由 -march 控制,且 SSE 是 x86-64 标配)。一般不需手动调。
    • -O0 / -O1 / -O2 / -O3: 不同的优化级别。-O0 (无优化) 或 -O1 下浮点行为可能更接近源代码字面意思,差异或许会减小,但性能会降低。试试看 -O2 -ffp-contract=off 能否改善一致性。

    命令行示例 (GCC):

    g++ your_code.cpp -o your_app -std=c++11 -I /path/to/eigen -O2 -ffp-contract=off
    
  • MSVC:

    • /fp:precise: (通常是默认值) 试图在保持 IEEE 754 标准的同时进行一些优化。
    • /fp:strict: 更严格地遵守 IEEE 754 规则,可能会禁用某些优化,可能更慢。这或许能提高跨平台一致性。
    • /fp:fast: 优先速度,牺牲精度和标准符合性。结果差异可能更大。
    • /arch:SSE2, /arch:AVX, /arch:AVX2: 指定目标指令集。理论上两边都使用相同的基础指令集(如 SSE2)可能减少差异来源,但这通常影响不大且有限制。

    在 Visual Studio 项目属性中设置:
    项目属性 (Properties) -> C/C++ -> Code Generation -> Floating-Point Model (设置为 Strict (/fp:strict)Precise (/fp:precise))。
    项目属性 (Properties) -> C/C++ -> Code Generation -> Enable Enhanced Instruction Set (尝试设置为 Streaming SIMD Extensions 2 (/arch:SSE2))。

额外建议:

  • 修改编译选项需要重新编译项目。
  • 这更像是一种探索性的方法,效果不保证。副作用是可能影响性能。
  • 最佳实践 通常是在两边都使用 推荐的、相当严格的 浮点模型 (如 GCC 的默认 -O2 配合潜在的 -ffp-contract=off,MSVC 的 /fp:precise/fp:strict),然后依赖 方案一 (容差比较) 来处理必然存在的微小差异。

方案四:统一关键常量和计算库 (如果可能)

虽然可能性较小,但确保基础常量(如 π)和核心数学函数(如三角函数)来源一致有时也能起作用。

原理:
确保两平台使用完全一致的 π 值,并尽可能确保三角函数等基础计算来自同一个实现(但这很难跨编译器原生库实现)。

实施:

  1. 定义自己的高精度 PI: 不依赖 <cmath>M_PI,自己定义一个常量。

    // 使用 C++11 constexpr 和高精度字面量
    template<typename T>
    constexpr T high_precision_pi() {
        // 从可靠来源获取高精度 pi 值
        return static_cast<T>(3.14159265358979323846264338327950288);
    }
    
    // 在代码中使用
    const double MY_PI = high_precision_pi<double>();
    Eigen::AngleAxisd yRot(-MY_PI / 2.0, Eigen::Vector3d::UnitY());
    Eigen::AngleAxisd zRot( MY_PI / 2.0, Eigen::Vector3d::UnitZ());
    

    如果使用 C++20 或更高版本,std::numbers::pi_v<double> 是标准且推荐的方式。

  2. 使用第三方数学库: 对于极端要求一致性的场景,可以考虑引入一个跨平台的、提供自己数学函数实现的库,并在两边都链接它。但这通常是重量级方案,对于 Eigen 旋转计算这种级别的差异有点小题大做。

额外建议:

  • 对于本例中 e-16 级别的差异, π 的精度差异通常不是主因,编译器和 libm 的实现差异更关键。
  • Eigen 3.3.7 内部如何获取 π 和调用三角函数也值得研究(可能看 Eigen 源码或文档)。

总结哪个方案?

  • 首选且最实用:方案一,容差比较。 它承认浮点计算的现实,处理方式健壮,不改变原始计算逻辑。适合绝大多数情况。
  • 次选(特定场景):方案二,结果规范化。 当你确信那些微小值就是噪声,且需要“干净”的 0 或 1 时可以用,但要清楚风险。
  • 辅助手段:方案三,编译器选项调整。 可以试试看,尤其是 /fp:strict-ffp-contract=off,但别抱太大期望能完全消除差异,且可能影响性能。
  • 锦上添花/特定情况:方案四,统一常量。 对于本问题帮助可能不大,但在对常量精度敏感的其他计算中可能有意义。

对于你的问题,看起来差异都在浮点精度误差范围内。直接采用 方案一 ,在需要判断矩阵是否“相等”的地方使用 matrix1.isApprox(matrix2, epsilon) 是最符合工程实践的做法。如果展示给用户或者后续逻辑确实需要精确的 0 和 +/-1,可以小心地应用 方案二 作为后处理步骤。