首页 > 解决方案 > 当我在禁用优化的情况下编译时,为什么 clang 不使用 memory-destination x86 指令?他们有效率吗?

问题描述

我编写了这个简单的汇编代码,运行它并使用 GDB 查看内存位置:

    .text

.global _main

_main:
    pushq   %rbp
    movl    $5, -4(%rbp)
    addl    $6, -4(%rbp)
    popq    %rbp
    ret

它直接在内存中添加 5 到 6,并且根据 GDB 它可以工作。所以这是直接在内存而不是 CPU 寄存器中执行数学运算。

现在用 C 编写相同的东西并将其编译为汇编,结果如下:

...  # clang output
    xorl    %eax, %eax
    movl    $0, -4(%rbp)
    movl    $5, -8(%rbp)
    movl    -8(%rbp), %ecx   # load a
    addl    $6, %ecx         # a += 6
    movl    %ecx, -8(%rbp)   # store a
....

在将它们添加在一起之前,它会将它们移动到寄存器中。

那么我们为什么不直接在内存中添加呢?

是不是比较慢?如果是这样,那为什么甚至允许直接在内存中添加,为什么汇编程序一开始没有抱怨我的汇编代码?

编辑:这是第二个汇编块的 C 代码,我在编译时禁用了优化。

#include <iostream>

int main(){
 int a = 5;
 a+=6; 
 return 0;
}

标签: cassemblyx86clangcompiler-optimization

解决方案


您禁用了优化,但您对 asm 看起来效率低下感到惊讶?好吧,不要。 您已经要求编译器快速编译:生成的二进制文件的编译时间短而不是运行时间短。 并具有调试模式一致性。

是的,在为现代 x86 CPU 进行调优时,GCC 和 clang 将使用 memory-destination add。如果您不需要将加法结果放在寄存器中,那将是有效的。不过,显然您的手写 asm 有一个重大的优化失误。 movl $5+6, -4(%rbp)会更有效率,因为这两个值都是汇编时常量,所以将添加留到运行时是可怕的。就像您的反优化编译器输出一样。

(更新:刚刚注意到您的编译器输出包括xor %eax,%eax,所以这看起来像 clang/LLVM,而不是我最初猜测的 gcc。这个答案中的几乎所有内容都同样适用于 clang,但gcc -O0不寻找 xor-zeroing 窥视孔优化-O0,使用mov $0, %eax.)

有趣的事实:gcc -O0实际上会addl $6, -4(%rbp)在你的main.


您已经从手写 asm 中知道,将立即数添加到内存可以编码为 x86add指令,因此唯一的问题是 gcc/LLVM 的优化器是否决定使用它。但是您禁用了优化。

内存目标 add 不会“在内存中”执行计算,CPU 内部必须加载/添加/存储。这样做时它不会干扰任何架构寄存器,但它不仅仅是将6DRAM 发送到要添加到那里的 DRAM。另请参阅num++ 是否可以是“int num”的原子?对于内存目标 ADD 的 C 和 x86 asm 详细信息,带/不带lock前缀以使其看起来是原子的。

有计算机架构研究将 ALU 放入 DRAM,因此计算可以并行发生,而不是要求所有数据通过内存总线传递到 CPU 以进行任何计算。这正成为一个越来越大的瓶颈,因为内存大小的增长速度快于内存带宽,而 CPU 吞吐量(使用宽 SIMD 指令)也比内存带宽增长得快。(需要更多的计算强度(每次加载/存储的 ALU 工作量)以使 CPU 不会停止。快速缓存有帮助,但有些问题的工作集很大,很难应用缓存阻塞。快速缓存确实可以最大程度地缓解问题的时间。)

但就目前而言,add $6, -4(%rbp)解码为在 CPU 中加载、添加和存储微指令。加载使用内部临时目标,而不是体系结构寄存器。

现代 x86 CPU 有一些隐藏的内部逻辑寄存器,多指令可用于临时。这些隐藏寄存器在发布/重命名阶段被重命名为物理寄存器,因为它们被分配到乱序后端,但在前端(解码器输出、uop 缓存、IDQ)微指令只能引用表示机器逻辑状态的“虚拟”寄存器。因此,内存目标 ALU 指令解码为的多个微指令可能正在使用隐藏的 tmp 寄存器。

我们知道这些存在供微代码/多指令指令使用:http: //blog.stuffedcow.net/2013/05/measuring-rob-capacity/称它们为“供内部使用的额外架构寄存器”。在作为 x86 机器状态的一部分的意义上,它们不是体系结构,只是在作为寄存器分配表 (RAT) 必须跟踪的逻辑寄存器的意义上,以便将寄存器重命名到物理寄存器文件上。x86 指令之间不需要它们的值,仅适用于一个 x86 指令中的微指令,尤其是微编码指令rep movsb(检查大小和重叠,并尽可能使用 16 或 32 字节加载/存储),但也适用于多-uop 内存+ALU 指令。

