c - 在禁用优化的情况下进行编译时,为什么clang不使用内存目标x86指令?他们有效率吗?

标签 c gcc assembly clang compiler-optimization

我编写了以下简单的汇编代码,然后运行它,并使用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;
}

最佳答案

您停用了优化功能,但惊讶的是ASM效率低下?好吧,别这样。您已经要求编译器快速编译:对于生成的二进制文件,编译时间短而不是运行时间短。 And with debug-mode consistency.

是的,在针对现代x86 CPU进行调整时,GCC和clang将使用内存目标添加。如果您不需要将加法结果存入寄存器,这将非常有效。显然,您的手写asm有一个主要的优化遗漏。 movl $5+6, -4(%rbp)效率更高,因为这两个值都是汇编时常量,因此将添加项保留到运行时很糟糕。就像您的反优化编译器输出一样。

(更新:刚注意到您的编译器输出包含xor %eax,%eax,所以它看起来像clang / LLVM,而不是我最初猜想的gcc。此答案中的几乎所有内容都同样适用于clang,但gcc -O0并不寻找xor-使用-O0mov $0, %eax将调零窥孔优化。)

有趣的事实:gcc -O0实际上将在您的addl $6, -4(%rbp)中使用main



您已经从手写的asm中知道,将立即数添加到内存中是encodeable as an x86 add instruction,因此唯一的问题是gcc / LLVM的优化器是否决定使用它。但是您禁用了优化。

内存目标添加不会在“内存中”执行计算,因此,CPU必须在内部进行加载/添加/存储。这样做不会打扰任何体系结构寄存器,而且不会只是将6发送到DRAM中添加到其中。另请参阅Can num++ be atomic for 'int num'?,以获取内存目标ADD的C和x86 asm详细信息,带/不带lock前缀以使其显得原子。

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

但是,按照目前的情况,add $6, -4(%rbp)会解码为在CPU内部加载,添加和存储uops。加载使用内部临时目标,而不是体系结构寄存器。

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

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

最初的8086并没有故障,甚至没有流水线。它可以直接加载到ALU输入中,然后在完成ALU后存储结果。它不需要在其寄存器文件中使用临时的“体系结构”寄存器,而只需在组件之间进行常规缓冲即可。大概这就是486之前的所有工作原理。甚至奔腾。




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


在这种情况下,如果我们假装该值已在内存中,则立即添加到内存是最佳选择。 (而不是仅仅从另一个立即数存储。)

