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

时间:2019-07-13 10:17:14

标签: c++ micro-optimization premature-optimization

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

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

(已经存在类似的question about this in 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);
}

3 个答案:

答案 0 :(得分:8)

如果此函数可以内联到传递编译时常数bool 的调用方中,那么您可以使用选项A(只要该函数足够小以内联) )。也就是说,如果可以使用模板arg,则通常通常不需要。除非if强迫您编写if(var) { foo<true>(arg); }else {foo<false>(arg); }来鼓励编译器使用两个版本的循环进行汇编。

所有现代编译器都足够聪明,可以内嵌小功能,然后完全优化if(constant) 。内联+恒定传播 是什么使得现代C ++可以有效地进行编译。


但是,如果在编译时不知道bool值,则选项B可能会更有效。(如果函数不经常运行,则其速度可能并不重要,并且差别可能很小。)

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


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

如果您确实具有这样的重复模式,则编译器可能会决定为您展开此循环,以便bool成为编译时常量。

或者,如果编译器没有决定自己发明新的内部循环,或者可以让编译器创建更好的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);
}

完美的预测不会消除检查分支条件的指令的前端+后端吞吐成本,如果编译器没有提升if并生成两个版本的循环。

如果编译器知道每次迭代只运行ifelse主体中的一个,则编译器可能会进行明显的简化/ 优化,但是即使在运行时进行检查并进行分支检查,也无法准确地预测出这些优化。


通常的“概要分析”堆栈溢出响应并不像大多数人认为的那样有用。首先,微基准测试是 hard 。完全衡量错误的事情或得出荒谬的结论是很容易的,因为您对可能的问题和不重要的事情并不了解。 (确保将您的CPU预热到最大turbo频率,然后首先初始化内存,这样您就不会将CoW映射到零页面,并且第一次定时传递不会支付页面错误和TLB遗失成本。优化编译启用,并检查效果是否与重复次数成线性比例。)

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

确保您所关心的编译器针对特定情况的asm是唯一的确定方法。

不同的编译器(或同一编译器的不同版本,或具有gcc -mtune=genericgcc -mtune=skylake之类的不同调整选项)肯定会在编译器是否决定反转/分割循环方面有所不同在两个循环之间选择一次。调整选项为此类决策设置启发式常量,并在静态代码大小与动态指令计数之间进行权衡的情况下展开循环。

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

答案 1 :(得分:2)

优化器可能会将任何真实代码与该伪造代码区别对待,无论如何,foo()bar()在任何情况下都可能占主导地位。

正如您所说,

“从理论的角度来看” ,问题在于specialCase是循环不变的,因此避免条件评估和分支该值将带来好处。但是实际上,编译器可能会发现它是循环不变的,并为您消除了该问题,因为每种解决方案之间的差异可能不会归因于循环不变的评估。

确定最快解决方案或差异是否足够大以证明丑陋,难以遵循或维护代码的唯一现实方法是分析它;一项活动可能占用您的生命多于任何一种解决方案所能节省的时间-编译器优化程序可能会产生更大的影响,而您不必担心这种微优化就可能提高您的生产率-这很可能是错误的经济。


还要考虑的另一种选择-给定 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。但是请记住,此答案中的基准数据来自此版本,这可能使某些编译器无法意识到函数指针在两次调用之间没有变化,因此可以内联。 (直到编译器尝试内联指向成员的函数之前,它不会意识到该函数不会更改类的指针成员。)


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

表明此成员函数指针与其他方法的性能相似的基准基于一个函数主体,该主体在增加static volatile int的延迟上遇到瓶颈。

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

更糟糕的是,存储转发延迟并不是恒定不变的,并且在存储和重新加载之间有更多开销(尤其是无关的存储)时,存储转发延迟会更快。请参见Loop with function call faster than an empty loopAdding a redundant assignment speeds up code when compiled without optimization(其中,调试版本将其循环计数器保留在内存中以创建此瓶颈)。即使在优化的版本中,使用volatile也会强制asm。

在Intel Sandybridge系列上,volatile的增量可以更快地获得 并具有更多的循环开销。因此,这种选择的循环体可以创建基准数字 如果您尝试将其推广到其他更典型的情况,那将极具误导性。正如我(彼得)在回答中所说的那样,微基准测试很难。有关更多详细信息,请参见评论中的讨论。

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

请注意,此答案是 not 的谨慎提示,无法得出关于实际代码中可能更快的结论

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

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

编者注的结尾


对于我将称为D的每个实现A,B和我的实现(我省略了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中,速度明显慢得多,而在优化版本中,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() and bar()实现中的差异可能会淹没差异。

您的结果可能非常取决于您的编译器,使用的目标和编译器选项,并且差异可能不那么显着,因此您可以得出所有 all 编译器的结论。

例如,在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 

我认为您可以得出的唯一结论是,您必须测试每种解决方案,以确定它们与编译器和目标实现以及您的 real 代码(不是虚构的循环)一起使用时的效果如何身体。

如果所有解决方案都满足您的性能要求,我建议您使用可读性最强/可维护性强的解决方案,并仅在性能出现问题时才考虑优化,这样您才能准确确定总体上哪一部分代码可以为您提供以最小的努力获得最大的影响。


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

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" ;
}

答案 2 :(得分:1)

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

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

相反,如果在编译时不知道specialCase,我会选择选项B,因为条件仅被评估一次