与GCC 5.4.0的一次昂贵的跳跃

时间:2016-12-06 09:22:39

标签: c++ gcc

我有一个看起来像这样的功能(仅显示重要部分):

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) && (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

这样写的,我的机器上的功能耗时~34ms。将条件更改为bool乘法后(使代码看起来像这样):

double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY)  {
...
  for(std::size_t i=std::max(0,-shift);i<max;i++) {
     if ((curr[i] < 479) * (l[i + shift] < 479)) {
       nontopOverlap++;
     }
     ...
  }
...
}

执行时间减少到~19ms。

使用的编译器是带有-O3的GCC 5.4.0,在使用godbolt.org检查生成的asm代码后,我发现第一个示例生成了跳转,而第二个示例没有生成跳转。我决定尝试GCC 6.2.0,它在使用第一个例子时也生成一个跳转指令,但GCC 7似乎不再生成一个。

找到这种方法来加速代码是相当可怕的,花了很长时间。为什么编译器会以这种方式运行?这是程序员应该注意的吗?还有其他类似的东西吗?

编辑:链接到godbolt https://godbolt.org/g/5lKPF3

4 个答案:

答案 0 :(得分:261)

逻辑AND运算符(&&)使用短路评估,这意味着仅在第一次比较评估为true时才进行第二次测试。这通常是您需要的语义。例如,请考虑以下代码:

if ((p != nullptr) && (p->first > 0))

在取消引用之前,必须确保指针为非null。如果没有进行短路评估,那么您就会有未定义的行为,因为您要取消引用空指针。

在条件评估是昂贵的过程的情况下,短路评估也可能产生性能增益。例如:

if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))

如果DoLengthyCheck1失败,则无需拨打DoLengthyCheck2

