如何在 gcc 和 VS 中强制使用 cmov

how to force the use of cmov in gcc and VS

我有这个简单的二进制搜索成员函数,其中 lastIndexnIterxi 是 class 成员:

uint32 scalar(float z) const
{
    uint32 lo = 0;
    uint32 hi = lastIndex;
    uint32 n = nIter;
    while (n--) {
        int mid = (hi + lo) >> 1;
        // defining this if-else assignment as below cause VS2015
        // to generate two cmov instructions instead of a branch
        if( z < xi[mid] ) 
            hi = mid;
        if ( !(z < xi[mid]) )
            lo = mid;
    }
    return lo;
}

gcc 和 VS 2015 都使用代码流分支翻译内部循环:

000000013F0AA778  movss       xmm0,dword ptr [r9+rax*4]  
000000013F0AA77E  comiss      xmm0,xmm1  
000000013F0AA781  jbe         Tester::run+28h (013F0AA788h) 
000000013F0AA783  mov         r8d,ecx  
000000013F0AA786  jmp         Tester::run+2Ah (013F0AA78Ah)  
000000013F0AA788  mov         edx,ecx  
000000013F0AA78A  mov         ecx,r8d

有没有办法在不编写内联汇编程序的情况下说服他们只使用 1 条 comiss 指令和 2 条 cmov 指令?

如果没有,有人可以建议如何为此编写 gcc 汇编程序模板吗?

请注意,我知道二进制搜索算法有多种变体,编译器很容易生成无分支代码,但这不是问题所在。

谢谢

如评论中所述,没有简单的方法来强制执行您的要求,尽管最近(> 4.4)版本的 gcc 似乎已经像您说的那样对其进行了优化。 编辑:有趣的是,gcc 6 series seems to use a branch, unlike both the gcc 5 and gcc 7系列,使用了两个cmov.

通常的 __builtin_expect 可能无法推动 gcc 使用 cmov,因为 cmov 通常在难以预测比较结果时很方便,而 __builtin_expect 告诉编译器可能的结果是什么——所以你只是把它推向了错误的方向。

不过,如果您发现这种优化非常重要,您的编译器版本通常会出错,并且出于某种原因您无法使用 PGO 帮助它,相关的 gcc 汇编模板应该是这样的:

    __asm__ (
        "comiss %[xi_mid],%[z]\n"
        "cmovb %[mid],%[hi]\n"
        "cmovae %[mid],%[lo]\n"
        : [hi] "+r"(hi), [lo] "+r"(lo)
        : [mid] "rm"(mid), [xi_mid] "xm"(xi[mid]), [z] "x"(z)
        : "cc"
    );

使用的约束是:

  • hilo 进入 "write" 变量列表,具有 +r 约束,因为 cmov 只能使用寄存器作为目标操作数,并且我们 有条件地 只覆盖其中一个(我们不能使用 =,因为这意味着该值总是被覆盖,因此编译器可以自由地给我们一个不同的目标比当前的注册,并在我们的 asm 块之后使用它来引用该变量);
  • mid在"read"列表中,rm因为cmov可以将寄存器或内存操作数作为输入值;
  • xi[mid]z 在 "read" 列表中;
    • z 具有特殊的 x 约束,这意味着 "any SSE register"(ucomiss 第一个操作数需要);
    • xi[mid]xm,因为第二个 ucomiss 操作数允许内存运算符;考虑到 zxi[mid] 之间的选择,我选择了最后一个作为直接从内存中获取的更好的候选者,因为 z 已经在寄存器中(由于 System V调用约定 - 无论如何都会在迭代之间缓存)并且 xi[mid] 仅用于此比较;
  • cc(FLAGS 寄存器)在 "clobber" 列表中 - 我们只破坏标志。

作为 ,避免条件移动指令是 GCC 版本 6 的一个怪癖。不过,他没有注意到的是,它仅适用于针对 Intel 处理器的优化。

使用 GCC 6.3,当针对 AMD 处理器时(即 -march= k8k10opteronamdfam10btver1bdver1btver2btver2bdver3bdver4znver1,可能还有其他),你得到的正是你想要的代码想要:

    mov     esi, DWORD PTR [rdi]
    mov     ecx, DWORD PTR [rdi+4]
    xor     eax, eax
    jmp     .L2
.L7:
    lea     edx, [rax+rsi]
    mov     r8, QWORD PTR [rdi+8]
    shr     edx
    mov     r9d, edx
    movss   xmm1, DWORD PTR [r8+r9*4]
    ucomiss xmm1, xmm0
    cmovbe  eax, edx
    cmova   esi, edx
.L2:
    dec     ecx
    cmp     ecx, -1
    jne     .L7
    rep ret

在针对任何一代英特尔处理器进行优化时,GCC 6.3 避免有条件的移动,更喜欢显式分支:

    mov      r9d, DWORD PTR [rdi]
    mov      ecx, DWORD PTR [rdi+4]
    xor      eax, eax
