使用GCC 5.4.0进行昂贵的跳转


171

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

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。将条件更改为布尔乘法(使代码如下所示)后:

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


17
为何编译器会以这种方式运行?只要生成的代码正确,编译器就可以按照自己的意愿进行操作。一些编译器在优化方面比其他编译器简单。
Jabberwocky

26
我的猜测是短路评估&&导致此。
詹斯(Jens)2013年

9
请注意,这就是为什么我们也有&
rubenvb

7
@Jakub排序最有可能提高执行速度,请参阅此问题
rubenvb

8
@rubenvb“不能评价”,实际上并不意味着任何事情有没有副作用的表达式。我怀疑vector会进行边界检查,并且GCC无法证明它不会超出边界。编辑:实际上,我不认为您做任何事情来阻止i + shift越界。
Random832

Answers:


263

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

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

在取消引用指针之前,必须确保该指针为非null。如果这不是短路评估,那么您将有未定义的行为,因为您将取消引用空指针。

在条件评估是昂贵的过程的情况下,短路评估也可能会提高性能。例如:

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

如果DoLengthyCheck1失败,则没有必要致电DoLengthyCheck2

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

    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说明),每个比较之后都有一个单独的条件跳转/分支(ja,或上面的跳转)。

一般的经验法则是分支缓慢,因此在紧密循环中应避免分支。几乎所有的x86处理器都是这样,从低端的8888(其缓慢的读取时间和非常小的预取队列(与指令高速缓存相比),再加上完全缺乏分支预测,意味着采用分支需要将高速缓存转储。 )到现代的实现中(那些较长的管道会使错误预测的分支同样昂贵)。注意我滑进去的小警告。自奔腾Pro以来的现代处理器都具有先进的分支预测引擎,旨在将分支的成本降至最低。如果可以正确地预测分支的方向,则成本最小。在大多数情况下,这种方法效果很好,但是如果您遇到分支预测变量不在您身边的病理情况,您的代码可能会变得非常慢。大概是您在这里的位置,因为您说数组未排序。

你说的基准证实,更换&&*使代码速度明显更快。当我们比较目标代码的相关部分时,其原因显而易见:

    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,之后带有setbe。XOR只是清除寄存器的标准技巧。的setbe是x86指令,其基于的标志的值的比特,并且通常被用来实现无网点的代码。在这里,setbe是的逆ja。如果比较小于或等于(由于寄存器已预先清零,否则它将设置为0),它将目标寄存器设置为1;如果比较大于等于,则将目标寄存器设置为ja分支。一旦在r15b和中获得了这两个值r14b寄存器,使用将它们相乘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操作的标志,不如使用r14d1或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变得更加智能。现在,它为上述技巧生成了几乎与原始代码相同的代码(除了一些稍微的指令重新排列)。因此,您问题的答案, “为什么编译器以这种方式运行?”,可能是因为它们并不完美!他们尝试使用启发式方法生成尽可能最佳的代码,但他们并不总是做出最佳决策。但是至少随着时间的推移,他们可以变得更聪明!

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

对于您是否需要程序员注意的问题,答案几乎肯定没有,除非您尝试通过微优化来加速某些热循环。然后,您坐下来进行拆卸,并找到调整方法。而且,正如我之前说过的那样,当您更新到较新版本的编译器时,请准备好重新考虑这些决策,因为它可能会对棘手的代码造成愚蠢的事情,或者可能已经改变了其优化试探法,可以回去了。使用原始代码。彻底评论!


3
好吧,没有通用的“更好”。这完全取决于您的情况,这就是为什么在进行这种低级性能优化时绝对必须进行基准测试。正如我在回答解释,如果你是在分支预测的损失大小,预测失误的分支会减缓你的代码下来不少。代码的最后一点不使用任何分支(请注意没有j*指令),因此在这种情况下它将更快。[续]
科迪·格雷


2
@ 8bit Bob是对的。我指的是预取队列。我可能不应该将其称为“缓存”,但是我并不十分担心措辞,也没有花很长时间尝试回忆具体细节,因为除了历史的好奇心之外,我没有发现有人在意什么。如果您想了解详细信息,迈克尔·阿布拉什(Michael Abrash)的《汇编语言禅》非常有用。整本书可在网上的各个地方找到;这是分支上适用的部分,但是您也应该阅读和理解有关预取的部分。
科迪·格雷

