Assembly 为什么gcc和clang生成mov reg,-1

Assembly 为什么gcc和clang生成mov reg,-1,assembly,gcc,clang,x86-64,micro-optimization,Assembly,Gcc,Clang,X86 64,Micro Optimization,我正在使用编译器资源管理器查看gcc和clang的一些输出,以了解这些编译器为某些代码发出的程序集。最近我查看了这段代码的输出 int compare_int64(int64_t left, int64_t right) { return (left < right) ? -1 : (left > right) ? 1 : 0; } 通用条款: 我注意到这段代码的大小是17个字节,比一个漂亮的16字节多一个字节(我正在使用的另一个非C++编译器中x64的默认代码对齐方式是1

我正在使用编译器资源管理器查看gcc和clang的一些输出,以了解这些编译器为某些代码发出的程序集。最近我查看了这段代码的输出

int compare_int64(int64_t left, int64_t right)
{
    return (left < right) ? -1 : (left > right) ? 1 : 0;
}
通用条款:

我注意到这段代码的大小是17个字节,比一个漂亮的16字节多一个字节(我正在使用的另一个非C++编译器中x64的默认代码对齐方式是16)。对于显示的gcc代码,我考虑使用
lea-edx、[eax-1]
或edx,-1
(当然,后者在
cmp
之前)来减小代码大小。有趣的是,当使用-Os时,gcc会插入一条
jl
指令,这对函数的性能来说是灾难性的

我不是专家,我查阅了Agner Fog的说明表手册,如果我没有被误认为是
mov
lea
的话,则计时/延迟是相等的

因此,实际问题是: 为什么两个编译器都使用5字节大小的指令而不是较短的3字节或4字节指令?
mov-reg,-1
替换为
lea-reg、[eax-1]
或reg,-1
是否无害?

在优化速度时
mov-reg,-1
被用来代替
或reg,-1
,因为前者使用寄存器作为“只写”操作数,CPU知道并使用它来高效地调度它(顺序错误)。然而,
或reg,-1
,即使将始终产生
-1
,CPU也不会将其识别为依赖项中断(仅写)指令

要说明它如何影响性能,请执行以下操作:

mov eax, [rdi]  # Imagine a cache-miss here.
mov [rsi], eax
mov eax, -1     # `mov eax, -1` is able to dispatch and execute without waiting
                # for the cache-miss to be served.
add eax, edx    # `add eax, edx` only needs to wait 1 cycle for `mov` to
                # complete (assuming `edx` is ready) and then it can
                # dispatch while cache-miss load from a few lines above
                # is still in progress.
现在这个代码:

mov eax, [rdi]   # Imagine a cache-miss here.
mov [rsi], eax
or eax, -1       # Now this instruction has to wait for the cache-miss
                 # load to complete.
add eax, edx     # And this one will be waiting too.

(该示例适用于任何当前x86-64 CPU,如Skylake/Ice Lake/Zen)

如果您在汇编中编写代码,并且确定寄存器不是当前正在进行的依赖链的一部分,那么您可以使用
或reg,-1
,这不会产生负面影响(当然,如果您的假设是正确的)

由于意外连接到依赖链的危险,编译器在优化速度时通常不使用
或reg,-1
生成-1

当我们需要一个0而不是-1时,我们很幸运,因为CPU可以识别一些习惯用法,例如
xor reg,reg
sub reg,reg
。它们的代码大小较小,并且CPU可以识别计算结果不依赖于寄存器(始终为零)

这些零习惯用法,除了代码较小外,通常也由CPU的前端部分处理,因此根据结果可以立即分派指令

零习惯用法也适用于向量寄存器:
vpxor xmm0,xmm0,xmm0
(生成零时不依赖于xmm0的先前值和零延迟)。有趣的是,向量寄存器也有一个-1习惯用法,即
vpcmpeqd xmm0,xmm0,xmm0
——这一习惯用法被认为是只写的(将值与自身进行比较总是正确的),但它仍然必须执行(因此它的延迟=1),至少在SKL/Zen CPU上执行

有关生成零的详细信息:

可以在Agner Fog的手册或CPU优化指南中找到具体识别的惯用语。TLDR是通用寄存器只有零惯用语,向量寄存器有零惯用语和全一惯用语

另见:(提及
lea-edx,[rax-1]


请注意实际函数。正如您从汇编中看到的,大多数工作实际上是试图生成您所请求的特定常量


如果你想用-1,0,1做的只是判断它是否为负/零/正,那么最好是产生
左-右
,只要你确保没有溢出,因为这将使减法结果本身不足以进行比较-在这种情况下,只使用-1,0,1)然后在上面分支/cmov。

或edx,-1
可能对
edx
以前的内容有输入依赖性。从数学上讲,它没有,但机器可能不知道。(
xor edx,edx
也会有同样的问题,但它是特殊情况。)注意
clang-Oz
使用
push-1;pop-eax
将其减少到3个字节。呃?问题是如何减少将-1放入寄存器所需的汇编指令的实际字节大小。是的,当然
push
/
pop
mov
慢,这就是为什么clang只在
-Oz
使用它,例如巨大的大小优化,即使有严重的性能损失。不幸的是,clang
-Oz
使用
lea-eax[rcx-1]
(3字节)并不明智。请注意,rcx,您不需要地址大小前缀。感谢您提及
lea-eax[rcx-1]
而不是
lea-eax[ecx-1]
对于获取3个字节,我错过了!请注意,
pcmpeqd xmm0,xmm0的延迟=1与正常指令不同,因为正如您所说,它是dep中断(在几乎所有CPU上,IIRC而不是Silvermont上)。后端,不尝试进行关键路径分析,因此在后端开始将其分派到执行端口后的1个周期内,“全一”结果已准备就绪。
left-right
的有符号比较结果是和SF的组合,而不仅仅是整数结果的MSB,因此它不是那么简单。使用32位整数输入s、 您可以将符号扩展到64位,然后进行减法运算。(可能还可以将高半部和/或右半部移到低半部,以减小输入宽度?不,这会将实际符号位与输入MSB位置混淆,我们已经知道不能直接使用它。)@ PTECORDES <代码>左->代码>我意在将结果本身用作C++中的返回值,而不是人工常量1, 0, 1。除非它们实际上提供了一个好处(例如,对于存储来说具有更小的大小),否则它们应该避免。
mov eax, [rdi]  # Imagine a cache-miss here.
mov [rsi], eax
mov eax, -1     # `mov eax, -1` is able to dispatch and execute without waiting
                # for the cache-miss to be served.
add eax, edx    # `add eax, edx` only needs to wait 1 cycle for `mov` to
                # complete (assuming `edx` is ready) and then it can
                # dispatch while cache-miss load from a few lines above
                # is still in progress.
mov eax, [rdi]   # Imagine a cache-miss here.
mov [rsi], eax
or eax, -1       # Now this instruction has to wait for the cache-miss
                 # load to complete.
add eax, edx     # And this one will be waiting too.