.L2:
    sub      ecx, 1
    cmp      ecx, -1
    je       .L6
.L8:
    lea      edx, [rax+r9]
    mov      rsi, QWORD PTR [rdi+8]
    shr      edx
    mov      r8d, edx
    vmovss   xmm1, DWORD PTR [rsi+r8*4]
    vucomiss xmm1, xmm0
    ja       .L4
    sub      ecx, 1
    mov      eax, edx
    cmp      ecx, -1
    jne      .L8
.L6:
    ret
.L4:
    mov      r9d, edx
    jmp      .L2

此优化决定的可能理由是条件移动在 Intel 处理器上效率相当低。 CMOV 在 Intel 处理器上有 2 个时钟周期的延迟,而在 AMD 上有 1 个时钟周期的延迟。此外,虽然 CMOV 指令在英特尔处理器上被解码为多个微操作(至少两个,没有微操作融合的机会),因为要求单个微操作具有不超过两个输入依赖性(条件移动具有至少三个:两个操作数和条件标志),AMD 处理器可以用单个宏操作实现 CMOV,因为它们的设计对单个宏操作的输入依赖性没有这样的限制。因此,GCC 优化器在 AMD 处理器上用条件移动 only 替换分支,这可能是性能上的胜利——在 Intel 处理器上 not 和不是在针对通用 x86 进行调整时。

