特征:编码风格对性能的影响

Eigen: Coding style's effect on performance

本文关键字:性能 影响 风格 编码 特征      更新时间:2023-10-16

根据我所读到的关于Eigen(此处)的内容,operator=()似乎充当了懒惰评估的"屏障"——例如,它导致Eigen停止返回表达式模板,并实际执行(优化)计算,将结果存储在=的左侧。

这似乎意味着一个人的"编码风格"会对性能产生影响——也就是说,使用命名变量来存储中间计算的结果可能会导致计算的某些部分"过早"评估,从而对性能产生负面影响。

为了验证我的直觉,我写了一个例子,并对结果感到惊讶(此处为完整代码):

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"一步一个脚印"地完成计算的每一部分
  2. CCD_ 4在一个表达式中完成整个计算
  3. test3采用了一种"混合"方法——使用一些中间变量

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

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

test1_us: 154994
test2_us: 365231
test3_us: 36613

(这是用g++-O3编译的——详见要点。)

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

所以,我的问题是:

  1. 为什么test3的性能比备选方案好得多
  2. 有没有一种技术可以用来(除了深入汇编代码)了解Eigen实际上是如何实现计算的
  3. 在您的特征码中,是否有一套准则可以在性能和可读性(使用中间变量)之间取得良好的折衷

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

这看起来像是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关闭调试断言,如下所示。

编辑

对于问题1

@ggael给出了一个很好的答案,即gcc未能对和循环进行矢量化。我的实验还发现,无论是gcc还是icctest2都和手工编写的naive for loop一样快,这表明矢量化是原因,并且通过下面提到的方法在test2中没有检测到临时内存分配,这表明Eigen正确地评估了表达式。

对于问题2

避免中间记忆是Eigen使用表达式模板的主要目的。因此,Eigen提供了一个宏Eigen_RUNTIME_NO_MALLOC和一个简单的函数,使您能够在计算表达式期间检查是否分配了中间内存。你可以在这里找到一个示例代码。请注意,这可能只在调试模式下工作。

EIGEN_RUNTIME_NO_MALLOC-如果已定义,则会引入一个新交换机可以通过调用set_is_malloc_allowed(bool)来打开和关闭。如果malloc是不允许的,Eigen尝试动态分配内存无论如何,断言都会失败。默认情况下未定义。

对于问题3

有一种方法可以使用中间变量,同时获得懒惰评估/表达式模板引入的性能改进。

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

由于在表达式类型Eigen::MatrixBase/...中确定模板参数可能很痛苦,因此可以使用auto。您可以在该页面中找到一些关于何时应该/不应该使用auto/表达式类型的提示。另一个页面还告诉如何将表达式作为函数参数传递而不计算它们。

根据@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在这种情况下取得的优异成绩归功于其巧妙的自动矢量化引擎。

在不修改test2的情况下解决gcc内联失败的另一种方法是为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关键字。请记住,大多数特征表达式都返回特殊的表达式数据类型(例如CwiseBinaryOp),对Matrix的赋值可能会迫使表达式进行求值(这就是您所看到的)。使用auto可以让编译器将返回类型推断为任何表达式类型,这将尽可能长时间地避免求值:

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

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

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

由于有很多事情需要考虑,比如缓存位置,所以应该以这种方式进行分析:

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的初始输入,然后在函数内部重新输入。