if语句和if-else语句,哪个更快?

90

我和朋友争论那两个代码片段哪个更快,以及为什么?

value = 5;
if (condition) {
    value = 6;
}

和:

if (condition) {
    value = 6;
} else {
    value = 5;
}

如果value是一个矩阵,会怎样呢?

注意:我知道有value = condition ? 6 : 5;这种写法可以更快,但那不是我的选择。

编辑(由于问题当前被暂停,工作人员要求添加):

  • 请考虑主流编译器(如g++、clang++、vc、mingw)生成的优化和非优化版本的x86汇编,或MIPS汇编。
  • 当汇编代码不同时,请解释为什么一种版本更快,以及原因(例如:“更好是因为没有分支,而分支具有以下问题blahblah”)。

174
优化将消除所有这些...无关紧要... - The Quantum Physicist
21
你可以对其进行性能分析,但我个人认为使用现代编译器不会有任何差别。 - George
25
如果使用 value = condition ? 6 : 5; 而不是 if/else,很可能会生成相同的代码。如果想了解更多信息,请查看汇编输出。 - Jabberwocky
8
在这种情况下,最重要的是避免分支,因为这里最昂贵的是它(管道重新加载、丢弃预取指令等)。 - Tommylee2k
11
只有在需要运行多次的循环内,微调速度才有意义,而且优化器可以像gcc在这个简单的例子中一样优化掉所有的分支指令,或者真实世界的性能将严重依赖于正确的分支预测(参见https://dev59.com/iWgu5IYBdhLWcg3wkH6P)。如果你不可避免地在循环内分支,你可以通过生成配置文件并重新编译来帮助分支预测器。 - Davislor
显示剩余9条评论
6个回答

290

简而言之: 在未优化的代码中,if 没有 else 看起来更加有效率,但是即使启用了最基本的优化级别,代码也会被重写为 value = condition + 5


尝试了一下并生成了以下代码的汇编:

int ifonly(bool condition, int value)
{
    value = 5;
    if (condition) {
        value = 6;
    }
    return value;
}

int ifelse(bool condition, int value)
{
    if (condition) {
        value = 6;
    } else {
        value = 5;
    }
    return value;
}

在禁用优化(-O0)的gcc 6.3上,相关差异为:

 mov     DWORD PTR [rbp-8], 5
 cmp     BYTE PTR [rbp-4], 0
 je      .L2
 mov     DWORD PTR [rbp-8], 6
.L2:
 mov     eax, DWORD PTR [rbp-8]
< p > 对于ifonly,而ifelse则有

 cmp     BYTE PTR [rbp-4], 0
 je      .L5
 mov     DWORD PTR [rbp-8], 6
 jmp     .L6
.L5:
 mov     DWORD PTR [rbp-8], 5
.L6:
 mov     eax, DWORD PTR [rbp-8]

后者看起来效率略低,因为它有一个额外的跳转,但两者都至少有两个、最多三个赋值语句,所以除非你真的需要挤出每一点性能(提示:除非你在工作航天飞机,否则你不需要,即使那样你可能也不需要),否则不会有明显差异。

然而,即使在最低的优化级别(-O1),这两个函数都会被简化为相同的形式:

test    dil, dil
setne   al
movzx   eax, al
add     eax, 5

这基本上相当于

return 5 + condition;
假设 condition 的值为零或一。更高的优化级别并没有真正改变输出,除了它们在开始时通过有效地清零 EAX 寄存器来避免使用 movzx
免责声明:你可能不应该自己编写 5 + condition(尽管标准保证将 true 转换为整数类型会得到 1),因为你的意图可能不会立即被阅读代码的人(包括你未来的自己)理解。这段代码的目的是展示编译器在两种情况下产生的代码(实际上)是相同的。Ciprian Tomoiaga 在评论中表达得很好:

人类的工作是为了 人类 编写代码,让 编译器机器 编写代码。


51
这是一个很棒的答案,应该被采纳。 - dtell
11
我从未想过使用加法(<-这就是Python对你的影响)。 - Ciprian Tomoiagă
26
除非你在编写优化器,否则不应该这样做!在几乎所有情况下,你应该让编译器进行此类优化,特别是当它们严重降低代码可读性时。只有在性能测试显示某个代码段存在问题时,才应该开始尝试进行优化,即使这样也要保持代码干净并注释良好,并且只进行可以产生明显影响的优化操作。 - Muzer
22
我本想回复Muzer,但那并没有为主题增加任何内容。不过我想再强调一下:一个人的工作是为人类编写代码,让编译器为机器编写代码。这是我从编译器开发者的角度说的(虽然我不是,但我学了一点相关知识)。 - Ciprian Tomoiagă
10
将值为 true 转换为 int 始终得到 1,没有例外。当然,如果你的条件只是“truthy”,而不是 bool 值的true,那就完全不同了。 - T.C.
显示剩余13条评论

45
CompuChip的回答表明,对于int它们都被优化为相同的汇编代码,所以没有区别。

如果value是一个矩阵呢?

我将以一种更加普遍的方式进行解释,即如果value的类型建造和分配非常昂贵(且移动成本很低)。

那么

T value = init1;
if (condition)
   value = init2;

这种方法并不是最优的,因为如果condition为真,你会进行不必要的init1初始化,然后进行复制赋值操作。

T value;
if (condition)
   value = init2;
else
   value = init3;

这样做已经比较好了。但如果默认构造代价高,而复制构造的代价比初始化还高,则仍然不够优化。

你可以使用条件运算符的解决方案,这是一个不错的方法:

T value = condition ? init1 : init2;

或者,如果您不喜欢条件运算符,可以创建一个像这样的辅助函数:
T create(bool condition)
{
  if (condition)
     return {init1};
  else
     return {init2};
}

T value = create(condition);

根据init1init2的不同,你也可以考虑以下内容:

auto final_init = condition ? init1 : init2;
T value = final_init;

但是我必须再次强调,这仅在构建和分配对于给定类型而言真正昂贵时才相关。即使如此,只有通过分析,你才能确切知道。


3
昂贵且未经过优化。例如,如果默认构造函数将矩阵清零,编译器可以意识到赋值只是要覆盖那些0,因此根本不需要清零,直接在该内存中写入。当然,优化器是棘手的东西,很难预测它们何时会启动或停止…… - Matthieu M.
当然是@MatthieuM。我所说的“昂贵”是指“即使经过编译器优化后,执行代价仍然很高(无论是CPU时钟、资源利用等度量方式)”。 - bolov
6
考虑一个含有非常大的已分配数组的类。默认构造函数会进行数组的分配和初始化,这是非常昂贵的。移动(而不是复制!)构造函数可以直接与源对象交换指针,而不需要对大型数组进行分配或初始化。 - TrentP
1
只要部分简单,我肯定更喜欢使用 ?: 运算符而不是引入新函数。毕竟,你可能不仅会将条件传递给函数,还会传递一些构造函数参数。其中有些可能根据条件甚至不会被 create() 使用。 - cmaster - reinstate monica
如果编译器只有从类中声明的构造函数,并且将零值清除放在单独编译的cpp文件中,则这是一种极不可能的优化。@MatthieuM - Kaz
显示剩余3条评论

12

在伪汇编语言中:

    li    #0, r0
    test  r1
    beq   L1
    li    #1, r0
L1:

可能

快或不快

    test  r1
    beq   L1
    li    #1, r0
    bra   L2
L1:
    li    #0, r0
L2:

根据实际CPU的复杂程度,性能从简单到复杂不等。
  • With any CPU manufactured after roughly 1990, good performance depends on the code fitting within the instruction cache. When in doubt, therefore, minimize code size. This weighs in favor of the first example.

  • With a basic "in-order, five-stage pipeline" CPU, which is still roughly what you get in many microcontrollers, there is a pipeline bubble every time a branch—conditional or unconditional—is taken, so it is also important to minimize the number of branch instructions. This also weighs in favor of the first example.

  • Somewhat more sophisticated CPUs—fancy enough to do "out-of-order execution", but not fancy enough to use the best known implementations of that concept—may incur pipeline bubbles whenever they encounter write-after-write hazards. This weighs in favor of the second example, where r0 is written only once no matter what. These CPUs are usually fancy enough to process unconditional branches in the instruction fetcher, so you aren't just trading the write-after-write penalty for a branch penalty.

    I don't know if anyone is still making this kind of CPU anymore. However, the CPUs that do use the "best known implementations" of out-of-order execution are likely to cut corners on the less frequently used instructions, so you need to be aware that this sort of thing can happen. A real example is false data dependencies on the destination registers in popcnt and lzcnt on Sandy Bridge CPUs.

  • At the highest end, the OOO engine will wind up issuing exactly the same sequence of internal operations for both code fragments—this is the hardware version of "don't worry about it, the compiler will generate the same machine code either way." However, code size still does matter, and now you also should be worrying about the predictability of the conditional branch. Branch prediction failures potentially cause a complete pipeline flush, which is catastrophic for performance; see Why is it faster to process a sorted array than an unsorted array? to understand how much difference this can make.

    If the branch is highly unpredictable, and your CPU has conditional-set or conditional-move instructions, this is the time to use them:

        li    #0, r0
        test  r1
        setne r0
    

    or

        li    #0, r0
        li    #1, r2
        test  r1
        movne r2, r0
    

    The conditional-set version is also more compact than any other alternative; if that instruction is available it is practically guaranteed to be the Right Thing for this scenario, even if the branch was predictable. The conditional-move version requires an additional scratch register, and always wastes one li instruction's worth of dispatch and execute resources; if the branch was in fact predictable, the branchy version may well be faster.


我会重新表述你的第二点,即CPU是否具有由于写后写冲突而延迟的乱序引擎。如果CPU具有可以处理此类冲突而不延迟的乱序引擎,则没有问题,但是如果CPU根本没有乱序引擎,也没有问题。 - supercat
@supercat,结尾的段落是为了涵盖这种情况,但我会考虑如何使其更清晰明了。 - zwol
我不知道现在的CPU是否具有缓存,会使连续执行的代码第二次比第一次运行更快(一些基于闪存的ARM部件具有可以缓冲几行闪存数据的接口,但可以按与执行速度相同的顺序获取代码,但让分支密集型代码在这些设备上快速运行的关键是将其复制到RAM中)。没有任何乱序执行的CPU比那些受写入-写入冲突延迟影响的CPU普遍得多。 - supercat
这非常有见地。 - Julien__

9
在未优化的代码中,第一个示例总是一次赋值,有时两次。第二个示例只会一次赋值。条件在两个代码路径上都相同,因此这不应该有影响。在经过优化的代码中,这取决于编译器。
像往常一样,如果您关心此问题,请生成汇编代码并查看编译器实际执行的操作。

1
如果担心性能问题,那么编译时不会选择不优化。但是,优化器的“好坏”当然取决于编译器/版本。 - old_timer
据我所知,对于编译器/ CPU 架构等方面没有任何注释,因此他们的编译器可能不进行优化。他们可能在任何从 8 位 PIC 到 64 位 Xeon 的设备上进行编译。 - Neil

8
什么会让你认为它们中的任何一个(即使只有一行代码)是更快还是更慢呢?
unsigned int fun0 ( unsigned int condition, unsigned int value )
{
    value = 5;
    if (condition) {
        value = 6;
    }
    return(value);
}
unsigned int fun1 ( unsigned int condition, unsigned int value )
{

    if (condition) {
        value = 6;
    } else {
        value = 5;
    }
    return(value);
}
unsigned int fun2 ( unsigned int condition, unsigned int value )
{
    value = condition ? 6 : 5;
    return(value);
}

高级语言的代码行数越多,编译器就有更多的内容可以处理,因此如果你想制定一个通用的规则,请给编译器更多的代码来处理。如果算法与上述情况相同,则可以预期最小化优化的编译器会自动识别出来。

00000000 <fun0>:
   0:   e3500000    cmp r0, #0
   4:   03a00005    moveq   r0, #5
   8:   13a00006    movne   r0, #6
   c:   e12fff1e    bx  lr

00000010 <fun1>:
  10:   e3500000    cmp r0, #0
  14:   13a00006    movne   r0, #6
  18:   03a00005    moveq   r0, #5
  1c:   e12fff1e    bx  lr

00000020 <fun2>:
  20:   e3500000    cmp r0, #0
  24:   13a00006    movne   r0, #6
  28:   03a00005    moveq   r0, #5
  2c:   e12fff1e    bx  lr

并不意外,它以不同的顺序执行了第一个函数,但执行时间相同。

0000000000000000 <fun0>:
   0:   7100001f    cmp w0, #0x0
   4:   1a9f07e0    cset    w0, ne
   8:   11001400    add w0, w0, #0x5
   c:   d65f03c0    ret

0000000000000010 <fun1>:
  10:   7100001f    cmp w0, #0x0
  14:   1a9f07e0    cset    w0, ne
  18:   11001400    add w0, w0, #0x5
  1c:   d65f03c0    ret

0000000000000020 <fun2>:
  20:   7100001f    cmp w0, #0x0
  24:   1a9f07e0    cset    w0, ne
  28:   11001400    add w0, w0, #0x5
  2c:   d65f03c0    ret

希望你明白,如果不是不同的实现实际上并不不同,你只需要尝试一下就可以了。

至于矩阵,不确定它有什么作用。

if(condition)
{
 big blob of code a
}
else
{
 big blob of code b
}

我只是要在大块的代码周围加上相同的if-then-else包装器,无论它们的值是5还是更复杂的东西。同样,即使比较是一大块代码,它仍然必须被计算,并且等于或不等于某些东西通常会被编译为负数,if(condition)do something通常被编译为if not condition goto。

00000000 <fun0>:
   0:   0f 93           tst r15     
   2:   03 24           jz  $+8         ;abs 0xa
   4:   3f 40 06 00     mov #6, r15 ;#0x0006
   8:   30 41           ret         
   a:   3f 40 05 00     mov #5, r15 ;#0x0005
   e:   30 41           ret         

00000010 <fun1>:
  10:   0f 93           tst r15     
  12:   03 20           jnz $+8         ;abs 0x1a
  14:   3f 40 05 00     mov #5, r15 ;#0x0005
  18:   30 41           ret         
  1a:   3f 40 06 00     mov #6, r15 ;#0x0006
  1e:   30 41           ret         

00000020 <fun2>:
  20:   0f 93           tst r15     
  22:   03 20           jnz $+8         ;abs 0x2a
  24:   3f 40 05 00     mov #5, r15 ;#0x0005
  28:   30 41           ret         
  2a:   3f 40 06 00     mov #6, r15 ;#0x0006
  2e:   30 41

我们最近在stackoverflow上与其他人一起进行了这个练习。在那个例子中,这个MIPS编译器不仅意识到这些函数是相同的,而且还有一个函数简单地跳转到另一个函数,以节省代码空间。但在这里没有这样做。

00000000 <fun0>:
   0:   0004102b    sltu    $2,$0,$4
   4:   03e00008    jr  $31
   8:   24420005    addiu   $2,$2,5

0000000c <fun1>:
   c:   0004102b    sltu    $2,$0,$4
  10:   03e00008    jr  $31
  14:   24420005    addiu   $2,$2,5

00000018 <fun2>:
  18:   0004102b    sltu    $2,$0,$4
  1c:   03e00008    jr  $31
  20:   24420005    addiu   $2,$2,5

还有一些其他的目标。

00000000 <_fun0>:
   0:   1166            mov r5, -(sp)
   2:   1185            mov sp, r5
   4:   0bf5 0004       tst 4(r5)
   8:   0304            beq 12 <_fun0+0x12>
   a:   15c0 0006       mov $6, r0
   e:   1585            mov (sp)+, r5
  10:   0087            rts pc
  12:   15c0 0005       mov $5, r0
  16:   1585            mov (sp)+, r5
  18:   0087            rts pc

0000001a <_fun1>:
  1a:   1166            mov r5, -(sp)
  1c:   1185            mov sp, r5
  1e:   0bf5 0004       tst 4(r5)
  22:   0204            bne 2c <_fun1+0x12>
  24:   15c0 0005       mov $5, r0
  28:   1585            mov (sp)+, r5
  2a:   0087            rts pc
  2c:   15c0 0006       mov $6, r0
  30:   1585            mov (sp)+, r5
  32:   0087            rts pc

00000034 <_fun2>:
  34:   1166            mov r5, -(sp)
  36:   1185            mov sp, r5
  38:   0bf5 0004       tst 4(r5)
  3c:   0204            bne 46 <_fun2+0x12>
  3e:   15c0 0005       mov $5, r0
  42:   1585            mov (sp)+, r5
  44:   0087            rts pc
  46:   15c0 0006       mov $6, r0
  4a:   1585            mov (sp)+, r5
  4c:   0087            rts pc

00000000 <fun0>:
   0:   00a03533            snez    x10,x10
   4:   0515                    addi    x10,x10,5
   6:   8082                    ret

00000008 <fun1>:
   8:   00a03533            snez    x10,x10
   c:   0515                    addi    x10,x10,5
   e:   8082                    ret

00000010 <fun2>:
  10:   00a03533            snez    x10,x10
  14:   0515                    addi    x10,x10,5
  16:   8082                    ret

和编译器

使用这个编码,人们期望不同的目标也能匹配

define i32 @fun0(i32 %condition, i32 %value) #0 {
  %1 = icmp ne i32 %condition, 0
  %. = select i1 %1, i32 6, i32 5
  ret i32 %.
}

