Eigen:编码风格对性能的影响

Eigen: Coding style's effect on performance

从我读到的有关 Eigen (here) 的内容来看,operator=() 似乎充当了惰性求值的 "barrier"——例如它导致 Eigen 停止返回表达式模板并实际执行(优化的)计算,将结果存储到 =.

的左侧

这似乎意味着一个人的 "coding style" 对性能有影响——即使用命名变量存储中间计算的结果可能会对性能产生负面影响,因为它会导致计算的某些部分被评价"too early".

为了验证我的直觉,我写了一个例子并对结果感到惊讶 (full code here):

using ArrayXf  = Eigen::Array <float, Eigen::Dynamic, Eigen::Dynamic>;
using ArrayXcf = Eigen::Array <std::complex<float>, Eigen::Dynamic, Eigen::Dynamic>;

float test1( const MatrixXcf & mat )
{
    ArrayXcf arr  = mat.array();
    ArrayXcf conj = arr.conjugate();
    ArrayXcf magc = arr * conj;
    ArrayXf  mag  = magc.real();
    return mag.sum();
}

float test2( const MatrixXcf & mat )
{
    return ( mat.array() * mat.array().conjugate() ).real().sum();
}

float test3( const MatrixXcf & mat )
{
    ArrayXcf magc   = ( mat.array() * mat.array().conjugate() );

    ArrayXf mag     = magc.real();
    return mag.sum();
}

上面给出了 3 种不同的方法来计算复值矩阵中的幅度系数和。

  1. test1 对计算的每个部分进行排序 "one step at a time."
  2. test2 在一个表达式中完成整个计算。
  3. test3 采用 "blended" 方法——有一定数量的中间变量。

我有点期待,因为 test2 将整个计算打包到一个表达式中,Eigen 将能够利用这一点并全局优化整个计算,提供最佳性能。

然而,结果令人惊讶(显示的数字以每次测试 1000 次执行的总微秒为单位):

test1_us: 154994
test2_us: 365231
test3_us: 36613

(这是用 g++ -O3 编译的——有关完整详细信息,请参阅 the gist。)

我预期最快的版本 (test2) 实际上是最慢的。另外,我预计最慢的版本 (test1) 实际上在中间。

所以,我的问题是:

  1. 为什么 test3 的表现比替代品好这么多?
  2. 是否可以使用一种技术(除了深入研究汇编代码)来了解 Eigen 实际如何执行您的计算?
  3. 是否有一套准则可以遵循以在 Eigen 代码中在性能和可读性(使用中间变量)之间取得良好的权衡?

在更复杂的计算中,在一个表达式中执行所有操作可能会影响可读性,因此我有兴趣找到正确的方法来编写既可读又高效的代码。

我只是想让您注意,您以非最佳方式进行了分析,所以实际上问题可能只是您的分析方法。

由于缓存位置等许多因素需要考虑,因此您应该以这种方式进行分析:

int warmUpCycles = 100;
int profileCycles = 1000;

// TEST 1
for(int i=0; i<warmUpCycles ; i++)
      doTest1();

auto tick = std::chrono::steady_clock::now();
for(int i=0; i<profileCycles ; i++)
      doTest1();  
auto tock = std::chrono::steady_clock::now();
test1_us = (std::chrono::duration_cast<std::chrono::microseconds>(tock-tick)).count(); 

// TEST 2


// TEST 3

一旦你以正确的方式进行了测试,那么你就可以得出结论..

我高度怀疑由于您一次分析一个操作,您最终在第三次测试中使用缓存版本,因为编译器可能会重新排序操作。

您还应该尝试不同的编译器,看看问题是否出在模板的展开上(优化模板有一个深度限制:很可能您可以用一个大表达式来解决它)。

此外,如果 Eigen 支持移动语义,没有理由说一个版本应该更快,因为它并不总是保证可以优化表达式。

请尝试让我知道,这很有趣。还要确保使用 -O3 等标志启用了优化,没有优化的分析是没有意义的。

为了防止编译器优化所有内容,请使用文件或 cin 的初始输入,然后在函数中重新输入输入。

看来是GCC的问题。英特尔编译器给出了预期的结果。

$ g++ -I ~/program/include/eigen3 -std=c++11 -O3 a.cpp -o a && ./a
test1_us: 200087
test2_us: 320033
test3_us: 44539

$ icpc -I ~/program/include/eigen3 -std=c++11 -O3 a.cpp -o a && ./a
test1_us: 214537
test2_us: 23022
test3_us: 42099

