使用 LEA 相对于 MOV 在从 C++ 编译的程序集中传递参数的优势

Gil*_*llé 4 c++ assembly x86-64 micro-optimization visual-c++

我正在尝试编译 C++ 代码时将参数传递给函数的方式。我尝试使用x64 msvc 19.35/latest编译器编译以下 C++ 代码以查看生成的程序集:

#include <cstdint>

void f(std::uint32_t, std::uint32_t, std::uint32_t, std::uint32_t);

void test()
{
    f(1, 2, 3, 4);
}
Run Code Online (Sandbox Code Playgroud)

并得到这个结果:

void test(void) PROC
        mov     edx, 2
        lea     r9d, QWORD PTR [rdx+2]
        lea     r8d, QWORD PTR [rdx+1]
        lea     ecx, QWORD PTR [rdx-1]
        jmp     void f(unsigned int,unsigned int,unsigned int,unsigned int)
void test(void) ENDP
Run Code Online (Sandbox Code Playgroud)

godbolt.org 上的结果

我不明白的是,为什么编译器在这个例子中选择使用lea而不是简单的。mov我了解它的机制lea以及它如何在每个寄存器中产生正确的值,但我希望有更简单的东西,例如:

void test(void) PROC
        mov     ecx, 1
        mov     edx, 2
        mov     r8d, 3
        mov     r9d, 4
        jmp     void f(unsigned int,unsigned int,unsigned int,unsigned int)
void test(void) ENDP
Run Code Online (Sandbox Code Playgroud)

而且,从我对现代CPU如何工作的一点了解来看,我感觉使用的版本会更慢,因为它增加了指令与指令lea之间的依赖关系。leamov

clang两者gcc都给出了我期望的结果,即 4x mov

Nat*_*dge 8

MSVC 的代码比朴素mov方法要小。(但正如您所指出的,由于依赖性,它可能会更慢;您必须对此进行测试。)

     1                                          bits 64
     2 00000000 BA02000000                      mov     edx, 2
     3 00000005 448D4A02                        lea     r9d, QWORD [rdx+2]
     4 00000009 448D4201                        lea     r8d, QWORD [rdx+1]
     5 0000000D 8D4AFF                          lea     ecx, QWORD [rdx-1]
     6                                  
     7 00000010 B901000000                      mov     ecx, 1
     8 00000015 BA02000000                      mov     edx, 2
     9 0000001A 41B803000000                    mov     r8d, 3
    10 00000020 41B904000000                    mov     r9d, 4
Run Code Online (Sandbox Code Playgroud)

mov ecx, 1为 5 个字节:1 个字节用于操作码 B8-BF,它也对寄存器进行编码,4 个字节用于 32 位立即数。特别是,与某些算术指令不同,无法mov使用零或符号扩展来使用更少的字节来编码更小的立即数。

lea ecx, [rdx-1]是3个字节。一个字节为操作码;1 个 MOD R/M 字节,对存储器操作数的有效地址的目标寄存器ecx和基址寄存器进行编码;rdx(这里是关键)8 位符号扩展位移的一个字节。

使用的指令r8,r9需要一个额外的字节作为REX前缀;但这对两者都是如此movlea所以这是一次清洗。


Pet*_*des 5

lea r32, [reg+disp8]是 3 个字节,而mov r32, imm32不是 5 个字节。
请参阅x86/x64 机器代码中打高尔夫球的提示和 Nate 的答案。

不幸的是 x86 缺少mov reg, sign_extended_imm8. 在其他条件相同(或几乎相同)的情况下,较小的代码大小通常更好,尤其是在可能必须来自旧解码的“冷”代码中。(也是出于 I-cache / iTLB 占用空间的原因。)


酷,我没有意识到任何编译器正在使用这种代码大小优化来具体化寄存器中的常量。 干得好,MSVC。GCC 和 Clang 也应该这样做,至少在-Os. 甚至可能是-O2/ -O3;在某些情况下,这并不是一个胜利,但我预计它在大多数 CPU 上平均来说都很好。

GCC/clang-Oz使用push imm8/pop reg进行代码大小优化,即使性能成本很高;神箭。这也是 3 个字节,但效率低得多。

Intel 自 Ice Lake 以来就有 4 个时钟lea(具有简单的寻址模式),而 Zen 一直都有。Skylake 及更早版本上以前为 2/时钟 LEA 吞吐量,但仍然只有 1 个周期延迟。(https://uops.info/


我感觉使用的版本lea会更慢,因为它增加了 lea 指令和mov指令之间的依赖关系。

所有 3 个都从 RDX 读取mov即时结果,因此存在良好的指令级并行性,而不是依赖链。而且RDX启动了一个新的依赖链,因此它可以早在前端发出它之后的循环中执行。

当读取结果之后的指令jmp进入管道时,lea如果它们调度到的执行单元上有任何空闲周期,则 s 可能已经执行。(或者,如果管道中有很多独立的工作,而我们只是后端 ALU 吞吐量的瓶颈,那么 tailcalled 函数中的指令也不会在执行单元上获得周期。除非它可能是一个负载ALU 或不忙的执行端口...但是mov-imm 也会遇到同样的问题,只是等待 ALU 执行端口吞吐量,而不是延迟。)

uop 是先安排最旧的就绪的,所以在正常情况下,前端远远领先于正在执行的最旧的指令,像这样的独立工作通常可以找到间隙。)

如果使用这些常量的任何指令将其与来自旧指令的数据一起使用,则实现常量的延迟很可能不会成为问题。 我认为 R8/R9/RCX 准备就绪之前的额外延迟不太可能最终导致现代乱序执行 x86 中的周期成本。

不过,将 ECX放在最后有点奇怪lea;许多函数首先查看它们的第一个参数,因此您希望它是 -immediatemov或第一个lea。所有三个lea都可以并行执行,但最后一个可能会在一个周期后由前端发出。在最旧就绪优先调度的情况下,如果有任何调度到同一端口(因为等待所有其他端口的微指令数量很高),那么它们将发生资源冲突,并且必须轮流调度。

我想知道编译器的算法是否会选择一个中间值,以使所有值更有可能位于[reg+disp8]紧凑寻址模式的范围内。(希望它也更喜欢选择一个“传统”寄存器,以便可以最小化 REX 前缀;如果它选择了 R8,那么所有三个 LEA 都需要一个 REX。)

如果执行端口压力相当均匀,则在同一周期中发出时,它们可能不会全部调度到不同的端口。有关Haswell 如何在同一周期中调度多个微指令的详细信息,请参阅x86_64 haswell 指令调度在已使用的端口而不是未使用的端口上。因此,这可能会造成资源冲突,导致结果之一lea直到mov结果准备好后 2 个周期才准备好。(如果 ROB 中还有一些较旧的微指令有一些间隙,则该端口空闲的 2 个周期。)

所以这不是很确定,但我的直觉是这在实践中不会成为问题。我猜测(并希望)MSVC 开发人员在一些现有代码库上对其进行了分析,没有发现任何严重的性能下降,并且希望发现平均而言有一些小的整体加速。