如何在 GCC 和 VS 中强制使用 CMOV

how to force the use of cmov in gcc and VS

本文关键字:CMOV VS GCC      更新时间:2023-10-16

我有一个简单的二叉搜索成员函数,其中lastIndexnIterxi是类成员:

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

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

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

请注意,我知道二叉搜索算法存在变体,编译器很容易生成分支自由代码,但这不是问题。

谢谢

正如Matteo Italia已经指出的那样,这种避免有条件移动指令的做法是GCC版本6的一个怪癖。但是,他没有注意到的是,它仅适用于针对英特尔处理器进行优化的情况。

在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

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

(或者,也许GCC开发人员只是阅读了Linus臭名昭著的咆哮。

然而,有趣的是,当你告诉 GCC 针对奔腾 4 处理器进行调整时(由于某种原因,你不能为 64 位版本执行此操作 - GCC 告诉你这个架构不支持 64 位,即使肯定有 P4 处理器实现了 EMT64),你确实得到了条件的移动:

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

我怀疑这是因为分支错误预测在奔腾 4 上非常昂贵,因为它的管道非常长,以至于单个错误预测分支的可能性超过了您可能从破坏循环携带的依赖项中获得的任何微小收益以及从CMOV增加的少量延迟。换句话说:错误预测的分支在P4上变得慢得多,但CMOV的延迟没有改变,所以这偏向于等式,有利于条件移动。

针对后来的架构进行调整,从Nocona到Haswell,GCC 6.3回到了其首选分支而不是条件移动的策略。

因此,尽管在紧密的内部循环背景下,这看起来像是一个重大的悲观情绪(对我来说也是如此),但如果没有基准来支持您的假设,请不要那么快就立即将其驳回。有时,优化器并不像看起来那么愚蠢。请记住,有条件移动的优点是它避免了分支错误预测的惩罚;条件移动的缺点是它增加了依赖关系链的长度,并且可能需要额外的开销,因为在 x86 上,只允许寄存器→寄存器或内存→寄存器条件移动(不允许常量→寄存器)。在这种情况下,所有内容都已注册,但仍需要考虑依赖项链的长度。Agner Fog在他的《汇编语言中的优化子例程》中为我们提供了以下经验法则:

[W]我们可以说,如果代码是依赖链的一部分并且预测率优于 75%,则条件跳转比条件移动更快。如果我们可以避免冗长的计算,条件跳转也是首选......选择另一个操作数时。

第二部分在这里不适用,但第一部分适用。这里肯定有一个循环携带的依赖链,除非你进入一个真正病态的案例,破坏分支预测(通常具有>90%的准确率),否则分支实际上可能会更快。事实上,Agner Fog继续说道:

循环携带的依赖链对条件移动的缺点特别敏感。例如,[此代码]

// 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;
}

与条件移动相比,使用循环内的分支更有效,即使分支的预测不佳。这是因为浮点条件移动添加到循环携带的依赖链中,并且因为具有条件移动的实现必须计算所有power*xp值,即使它们未被使用。

循环携带依赖链的另一个示例是排序列表中的二叉搜索。如果要搜索的项目随机分布在整个列表中,则分支预测率将接近 50%,并且使用条件移动会更快。但是,如果项目通常彼此靠近,以便预测率会更好,那么使用条件跳转比条件移动更有效,因为每次进行正确的分支预测时,依赖链都会被破坏。

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

从理论上讲,很难对此进行推理,更难正确猜测,因此您需要实际使用现实世界的数字对其进行基准测试。

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

  1. 升级到更高版本的 GCC,如 7.1,即使在面向英特尔处理器时,也能在 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函数,这允许我们出于可读性目的将其写

    成外行。

但是,我绝对建议您使用内联程序集重写循环的任何部分。所有标准原因都适用于避免内联装配,但在这种情况下,即使是对提高性能的渴望也不是使用它的令人信服的理由。如果您尝试将内联程序集扔到该循环的中间,则更有可能混淆编译器的优化器,从而导致低于标准的代码比您只是将编译器留给自己的设备时更糟糕的代码。您可能必须以内联程序集编写整个函数才能获得良好的结果,即使这样,当 GCC 的优化器尝试内联函数时,也可能会产生溢出效应。


MSVC呢?好吧,不同的编译器具有不同的优化器,因此具有不同的代码生成策略。如果您一心想哄骗所有目标编译器发出特定的汇编代码序列,事情可能会很快开始变得非常丑陋。

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

不过,有一个很好的解决方案,效果很好:使用条件运算符。换句话说,如果你像这样编写代码:

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

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

确实无法在VS 2010上产生CMOV指令,但仅在构建64位二进制文件时。如果需要确保此方案也生成无分支代码,则可以使用上述ConditionalSelect函数。

正如评论中所说,没有简单的方法来强迫你问什么,尽管似乎最近的(>4.4)版本的 gcc 已经像你说的那样优化了它。编辑:有趣的是,GCC 6系列似乎使用了一个分支,不像GCC 5和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在"读取"列表中,rmascmov可以将寄存器或内存操作数作为输入值;
  • xi[mid]z都在"读取"列表中;
    • z具有特殊的x约束,表示"任何 SSE 寄存器"(ucomiss第一个操作数需要);
    • xi[mid]具有xm,因为第二个ucomiss操作数允许内存运算符; 给定在zxi[mid]之间进行选择,我选择了最后一个作为直接从内存中获取的更好候选者,因为z已经在寄存器中(由于 System V 调用约定 - 并且无论如何都会在迭代之间缓存),并且xi[mid]仅用于此比较;
  • cc(FLAGS寄存器)在"clobber"列表中 - 我们确实破坏了标志,没有别的。