(或者,也许 GCC 开发人员刚刚阅读了 Linus's infamous rant。:-)

不过,有趣的是,当您告诉 GCC 为 Pentium 4 处理器进行调优时(出于某种原因您不能对 64 位构建执行此操作 — GCC 告诉您此架构不支持 64 位,即使肯定有实现 EMT64 的 P4 处理器),您 执行 获得条件移动:

    push    edi
    push    esi
    push    ebx
    mov     esi, DWORD PTR [esp+16]
    fld     DWORD PTR [esp+20]
    mov     ebx, DWORD PTR [esi]
    mov     ecx, DWORD PTR [esi+4]
    xor     eax, eax
    jmp     .L2
.L8:
    lea     edx, [eax+ebx]
    shr     edx
    mov     edi, DWORD PTR [esi+8]
    fld     DWORD PTR [edi+edx*4]
    fucomip st, st(1)
    cmovbe  eax, edx
    cmova   ebx, edx
.L2:
    sub     ecx, 1
    cmp     ecx, -1
    jne     .L8
    fstp    st(0)
    pop     ebx
    pop     esi
    pop     edi
    ret

我怀疑这是因为分支预测错误 所以 在 Pentium 4 上很昂贵,因为它的流水线非常长,所以有可能单个错误预测的分支超过了你可能从打破循环携带的依赖性和从 CMOV 增加的延迟中获得的任何小收益。换句话说:预测错误的分支在 P4 上变得 很多 慢,但是 CMOV 的延迟没有改变,所以这使等式偏向有条件的移动。

GCC 6.3 针对从 Nocona 到 Haswell 的后期架构进行了调优,回到了优先选择分支而不是条件移动的策略。

所以,虽然这 看起来 像是在紧密的内部循环的背景下的主要悲观情绪(对我来说也是这样),但不要这样在没有基准来支持您的假设的情况下迅速将其解雇。有时,优化器并不像看起来那么笨。请记住,条件移动的优点是它避免了分支预测错误的惩罚;条件移动的缺点是它增加了依赖链的长度并且可能需要额外的开销,因为在 x86 上,只允许寄存器→寄存器或内存→寄存器条件移动(没有常量→寄存器)。在这种情况下,一切都已经注册,但仍然需要考虑依赖链的长度。 Agner Fog 在他的 Optimizing Subroutines in Assembly Language 中为我们提供了以下经验法则:

[W]e can say that a conditional jump is faster than a conditional move if the code is part of a dependency chain and the prediction rate is better than 75%. A conditional jump is also preferred if we can avoid a lengthy calculation ... when the other operand is chosen.

第二部分不适用于此处,但第一部分适用。这里 绝对 一个循环携带的依赖链,除非你进入一个真正病态的情况,破坏分支预测(通常有 >90% 的准确性),分支实际上可能更快.事实上,Agner Fog 继续说道:

Loop-carried dependency chains are particularly sensitive to the disadvantages of conditional moves. For example, [this code]

// Example 12.16a. Calculate pow(x,n) where n is a positive integer
double x, xp, power;
unsigned int n, i;
xp=x; power=1.0;
for (i = n; i != 0; i >>= 1) {
    if (i & 1) power *= xp;
    xp *= xp;
}

works more efficiently with a branch inside the loop than with a conditional move, even if the branch is poorly predicted. This is because the floating point conditional move adds to the loop-carried dependency chain and because the implementation with a conditional move has to calculate all the power*xp values, even when they are not used.

Another example of a loop-carried dependency chain is a binary search in a sorted list. If the items to search for are randomly distributed over the entire list then the branch prediction rate will be close to 50% and it will be faster to use conditional moves. But if the items are often close to each other so that the prediction rate will be better, then it is more efficient to use conditional jumps than conditional moves because the dependency chain is broken every time a correct branch prediction is made.

如果您列表中的项目实际上是随机的或接近随机的,那么您将成为重复分支预测失败的受害者,并且有条件的移动会更快。否则,在更常见的情况下,分支预测将在 >75% 的时间内成功,这样您将体验到分支带来的性能提升,而不是会扩展依赖链的条件移动。

理论上很难推理,更难猜对,所以你需要用真实世界数字进行实际基准测试。

如果您的基准测试确认条件移动确实更快,您有几个选择:

  1. 升级到更高版本的 GCC,如 7.1,即使在针对 Intel 处理器时也会在 64 位构建中生成条件移动。
  2. 告诉 GCC 6.3 为 AMD 处理器优化您的代码。 (甚至可能只是让它优化一个特定的代码模块,以尽量减少全局影响。)
  3. 发挥真正的创意(丑陋且可能不可移植),用 C 语言编写一些无分支的比较和设置操作的代码。这可能会让编译器发出一个条件移动指令,或者它可能会让编译器发出一系列位旋转指令。您必须检查输出才能确定,但​​如果您的目标真的只是为了避免分支预测错误的惩罚,那么两者都可以。

    例如,像这样的东西:

    inline uint32 ConditionalSelect(bool condition, uint32 value1, uint32 value2)
    {
       const uint32 mask = condition ? static_cast<uint32>(-1) : 0;
       uint32 result = (value1 ^ value2);   // get bits that differ between the two values
       result &= mask;                      // select based on condition
       result ^= value2;                    // condition ? value1 : value2
       return result;
    }
    

    然后您可以像这样在内部循环中调用它:

    hi = ConditionalSelect(z < xi[mid], mid, hi);
    lo = ConditionalSelect(z < xi[mid], lo, mid);
    

    GCC 6.3 在针对 x86-64 时为此生成以下代码:

        mov     rdx, QWORD PTR [rdi+8]
        mov     esi, DWORD PTR [rdi]
        test    edx, edx
        mov     eax, edx
        lea     r8d, [rdx-1]
        je      .L1
        mov     r9, QWORD PTR [rdi+16]
        xor     eax, eax
    .L3:
        lea     edx, [rax+rsi]
        shr     edx
        mov     ecx, edx
        mov     edi, edx
        movss   xmm1, DWORD PTR [r9+rcx*4]
        xor     ecx, ecx
        ucomiss xmm1, xmm0
        seta    cl               // <-- begin our bit-twiddling code
        xor     edi, esi
        xor     eax, edx
        neg     ecx
        sub     r8d, 1           // this one's not part of our bit-twiddling code!
        and     edi, ecx
        and     eax, ecx
        xor     esi, edi
        xor     eax, edx         // <-- end our bit-twiddling code
        cmp     r8d, -1
        jne     .L3
    .L1:
        rep ret
    

    请注意,内部循环完全没有分支,这正是您想要的。它可能不如两个 CMOV 指令那么有效,但它会比长期错误预测的分支更快。 (不言而喻,GCC 和任何其他编译器都足够智能,可以内联 ConditionalSelect 函数,这允许我们出于可读性目的将其写成行外。)

但是,我绝对不会推荐的是使用内联汇编重写循环的任何部分。 All of the standard reasons apply for avoiding inline assembly,但在这种情况下,即使是提高性能的愿望也不是使用它的令人信服的理由。如果您尝试将内联汇编放入该循环的中间,您更有可能混淆编译器的优化器,导致低于标准的代码比您将编译器留在自己的设备上时得到的代码更糟糕.您可能必须在内联汇编中编写 整个函数 才能获得好的结果,即使那样,当 GCC 的优化器试图内联该函数时,也可能会产生溢出效应。


MSVC 呢?嗯,不同的编译器有不同的优化器,因此有不同的代码生成策略。如果您下定决心要诱使所有目标编译器发出特定的汇编代码序列,事情很快就会变得非常糟糕。

在 MSVC 19 (VS 2015) 上,当目标为 32 位时,您可以按照获取条件移动指令的方式编写代码。但这在构建 64 位二进制文​​件时不起作用:你得到的是分支,就像针对 Intel 的 GCC 6.3 一样。

不过,有一个很好的解决方案,效果很好:use the conditional operator。也就是说,如果你这样写代码:

hi = (z < xi[mid]) ? mid : hi;
lo = (z < xi[mid]) ? lo  : mid;

那么 VS 2013 和 2015 将始终发出 CMOV 指令,无论您是构建 32 位还是 64 位二进制文​​件,无论您是在优化大小 (/O1) 还是速度 (/O2),以及您是针对 Intel (/favor:Intel64) 还是 AMD (/favor:AMD64) 进行优化。

确实 无法在 VS 2010 上返回 CMOV 指令,但仅在构建 64 位二进制文​​件时。如果您需要确保此场景也生成无分支代码,那么您可以使用上面的 ConditionalSelect 函数。