现代x86是从8086演变而来的。现代x86 asm中有许多缓慢的处理方法,但是在不破坏向后兼容性的前提下,不能禁止任何一种。例如,enter指令是在186年添加的,以支持嵌套的Pascal过程,但是现在非常慢。 loop指令自8086年就已经存在,但是对于编译器来说,它的使用速度太慢了,因为我认为大约是486,也许是386。(Why is the loop instruction slow? Couldn't Intel have implemented it efficiently?

x86绝对是您应该认为允许与有效之间存在任何联系的最后一个体系结构。它与ISA设计的硬件相差很远。但是总的来说,在大多数ISA上都是不正确的。例如PowerPC的某些实现(尤其是PlayStation 3中的Cell处理器)的微编码变量计数移位缓慢,但是该指令是PowerPC ISA的一部分,因此完全不支持该指令将非常痛苦,并且不值得using multiple instructions而不是让微码在热循环之外执行。

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

普通的汇编器只会抱怨不可编码的指令。绩效分析不是他们的工作。他们的工作是将文本转换为输出文件中的字节(可以选择包含目标文件元数据),从而允许您出于自己认为有用的目的创建所需的任何字节序列。



要避免速度下降,需要一次查看多个指令

您可以使代码变慢的大多数方法都包含明显不错的指令,只是整体组合很慢。通常,检查性能错误需要一次查看多个指令。

例如此代码将cause a partial-register stall on Intel P6-family CPUs

mov  ah, 1
add  eax, 123


这些指令中的任何一个都可能是高效代码的一部分,因此,汇编器(只需要单独查看每个指令)就不会发出警告。尽管写AH完全是个问题。通常是个坏主意。也许一个更好的例子是在Snb系列价格便宜之前,在CPU上的dec/jnz循环中的partial-flag stalladcProblems with ADC/SBB and INC/DEC in tight loops on some CPUs

如果您正在寻找一种工具来警告您有关昂贵的说明,则GAS并非如此。诸如IACA或LLVM-MCA之类的静态分析工具可能会帮助您在一段代码中向您展示昂贵的指令。 (What is IACA and how do I use it?(How) can I predict the runtime of a code snippet using LLVM Machine Code Analyzer?)它们旨在分析循环,但是向它们提供代码块(无论它是否为循环主体)将使您了解前端每条指令的成本,以及也许关于延迟。

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


Assembly - How to score a CPU instruction by latency and throughput
How many CPU cycles are needed for each assembly instruction?
What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand?




GCC / clang -O0的最大作用是no optimization at all between statements,将所有内容溢出到内存中并重新加载,因此每个C语句均由单独的asm指令块完全实现。 (用于一致的调试,包括在任何断点处停止时修改C变量)。

但是,即使在一个语句的asm块中,clang -O0显然也会跳过优化过程,该过程决定使用CISC内存目标指令是否将是一个胜利(考虑到当前的调整)。因此,clang最简单的代码生成趋向于将CPU用作加载存储机器,并使用单独的加载指令将内容存储在寄存器中。

GCC -O0碰巧会像您期望的那样编译主程序。 (启用优化后,由于未使用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




如何使用内存目标add查看clang / LLVM

I put these functions on the Godbolt compiler explorer with clang8.2 -O3。每个函数编译为一个asm指令,对于x86-64,默认为-mtune=generic。 (因为现代的x86 CPU可以高效地解码内存目标添加,最多可以将内部uos作为单独的加载/添加/存储指令,而有时通过微融合来简化加载/添加部分。)

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甚至没有试图不吮吸,只是为了快速编译,并遵守将所有内容保留在语句之间的内存中的约束。

铛-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




另请参阅How to remove "noise" from GCC/clang assembly output?,以获取有关编写无需优化即可编译为有趣的asm的函数的更多信息。



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

P5 Pentium microarchitecture (from 1993)不会解码为类似RISC的内部uops。复杂的指令需要更长的时间才能运行,并增加其有序的双问题-超标量管道。因此,GCC避免使用x86指令的更多RISCy子集,从而使P5可以更好地进行流水线处理。

# 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's instruction tables,按顺序P5上的add $imm, (mem)需要3个时钟周期,但可以在U或V管道中配对。自从我阅读了他的微体系结构指南的P5 Pentium部分以来已经有一段时间了,但是顺序管道肯定必须按照程序顺序开始每条指令。 (不过,慢速指令(包括存储)可以在其他指令开始后稍后完成。但是这里的添加和存储取决于前一条指令,因此它们肯定要等待)。

如果您感到困惑,英特尔仍然将Pentium和Celeron品牌名称用于Skylake等低端现代CPU。这不是我们在说的。我们正在谈论的是原始的Pentium微体系结构,而现代Pentium品牌的CPU甚至与之无关。

GCC拒绝不带-mtune=pentium-m32,因为没有64位Pentium CPU。第一代Xeon Phi使用骑士角uarch,基于顺序排列的P5 Pentium,其矢量扩展名与添加的AVX512类似。但是gcc似乎不支持-mtune=knc。 Clang可以,但是选择使用此处和-m32 -mtune=pentium添加的内存目标。

LLVM项目直到P5被淘汰(KNC除外)之后才开始,而gcc则在P5被广泛用于x86台式机的同时积极开发和调整。因此,gcc仍然了解一些P5调优知识也就不足为奇了,而LLVM并没有真正将它与现代x86区别对待,后者将内存目标指令解码为多个uops,并且可以无序执行它们。

关于c - 在禁用优化的情况下进行编译时,为什么clang不使用内存目标x86指令?他们有效率吗?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/54391268/

相关文章:

c - 为什么我的程序在 linux-gcc 中出现段错误,但在 mingw-gcc 中却没有?

c++ - C/C++ 中的最小 double 值

c - arm-linux-gnuueabi-gcc 找不到安装到自定义目录的库

assembly - x86_64 - 为什么用 rdtsc/rdtscp 给一个程序计时会给出不合理的大数字?

assembly - LEA 和其他指令如何运作?

c - 协议(protocol)特定的套接字创建和套接字选项信息

c - 将数据从外部设备保存到数组

c++ - 为什么类的析构函数在只包含类的头文件的cpp中是 "inlined"

c - 当我将 C 程序从 OSX 移动到 Ubuntu 机器时,为什么必须重新编译它?

汇编语言循环不工作