c++ - if else 比 if + default 快吗?

标签 c++ c assembly optimization

我进行了一个简单的实验,将 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 s 在 je 之前或之后完成.

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

最佳答案

For the functions above, I got the exact same assembly code (using cmovne).



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

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

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

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 的情况下无法进行相同的优化,你试图超越它。

However when adding an extra variable ... The assembly suddenly becomes different



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

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

It seems that the only difference is if the top "mov"s are done before or after the "je".

Now (sorry my assembly is a bit crude), isn't it always better to have the movs after the jump, in order to save pipeline flushes?



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

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

但是,您可能认为拥有 mov 更好。 s 在分支之后,但不是因为您建议的原因。现代处理器(我相信这是 Nehalem 和更高版本,但如果我需要验证,我会在 Agner Fog's excellent optimization guides 中查找)在某些情况下能够进行宏操作融合。基本上,宏操作融合意味着 CPU 的解码器将两个符合条件的指令合并为一个微操作,从而节省流水线所有阶段的带宽。一个 cmptest指令后跟条件分支指令,如您在 test3 中看到的, 有资格进行宏操作融合(实际上,还有其他条件必须满足,但此代码确实满足这些要求)。在 cmp 之间调度其他指令和 je ,正如您在 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
test2 的目标代码之间的另一个区别和 test3是代码大小。感谢优化器发出的用于对齐分支目标的填充,test3 的代码比 test2 大 4 个字节.然而,这不太可能是足够重要的差异,特别是如果这段代码不是在一个紧密循环中执行的,它保证在缓存中很热。

那么,这是否意味着您应该始终像在 test2 中所做的那样编写代码? ?
嗯,不,有几个原因:
  • 正如我们所见,这可能是一种悲观情绪,因为优化器可能无法识别该模式。
  • 您应该首先编写可读性和语义正确性的代码,只有当您的分析器表明它实际上是一个瓶颈时才返回进行优化。然后,您应该仅在检查和验证编译器发出的目标代码后进行优化,否则最终可能会出现悲观化。 (标准的“信任您的编译器,直到证明其他情况”建议。)
  • 尽管在某些非常简单的情况下它可能是最佳的,但“预设”习语是不可推广的。如果您的初始化很耗时,则在可能的情况下跳过它可能会更快。 (有一个例子讨论了 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 这样的函数时。在一个紧密的循环内。
  • 即使您可以保证以某种方式编写代码会导致编译器发出无分支代码,但这不一定会更快!虽然分支在被错误预测时很慢,但错误预测实际上非常罕见。现代处理器拥有极其出色的分支预测引擎,在大多数情况下预测准确率超过 99%。

    条件移动非常适合避免分支错误预测,但它们具有增加依赖链长度的重要缺点。相比之下,正确预测的分支会破坏依赖链。 (这可能就是当您添加额外变量时 GCC 不发出两个 CMOV 指令的原因。)如果您预计分支预测失败,条件移动只是性能上的胜利。如果您可以指望约 75% 或更高的预测成功率,条件分支可能会更快,因为它打破了依赖链并具有更低的延迟。我怀疑这里就是这种情况,除非 c每次调用该函数时,都会在 99 和非 99 之间快速来回交替。 (参见 Agner Fog's "Optimizing subroutines in assembly language",第 70-71 页。)
  • 关于c++ - if else 比 if + default 快吗?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/41359023/

    相关文章:

    python - 打包包含 C 共享库的 Python 库的最佳方法?

    Linux 汇编程序错误 "impossible constraint in ‘asm’“

    c++ - 有条件地启用替代赋值运算符

    c++ - 运行 gcc -gcodeview

    c++ - 负无穷大

    c - 如何将char类型的结果存储在指针中?

    linux - 如何在 NASM、Linux、32 位中选择对齐方式

    c++ - 在主要 C/C++ 编译器生成的代码中注册分配规则

    c++ - unique_ptr、pimpl/forward 声明和完整定义

    c++ - 方法不会重载