在围绕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
指令方法要求更多的内存读取,对执行速度没有负面影响。
但在本例中,由于没有看到内存,而只看到使用了eax
、esi
、edi
寄存器,因此我认为编译后的汇编代码也可以如下生成:
average_1:
cmp edi, esi
jnb .L2
xor edi, esi
xor esi, edi
xor edi, esi
...
在没有内存读取的情况下,相同数量的指令,我没有看到任何不良影响,并且觉得它被更改很奇怪。显然,有一些事情我没有考虑清楚,但它是什么?
编辑:澄清一下,这里我的问题不是“为什么不使用XOR交换”,而是
- 当使用XOR交换时,尽管它 * 似乎不会影响执行速度在此特定情况下*,但为什么仍对其进行优化?*
1条答案
按热度按时间idfiyjo81#
Clang也做同样的事情,可能是因为编译器构造和CPU架构的原因:
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在其上禁用了移动消除作为一个错误解决方案;不确定是否为老虎湖重新启用。)
还涉及:
xchg reg,reg
上只有2个微操作。如果要分支,只需复制求平均值代码
GCC真正错过的优化(即使是
-O3
)是尾部复制导致了相同的静态代码大小,只是多了几个字节,因为这些大多是2字节指令。最大的好处是a<b
路径变成了和另一个路径一样长,而不是两倍长,首先做一个交换,然后运行相同的3个uop来求平均值。-ftracer
(https://godbolt.org/z/es7a3bEPv)为您完成此操作,优化掉交换。**(这是手动使用only enabled或将其作为-fprofile-use
的一部分,而不是在-O3
中,因此在没有PGO的情况下一直使用可能不是一个好主意,可能会使冷函数/代码路径中的机器码膨胀。)在源(Godbolt)中手动执行此操作:
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显示了我注解时现有答案中的代码)。
另一个不错的选择是这样,但通常不如加宽:
如果编译器识别出这些习惯用法中的任何一个,他们可以使用asm
add
/rcr
技巧,这比扩展到unsigned __int128
来平均uint64_t
更有效。