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

C++: Performance impact of if inside loops

我需要遍历大量 (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:

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();
      }
    }
  }
}

我知道这属于 premature Optimization。但是,从理论的角度来看,我会对这些片段在使用 -O3 (GCC / Clang) 编译时在生成的程序集和速度方面的差异感兴趣。

(已经存在类似的 question about this in Perl 但我想具体了解 C++。)

(编辑)specialCase 在编译时已知吗?

不是真的。调用本身在另一个循环中,一些迭代的处理方式不同。所以类似(但不一定等距但独立于用户输入):

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

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

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

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

所有现代编译器都足够聪明,可以内联小函数,然后完全优化掉 if(constant)。内联 + constant-propagation 是什么使现代 C++ 可以高效编译。


但是如果 compile-time 处的 bool 值未知,选项 B 可能更有效。(如果函数不经常 运行它的速度在大局中可能无关紧要,差异可能很小。)

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


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

如果你确实有这样的重复模式,编译器可能会决定为你展开这个循环,这样 bool 就变成了一个 compile-time 常量。

或者你可以 hand-hold 如果编译器不决定发明新的 inner-loops 本身,那么你可以 hand-hold 编译器可能会制作更好的 asm,并且将包含另一个完整循环的循环展开 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);
}

完美预测并没有摆脱检查分支条件的指令的 front-end + back-end 吞吐量成本 ,如果编译器没有' t 提升 if 并生成循环的两个版本。

如果编译器知道 ifelse 主体中只有一个 显着 简化/ 优化是可能的 运行s 每次迭代,但在 运行 时间检查和分支会错过那些优化,即使它预测得很好。


"profile it" 通常的 Stack Overflow 响应并不像大多数人认为的那样有用。首先,微基准测试 hard。很容易完全测量错误的事物,或者得出无意义的结论,因为您对什么可能重要什么不重要了解得不够多。 (确保将你的 CPU 预热到最大 turbo 频率并首先初始化内存,这样你就没有 CoW 映射到零页并且第一次计时传递不支付 page-fault + TLB-miss 成本。在启用优化的情况下进行编译,并检查性能是否与重复次数呈线性关系。)

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

使用您关心的编译器查看您的特定情况的 asm 是确定的唯一方法。

不同的编译器(或同一编译器的不同版本,或具有不同的调整选项,如 gcc -mtune=genericgcc -mtune=skylake)肯定会影响编译器是否决定 invert/split 在两个循环之间循环到 select 一次。调整选项为这样的决策设置启发式常量,并在静态 code-size 和动态 instruction-count.

之间进行权衡的循环展开

其中一部分可能取决于 if() 之外的工作量,并且在拆分时必须原样复制。

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

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

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

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

"From a theoretical point of view" 正如您所说,问题是 specialCase 是循环不变的,因此避免条件评估和对该值进行分支会带来好处.但是在实践中,编译器可能会发现它是循环不变的并为您消除该问题,因此每个解决方案之间的差异可能不会归结为 loop-invariant 评估。

确定最快解决方案或差异是否足以证明更丑陋、更难遵循或维护代码的唯一现实方法是对其进行概要分析; activity 可能会占用你更多的时间,而不是任何一种解决方案所能节省的时间——编译器优化器可能会产生更大的影响,并且你的工作效率可能会因为不担心这种 micro-optimisation 而提高——它是很可能是虚假经济。


还要考虑的替代选项 - 给定 pointer-to-member-function 成员: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++ Call Pointer To Member Function 了解如何使用包含 pointer-to-member-function 的局部变量。但请记住,这个答案中的基准数据来自这个版本,这可能阻止了一些编译器意识到函数指针在调用之间没有变化,因此可以被内联。 (直到编译器尝试内联 pointed-to 成员函数,它才会意识到该函数不会更改 class 的 pointer-member。)


编者注:版本 D 的基准测试数字可能不代表它与大多数循环体一起使用。

显示此 pointer-to-member-function 具有与其他方法相似性能的基准是基于函数体,该函数体在递增 static volatile int 的延迟上存在瓶颈。

在生成的 asm 中,创建了一个 loop-carried 依赖链,其中包括 store-forwarding 延迟。首先,这会隐藏很多循环开销。在像任何 x86 一样的现代 out-of-order 执行 CPU 中,成本不仅会增加。事情可以重叠:很多循环开销可以 运行 在延迟瓶颈的阴影下。

更糟糕的是,store-forwarding 延迟不是恒定的,并且可以 更快 当存储和重新加载之间存在更多开销时,尤其是不相关的存储。请参阅 and (其中调试版本将其循环计数器保留在内存中以造成此瓶颈)。即使在优化构建中,使用 volatile 也会像那样强制 asm。

在 Intel Sandybridge-family 上,volatile 增量可以更快,但循环开销更多。 所以这个选择循环体创建基准数字 如果您试图将其推广到其他更典型的案例,那将极具误导性。正如我(彼得)在回答中所说,微基准测试很难。有关详细信息,请参阅评论中的讨论。

这个问题中的基准数字是针对这段代码的,但你应该期望其他循环体在质量上有所不同。

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

但我要补充一点,内循环内的 non-inlined 函数调用几乎总是比内循环内的 easily-predicted 分支更昂贵。 non-inline 函数调用强制编译器更新内存中暂时只存在于寄存器中的所有值,因此内存状态与 C++ 抽象机匹配。至少对于全局变量和静态变量,以及任何 pointed-to / 可通过函数 args 访问的内容(包括成员函数的 this )。它还破坏了所有 call-clobbered 寄存器。

所以 performance-wise,我希望在循环外初始化的 pointer-to-member-function 类似于选项 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 默认调试:(注意:不要计时debug-mode,那几乎总是没用的。)

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,un-optimised 和优化代码之间的区别远没有那么显着(可能是由于 VC+ 中的功能要强大得多) + 调试器会带来很大的开销),而对于优化后的代码,解决方案之间的差异就不那么重要了。

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

volatile 增量将性能限制在与 debug-mode(将循环计数器保存在内存中)大致相同的情况下并不奇怪;两个单独的 store-forwarding 延迟链可以重叠。)

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 

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

如果所有的方案都满足你的性能要求,我建议你使用最readable/maintainable的方案,只有当性能成为问题时,你才能准确地识别出整体代码的哪一部分时才考虑优化将以最少的努力给您最大的影响。


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

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 << " seconds\n" ;

    start = std::clock() ;
    ifInLoopA( false, acc ) ;
    std::cout << "ifInLoopA( false, acc ) : " << static_cast<double>((clock() - start)) / CLOCKS_PER_SEC << " seconds\n" ;

    start = std::clock() ;
    ifInLoopB( true, acc ) ;
    std::cout << "ifInLoopB( true, acc )  : " << static_cast<double>((clock() - start)) / CLOCKS_PER_SEC << " seconds\n" ;

    start = std::clock() ;
    ifInLoopB( false, acc ) ;
    std::cout << "ifInLoopB( false, acc ) : " << static_cast<double>((clock() - start)) / CLOCKS_PER_SEC << " seconds\n" ;

    start = std::clock() ;
    ifInLoopD( true, acc ) ;
    std::cout << "ifInLoopD( true, acc )  : " << static_cast<double>((clock() - start)) / CLOCKS_PER_SEC << " seconds\n" ;

    start = std::clock() ;
    ifInLoopD( false, acc ) ;
    std::cout << "ifInLoopD( false, acc ) : " << static_cast<double>((clock() - start)) / CLOCKS_PER_SEC << " seconds\n" ;
}