C++:if 内部循环的性能影响

C++: Performance impact of if inside loops

本文关键字:性能 影响 循环 内部 if C++      更新时间:2023-10-16

我需要迭代大量(2D)数据,并且有时只处理特殊情况。对于我的应用程序来说,速度是最关键的因素。

(我)很快想到的选项是:

选项 A:

  • 更具可读性
  • 由于循环内部的比较而影响性能?
void ifInLoop(bool specialCase, MyClass &acc) {
for (auto i = 0; i < n; ++i) {
for (auto j = 0; j < n; ++j) {
if (specialCase) {
acc.foo();
} else {
acc.bar();
}
}
}
}

选项 B:

  • 代码复制
void loopsInIf(bool specialCase, MyClass &acc) {
if (specialCase) {
for (auto i = 0; i < n; ++i) {
for (auto j = 0; j < n; ++j) {
acc.foo();
}
}
} else {
for (auto i = 0; i < n; ++i) {
for (auto j = 0; j < n; ++j) {
acc.bar();
}
}
}
}

选项 C:

  • 模板
  • 叫起来丑
  • 基本和B一样?
template <bool specialCase> 
void templateIf(MyClass &acc) {
for (auto i = 0; i < n; ++i) {
for (auto j = 0; j < n; ++j) {
if (specialCase) {
acc.foo();
} else {
acc.bar();
}
}
}
}

我知道这属于过早优化。但是,从理论的角度来看,我会对使用这些片段在使用-O3(GCC/Clang)编译时在生产汇编和速度方面的差异感兴趣。

