是if else比if+default更快

Is if else faster than if + default?

本文关键字:if+default 更快 else if      更新时间:2023-10-16

我做了一个简单的实验,将if-else与仅if(预设默认值)进行比较。示例:

void test0(char c, int *x) {
*x = 0;
if (c == 99) {
*x = 15;
}
}
void test1(char c, int *x) {
if (c == 99) {
*x = 15;
} else {
*x = 0;
}
}

对于上面的函数,我得到了完全相同的汇编代码(使用cmovne)。

但是,当添加一个额外的变量时:

void test2(char c, int *x, int *y) {
*x = 0;
*y = 0;
if (c == 99) {
*x = 15;
*y = 21;
}
}
void test3(char c, int *x, int *y) {
if (c == 99) {
*x = 15;
*y = 21;
} else {
*x = 0;
*y = 0;
}
}

组装突然变得不同:

test2(char, int*, int*):
cmp     dil, 99
mov     DWORD PTR [rsi], 0
mov     DWORD PTR [rdx], 0
je      .L10
rep ret
.L10:
mov     DWORD PTR [rsi], 15
mov     DWORD PTR [rdx], 21
ret
test3(char, int*, int*):
cmp     dil, 99
je      .L14
mov     DWORD PTR [rsi], 0
mov     DWORD PTR [rdx], 0
ret
.L14:
mov     DWORD PTR [rsi], 15
mov     DWORD PTR [rdx], 21
ret

似乎唯一的区别是顶部的mov是在je之前还是之后完成的。

现在(很抱歉,我的程序集有点粗糙),为了节省管道刷新,在跳转后使用mov不是总是更好吗?如果是这样,优化器(gcc6.2-O3)为什么不使用更好的方法呢?

对于上面的函数,我得到了完全相同的汇编代码(使用cmovne)。

当然,有些编译器可能会进行优化,但这并不能保证。对于这两种编写函数的方法,您很可能会得到不同的目标代码。

事实上,没有优化是有保证的(尽管现代优化编译器在大多数时候都做得很好),所以你应该编写代码来捕捉你想要的语义,或者你应该验证生成的目标代码并编写代码来确保你得到预期的输出。

以下是旧版本的MSVC在瞄准x86-32时将生成的内容(主要是因为他们不知道使用CMOV指令):

test0 PROC
cmp      BYTE PTR [c], 99
mov      eax, DWORD PTR [x]
mov      DWORD PTR [eax], 0
jne      SHORT LN2
mov      DWORD PTR [eax], 15
LN2:
ret      0
test0 ENDP
test1 PROC
mov      eax, DWORD PTR [x]
xor      ecx, ecx
cmp      BYTE PTR [c], 99
setne    cl
dec      ecx
and      ecx, 15
mov      DWORD PTR [eax], ecx
ret      0
test1 ENDP

请注意,test1为您提供了无分支代码,该代码利用SETNE指令(一个条件集,它将根据条件代码将其操作数设置为0或1——在本例中为NE)以及一些位操作来产生正确的值。CCD_ 8使用条件分支跳过对CCD_ 9的15的分配。

这之所以有趣,是因为它几乎与你所期望的完全相反。天真地说,人们可能会认为test0将是你握住优化器的手并让它生成无分支代码的方式。至少,这是我脑海中的第一个想法。但事实并非如此!优化器能够识别if/else习语并进行相应的优化!在test0的情况下,它无法进行同样的优化,因为你试图智胜它

但是,当添加额外的变量时。。。组件突然变成不同的

好吧,这并不奇怪。代码中的微小更改通常会对发出的代码产生重大影响。优化者不是魔法;它们只是非常复杂的模式匹配器。你改变了模式!

诚然,优化编译器可以在这里使用两个条件移动来生成无分支代码。事实上,这正是Clang 3.9对test3所做的(但对test2则不然,这与我们上面的分析一致,即优化器可能比不寻常的模式更能识别标准模式)。但海湾合作委员会不这么做。同样,不能保证执行特定的优化。

似乎唯一的区别是顶部的"mov"是在"je"之前还是之后完成的。

现在(很抱歉,我的程序集有点粗糙),为了节省管道冲洗,在跳转后使用mov不是总是更好吗?

不,不是真的。在这种情况下,这不会改善代码。如果分支预测错误,不管怎样,都将进行管道刷新。推测性预测错误的代码是ret指令还是mov指令并不重要。

ret指令紧跟在条件分支之后的唯一原因是,如果您手动编写程序集代码,而不知道使用rep ret指令。这对于某些AMD处理器来说是必要的技巧,可以避免分支预测惩罚。除非你是一位组装大师,否则你可能不会知道这个技巧。但编译器确实如此,而且也知道当你专门针对没有这种怪癖的英特尔处理器或不同一代AMD处理器时,这是没有必要的。