最初的 8086 没有乱序,甚至没有流水线。它可以直接加载到 ALU 输入中,然后在 ALU 完成后存储结果。 它的寄存器文件中不需要临时的“架构”寄存器,只需要组件之间的正常缓冲。这大概就是 486 之前的所有工作方式。甚至可能是奔腾。


慢吗?如果是这样,那么为什么直接添加内存甚至允许,为什么汇编程序一开始没有抱怨我的汇编代码?

在这种情况下,如果我们假设该值已经在内存中,那么立即添加到内存是最佳选择。(而不是仅仅从另一个直接常量存储。)

现代 x86 是从 8086 演变而来的。在现代 x86 asm 中有很多缓慢的方式来做事,但在不破坏向后兼容性的情况下,没有一个是不允许的。例如,该enter指令是在 186 中添加的,以支持嵌套的 Pascal 过程,但现在非常慢。该loop指令自 8086 年以来就已经存在,但是对于编译器来说太慢了,因为我认为大约 486 可能是 386 后无法使用。(为什么循环指令很慢?英特尔不能有效地实现它吗?

x86 绝对是您认为允许和高效之间存在任何联系的最后一个架构。 它与 ISA 设计硬件相去甚远。但总的来说,大多数 ISA 都不是这样。例如,PowerPC 的某些实现(尤其是 PlayStation 3 中的 Cell 处理器)具有缓慢的微编码变量计数移位,但该指令是 PowerPC ISA 的一部分,因此根本不支持该指令将非常痛苦,并且不值得使用多个指令而不是让微代码在热循环之外执行它。

您可能会编写一个拒绝使用或警告过已知缓慢指令的汇编程序,例如enteror loop,但有时您正在优化大小而不是速度,然后是缓慢但小的指令(例如loop很有用)。(https://codegolf.stackexchange.com/questions/132981/tips-for-golfing-in-x86-x64-machine-code,并查看 x86 机器代码答案,例如我的 8 字节 32 位 GCD 循环x86 代码使用许多小而慢的指令,例如 3-uop 1-byte xchg eax, r32,甚至inc/loop作为 4-byte test ecx,ecx/的 3-byte 替代品jnz)。优化代码大小在现实生活中对于引导扇区或有趣的东西(如 512 字节或 4k“演示”)很有用,它们仅在少量的可执行文件中绘制精美的图形并播放声音。或者对于在启动期间只执行一次的代码,较小的文件大小更好。或者在程序的生命周期中很少执行,较小的 I-cache 占用空间比吹走大量缓存(并遭受前端等待代码获取的停顿)要好。一旦指令字节实际到达 CPU 并被解码,这可能超过最大效率。特别是如果与代码大小节省相比差异很小。

普通的汇编程序只会抱怨不可编码的指令;绩效分析不是他们的工作。他们的工作是将文本转换为输出文件中的字节(可选地使用对象文件元数据),允许您创建任何您认为可能有用的字节序列。


避免减速需要同时查看超过 1 条指令

使代码变慢的大多数方法都涉及到显然不是坏的指令,只是整体组合很慢。 通常,检查性能错误需要一次查看多于 1 条指令。

例如,此代码将导致 Intel P6 系列 CPU 上的部分寄存器停顿

mov  ah, 1
add  eax, 123

这些指令中的任何一条都可能成为高效代码的一部分,因此汇编程序(只需分别查看每条指令)不会警告您。虽然写 AH 是很值得怀疑的;通常是个坏主意。也许一个更好的例子是循环中的部分标志停顿dec/jnzadcSnB 系列变得便宜之前,在 CPU 上。 ADC/SBB 和 INC/DEC 在某些 CPU 上的紧密循环中出现问题

如果您正在寻找一种工具来警告您有关昂贵的指令,那么 GAS不是它。 IACA 或 LLVM-MCA 等静态分析工具可能有助于在代码块中显示昂贵的指令。什么是 IACA 以及如何使用它?(如何)我可以使用 LLVM 机器代码分析器预测代码片段的运行时间?)他们的目的是分析循环,但是不管它是循环还是给他们一个代码块body 与否将使他们向您显示每条指令在前端花费了多少微指令,也许还有一些关于延迟的信息。

但实际上,您必须更多地了解您正在优化的管道,以了解每条指令的成本取决于周围的代码(它是否是长依赖链的一部分,以及整体瓶颈是什么)。有关的:


GCC/clang-O0的最大效果是语句之间根本没有优化,将所有内容都溢出到内存并重新加载,因此每个 C 语句完全由单独的 asm 指令块实现。(为了一致的调试,包括在任何断点处停止时修改 C 变量)。

但即使在一条语句的 asm 块内,clang -O0显然也跳过了决定使用 CISC 内存目标指令是否会获胜的优化阶段(鉴于当前的调整)。因此,clang 最简单的代码生成倾向于将 CPU 用作加载存储机器,使用单独的加载指令将内容放入寄存器。

GCC -O0碰巧像您期望的那样编译您的 main 。(启用优化后,它当然只编译为xor %eax,%eax/ ret,因为a未使用。)

main:
    pushq   %rbp
    movq    %rsp, %rbp
    movl    $5, -4(%rbp)
    addl    $6, -4(%rbp)
    movl    $0, %eax
    popq    %rbp
    ret

如何使用 memory-destination 查看 clang/LLVMadd

我使用 clang8.2 -O3 将这些函数放在 Godbolt 编译器资源管理器中每个函数编译为一条 asm 指令,默认-mtune=generic为 x86-64。 (因为现代 x86 CPU 可以有效地解码内存目标添加,最多与单独的加载/添加/存储指令一样多的内部微指令,并且有时会减少加载+添加部分的微融合。)

void add_reg_to_mem(int *p, int b) {
    *p += b;
}

 # I used AT&T syntax because that's what you were using.  Intel-syntax is nicer IMO
    addl    %esi, (%rdi)
    ret

void add_imm_to_mem(int *p) {
    *p += 3;
}

  # gcc and clang -O3 both emit the same asm here, where there's only one good choice
    addl    $3, (%rdi)
    ret

gcc -O0输出完全是脑残,例如重新加载两次p,因为它在计算+3. 我也可以使用全局变量而不是指针来为编译器提供一些它无法优化的东西。 -O0因为那可能不会那么可怕。

    # gcc8.2 -O0 output
    ... after making a stack frame and spilling `p` from RDI to -8(%rbp)
    movq    -8(%rbp), %rax        # load p
    movl    (%rax), %eax          # load *p, clobbering p
    leal    3(%rax), %edx         # edx = *p + 3
    movq    -8(%rbp), %rax        # reload p
    movl    %edx, (%rax)          # store *p + 3

从字面上看,GCC 甚至没有试图不吸,只是为了快速编译,并尊重在语句之间将所有内容保存在内存中的约束。

clang -O0 输出恰好对此不那么可怕:

 # clang -O0
   ... after making a stack frame and spilling `p` from RDI to -8(%rbp)
    movq    -8(%rbp), %rdi    # reload p
    movl    (%rdi), %eax      # eax = *p
    addl    $3, %eax          # eax += 3
    movl    %eax, (%rdi)      # *p = eax

另请参阅如何从 GCC/clang 程序集输出中删除“噪音”?了解更多关于编写无需优化即可编译为有趣 asm 的函数的信息。


如果我用 编译-m32 -mtune=pentium,gcc -O3 将避免 memory-dst 添加:

P5 Pentium微架构(从 1993 年开始)不解码为类似 RISC内部微指令。复杂的指令需要更长的时间才能运行,并且会破坏其有序的双问题超标量管道。因此 GCC 避免使用它们,使用 P5 可以更好地流水线的 x86 指令的更多 RISCy 子集。

# gcc8.2 -O3 -m32 -mtune=pentium
add_imm_to_mem(int*):
    movl    4(%esp), %eax    # load p from the stack, because of the 32-bit calling convention

    movl    (%eax), %edx     # *p += 3 implemented as 3 separate instructions
    addl    $3, %edx
    movl    %edx, (%eax)
    ret

您可以在上面的 Godbolt 链接上自己尝试一下;这就是它的来源。只需在下拉菜单中将编译器更改为 gcc 并更改选项即可。

不确定这实际上是一场胜利,因为他们是背靠背的。要让它成为真正的胜利,gcc 必须交错一些独立的指令。根据Agner Fog 的指令表add $imm, (mem)按顺序 P5 需要 3 个时钟周期,但可以在 U 或 V 管道中配对。自从我通读他的微架构指南的 P5 Pentium 部分以来已经有一段时间了,但是按顺序流水线肯定必须按程序顺序启动每条指令。(包括存储在内的慢指令可以在其他指令启动后稍后完成。但是这里的添加和存储依赖于前一条指令,所以它们肯定要等待)。

如果您感到困惑,英特尔仍将奔腾和赛扬品牌名称用于 Skylake 等低端现代 CPU。这不是我们在谈论的。我们谈论的是原始的 Pentium微架构,现代 Pentium 品牌的 CPU 甚至与它无关。

GCC 拒绝-mtune=pentium没有-m32,因为没有 64 位 Pentium CPU。第一代 Xeon Phi 使用 Knight's Corner uarch,基于有序 P5 Pentium,添加了类似于 AVX512 的矢量扩展。但 gcc 似乎不支持-mtune=knc. Clang 可以,但选择使用 memory-destination add here for that 和 for -m32 -mtune=pentium

LLVM 项目直到 P5 过时(KNC 除外)之后才开始,而 gcc 被积极开发和调整,而 P5 广泛用于 x86 桌面。因此,gcc 仍然知道一些 P5 调整的东西也就不足为奇了,而 LLVM 并没有真正将它与现代 x86 区别对待,后者将内存目标指令解码为多个 uop,并且可以乱序执行它们。


推荐阅读