但是,在生成的二进制文件中,短路操作通常会产生两个分支,因为这是编译器保留这些语义的最简单方法。 (这就是为什么,硬币的另一方面,短路评估有时可以抑制优化潜力。)你可以通过查看为{{1}生成的目标代码的相关部分来看到这一点。 GCC 5.4声明:

if

这里你会看到两个比较( movzx r13d, WORD PTR [rbp+rcx*2] movzx eax, WORD PTR [rbx+rcx*2] cmp r13w, 478 ; (curr[i] < 479) ja .L5 cmp ax, 478 ; (l[i + shift] < 479) ja .L5 add r8d, 1 ; nontopOverlap++ 指令),每个比较后面跟一个单独的条件跳转/分支(cmp,或者如果超过那个跳转)。

一般的经验法则是分支很慢,因此要在紧密的循环中避免。几乎所有x86处理器都是如此,从简陋的8088(其缓慢的获取时间和极小的预取队列[与指令缓存相当],加上完全缺乏分支预测,意味着所采用的分支需要缓存被转储)对于现代实施(其长管道使错误预测的分支同样昂贵)。请注意我在那里滑倒的小警告。 Pentium Pro以来的现代处理器拥有先进的分支预测引擎,旨在最大限度地降低分支机构的成本。如果可以正确预测分支的方向,则成本最小。大多数情况下,这种方法效果很好,但如果您遇到分支预测器不在您身边的病态情况,your code can get extremely slow。这可能是你在这里的地方,因为你说你的数组没有被排除。

您说基准确认用ja替换&&会使代码明显加快。当我们比较目标代码的相关部分时,原因很明显:

*

这有点反直觉,这可能会更快,因为这里有更多指令,但这就是优化如何有效。您会在此处看到相同的比较( movzx r13d, WORD PTR [rbp+rcx*2] movzx eax, WORD PTR [rbx+rcx*2] xor r15d, r15d ; (curr[i] < 479) cmp r13w, 478 setbe r15b xor r14d, r14d ; (l[i + shift] < 479) cmp ax, 478 setbe r14b imul r14d, r15d ; meld results of the two comparisons cmp r14d, 1 ; nontopOverlap++ sbb r8d, -1 ),但现在,每个比较前面都有cmp,后跟xor。 XOR只是清除寄存器的标准技巧。 setbe是一个x86指令,它根据标志的值设置一个位,通常用于实现无分支代码。在这里,setbesetbe的倒数。如果比较低于或等于(由于寄存器预先归零,否则它将为0),它将其目标寄存器设置为1,而如果比较高于ja,则ja分支。在r15br14b寄存器中获取这两个值后,使用imul将它们相乘。乘法传统上是一个相对较慢的操作,但它在现代处理器上速度很快,而且速度特别快,因为它只能乘以两个字节大小的值。

您可以很容易地使用按位AND运算符(&)替换乘法,而不进行短路评估。这使得代码更加清晰,并且是编译器通常认可的模式。但是当您使用代码执行此操作并使用GCC 5.4进行编译时,它将继续发出第一个分支:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13w, 478         ; (curr[i] < 479)
    ja      .L4

    cmp     ax, 478           ; (l[i + shift] < 479)
    setbe   r14b

    cmp     r14d, 1           ; nontopOverlap++
    sbb     r8d, -1

没有技术原因它必须以这种方式发出代码,但由于某种原因,它的内部启发式告诉它这更快。如果分支预测器在你身边, 可能会更快,但如果分支预测失败的次数多于成功的话,它可能会更慢。

新一代编译器(以及其他编译器,如Clang)知道这条规则,有时会用它来生成你手工优化所需的相同代码。我经常看到Clang将&&表达式翻译为与我使用&时发出的代码相同的代码。以下是GCC 6.2的相关输出,您的代码使用普通&&运算符:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L7

    xor     r14d, r14d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r14b

    add     esi, r14d         ; nontopOverlap++

注意这个是多么聪明!它使用签名条件(jgsetle)而不是无符号条件(jasetbe),但这并不重要。您可以看到它仍然为第一个条件(如旧版本)执行比较和分支,并使用相同的setCC指令为第二个条件生成无分支代码,但它在以下方面的效率更高。怎么做增量。它不是进行第二次冗余比较来设置sbb操作的标志,而是使用r14d将为1或0的知识来简单地无条件地将此值添加到nontopOverlap。如果r14d为0,则添加为无操作;否则,它会增加1,完全像它应该做的那样。

当您使用短路&&运算符而不是按位&运算符时,GCC 6.2实际上会生成更多高效代码:

    movzx   r13d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r13d, 478         ; (curr[i] < 479)
    jg      .L6

    cmp     eax, 478          ; (l[i + shift] < 479)
    setle   r14b

    cmp     r14b, 1           ; nontopOverlap++
    sbb     esi, -1

分支和条件集仍然存在,但现在它又回到了不那么聪明的递增nontopOverlap的方式。这是一个重要的教训,为什么在尝试超越你的编译器时应该小心!

但是,如果您能够使用基准测试来证明,那么分支代码实际上更慢,那么尝试巧妙地编写您的编译器可能会付出代价。您只需仔细检查反汇编就可以这样做 - 并准备在升级到更高版本的编译器时重新评估您的决策。例如,您拥有的代码可以重写为:

nontopOverlap += ((curr[i] < 479) & (l[i + shift] < 479));

这里根本没有if语句,绝大多数编译器都不会考虑为此发出分支代码。海湾合作委员会也不例外;所有版本都会生成类似于以下内容的内容:

    movzx   r14d, WORD PTR [rbp+rcx*2]
    movzx   eax,  WORD PTR [rbx+rcx*2]

    cmp     r14d, 478         ; (curr[i] < 479)
    setle   r15b

    xor     r13d, r13d        ; (l[i + shift] < 479)
    cmp     eax, 478
    setle   r13b

    and     r13d, r15d        ; meld results of the two comparisons
    add     esi, r13d         ; nontopOverlap++

如果您一直关注前面的示例,那么您应该对此非常熟悉。两种比较均以无分支方式完成,中间结果为and,然后此结果(将为0或1)addnontopOverlap。如果你想要无分支代码,这实际上可以确保你得到它。

GCC 7变得更加智能。它现在为上述技巧生成几乎相同的代码(除了一些轻微的重新排列指令)作为原始代码。那么,你问题的答案,&#34;为什么编译器会这样做?&#34; ,可能是因为它们并不完美!他们尝试使用启发式算法来生成最佳代码,但他们并不总能做出最佳决策。但至少他们可以随着时间的推移变得更聪明!

查看这种情况的一种方法是分支代码具有更好的最佳案例性能。如果分支预测成功,则跳过不必要的操作将导致稍快的运行时间。但是,无分支代码具有更好的最坏情况性能。如果分支预测失败,则根据需要执行一些额外的指令以避免分支将肯定比错误预测的分支更快。即使是最聪明,最聪明的编译器也很难做出这样的选择。

关于这是程序员需要注意的问题,答案几乎肯定是否定的,除了某些热循环,你试图通过微优化加速。然后,您坐下来进行反汇编并找到调整它的方法。并且,正如我之前所说的那样,当你更新到更新版本的编译器时,准备重新审视这些决定,因为它可能会对你的棘手代码做一些愚蠢的事情,或者它可能已经改变了它的优化启发式,你可以回去使用您的原始代码。评论彻底!

答案 1 :(得分:23)

需要注意的一件重要事情是

(curr[i] < 479) && (l[i + shift] < 479)

(curr[i] < 479) * (l[i + shift] < 479)

在语义上不等同!特别是,如果您遇到以下情况:

  • 0 <= ii < curr.size()都是真的
  • curr[i] < 479是假的
  • i + shift < 0i + shift >= l.size()为真

然后表达式(curr[i] < 479) && (l[i + shift] < 479)保证是一个明确定义的布尔值。例如,它不会导致分段错误。

但是,在这些情况下,表达式(curr[i] < 479) * (l[i + shift] < 479)未定义的行为; 允许导致分段错误。

这意味着对于原始代码片段,例如,编译器不能只编写执行两次比较并执行and操作的循环,除非编译器也能证明{{1}在他们不需要的情况下永远不会导致段错误。

简而言之,原始代码提供的优化机会少于后者。 (当然,编译器是否认识到这个机会是一个完全不同的问题)

您可以通过改为修复原始版本

l[i + shift]

答案 2 :(得分:17)

&&运算符实现短路评估。这意味着仅在第一个操作数计算为true时才评估第二个操作数。在这种情况下,这肯定会导致跳跃。

您可以创建一个小例子来显示:

#include <iostream>

bool f(int);
bool g(int);

void test(int x, int y)
{
  if ( f(x) && g(x)  )
  {
    std::cout << "ok";
  }
}

The assembler output can be found here

您可以看到生成的代码首先调用f(x),然后检查输出,并在g(x)时跳转到true的评估。否则它会离开函数。

使用&#34; boolean&#34;相反,乘法会强制每次都对两个操作数进行评估,因此不需要跳转。

根据数据的不同,跳转会导致速度变慢,因为它会干扰CPU的管道以及推测执行等其他事情。通常,分支预测会有所帮助,但如果您的数据是随机的,那么可以预测的数据并不多。

答案 3 :(得分:-2)

这可能是因为当您使用逻辑运算符&&时,编译器必须检查if语句成功的两个条件。但是在第二种情况下,由于您隐式地将int值转换为bool,因此编译器会根据传入的类型和值以及(可能)单个跳转条件进行一些假设。编译器也可能通过位移完全优化jmps。