逻辑AND运算符(&&
)使用短路评估,这意味着仅当第一个比较评估为true时才进行第二次测试。这通常正是您所需要的语义。例如,考虑以下代码:
if ((p != nullptr) && (p->first > 0))
在取消引用指针之前,必须确保该指针为非null。如果这不是短路评估,那么您将有未定义的行为,因为您将取消引用空指针。
在条件评估是昂贵的过程的情况下,短路评估也可能会提高性能。例如:
if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))
如果DoLengthyCheck1
失败,则没有必要致电DoLengthyCheck2
。
但是,在生成的二进制文件中,短路操作通常会导致两个分支,因为这是编译器保留这些语义的最简单方法。(这就是为什么在硬币的另一面,短路评估有时会抑制优化潜力的原因。)您可以通过查看if
GCC 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++
注意这是多么聪明!它使用签名条件(jg
和setle
),而不是无符号的条件(ja
和setbe
),但这并不重要。您可以看到它仍然像旧版本一样对第一个条件进行比较和分支,并使用相同的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)汇总add
为nontopOverlap
。如果您需要无分支的代码,这实际上将确保您能够获得它。
GCC 7变得更加智能。现在,它为上述技巧生成了几乎与原始代码相同的代码(除了一些稍微的指令重新排列)。因此,您问题的答案, “为什么编译器以这种方式运行?”,可能是因为它们并不完美!他们尝试使用启发式方法生成尽可能最佳的代码,但他们并不总是做出最佳决策。但是至少随着时间的推移,他们可以变得更聪明!
解决这种情况的一种方法是分支代码具有更好的最佳情况性能。如果分支预测成功,则跳过不必要的操作将导致运行时间稍短。但是,无分支代码具有更好的最坏情况性能。如果分支预测失败,则必要时执行一些其他指令以避免分支肯定比错误预测的分支要快。即使是最聪明,最聪明的编译器也很难做出选择。
对于您是否需要程序员注意的问题,答案几乎肯定没有,除非您尝试通过微优化来加速某些热循环。然后,您坐下来进行拆卸,并找到调整方法。而且,正如我之前说过的那样,当您更新到较新版本的编译器时,请准备好重新考虑这些决策,因为它可能会对棘手的代码造成愚蠢的事情,或者可能已经改变了其优化试探法,可以回去了。使用原始代码。彻底评论!