为什么在关闭优化时,Clang不使用内存目标x86指令?它们是否高效?

20

我编写了这个简单的汇编代码,运行并使用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;
}

11
C代码似乎未经过优化,因此存在额外的加载和存储操作。使用“-O3”编译并查看效果。 - Michael Petch
7
@Sam 我的意思是:实际上并没有直接“在内存中”添加,目标操作数仍然必须从内存(或缓存)中提取到CPU寄存器中进行添加。这是隐式完成的。我之所以添加这个解释,是因为特别是标题暗示着内存(RAM)可以执行算术运算,而在我知道的任何平台上都不是真实的情况;) - Ctx
2
@Sam 这不是一个架构寄存器,而是一个未命名的硬件寄存器。 - Ctx
3
我建议将这个添加操作放进一个函数中,并增加两个参数来检查代码:https://godbolt.org/z/ZmySpq 。Godbolt 是一个有用的在线查看生成的代码的工具。 - Michael Petch
6
当你关闭优化时,抱怨编译器的代码生成是不现实的。 - Raymond Chen
显示剩余5条评论
1个回答

36
你禁用了优化,然后惊讶于汇编代码效率低下?那就不要惊讶。你请求编译器快速编译:生成二进制文件所需的时间很短,而不是运行时所需的时间。并保持调试模式一致性。 是的,GCC和clang在针对现代x86 CPU进行调优时会使用内存目标加法。如果您不需要将加法结果放在寄存器中,那么它是有效的。显然,您手写的汇编代码存在严重的优化问题。movl $5+6, -4(%rbp)将更加高效,因为两个值都是汇编时常量,因此将加法留到运行时是可怕的,就像您的反优化编译器输出一样。
(更新:刚注意到您的编译器输出包括xor %eax,%eax,因此这看起来像是clang/LLVM,而不是我最初猜测的gcc。几乎所有答案中的内容同样适用于clang,但gcc -O0在-O0时不会寻找xor-zeroing peephole优化,而是使用mov $0,%eax。)
有趣的事实:gcc -O0实际上会在您的main中使用addl $6,-4(%rbp)。
您已经知道通过手写的汇编语言将立即数添加到内存中是可以编码为x86 add指令,所以唯一的问题是gcc/LLVM的优化器是否决定使用它。但是您禁用了优化。 内存目的地加法不会在内存中执行计算,CPU必须在内部加载/加/存储。 在执行此操作时,它不会干扰任何架构寄存器,但它不会只是将6发送到DRAM以在那里进行添加。有关内存目的地ADD的C和x86 asm详细信息,请参见Can num++ be atomic for 'int num'?,其中包括是否使用锁定前缀使其出现原子性。
存在计算机体系结构研究,旨在将ALU放入DRAM中,以便可以并行进行计算,而无需所有数据通过内存总线传递到CPU以进行任何计算。随着内存大小增长得比内存带宽快,而CPU吞吐量(具有宽SIMD指令)也增长得比内存带宽快,这变得越来越成为瓶颈。(要求CPU具有更多的计算强度(每个加载/存储的ALU工作量),以避免停顿。快速缓存可以帮助解决大多数问题,但某些问题具有大的工作集且难以应用缓存块。快速缓存大多数情况下可以缓解问题。)
但目前来看,add $6, -4(%rbp)在你的CPU内解码成为加载、加法和存储微操作。该加载使用一个内部临时目标而不是架构寄存器。
现代x86 CPU具有一些隐藏的内部逻辑寄存器,用于多uop指令的临时存储。这些隐藏寄存器在分配到乱序后端时会被重命名为物理寄存器,但在前端(解码器输出、uop缓存、IDQ)中,uops只能引用代表机器逻辑状态的“虚拟”寄存器。 因此,将内存目的地ALU指令解码为多个uop可能正在使用隐藏的tmp寄存器。
我们知道这些存在以供微代码/多uop指令使用:http://blog.stuffedcow.net/2013/05/measuring-rob-capacity/将其称为“用于内部使用的额外架构寄存器”。它们在x86机器状态的意义上不是架构寄存器,只是需要寄存器分配表(RAT)跟踪以重命名为物理寄存器文件的逻辑寄存器。它们的值在x86指令之间不需要,仅用于一个x86指令内的uops,特别是像rep movsb这样的微代码指令(它检查大小和重叠,并在可能的情况下使用16或32字节的加载/存储),但也用于多uop内存+ALU指令。

原始的8086处理器没有采用乱序执行,甚至没有采用流水线技术。它只能将数据直接加载到ALU输入端,然后在ALU完成计算后存储结果。它的寄存器文件中不需要临时的“体系结构”寄存器,只需要在组件之间进行正常的缓存。这可能就是直到486处理器的所有处理器的工作方式,甚至可能是Pentium处理器。


在这种情况下,如果我们假设该值已经在内存中,则将立即数添加到内存是最佳选择。(而不仅仅是从另一个立即常量存储。)现代x86从8086进化而来。在现代x86汇编中有很多缓慢的方法,但没有一种可以在不破坏向后兼容性的情况下禁止使用。例如,enter指令是在186年时为了支持嵌套的Pascal过程而添加的,但现在非常缓慢。循环指令自8086以来就存在,但自486或386以来编译器从未使用过,因为它们太慢了。(为什么循环指令很慢?英特尔不能有效地实现它吗?)

x86绝对是最后一个你应该认为有允许和效率之间任何联系的架构。它已经远离ISA设计的硬件进化了。但通常在大多数ISA上都不是这样的。例如,一些PowerPC的实现(特别是PlayStation 3中的Cell处理器)具有缓慢的微编码可变计数移位,但该指令是PowerPC ISA的一部分,因此完全不支持该指令将非常痛苦,并且不值得在热循环之外使用多个指令而不是让微码去做。

