当前位置: 首页 > 知识库问答 >
问题:

Clang和GCC对movzx的奇怪使用

符允晨
2023-03-14

我知道movzx可以用于打破依赖关系,但我偶然发现Clang和GCC都使用了movzx,我真的看不出它们有什么好处。下面是我在godbolt上尝试的一个简单示例:

#include <stdint.h>

int add2bytes(uint8_t* a, uint8_t* b) {
    return uint8_t(*a + *b);
}

对于gcc12-O3:

add2bytes(unsigned char*, unsigned char*):
        movzx   eax, BYTE PTR [rsi]
        add     al, BYTE PTR [rdi]
        movzx   eax, al
        ret

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

有了clang14-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

它在movzx似乎更合理的地方使用mov,然后零将al扩展到eax,但是在开始时使用movzx不是更好吗?

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

我找到的最近的帖子是https://stackoverflow.com/a/64915219/14730360在那里,他们展示了类似的movzx用法,这些用法似乎无用和/或不合适。

编译器在这里真的使用movzx很差吗,还是我遗漏了什么?

共有3个答案

狄玉书
2023-03-14

最终的MOVZX是由该函数返回一个从字节扩展的int这一事实强制的。它必须在clang版本中存在,但对于gcc,一个是额外的。

仇正平
2023-03-14

clang位实际上似乎是合理的。如果您写入al然后从eax读取,您会得到部分寄存器失速。使用movzx会打破这个部分寄存器失速。

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

换句话说,前24位必须归零,低8位必须计算,但这两个动作可以按任意顺序进行。clang只是选择先加,再加零。

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

金皓君
2023-03-14

这两个编译器在这里都做得很差,但clang的代码尤其糟糕,在任何地方都没有优势。除十年前的Intel CPU(重命名low-8部分寄存器)外,所有处理器都有一个容易避免的缺点。

最佳的ASM是你建议的,movzx加载,然后字节添加,留下一个uint8_t结果在低字节,正确的零扩展为intC语义学所要求的。

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

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

