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

时间:2021-04-25 20:21:03

标签: 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;
}

本练习的重点不是针对 C++,因为 C++ 无论如何都可能会内联此代码,而是在调用此类函数时。

使用 -O3 这是输出:

叮当声:

xor     ecx, ecx
cmp     rdi, rsi
setg    cl
mov     eax, -1
cmovge  eax, ecx
ret

gcc:

xor     eax, eax
cmp     rdi, rsi
mov     edx, -1
setg    al
cmovl   eax, edx
ret

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

我不是专家,查看了 Agner Fog 的说明表手册,如果我没有误认为 movleaor,时间/延迟是相等的。

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

1 个答案:

答案 0 :(得分:4)

当优化速度时,使用 mov reg, -1 而不是 or reg, -1 因为前者使用寄存器作为“只写”操作数,CPU 知道并使用它来有效地调度它(超出命令)。而 or 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)。

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

由于存在意外附加到依赖链的危险,编译器在优化速度时通常不会使用 or reg, -1 来生成 -1。

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

这些零习语除了代码量较小外,通常也由 CPU 的前端部分处理,因此依赖于结果的指令将立即能够调度。

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

关于产生零的更多信息:What is the best way to set a register to zero in x86 assembly: xor, mov or and?

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

另见:Set all bits in CPU register to 1 efficiently(提及 lea edx, [rax - 1])。


注意实际功能。正如您从汇编中看到的,大部分工作实际上是在尝试生成您请求的特定常量。

如果您打算对 -1,0,1 做的只是关于它是否为负/零/正的分支,那么最好生成 left - right只要您确保有没有溢出,因为那会使单独的减法结果不足以进行比较 - 在​​这种情况下,只需使用 -1, 0, 1) 然后就使用 branch/cmov 即可。

相关问题