你可以编写一个汇编器,拒绝使用或警告已知缓慢的指令,例如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-byte xchg eax, r32,甚至inc/loop作为4-byte test ecx,ecx/jnz的3-byte替代品)。在现实生活中,优化代码大小对于引导扇区或有趣的512字节或4k“演示文稿”等非常有用,它们只需极少量的可执行文件即可绘制出酷炫的图形并播放声音。或者对于只在启动期间执行一次的代码,较小的文件大小更好。或者在程序的生命周期内很少执行的代码,较小的I-cache占用比大量缓存更好(并遭受前端停顿等待代码获取)。这可以超过实际到达CPU并解码指令字节的最大效率。特别是如果与代码大小节省相比,那里的差异很小。
普通的汇编器只会抱怨那些无法编码的指令;性能分析不是它们的工作。它们的工作是将文本转换为输出文件中的字节(可选地带有对象文件元数据),使您能够为任何您认为有用的目的创建任何字节序列。

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

大多数导致代码变慢的方式都涉及到并非显然糟糕的指令,只是总体组合导致了缓慢。普遍检查性能问题需要同时查看超过1个指令。

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

mov  ah, 1
add  eax, 123

这些指令中的任何一个单独使用都可能成为高效代码的一部分,因此汇编程序(只需单独查看每个指令)不会警告您。尽管写AH本身就很可疑;通常是个坏主意。也许更好的例子是在早于SnB系列使其变得便宜之前的CPU上,在adc循环中使用dec/jnz的partial-flag stall。如果您正在寻找一种工具来警告您昂贵的指令,那么GAS并不是这样的工具。ADC/SBB和INC/DEC在某些CPU上紧密循环中存在问题静态分析工具如IACA或LLVM-MCA可能有所帮助,以显示代码块中的昂贵指令。它们旨在分析循环,但将代码块馈送给它们,无论是循环体还是其他内容,都将使它们显示每个指令在前端的uops数量,以及可能与延迟有关的信息。 什么是IACA以及如何使用它?(如何)使用LLVM机器代码分析器预测代码片段的运行时间? 但是,您需要更多地了解正在优化的管道,才能理解每个指令的成本取决于周围代码的情况(是否是长依赖链的一部分以及整体瓶颈是什么)。相关内容:
GCC/clang的-O0选项的最大作用是在语句之间没有进行任何优化,将所有内容都溢出到内存并重新加载,因此每个C语句都由一组单独的汇编指令完全实现,以便进行一致的调试,包括在任何断点处停止时修改C变量。但即使在一个语句的汇编块内部,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

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

我将这些函数放在了使用clang8.2 -O3的Godbolt编译器资源上每个函数都编译成了一个汇编指令,使用默认的-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 的输出结果非常糟糕,例如在计算+3时因为它破坏了指针而重新加载了p两次。我也可以使用全局变量而不是指针,以便给编译器一些无法优化的东西。对于这种情况,-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汇编输出中去除“噪音”?,了解更多关于编写函数以产生有趣汇编代码而不被优化掉的信息。


如果我使用-m32 -mtune=pentium编译,gcc -O3会避免内存目标地址加法: P5 Pentium微架构(来自1993年)不会解码为类似RISC的内部uops。复杂指令需要更长的运行时间,并且会使其按顺序双发射超标量流水线变得混乱。因此,GCC避免使用它们,使用更RISCy的x86指令子集,可以更好地对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的指令表,在顺序P5上执行add $imm,(mem)需要3个时钟周期,但可以在U或V管道中成对。我已经有一段时间没有阅读他的微体系结构指南中关于P5 Pentium部分的内容了,但是顺序管道绝对必须按程序顺序开始每个指令。(缓慢的指令,包括存储,可以稍后完成,但是这里的add和store依赖于前一个指令,因此它们肯定必须等待)。
如果您感到困惑,英特尔仍然使用奔腾和赛扬品牌名称来命名像Skylake这样的低端现代CPU。这不是我们讨论的问题。我们正在谈论原始的Pentium 微架构,现代的Pentium品牌CPU甚至与之无关。
GCC拒绝使用-mtune=pentium而不使用-m32,因为没有64位Pentium CPU。第一代Xeon Phi使用基于顺序P5 Pentium的Knight's Corner uarch,并添加了类似于AVX512的矢量扩展。但是gcc似乎不支持-mtune=knc。Clang支持,但选择在这里使用内存目标加法以及-m32 -mtune=pentium
LLVM项目直到P5过时后才开始,而gcc在P5广泛用于x86桌面时仍在积极开发和调整。因此,gcc仍然知道一些P5调优技巧,而LLVM实际上并不将其与解码内存目标指令为多个uops并可以无序执行的现代x86区别对待。

9
投反对票者认为,这篇文章过于冗长、啰嗦,并且花费了很长时间才能表达出要点,但他相信文章并没有错误。请解释你认为这篇文章存在的问题。 - Peter Cordes
我不是一个踩贴者,但我非常确定“冗长、啰嗦并且需要很长时间才能到达重点”是被踩的原因。这并不代表一个好的答案。 - Stjepan Bakrac
1
@StjepanBakrac:重新阅读问题后,它确实在询问什么是有效的,而我的答案立即得出了这一点。它很长,可能有点冗长,但再次审查它时,我认为我没有掩盖实际观点。我首先写的部分是代码示例,其中gcc和clang使用“-O3”发出内存目标ADD,这不是此答案所做的唯一观点。我希望大部分内容都是可理解和有用的,并且按照某种合理的顺序呈现,特别是在我发布上一个评论后整理问题之后。你觉得难以理解吗? - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接