C语言 为什么使用MOV指令将XOR交换优化为普通交换?

laximzn5  于 2023-03-07  发布在  其他
关注(0)|答案(1)|浏览(172)

在围绕Compiler Explorer进行测试时,我试用了以下无溢出函数来计算2个无符号32位整数的平均值:

uint32_t average_1(uint32_t a, uint32_t b)
{
    if(a < b){
        a ^= b;
        b ^= a;
        a ^= b;
    }
    return b + (a - b) / 2;
}

它们被编译成这样:(与-O1-O2-O3优化激活时相同)

average_1:
        cmp     edi, esi
        jnb     .L2
        mov     eax, edi
        mov     edi, esi
        mov     esi, eax
.L2:
        sub     edi, esi
        shr     edi
        lea     eax, [rdi+rsi]
        ret

其中代码的交换部分被优化以使用具有1个附加寄存器的mov指令。
我看过这些问题:
Why don't people use xor swaps?
Cost of swapping variables through mov, xor
然后得到:

  • 使用XOR交换更难解释其本身,并且
  • mov指令方法要求更多的内存读取,对执行速度没有负面影响。

但在本例中,由于没有看到内存,而只看到使用了eaxesiedi寄存器,因此我认为编译后的汇编代码也可以如下生成:

average_1:
        cmp     edi, esi
        jnb     .L2
        xor     edi, esi
        xor     esi, edi
        xor     edi, esi

...

在没有内存读取的情况下,相同数量的指令,我没有看到任何不良影响,并且觉得它被更改很奇怪。显然,有一些事情我没有考虑清楚,但它是什么?
编辑:澄清一下,这里我的问题不是“为什么不使用XOR交换”,而是

  • 当使用XOR交换时,尽管它 * 似乎不会影响执行速度在此特定情况下*,但为什么仍对其进行优化?*
idfiyjo8

idfiyjo81#

Clang也做同样的事情,可能是因为编译器构造和CPU架构的原因:

  • 在某些情况下,将该逻辑分解成仅仅一个交换可以允许更好的优化;这对于编译器来说是有意义的,这样它就可以通过交换来跟踪值。
  • 对于交换寄存器来说,xor-swap完全是垃圾,唯一的优点是它不需要临时寄存器,但是xchg reg,reg已经做得更好了。

GCC的优化器识别xor-swap模式并将其解开以遵循原始值,这并不令人惊讶。一般来说,这使得通过swap进行常量传播和值域优化成为可能,特别是在交换不以被交换的var的值为条件的情况下。这种模式识别可能在将程序逻辑转换为GIMPLE之后不久发生(SSA)表示,因此此时它将忘记原始源曾经使用过异或交换,并且不会考虑以那种方式发出asm。
希望有时候这能让它优化到只有一个mov,或者两个mov,这取决于周围代码的寄存器分配(例如,如果VAR之一可以移动到新的寄存器,而不是必须结束回到原始位置)。以及是否两个变量实际上稍后被使用,或者仅仅一个。或者如果它可以完全解开无条件交换,可能没有mov指令。
但最坏的情况是,三个需要临时寄存器的mov指令仍然更好,除非寄存器用完。我猜GCC不够聪明,无法使用xchg reg,reg来代替溢出其他内容或保存/恢复另一个tmp reg,因此可能存在这种优化实际上有害的极端情况。
(显然GCC -Os * 确实 * 有一个窥视孔优化,使用xchg reg,reg而不是3x mov:PR 92549在GCC10中得到了修复。在RTL-〉汇编期间,它很晚才找到它。是的,它在这里工作:把你的异或交换变成一个xchghttps://godbolt.org/z/zs969xh47

异或交换的延迟更长,并且无法消除移动

在没有内存读取的情况下,相同数量的指令,我没有看到任何不良影响,并且觉得它被更改很奇怪。显然有一些事情我没有考虑清楚,但它是什么?
指令数只是three things that are relevant for perf analysis之一的粗略代表:前端微操作、延迟和后端执行端口。(以及机器代码大小(以字节为单位):x86机器代码指令是可变长度的。)
它的机器码字节数和前端微指令数相同,但关键路径延迟更差:举例来说,从输入a到输出a的3个循环用于"异或"交换,且从输入b到输出a的2个循环。
MOV-swap从输入到输出最多有1个周期和2个周期的延迟,或者更少的移动消除。(这也可以避免使用后端执行端口,特别是像IvyBridge和Tiger Lake这样的CPU,前端比整数ALU端口的数量更宽。还有Ice Lake,除了Intel在其上禁用了移动消除作为一个错误解决方案;不确定是否为老虎湖重新启用。)
还涉及:

如果要分支,只需复制求平均值代码

GCC真正错过的优化(即使是-O3)是尾部复制导致了相同的静态代码大小,只是多了几个字节,因为这些大多是2字节指令。最大的好处是a<b路径变成了和另一个路径一样长,而不是两倍长,首先做一个交换,然后运行相同的3个uop来求平均值。

    • 更新:GCC将使用-ftracerhttps://godbolt.org/z/es7a3bEPv)为您完成此操作,优化掉交换。**(这是手动使用only enabled或将其作为-fprofile-use的一部分,而不是在-O3中,因此在没有PGO的情况下一直使用可能不是一个好主意,可能会使冷函数/代码路径中的机器码膨胀。)

在源(Godbolt)中手动执行此操作:

uint32_t average_1_taildup(uint32_t a, uint32_t b)
{
    if(a < b){
        return a + (b - a) / 2;
    }else {
        return b + (a - b) / 2;
    }
}
# GCC11.2 -O3
average_1_taildup:
        cmp     edi, esi
        jnb     .L5
        sub     esi, edi
        shr     esi
        lea     eax, [rsi+rdi]
        ret
.L5:
        sub     edi, esi
        shr     edi
        lea     eax, [rdi+rsi]
        ret

Clang使用cmov将版本1和1_taildup编译成代码(例如cmp/mov/cmovb/cmovb,或者为尾部复制版本制造一点混乱)。

    • 但是,如果您打算使用无分支,那么average_3更好**:

一个二个一个一个
GCC和clang的版本都只有5条指令(加上ret),但clang将其安排为从输入到输出的关键路径延迟只有3个周期(3条单uop指令),即使没有mov-消除(GCC有一个链,包括mov在内有4条指令长)。

有效非溢出无符号均值

另请参见Efficient overflow-immune arithmetic mean in C/C++-扩展到uint64_t在64位机器上甚至更便宜,特别是在内联时(如问题下的注解中所讨论的,例如https://godbolt.org/z/sz53eEYh9显示了我注解时现有答案中的代码)。
另一个不错的选择是这样,但通常不如加宽:

return (a&b) + (a^b)/2;

如果编译器识别出这些习惯用法中的任何一个,他们可以使用asm add/rcr技巧,这比扩展到unsigned __int128来平均uint64_t更有效。

相关问题