Clang 和 GCC 对 movzx 的奇怪使用

Hin*_*tro 53 c++ x86 assembly gcc clang

我知道这movzx可以用于打破依赖关系,但我偶然发现了movzxClang 和 GCC 的一些用途,我真的看不出它们有什么用处。这是我在 Godbolt 编译器浏览器上尝试的一个简单示例:

#include <stdint.h>

int add2bytes(uint8_t* a, uint8_t* b) {
    return uint8_t(*a + *b);
}
Run Code Online (Sandbox Code Playgroud)

与海湾合作委员会 12 -O3

add2bytes(unsigned char*, unsigned char*):
        movzx   eax, BYTE PTR [rsi]
        add     al, BYTE PTR [rdi]
        movzx   eax, al
        ret
Run Code Online (Sandbox Code Playgroud)

如果我理解正确的话,这里的第一个movzx打破了对先前eax值的依赖,但第二个是什么movzx做什么?我认为它不会破坏任何依赖关系,也不应该影响结果。

使用 clang 14 -O3,情况更加奇怪:

add2bytes(unsigned char*, unsigned char*):                       # @add2bytes(unsigned char*, unsigned char*)
        mov     al, byte ptr [rsi]
        add     al, byte ptr [rdi]
        movzx   eax, al
        ret
Run Code Online (Sandbox Code Playgroud)

它使用movwheremovzx看起来更合理,然后零扩展aleax,但这样做不是更好吗movzx,但是一开始

我这里还有两个例子: https: //godbolt.org/z/z45xr4hq1 GCC 生成合理的和奇怪的movzx,而 Clang 对mov r8 m和 的使用movzx对我来说毫无意义。我也尝试添加-march=skylake以确保这不是真正旧架构的功能,但生成的程序集看起来或多或少相同。

我发现的最接近的帖子是/sf/answers/4544065361/,他们显示了类似的内容movzx用途,但似乎无用和/或不合时宜。

编译器真的使用吗movzx在这里真的使用得很差吗,还是我遗漏了一些东西?

编辑:我已经打开了 Clang 和 GCC 的错误报告:

https://github.com/llvm/llvm-project/issues/56498

https://gcc.gnu.org/bugzilla/show_bug.cgi?id=106277

使用内联汇编的临时解决方法: https ://godbolt.org/z/7qob8G3j7

#define addb(a, b) asm (\
    "addb %1, %b0"\
    : "+r"(a) : "mi"(b))

int add2bytes(uint8_t* a, uint8_t* b) {
    int ret = *a;
    addb(ret, *b);
    return ret;
}
Run Code Online (Sandbox Code Playgroud)

现在 Clang -O3 产生:

add2bytes(unsigned char*, unsigned char*):                       # @add2bytes(unsigned char*, unsigned char*)
        movzx   eax, byte ptr [rdi]
        add     al, byte ptr [rsi]
        ret
Run Code Online (Sandbox Code Playgroud)

Pet*_*des 52

两个编译器在这里都做得很糟糕,但 clang 的代码尤其糟糕,并且在任何地方都没有真正的优势。除了十年前的英特尔 CPU(重新命名低 8 部分寄存器)之外,所有其他产品都有一个容易避免的缺点。