>

  • Core 2/Nehalem(P6系列中具有64位功能的成员):如果您编写AL,AL将与RAX分开重命名。稍后读取EAX将在插入合并uop时使前端停顿大约3个周期。没有之前的P6系列那么糟糕,但仍然是需要避免的重大惩罚。但是这些CPU已经相当过时,GCC的-mtune=通用不应该为最新的GCC增加太多权重。(特别是考虑到当前每晚GCC的行为现在不会在一年内或更长时间内被大多数稳定版本的发行版烘焙到广泛使用的二进制包中。)

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

    Sandybridge可能还有Ivy Bridge: AL仍然单独重命名,但是可以插入一个合并的uop而不会有任何停顿,与其他uop循环。

    mov al,[rdi]仍然没有虚假的dep或合并uop。但是,稍后读取触发合并uop的EAX(将添加al的结果与来自movzx EAX的RAX的高字节合并,[rdi])将被插入,就像我们在机器代码中放入movzx EAX,al一样便宜。(如果RAX的上部字节均为零,则merge或extend等效。)

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

    mov al,[rdi]很糟糕:错误的依赖关系并在后端花费一个ALU uop进行合并。因此,它是通过存储的内存操作数在关键路径上的额外加载延迟。

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

    GCC的代码是Core2/Nehalem与现代CPU之间的合理权衡:使用movzx加载,以避免错误的dep写入部分寄存器。和最终的movzx,以避免调用者中的部分寄存器暂停。

    但如果它要这样做,选择EDX或ECX作为临时版本可能会对现代英特尔造成较小的伤害,因为英特尔可以在movzx r32, r8执行零延迟mov-消除,但不是在同一个寄存器内。它仍然需要前端uop,因此它对吞吐量不免费,只有延迟和后端端口。这是一个持久的错过优化;我不认为GCC或clang知道寻找它;他们通常零扩展32-

       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).
    

    如果专门针对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]
    

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

    由于Core2/Nehalem在这一点上高度保守,GCC选择使用movzx;GCC12中的mtune=generic可能不关心P6系列部分寄存器暂停,因为这些CPU已经使用了十多年。尤其是在64位代码中,最坏的情况是Core2/Nehalem,在早期P6系列上没有合并uop的情况下,甚至更长的暂停时间。(64位代码更有可能在较新的CPU上运行;m32的一个用例是为旧的仅32位CPU生成代码。)

    这很可能是一个需要更新的意图调整选择。这绝对是一个遗漏的优化,-mtune=k8通过znver3,或silvermont-Family,或sandybridge或更新版本。

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

    看起来,clang只是在忽略movzx的情况下将8位值加载到8位寄存器中,然后在最后实现了对结果进行零扩展。

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

    通常情况下,最好开始使用movzx进行窄负载,这样更正常。

    您可能想报告一个错过的优化错误,尤其是clang。他们的代码生成已经是P6家族的一个巨大中指,大部分时间都使用部分寄存器,所以他们可能有兴趣尝试生成2指令版本。https://github.com/llvm/llvm-project/issues

    而且https://gcc.gnu.org/bugzilla/enter_bug.cgi?product=gcc(使用关键字missed optimization for GCC bugs。请随意链接此堆栈溢出帖子,和/或引用我的任何评论,如果你愿意,还可以引用Godbolt链接。GCC开发者更喜欢

    另见:

    • 为什么GCC不使用部分寄存器?
    • Haswell/Skylake上的部分寄存器究竟如何执行?编写AL似乎对RAX有错误的依赖,AH不一致

  •  类似资料:
    • 使用方式如下: 这是有效的解决方案吗?如果删除未使用的“魔术”变量 - 我在返回字符串后有分割错误。做错了什么? $gcc--version gcc(Debian 4.4.5-8)4.4.5 $uname-Linux深度站(挤压)2.6.32-5-686#1 SMP 5月10日星期五08:33:48 UTC 2013 i686 GNU/Linux

    • 我非常困惑为什么gcc会为const数组上的简单for循环生成这种(看似)非最佳代码。 结果: 我主要关心的是: 为什么无用的第一个元素比较在?这永远不会命中,也永远不会被分支回。它最终只是第一次迭代的重复代码。 < li >有没有更好的方法来编写这个非常简单的循环,这样gcc就不会产生这种奇怪的代码? < li >有没有我可以利用的编译器标志/优化?< code>O3只是展开循环,我也不希望这样

    • 问题内容: 我正在上大学,并且对于一个正在使用C的项目,我们已经探索了GCC和Clang,并且Clang似乎比GCC更友好。结果,我想知道使用clang(相对于GCC)在Linux上用C和C ++进行开发有什么优点或缺点? 就我而言,这将用于学生级别的课程,而不是生产课程。 如果使用Clang,应该使用GDB调试并使用GNU Make,还是使用其他调试器和make实用程序? 问题答案: 编辑: 海

    • 我试图使用clang和gcc交叉编译一个项目,但在使用时,我发现了一些奇怪的差异,例如。 现在,当涉及NAN时,我期望类型行为,但clang和gcc给出不同的结果: 当我使用它时,_mm_max_ps做了预期的事情。我尝试过使用,,但似乎没有效果。有什么想法可以让编译器之间的行为相似吗? 这里是锁销连接

    • 当一个类具有 constexpr 成员函数并且该成员函数正在 constexpr 上下文中的 l 值对象上求值时,clang 和 gcc 不同意结果是否为 constexpr 值。为什么?是否有既不需要默认可构造性也不需要复制可构造性的解决方法? 当对象按值传递时,两个编译器都会成功编译。 Clang版本trunk,8,7: 和 gcc 版本主干,8.1、7.4:编译没有错误 https://go

    • 我有以下代码来解析一个JSON文件: 要处理以下JSON文件: 如果我执行此代码,我将收到以下错误: 所以我开始一步一步地调试应用程序,看看part processing()中的哪个代码部分抛出了这个异常。令人惊讶的是,那里的所有代码都正常执行:没有抛出异常,也没有返回结果I except。 更让我惊讶的是,当我稍微改变第一种方法的代码时,它可以在不产生异常的情况下工作。 我不知道println方