; Function Attrs: norecurse nounwind readnone
define i32 @fun1(i32 %condition, i32 %value) #0 {
  %1 = icmp eq i32 %condition, 0
  %. = select i1 %1, i32 5, i32 6
  ret i32 %.
}

; Function Attrs: norecurse nounwind readnone
define i32 @fun2(i32 %condition, i32 %value) #0 {
  %1 = icmp ne i32 %condition, 0
  %2 = select i1 %1, i32 6, i32 5
  ret i32 %2
}


00000000 <fun0>:
   0:   e3a01005    mov r1, #5
   4:   e3500000    cmp r0, #0
   8:   13a01006    movne   r1, #6
   c:   e1a00001    mov r0, r1
  10:   e12fff1e    bx  lr

00000014 <fun1>:
  14:   e3a01006    mov r1, #6
  18:   e3500000    cmp r0, #0
  1c:   03a01005    moveq   r1, #5
  20:   e1a00001    mov r0, r1
  24:   e12fff1e    bx  lr

00000028 <fun2>:
  28:   e3a01005    mov r1, #5
  2c:   e3500000    cmp r0, #0
  30:   13a01006    movne   r1, #6
  34:   e1a00001    mov r0, r1
  38:   e12fff1e    bx  lr


fun0:
    push.w  r4
    mov.w   r1, r4
    mov.w   r15, r12
    mov.w   #6, r15
    cmp.w   #0, r12
    jne .LBB0_2
    mov.w   #5, r15
