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 种不同的方法来计算复值矩阵中的幅度系数和。
test1
对计算的每个部分进行排序 "one step at a time."
test2
在一个表达式中完成整个计算。
test3
采用 "blended" 方法——有一定数量的中间变量。
我有点期待,因为 test2
将整个计算打包到一个表达式中,Eigen 将能够利用这一点并全局优化整个计算,提供最佳性能。
然而,结果令人惊讶(显示的数字以每次测试 1000 次执行的总微秒为单位):
test1_us: 154994
test2_us: 365231
test3_us: 36613
(这是用 g++ -O3 编译的——有关完整详细信息,请参阅 the gist。)
我预期最快的版本 (test2
) 实际上是最慢的。另外,我预计最慢的版本 (test1
) 实际上在中间。
所以,我的问题是:
- 为什么
test3
的表现比替代品好这么多?
- 是否可以使用一种技术(除了深入研究汇编代码)来了解 Eigen 实际如何执行您的计算?
- 是否有一套准则可以遵循以在 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 循环一样快,gcc
和 icc
都一样,这表明向量化是原因,没有临时内存分配通过下面提到的方法在 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();
}
这基本上应该更接近您的第二个测试用例。在某些情况下,我在保持可读性的同时取得了很好的性能改进(你 而不是 想要拼出表达式模板类型)。当然,您的里程数可能会有所不同,因此请仔细进行基准测试:)
从我读到的有关 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 种不同的方法来计算复值矩阵中的幅度系数和。
test1
对计算的每个部分进行排序 "one step at a time."test2
在一个表达式中完成整个计算。test3
采用 "blended" 方法——有一定数量的中间变量。
我有点期待,因为 test2
将整个计算打包到一个表达式中,Eigen 将能够利用这一点并全局优化整个计算,提供最佳性能。
然而,结果令人惊讶(显示的数字以每次测试 1000 次执行的总微秒为单位):
test1_us: 154994
test2_us: 365231
test3_us: 36613
(这是用 g++ -O3 编译的——有关完整详细信息,请参阅 the gist。)
我预期最快的版本 (test2
) 实际上是最慢的。另外,我预计最慢的版本 (test1
) 实际上在中间。
所以,我的问题是:
- 为什么
test3
的表现比替代品好这么多? - 是否可以使用一种技术(除了深入研究汇编代码)来了解 Eigen 实际如何执行您的计算?
- 是否有一套准则可以遵循以在 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 循环一样快,gcc
和 icc
都一样,这表明向量化是原因,没有临时内存分配通过下面提到的方法在 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();
}
这基本上应该更接近您的第二个测试用例。在某些情况下,我在保持可读性的同时取得了很好的性能改进(你 而不是 想要拼出表达式模板类型)。当然,您的里程数可能会有所不同,因此请仔细进行基准测试:)