最佳的 asm 是您建议的,movzx加载,然后字节添加,将uint8_t结果保留在低字节中,并int根据 C 语义的要求正确地零扩展。(感谢您向上游报告:https://github.com/llvm/llvm-project/issues/56498 - 我在那里评论说,movzx即使 LLVM 不需要结果为零,对于一般字节加载来说也是一个好主意 -延长。)


A在某个地方movzx是必需的,但它可以在初始加载中。(无论如何,对于字节加载来说,A通常是一个好主意,以避免对旧 RAX 的错误依赖;clang 选择保存 1 个字节可能不是一个好主意,即使它最终不需要单独的一个字节。)movzxmovzx

在 x86-64 CPU 中,基本上存在三种相关行为。

  • Core 2 / Nehalem(P6 系列的 64 位功能成员):如果您编写 AL,则 AL 与 RAX 分开重命名。稍后读取 EAX 将使前端停止大约 3 个周期,同时插入合并微指令。比早期的 P6 系列要好一些,但仍然是需要避免的重大损失。但这些 CPU 相当过时,GCC 不-mtune=generic应该对最新的 GCC 给予太多重视。(特别是考虑到当前的夜间 GCC 行为现在不会被大多数稳定版本发行版在一年或更长时间内纳入广泛使用的二进制包中。)

    当调用者读取 EAX 时,返回int最后一条指令写入的时间al可能会导致惩罚。但mov al, [rdi]可以在没有任何错误依赖或合并成本的情况下运行。

  • Sandybridge和 Ivy Bridge:AL 仍单独重命名,但可以在与其他 uop 的循环中插入合并 uop,而不会出现任何停顿。

    mov al, [rdi]仍然没有错误的 dep 或合并 uop。但是稍后读取 EAX 触发合并 uop(将结果add al与来自 的 RAX 的高字节合并movzx eax, [rdi])将被插入,就像我们将 a 放入movzx eax, al机器代码一样便宜。(如果 RAX 的高位字节全为零,则合并或扩展是等效的。)

  • Haswell 及更高版本(也许还有 IvB),以及所有其他 x86 供应商,以及来自 Intel 的低功耗 CPU(如 Silvermont 系列):根本没有部分寄存器重命名。(英特尔 SnB 系列上的 AH/BH/CH/DH 除外)。最后一个CPU没有属于此类别的 CPU 已有近十年的历史,而最后一款受到重大处罚的 CPU(P6 系列)也已有十多年的历史。

    mov al, [rdi]糟糕:错误的依赖关系并会在后端花费一个 ALU uop 来合并。因此,通过存储内存操作数的任何内容,关键路径中都会有额外的加载延迟。

    写入 AL 后读取 EAX 的惩罚为零;这根本不是特例;合并发生在你写 AL 的时候。


GCC 的代码是 Core2 / Nehalem 与现代 CPU 之间的明智权衡:加载以movzx避免错误的 dep 写入部分寄存器。最后一个movzx是为了避免调用者中的部分寄存器停顿。

但如果要这样做,选择 EDX 或 ECX 作为临时寄存器可能会对现代英特尔造成较小的伤害,因为英特尔可以在 上进行零延迟mov消除movzx r32, r8,但不能在同一寄存器内进行。它仍然需要前端微操作,因此它对于吞吐量来说不是免费的,只有延迟和后端端口。这是一个持续性的优化失误;我认为 GCC 或 clang 不知道要寻找那个;mov esi,esi例如,它们通常使用函数 arg 将 32->64 进行零扩展。

   movzx  edx, byte ptr [rdi]
   add     dl, [rsi]
   movzx  eax, dl             # mov-elimination possible on IvB and later (except Ice Lake with updated microcode which breaks mov-elim).
Run Code Online (Sandbox Code Playgroud)

如果专门针对 Core2 / Nehalem 进行优化,您可以这样做:

   xor   eax, eax      # off the critical path, avoids partial-reg stalls for later reads of EAX after writing AL
   mov    al, [rdi]
   add    al, [rsi]
Run Code Online (Sandbox Code Playgroud)

这对于后来的 CPU 来说还不错,尽管它mov al, [rdi]仍然是微熔丝加载 + ALU uop,因此它具有额外的加载延迟,并在调度程序中占用一个额外的槽,并在后端执行端口上占用一个周期。因此,3 个后端微指令,从 IvB 中的 2 个增加到后来movzx如果您选择不同的寄存器则被消除。

movzx由于 Core2/Nehalem,GCC 在这一点上选择使用是非常保守的;也许-mtune=generic在 GCC12 中不应该关心 P6 系列部分寄存器停顿,因为这些 CPU 已经有十多年的历史了。特别是在 64 位代码中,最坏的情况是 Core2/Nehalem,而不是早期 P6 系列上没有合并 uop 的更长的停顿。(64 位代码更有可能在较新的 CPU 上运行;用例之一-m32是为旧的 32 位 CPU 编写代码。)

这很可能是一个需要更新的有意调整选择。-march这绝对是/ -mtune= k8through znver3、 or silvermont-family 、 orsandybridge或 newer错过的优化。

(另请注意,一些应该根据-mtune设置而有所不同的选择实际上并没有。GCC 只有一种方法,它总是做一些事情,并且添加挂钩以使其根据调整标志而有所不同尚未完成。Clang 是相同的方式。例如-mtune=core2仍然不知道避免部分寄存器停顿!)


Clang通常会危险地编写部分寄存器,否则当它们在单个函数中没有明显的循环携带时会忽略错误的依赖关系(这可能会咬它的屁股))。当跳过异或清零时,这可以节省整个指令,但通常只节省 1 个字节似乎不值得。这是一个错误的依赖关系,意味着 mov load 解码为 load + ALU merge uops(将新的低字节合并到现有的 64 位寄存器中)。