.LBB0_2:
    pop.w   r4
    ret

fun1:
    push.w  r4
    mov.w   r1, r4
    mov.w   r15, r12
    mov.w   #5, r15
    cmp.w   #0, r12
    jeq .LBB1_2
    mov.w   #6, r15
.LBB1_2:
    pop.w   r4
    ret


fun2:
    push.w  r4
    mov.w   r1, r4
    mov.w   r15, r12
    mov.w   #6, r15
    cmp.w   #0, r12
    jne .LBB2_2
    mov.w   #5, r15
.LBB2_2:
    pop.w   r4
    ret

从技术上讲,这些解决方案在性能上有所差异,有时结果为5的情况比结果为6的情况快,反之亦然。一个分支是否比执行更快?这可能是有争议的,但执行应该会有所不同。但这更多是代码中if条件与if否定条件的区别,导致编译器执行if跳过else而不是执行整个语句。但这并不一定是由于编码风格,而是由于语法中的比较、if和else情况。


0

好的,既然“assembly”是其中一个标签,我就假设你的代码是伪代码(不一定是C语言),并用人工将其翻译成6502汇编语言。

第一种选项(没有else)

        ldy #$00
        lda #$05
        dey
        bmi false
        lda #$06
false   brk

第二个选择(带有else语句)

        ldy #$00
        dey
        bmi else
        lda #$06
        sec
        bcs end