6
@Hurkyl我觉得整个答案都在回答这个问题。您是对的,我没有真正明确地指出它,但是似乎已经足够长了。:-)任何花时间阅读整本书的人都应该对这一点有足够的了解。但是,如果您认为缺少某些内容,或者需要进一步澄清,请不要对编辑包含该答案的内容感到羞耻。有些人不喜欢这样,但我绝对不介意。我对此做了一个简短的评论,并修改了8bittree建议的措辞。
科迪·格雷

2
呵呵,谢谢您的补充,@ green。我没有任何具体建议。与一切一样,您通过做,看到和体验成为专家。我已经阅读了有关x86体系结构,优化,编译器内部以及其他低级内容的所有知识,但我仍然只知道其中一小部分。最好的学习方法是动手动手。但是,在甚至没有希望开始之前,您需要对C(或C ++),指针,汇编语言以及所有其他低层次的基础知识有扎实的了解。
科迪·格雷

23

需要注意的重要一件事是

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

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

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

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

然后表达 (curr[i] < 479) && (l[i + shift] < 479)肯定是定义良好的布尔值。例如,它不会引起分段错误。

但是,在这种情况下,表达式(curr[i] < 479) * (l[i + shift] < 479)未定义的行为;它不准造成分段错误。

举例来说,这意味着对于原始代码段,编译器不能仅仅编写一个执行比较和and操作的循环,除非编译器还可以证明l[i + shift]在不需要的情况下绝不会导致段错误。

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

您可以通过执行以下操作来修复原始版本

bool t1 = (curr[i] < 479);
bool t2 = (l[i + shift] < 479);
if (t1 && t2) {
    // ...

这个!根据shift(和max)的值,这里有UB ...
Matthieu M.

18

&&运营商实现了短路的评价。这意味着仅当第一个操作数求值为时,才对第二个操作数求值true。在这种情况下,这肯定会导致跳跃。

您可以创建一个小示例来说明这一点:

#include <iostream>

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

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

汇编器输出可以在这里找到

您可以先看到生成的代码f(x),然后检查输出,然后跳转到执行g(x)此操作时的评估true。否则,它将离开该功能。

取而代之,使用“布尔”乘法强制每次都对两个操作数求值,因此不需要跳转。

根据数据,跳转可能会导致速度变慢,因为它会干扰CPU的流水线以及其他诸如推测执行之类的事情。通常,分支预测会有所帮助,但是如果您的数据是随机的,那么可以预测的就很少。


1
您为什么说乘法每次都强制对两个操作数求值?0 * x = x * 0 = 0,与x的值无关。作为优化,编译器也可以“缩短”乘法。例如,请参阅stackoverflow.com/questions/8145894/…。而且,与&&运算符不同,可以使用第一个或第二个参数对乘法进行延迟求值,从而为优化提供了更大的自由度。
SomeWittyUsername

@Jens-“通常情况下,分支预测会有所帮助,但是如果您的数据是随机的,那么可以预测的就很少。” -很好的答案。
SChepurin

1
@SomeWittyUsername好吧,编译器当然可以自由进行任何可观察行为的优化。这可能会或可能不会对其进行转换,从而忽略了计算。如果您进行计算0 * f()f具有可观察到的行为,则编译器必须调用它。不同之处在于,必须对进行短路评估,&&但如果可以证明与短路评估是等效的,则允许这样做*
詹斯(Jens)2013年

@SomeWittyUsername仅在可以从变量或常量预测0值的情况下使用。我猜这些情况很少。当然,由于涉及阵列访问,因此在OP的情况下无法完成优化。
圣地亚哥塞维利亚

3
@Jens:短路评估不是强制性的。仅要求代码表现为好像短路一样;允许编译器使用它喜欢的任何方式来达到结果。

-2

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


8
跳跃来自这样一个事实,即当且仅当第一个条件为真时,才评估第二个条件。代码不得对它进行其他评估,因此编译器无法更好地对其进行优化,并且仍然是正确的(除非可以推断出第一条语句始终为真)。
rubenvb
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.