看起来 clang 只是做了通常的事情,将 8 位值加载到 8 位寄存器中movzx,忽略 ,然后最终意识到它需要对结果进行零扩展。

寻找机会将零扩展(在狭义数学之后)折叠到早期加载中的优化过程将是有用的。和/或以其他方式寻找方法来证明值已经为零扩展(如果它不这样做)。

一般来说,最好开始做窄负载,movzx所以更常见的情况是这样。


您可能想要报告错过优化的错误,尤其是 clang。大多数时候,他们的代码生成已经对 P6 系列竖起了中指,并且使用了部分寄存器,因此他们可能有兴趣尝试生成 2 指令版本。https://github.com/llvm/llvm-project/issues

另外https://gcc.gnu.org/bugzilla/enter_bug.cgi?product=gcc(使用关键词missed-optimization来处理GCC错误。请随意链接这个堆栈溢出帖子,和/或引用我的任何评论,如果你想要,以及 Godbolt 链接。GCC 开发人员更喜欢使用 AT&T 语法来进行 x86 讨论/错误。)

也可以看看:


我这里还有两个例子: https: //godbolt.org/z/z45xr4hq1 GCC 生成合理且奇怪的 movzx,而 Clang 对 mov 和 movzx 的使用对我来说毫无意义。

clangmov ecx, edx从 32 到 64 而不是从 8 到 64 的零扩展是因为它依赖于 x86-64 SysV 调用约定的非官方扩展,即窄参数扩展到 32 位。AMD Zen CPU 可以对 -byte 进行 mov 消除,mov ecx, edx但不能对movzx-byte 进行移动消除,因此这实际上更高效,并且可以节省代码大小。

(GCC 和 clang 都使调用者尊重这个非官方的调用约定功能,但只有 clang 使被调用者依赖它。ICC 也不这样做,因此与 clang 不兼容 ABI。)

intptr_t如果您要使用 1 来索引数组,那么对于所有较窄的参数来说,当然需要扩展 to 。(用抽象 C 术语来说,这只是使用指针数学值的一部分)。64 位寄存器的至少高 32 位允许存在高位垃圾。

  • 谢谢你这么详细的回答!我正在为 Clang 和 GCC 撰写报告,我将链接这篇文章。 (2认同)

MSa*_*ers 8

叮当声实际上似乎很合理。如果您写入然后读取,则会出现部分寄存器停顿。使用打破了这个部分寄存器停顿。aleaxmovzx

初始 mov toal对现有值没有依赖关系eax(由于寄存器重命名),因此依赖关系只是不可避免的依赖关系(等待 [rsi]、等待 [rdi]、在零扩展之前等待 add 完成)。

换句话说,高 24 位必须清零,低 8 位必须计算,但这两个操作可以按任意顺序完成。clang 只是选择先加,后加零。

[编辑] 至于 GCC,这似乎是一个特别糟糕的选择。如果它选择bl作为临时寄存器,则最后一个movzx在 Haswell/SkyLake 上将是零延迟,但移动消除不适用于 al 到 eax


归档时间:

查看次数:

3028 次

最近记录:

3 年,1 月 前