【问题标题】:Is if else faster than if + default?if else 是否比 if + default 更快?
【发布时间】:2017-05-12 13:23:45
【问题描述】:

我进行了一个简单的实验,将 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

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

现在(对不起,我的组装有点粗糙),在跳转后使用movs 不是总是更好,以节省管道冲洗吗?如果是这样,为什么优化器 (gcc6.2 -O3) 不使用更好的方法?

【问题讨论】:

  • 我预测这个问题会有很多人赞成 :)
  • 如果你想知道 X 是否比 Y 快,你应该分析,profileprofile。只有准确的时间安排才能解决问题。
  • 原因可能是因为这两个指针可能指向同一个对象。例如,如果它是一个内存映射的输出寄存器,那么两段代码将产生截然不同的结果。
  • @StoryTeller:它们不是volatile,因此允许编译器假设它不必在*x = 0 存储c==99。代码差异是由于编译器从字面上实现代码,因为缺少任何其他提示。通过配置文件引导的优化,它会知道 if() 条件被采用/不采用的可能性,并相应地布置代码,因此最常见的情况是快速路径。 (Or with likely/unlikely macros using GNU C __builtin_expect())
  • @StoryTeller:是的,潜在的别名意味着编译器需要按程序顺序执行最后一组存储。但是两个分支都通过两个指针存储,所以它并没有真正减少优化的可能性。如果有两个间接级别,第一个存储可以更改第二个存储的目的地,那将是另一回事。但是由于*x 不能别名y,只能别​​名*y,我同意添加restrict 可能在实践或理论上都没有关系。

标签: c++ c assembly optimization


【解决方案1】:

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

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

事实上,no 优化是有保证的(尽管现代优化编译器在大多数情况下都做了令人印象深刻的工作),因此您应该编写代码来捕获您想要它具有的语义含义,您应该验证生成的目标代码并编写代码以确保您获得预期的输出。

以下是针对 x86-32(主要是 because they don't know to use the CMOV instruction)时,旧版本的 MSVC 将生成的内容:

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)的无分支代码一些位操作以产生正确的值。 test0 使用条件分支跳过将 15 分配给 *x

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

但是当添加一个额外的变量时......程序集突然变得不同

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

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

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

现在(抱歉我的组装有点粗糙),为了节省管道刷新,在跳转之后使用 mov 不是总是更好吗?

不,不是真的。在这种情况下,这不会改进代码。如果分支被错误预测,无论如何你都会有一个管道刷新。推测性错误预测的代码是 ret 指令还是 mov 指令并不重要。

ret 指令紧跟在条件分支之后的唯一原因是您手动编写汇编代码并且不知道使用rep ret 指令。这是a trick necessary for certain AMD processors that avoids a branch-prediction penalty。除非您是装配大师,否则您可能不会知道这个技巧。但是编译器会这样做,而且当您专门针对没有这种怪癖的 Intel 处理器或不同代 AMD 处理器时,编译器也知道没有必要。

但是,在分支之后使用movs 可能是正确的,但不是因为您建议的原因。现代处理器(我相信这是 Nehalem 和更高版本,但如果我需要验证,我会在 Agner Fog's excellent optimization guides 中查找它)在某些情况下能够进行宏操作融合。基本上,宏操作融合意味着 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

test2test3 的目标代码之间的另一个区别是代码大小。由于优化器发出的填充来对齐分支目标,test3 的代码比test2 大 4 个字节。但是,这不太可能有足够的差异,特别是如果这段代码没有在一个紧密的循环中执行,因为它保证在缓存中是热的。

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

  1. 正如我们所见,可能是一种悲观,因为优化器可能无法识别该模式。
  2. 您应该为可读性和语义正确性编写代码首先,只有当您的分析器表明它实际上是一个瓶颈时才返回优化它。然后,您应该只在检查和验证编译器发出的目标代码之后进行优化,否则您最终可能会感到悲观。 (标准“相信你的编译器,直到证明不是这样”的建议。)
  3. 尽管在某些非常简单的情况下它可能是最佳的,但“预设”成语是不可推广的。如果您的初始化很耗时,那么尽可能跳过它可能会更快。 (讨论了一个例子here, in the context of VB 6,其中字符串操作非常慢,以至于在可能的情况下省略它实际上会比花哨的无分支代码更快的执行时间。更一般地说,如果你能够围绕一个函数进行分支,同样的理由也适用打电话。)

    即使在这里,它看起来会产生非常简单且可能更优化的代码,但实际上可能会更慢,因为在 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 和非 99 之间快速来回交替。 (参见Agner Fog's "Optimizing subroutines in assembly language",第 70-71 页。)

【讨论】:

  • 哇。感谢并再次感谢 Agner Fog 的链接。
  • 很好的答案,涵盖了编译器对此类代码的真正作用。我认为在一般情况下,我建议编写只写入一次变量的代码,而不是至少一次,有时两次。在发生第二个存储的情况下,编译器似乎有时难以优化第一个存储。它也不太可能导致优化器出现混叠问题。 (当然,硬件通常非常擅长将其吸收到存储缓冲区和 L1 缓存吞吐量中。到同一位置的第二个存储非常便宜。)
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2012-07-07
  • 2010-12-20
  • 2012-06-02
  • 2018-07-10
  • 2010-10-20
  • 1970-01-01
  • 2013-09-19
相关资源
最近更新 更多