(在Perl中已经存在类似的问题,但我想具体了解C++。

(编辑)编译时specialCase知道吗?

没有。调用本身位于另一个循环中,并且某些迭代的处理方式不同。所以像这样(但不一定等距,但与用户输入无关):

for (int i = 0; i < m; ++i) {
ifInLoop(i % 10, acc);
}

我将如何在此处使用选项 C?引入一个额外的如果,因此我希望它与 B 非常相似。

for (int i = 0; i < m; ++i) {
if (i % 10)
templateIf<true>(acc);
else
templateIf<false>(acc);
}

如果此函数可以内联到传递编译时常量bool的调用方中,则可以使用选项 A(只要函数足够小以内联)。 即,如果模板参数是可能的,您通常实际上不需要它。 除非强制你编写if(var) { foo<true>(arg); }else {foo<false>(arg); }以鼓励编译器使用 2 个版本的循环制作 asm。

所有现代编译器都足够聪明,可以内联小函数,然后完全优化if(constant)。 内联 + 常量传播 是使现代C++可以高效编译的原因。


但是,如果在编译时不知道布尔值,则选项 B 可能更有效。(如果函数不经常运行,则其速度在大局中可能无关紧要,差异可能很小。

这是静态代码大小(I缓存占用空间)与动态指令计数之间的权衡。 或者,如果特殊情况很少运行,则该版本的循环可能会在缓存中保持冷。


for (int i = 0; i < m; ++i) {
ifInLoop(i % 10, acc);
}

如果你确实有这样的重复模式,编译器可能会决定为你展开这个循环,这样布尔值就变成了编译时常量。

或者,如果编译器本身不决定发明新的内部循环,你可以让编译器变得更好,并且包含另一个完整循环的循环的 10 个展开对于编译器的启发式来说太多了。

int i;
for (i = 0; i < m-9; i+=10) {   // potentially runs zero times if signed m <= 9
ifInLoop(false, acc);    // this is the j=0
for (int j=1; j<10 ; j++)   // j = i%10
ifInLoop(true, acc);     // original i = i+j  in case you need it
}
// cleanup loop:
for ( ; i < m ; i++) {
ifInLoop(i % 10, acc);
}

如果编译器不提升 if 并生成两个版本的循环,则完美预测并不能摆脱检查分支条件的指令的前端 + 后端吞吐量成本

如果编译器知道每次迭代都只有一个ifelse体运行,则可能会进行重大的简化/优化,但是在运行时和分支进行检查会错过这些优化,即使它预测完美。


通常的 Stack Overflow 响应"分析它"并不像大多数人想象的那么有用。 首先,微基准测试很难。 很容易完全衡量错误的事情,或者得出无意义的结论,因为你对什么可能重要,什么不重要知之甚少。 (确保将 CPU 预热到最大睿频频率并首先初始化内存,这样您就不会将 CoW 映射到零页,并且第一次定时传递不会支付页面错误 + TLB 未命中成本。 在启用优化的情况下进行编译,并检查性能是否与重复计数成线性关系。

分析一个测试用例不会告诉您一般成本。 您错过了哪些优化,以及编译器是否愿意为您拆分循环并提升分支,取决于循环的细节(可能包括循环体的复杂程度)。

查看您关心的编译器的特定案例的 asm 是确定的唯一方法。

不同的编译器(或同一编译器的不同版本,或具有不同的调优选项,如 gcc-mtune=genericvs.gcc -mtune=skylake)肯定会对编译器是否决定反转/拆分循环以在两个循环之间选择一次产生影响。 调优选项为此类决策设置启发式常量,并在静态代码大小和动态指令计数之间进行权衡的循环展开。

其中一部分可能取决于if()之外的工作量,并且在拆分时必须保持不变地重复。

对于这种情况,选项 C 是最好的。如果你可以使用template<bool specialCase>这意味着在编译时必须知道specialCase,因此你可以使用如果constexpr如图所示

if constexpr(specialCase)
{
acc.foo()
}
else
{
acc.bar()
}

相反,如果在编译时不知道 specialCase,我会选择选项 B,因为条件只计算一次

优化器可能会以与假代码不同的方式对待任何真实代码,并且无论foo()bar()做什么,在任何情况下都可能占主导地位。

正如你所说,"从理论的角度来看">,问题是specialCase是循环不变的,因此避免条件评估和对该值的分支将带来好处。但是,在实践中,编译器可能会发现它是循环不变的,并为您消除该问题,因此每个解决方案之间的差异可能不归结为循环不变计算。

确定最快的解决方案或差异是否足以证明更丑陋、更难遵循或维护代码的唯一现实方法是分析它;一项活动可能会占用你比任何一种解决方案更多的生活 - 编译器优化器可能会产生更大的影响,并且你的生产力可能会通过不担心这种微优化来提高 - 这很可能是一个虚假的经济。


还要考虑的另一种选择 - 给定指向成员函数成员的指针:然后void (MyClass::*foobar)() ;

void ifInLoopD( bool specialCase, MyClass& acc )
{
// FIXME: use a local, not class member, for the pointer-to-member-function
acc.foobar = specialCase ? &MyClass::foo : &MyClass::bar ;
for( auto i = 0; i < n; ++i )
{
for( auto j = 0; j < n; ++j )
{
(acc.*acc.foobar)() ;
}
}
}

有关如何使用包含指向成员函数的指针的局部变量C++请参阅调用指向成员函数的指针。 但请记住,此答案中的基准数据来自此版本,这可能阻止了一些编译器意识到函数指针在调用之间没有变化,因此可以内联。 (在编译器尝试内联指向成员函数之前,它不会意识到该函数不会更改类的指针成员。


编者注:版本 D 的基准测试数字可能不代表将其用于大多数循环体。

显示此指向成员函数的指针与其他方法具有相似性能的基准测试基于函数体,该函数体在增加static volatile int的延迟上存在瓶颈。

在生成的 asm 中,这将创建一个包含存储转发延迟的循环承载依赖项链。 首先,这可以隐藏很多循环开销。 在像任何 x86 一样的现代无序执行 CPU 上,成本不会加起来。 事情可能会重叠:大量的循环开销可能会在延迟瓶颈的阴影下运行。

更糟糕的是,存储转发延迟不是恒定的,当存储和重新加载之间的开销更多(尤其是不相关的存储)时,可能会变得更快。 请参阅函数调用比空循环更快的循环和添加冗余赋值可在编译时加快代码速度(调试版本将其循环计数器保留在内存中以创建此瓶颈)。 即使在优化的构建中,也像这样使用volatile力 asm。

在英特尔 Sandybridge 家族上,volatile增量可以更快,环路开销更大。因此,这种环体的选择创造了基准数字 如果您尝试推广到其他更典型的案例,则极具误导性。 正如我(彼得)在我的回答中所说,微基准测试很难。 有关更多详细信息,请参阅评论中的讨论。

此问题中的基准数字适用于此代码,但您应该期望其他循环体在性质上有所不同。

请注意,这个答案小心翼翼地不要得出任何关于实际代码中可能更快的结论

但我要补充一点,内部循环中的非内联函数调用几乎总是比内部循环中易于预测的分支更昂贵。 非内联函数调用强制编译器更新内存中暂时仅在寄存器中的所有值,以便内存状态与抽象计算机C++匹配。 至少对于全局变量和静态变量,以及任何可以通过函数参数指向/访问的内容(包括成员函数的this)。 它还会破坏所有呼叫破坏的寄存器。

因此,在性能方面,我希望在循环外部初始化的指向成员的指针函数类似于选项 A(if()内部),但几乎总是更糟。 或者,如果它们都优化远离恒定传播,则相等。

编者按完


对于每个实现 A、B 和我的实现,我称之为 D,(我省略了 C,因为我无法弄清楚您打算如何在实际实现中使用它),并给出:

class MyClass
{
public:
void foo(){ volatile static int a = 0 ; a++ ; }
void bar(){ volatile static int a = 0 ; a++ ; }
// FIXME: don't put a tmp var inside the class object!
// but keep in mind the benchmark results below *are* done with this
void (MyClass::*foobar)() ;
} acc ;
static const int n = 10000 ;

我得到了以下结果:

VC++ 2019 默认调试:(注意:不要对调试模式进行计时,这几乎总是无用的。

ifInLoopA( true, acc )  : 3.146 seconds
ifInLoopA( false, acc ) : 2.918 seconds
ifInLoopB( true, acc )  : 2.892 seconds
ifInLoopB( false, acc ) : 2.872 seconds
ifInLoopD( true, acc )  : 3.078 seconds
ifInLoopD( false, acc ) : 3.035 seconds

VC++ 2019 默认版本:

ifInLoopA( true, acc )  : 0.247 seconds
ifInLoopA( false, acc ) : 0.242 seconds
ifInLoopB( true, acc )  : 0.234 seconds
ifInLoopB( false, acc ) : 0.242 seconds
ifInLoopD( true, acc )  : 0.219 seconds
ifInLoopD( false, acc ) : 0.205 seconds

正如你所看到的,在调试解决方案D中明显较慢,而在优化的版本中,它明显更快。 此外,specialCase值的选择也会产生边际效应——尽管我不完全确定为什么。

我将发布版本的n增加到 30000 以获得更好的分辨率:

VC++ 2019 默认版本 n=30000:

ifInLoopA( true, acc )  : 2.198 seconds
ifInLoopA( false, acc ) : 1.989 seconds
ifInLoopB( true, acc )  : 1.934 seconds
ifInLoopB( false, acc ) : 1.979 seconds
ifInLoopD( true, acc )  : 1.721 seconds
ifInLoopD( false, acc ) : 1.732 seconds

显然,解决方案 A 对specialCase最敏感,并且可能需要确定性行为来避免,但这种差异可能会被实际foo() andbar() 实现的差异所淹没。

您的结果可能在很大程度上取决于您使用的编译器、目标和编译器选项,并且差异可能不是那么大,以至于您可以得出所有编译器的任何结论。

例如,在 https://www.onlinegdb.com/使用 g++ 5.4.1,未优化代码和优化代码之间的差异远没有那么显著(可能是由于 VC++ 调试器中更大的功能带来了显着的开销),而对于优化的代码,解决方案之间的差异要小得多。

(编者注:MSVC 调试模式在函数调用中包含间接寻址以允许增量重新编译,因此这可以解释调试模式下的巨大额外开销。 不计时调试模式的另一个原因。

对于volatile增量来说,将性能限制为与调试模式大致相同(将循环计数器保留在内存中)也就不足为奇了;两个单独的存储转发延迟链可以重叠。

https://www.onlinegdb.com/C++14 个默认选项,n = 30000

ifInLoopA( true, acc )  : 3.29026 seconds
ifInLoopA( false, acc ) : 3.08304 seconds
ifInLoopB( true, acc )  : 3.21342 seconds
ifInLoopB( false, acc ) : 3.26737 seconds
ifInLoopD( true, acc )  : 3.74404 seconds
ifInLoopD( false, acc ) : 3.72961 seconds

https://www.onlinegdb.com/C++14 默认值 -O3,n=30000

ifInLoopA( true, acc )  : 3.07913 seconds                                                                                                      
ifInLoopA( false, acc ) : 3.09762 seconds                                                                                                      
ifInLoopB( true, acc )  : 3.13735 seconds                                                                                                      
ifInLoopB( false, acc ) : 3.05647 seconds                                                                                                      
ifInLoopD( true, acc )  : 3.09078 seconds                                                                                                      
ifInLoopD( false, acc ) : 3.04051 seconds 

我认为您可以得出的唯一结论是,您必须测试每个解决方案,以确定它们与您的编译器和目标实现以及您的真实代码而不是虚构循环体的协同工作情况。

如果所有解决方案都满足你的性能要求,我建议你使用最易读/可维护的解决方案,并且只有在性能成为一个问题时才考虑优化,当你能够准确地确定代码的哪一部分会给你带来最大的影响最少的努力。


为了完整起见并允许您执行自己的评估,这是我的测试代码

class MyClass
{
public:
void foo(){ volatile static int a = 0 ; a++ ; }
void bar(){ volatile static int a = 0 ; a++ ; }
void (MyClass::*foobar)() ;
} acc ;
static const int n = 30000 ;
void ifInLoopA( bool specialCase, MyClass& acc ) {
for( auto i = 0; i < n; ++i ) {
for( auto j = 0; j < n; ++j ) {
if( specialCase ) {
acc.foo();
}
else {
acc.bar();
}
}
}
}
void ifInLoopB( bool specialCase, MyClass& acc ) {
if( specialCase ) {
for( auto i = 0; i < n; ++i ) {
for( auto j = 0; j < n; ++j ) {
acc.foo();
}
}
}
else {
for( auto i = 0; i < n; ++i ) {
for( auto j = 0; j < n; ++j ) {
acc.bar();
}
}
}
}
void ifInLoopD( bool specialCase, MyClass& acc )
{
acc.foobar = specialCase ? &MyClass::foo : &MyClass::bar ;
for( auto i = 0; i < n; ++i )
{
for( auto j = 0; j < n; ++j )
{
(acc.*acc.foobar)() ;
}
}
}

#include <ctime>
#include <iostream>
int main()
{
std::clock_t start = std::clock() ;
ifInLoopA( true, acc ) ;
std::cout << "ifInLoopA( true, acc )  : " << static_cast<double>((clock() - start)) / CLOCKS_PER_SEC << " secondsn" ;
start = std::clock() ;
ifInLoopA( false, acc ) ;
std::cout << "ifInLoopA( false, acc ) : " << static_cast<double>((clock() - start)) / CLOCKS_PER_SEC << " secondsn" ;
start = std::clock() ;
ifInLoopB( true, acc ) ;
std::cout << "ifInLoopB( true, acc )  : " << static_cast<double>((clock() - start)) / CLOCKS_PER_SEC << " secondsn" ;
start = std::clock() ;
ifInLoopB( false, acc ) ;
std::cout << "ifInLoopB( false, acc ) : " << static_cast<double>((clock() - start)) / CLOCKS_PER_SEC << " secondsn" ;
start = std::clock() ;
ifInLoopD( true, acc ) ;
std::cout << "ifInLoopD( true, acc )  : " << static_cast<double>((clock() - start)) / CLOCKS_PER_SEC << " secondsn" ;
start = std::clock() ;
ifInLoopD( false, acc ) ;
std::cout << "ifInLoopD( false, acc ) : " << static_cast<double>((clock() - start)) / CLOCKS_PER_SEC << " secondsn" ;
}