icpc 版本相比,gcc 似乎无法优化您的 test2

为了获得更精确的结果,您可能希望通过 -DNDEBUG 关闭调试断言,如 here 所示。

编辑

对于问题 1

@ggael 给出了一个很好的答案,即 gcc 无法矢量化求和循环。我的实验还发现 test2 与手写的朴素 for 循环一样快,gccicc 都一样,这表明向量化是原因,没有临时内存分配通过下面提到的方法在 test2 中检测到,表明 Eigen 正确计算了表达式。

第2题

避免中间记忆是Eigen使用表达式模板的主要目的。所以Eigen提供了一个宏EIGEN_RUNTIME_NO_MALLOC and a simple function to enable you check whether an intermediate memory is allocated during calculating the expression. You can find a sample code here。请注意,这可能仅适用于调试模式。

EIGEN_RUNTIME_NO_MALLOC - if defined, a new switch is introduced which can be turned on and off by calling set_is_malloc_allowed(bool). If malloc is not allowed and Eigen tries to allocate memory dynamically anyway, an assertion failure results. Not defined by default.

第3题

有一种方法可以使用中间变量,同时获得惰性 evaluation/expression 模板引入的性能提升。

方法是使用正确数据类型的中间变量。您应该使用表达式类型 Eigen::MatrixBase/ArrayBase/DenseBase,而不是使用 Eigen::Matrix/Array,它指示要计算的表达式,这样表达式只被缓冲而不被计算。这意味着您应该将表达式存储为中间值,而不是表达式的结果,条件是该中间值将在以下代码中仅使用一次。

由于确定表达式类型 Eigen::MatrixBase/... 中的模板参数可能很痛苦,您可以改用 auto。您可以在 should/should 不使用 auto/表达式类型时找到一些提示 this page. Another page 还告诉您如何将表达式作为函数参数传递而不对它们求值。

根据@ggael 的回答中关于 .abs2() 的指导性实验,我认为另一个准则是避免重新发明轮子。

发生的事情是,由于 .real() 步骤,Eigen 不会显式矢量化 test2。因此它将调用标准 complex::operator* 运算符,不幸的是,gcc 从未内联该运算符。另一方面,其他版本使用 Eigen 自己的复数矢量化乘积实现。

相比之下,ICC 确实内联 complex::operator*,因此 test2 是 ICC 最快的。也可以将test2重写为:

return mat.array().abs2().sum();

在所有编译器上获得更好的性能:

gcc:
test1_us: 66016
test2_us: 26654
test3_us: 34814

icpc:
test1_us: 87225
test2_us: 8274
test3_us: 44598

clang:
test1_us: 87543
test2_us: 26891
test3_us: 44617

ICC 在本案例中的优异成绩归功于其巧妙的自动矢量化引擎。

另一种解决 gcc 内联失败而不修改 test2 的方法是为 complex<float> 定义您自己的 operator*。例如,在文件顶部添加以下内容:

namespace std {
  complex<float> operator*(const complex<float> &a, const complex<float> &b) {
    return complex<float>(real(a)*real(b) - imag(a)*imag(b), imag(a)*real(b) + real(a)*imag(b));
  }
}

然后我得到:

gcc:
test1_us: 69352
test2_us: 28171
test3_us: 36501

icpc:
test1_us: 93810
test2_us: 11350
test3_us: 51007

clang:
test1_us: 83138
test2_us: 26206
test3_us: 45224

当然,这个技巧并不总是被推荐,因为与 glib 版本相比,它可能会导致溢出或数字取消问题,但这就是 icpc 和其他矢量化版本计算的内容。

我以前做过的一件事是经常使用 auto 关键字。请记住,大多数 Eigen 表达式 return 特殊表达式数据类型(例如 CwiseBinaryOp),赋值回 Matrix 可能会强制对表达式求值(这就是您所看到的)。使用 auto 允许编译器将 return 类型推断为任何表达式类型,这将尽可能避免评估:

float test1( const MatrixXcf & mat )
{
    auto arr  = mat.array();
    auto conj = arr.conjugate();
    auto magc = arr * conj;
    auto mag  = magc.real();
    return mag.sum();
}

这基本上应该更接近您的第二个测试用例。在某些情况下,我在保持可读性的同时取得了很好的性能改进(你 而不是 想要拼出表达式模板类型)。当然,您的里程数可能会有所不同,因此请仔细进行基准测试:)