在测试编译器资源管理器时,我尝试了以下无溢出函数来计算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
指令方法对执行速度没有负面影响,因为它需要更多的内存读取
但在这种情况下,不考虑内存,只考虑使用eax
、esi
、edi
寄存器,我认为编译后的汇编代码也可以生成为:
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
更有效。