c-为什么XOR交换被优化为使用MOV指令的正常交换



在测试编译器资源管理器时,我尝试了以下无溢出函数来计算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个附加寄存器的CCD_ 4指令。

我已经通读了这些问题:

为什么人们不使用xor交换
通过mov、xor 交换变量的成本

并得到:

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

但在这种情况下,不考虑内存,只考虑使用eaxesiedi寄存器,我认为编译后的汇编代码也可以生成为:

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

没有内存读取,指令数量相同,我没有看到任何不良影响,对它的更改感到奇怪。很明显,有一些事情我没有想清楚,但它是什么?


编辑:要明确的是,这里我的问题不是关于";为什么不使用XOR交换;而是
">当使用XOR交换时,尽管在这种特殊情况下似乎不会影响执行速度,但为什么它仍然被优化了";

Clang也做同样的事情。可能是由于编译器构造和CPU体系结构的原因:

  • 在某些情况下,将该逻辑分解为交换可能会实现更好的优化;对于编译器来说,尽早这样做是有意义的,这样它就可以在交换过程中跟踪值。

  • Xor交换对于交换寄存器来说完全是垃圾,唯一的优点是它不需要临时的。但xchg reg,reg已经做得更好了。


GCC的优化器识别出xor交换模式并将其解开以遵循原始值,我对此并不感到惊讶。一般来说,这使得通过交换实现恒定传播和值范围优化成为可能,尤其是在交换不以被交换的变量的值为条件的情况下。这种模式识别可能在将程序逻辑转换为GIMPLE(SSA)表示后不久发生,因此在这一点上,它将忘记原始源曾经使用过xor交换,而不会考虑以这种方式发出asm。

希望有时这能让它优化到只有一个mov或两个mov,这取决于周围代码的寄存器分配(例如,如果其中一个var可以移动到新的寄存器,而不必最终回到原始位置)。以及以后是否实际使用这两个变量,或者只使用一个。或者,如果它可以完全解开无条件交换,可能没有mov指令。

但在最坏的情况下,三条需要临时寄存器的mov指令仍然更好,除非寄存器用完。我想GCC还不够聪明,不能使用xchg reg,reg来代替溢出其他内容或保存/恢复另一个tmp-reg,所以可能会出现这种优化实际上会带来伤害的情况。

(显然,GCC-Os确实有一个窥视孔优化,使用xchg reg,reg而不是3xmov:PR 92549对于GCC10是固定的。在RTL->装配是的,它在这里起作用:将xor交换变成xchg:https://godbolt.org/z/zs969xh47)

xor-swap具有更差的延迟并无法消除mov

没有内存读取,指令数量相同,我没有看到任何不良影响,并且对它的更改感到奇怪。很明显,有一些事情我没有想清楚,但它是什么?

指令计数只是与性能分析相关的三件事之一的粗略代理:前端uop、延迟和后端执行端口。(机器代码大小以字节为单位:x86机器代码指令的长度可变。)

它的机器代码字节大小相同,前端uop数量相同,但关键路径延迟更差:例如,从输入a到输出a的3个周期用于xor交换,从输入b到输出a的2个周期。

MOV交换从输入到输出具有最差的1周期和2周期延迟,或者在MOV消除的情况下具有更小的延迟。(这也可以避免使用后端执行端口,尤其是与IvyBridge和Tiger Lake等前端比整数ALU端口数量更宽的CPU相关。还有Ice Lake,除了Intel将其禁用mov消除作为一种错误的解决方法外;不确定是否为Tiger Lak重新启用了它。)

也相关:

  • 为什么XCHG reg,reg是现代英特尔体系结构上的3微操作指令?-并且这3个uop不能从mov消除中受益。但在现代AMD上,CCD_ 21只有2个uops

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

GCC在这里真正遗漏的优化(即使使用-O3)是,尾部重复导致大约相同的静态代码大小,只有几个额外的字节,因为这些大多是2字节的指令。最大的好处是,a<b路径的长度与另一条路径的长度相同,而不是两倍长,先进行交换,然后运行相同的3个uop进行平均。

更新:GCC将使用-ftracer为您执行此操作(https://godbolt.org/z/es7a3bEPv),优化了交换(这只是手动启用的或作为-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是更好的

uint32_t average_3(uint32_t a, uint32_t b)
{
return (a & b) + ((a ^ b) >> 1);
}
# clang 13.0 -O3
average_3:
mov     eax, esi
and     eax, edi
xor     esi, edi
shr     esi
add     eax, esi
ret

GCC和clang的版本都只有5条指令(加上ret),但clang对其进行了安排,因此从输入到输出的关键路径延迟只有3个周期(3条单uop指令),即使没有mov消除。(GCC有一个包含mov的4条指令长的链。)

有效非溢出无符号均值

另请参阅C/C++中的高效溢出免疫算术平均值-扩展到uint64_t可能更便宜,尤其是在64位机器上内联时。(正如在问题下的评论中所讨论的,例如。https://godbolt.org/z/sz53eEYh9显示了我发表评论时现有答案中的代码。)

另一个好的选择是这样,但通常不如拓宽好:

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

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

最新更新