else    lda #$05
end     brk

假设:条件被设置为 Y 寄存器上的0或1,它将在两个选项的第一行设置(结果将在累加器中)。

因此,在计算每种情况的可能性后,我们发现第一种构造通常更快;当条件为0时需要9个周期,当条件为1时需要10个周期,而第二个选项在条件为0时也是9个周期,但在条件为1时是13个周期。(循环计数不包括最后的 BRK)。

结论:If onlyIf-Else 构造更快。

为了完整起见,这里是一个优化的解决方案:value = condition + 5

ldy #$00
lda #$00
tya
adc #$05
brk

这将把我们的时间缩短到8个周期(再次不包括结尾处的BRK)。


6
很不幸,对于这个问题,将同一源代码输入 C 编译器(或 C++ 编译器)与输入 Glen 的大脑中会产生截然不同的输出结果。在源代码层面上,任何方案之间都没有差异或“优化”潜力。只需使用最易读的方案(大概是 if/else 方案)即可。 - Quuxplusone
1
没错。编译器可能会将两个变量都优化为最快的版本,也可能会增加额外的开销,这远远超过了两者之间的差异。或者两者都有可能。 - jpaugh
1
假设它“不一定是C”似乎是一个明智的选择,因为问题被标记为C++(不幸的是,它没有声明所涉及变量的类型)。 - Toby Speight

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接