然而,您可能是对的,最好在分支之后使用mov,但不是出于您建议的原因。现代处理器(我相信这是Nehalem和更高版本,但如果需要验证的话,我会在Agner Fog的优秀优化指南中查找它)能够在某些情况下进行宏操作融合。基本上,宏操作融合意味着CPU的解码器将两个符合条件的指令组合成一个微操作,从而在流水线的各个阶段节省带宽。cmptest指令后面跟着条件分支指令(如test3中所示)符合宏操作融合的条件(实际上,还必须满足其他条件,但此代码确实满足这些要求)。在cmpje之间调度其他指令,如您在test2中看到的,会使宏操作融合变得不可能,可能会使代码执行得更慢。

不过,可以说,这是编译器中的一个优化缺陷。它可以重新排序mov指令,将je放在cmp之后,保留宏操作融合的能力:

test2a(char, int*, int*):
mov     DWORD PTR [rsi], 0    ; do the default initialization *first*
mov     DWORD PTR [rdx], 0
cmp     dil, 99               ; this is now followed immediately by the conditional
je      .L10                  ;  branch, making macro-op fusion possible
rep ret
.L10:
mov     DWORD PTR [rsi], 15
mov     DWORD PTR [rdx], 21
ret

CCD_ 30和CCD_ 31的目标代码之间的另一个区别是代码大小。由于优化器发出填充以对齐分支目标,test3的代码比test2大4个字节。不过,这不太可能有足够的区别,尤其是如果这段代码不是在保证在缓存中处于热状态的紧密循环中执行的。

那么,这是否意味着您应该始终像在test2中那样编写代码
嗯,不,有几个原因:

  1. 正如我们所看到的,它可能是一个令人讨厌的东西,因为优化器可能无法识别模式
  2. 您应该首先为可读性和语义正确性编写代码,只有当您的探查器指示它实际上是一个瓶颈时,才返回进行优化。然后,您应该只在检查和验证编译器发出的目标代码后进行优化,否则可能会出现令人讨厌的结果。(标准的"在得到证明之前,请相信您的编译器"建议。)
  3. 尽管在某些非常简单的情况下它可能是最优的,但"预设"习语是不可推广的。如果初始化很耗时,那么在可能的情况下跳过它可能会更快。(这里讨论了一个例子,在VB6的上下文中,字符串操作非常慢,在可能的情况下删除它实际上会比花哨的无分支代码更快地执行时间。更普遍地说,如果你能够围绕函数调用进行分支,同样的原理也适用。)

    即使在这里,它似乎会产生非常简单且可能更优化的代码,但实际上它可能会更慢,因为在c等于99的情况下,您要向内存写入两次,而在c等于99的情况下,您什么都不保存。

    您可以通过重写代码来节省此成本,使其将最终值累积在临时寄存器中,只在最后将其存储到内存中,例如

    test2b(char, int*, int*):
    xor     eax, eax               ; pre-zero the EAX register
    xor     ecx, ecx               ; pre-zero the ECX register
    cmp     dil, 99
    je      Done
    mov     eax, 15                ; change the value in EAX if necessary
    mov     ecx, 21                ; change the value in ECX if necessary
    Done:
    mov     DWORD PTR [rsi], eax   ; store our final temp values to memory
    mov     DWORD PTR [rdx], ecx
    ret
    

    但这会破坏两个附加寄存器(eaxecx),并且实际上可能不会更快。你必须对它进行基准测试。或者相信编译器会在它实际上是最优的时候发出这个代码,比如当它在一个紧密循环中内联了一个像test2这样的函数时。

  4. 即使您可以保证以某种方式编写代码会导致编译器发出无分支代码,这也不一定会更快!虽然分支在预测失误时速度较慢,但预测失误实际上相当罕见。现代处理器具有非常良好的分支预测引擎,在大多数情况下实现超过99%的预测准确率。

    条件移动对于避免分支预测失误非常有用,但它们的重要缺点是增加了依赖链的长度。相反,正确预测的分支会破坏依赖链。(这可能是GCC在添加额外变量时不发出两条CMOV指令的原因。)如果预计分支预测失败,则条件移动只是性能上的胜利。如果你可以指望预测成功率达到约75%或更好,那么条件分支可能更快,因为它打破了依赖链,延迟更低。我怀疑这里会是这样,除非每次调用函数时c在99和not-99之间快速来回切换。(参见Agner Fog的"优化汇编语言中的子程序",第70–71页。)