前端开发入门到精通的在线学习网站

网站首页 > 资源文章 正文

不懂汇编,叫什么程序员?(终章)(汇编应该怎么学)

qiguaw 2024-10-04 19:43:40 资源文章 13 ℃ 0 评论

#来点儿干货#

这是汇编部分的最后一章,可能大部分的程序员现阶段已经接触不到汇编部分了,但是了解一下底层部分,还是可以帮助我们对整个的计算机体系结构有一个更清楚的认知的。

机器码布局(Machine Code Layout)

程序员们喜欢在将CPU的流水线划分为两部分:前端和后端,前段指的是指令从内存中获取并解码的部分,后端代表指令被调度并最终执行的部分。

通常,性能受到执行阶段的限制,因此,我们大部分的努力将用于围绕后端进行优化。

但有时候,相反的情况也会发生,比如前端部分无法足够快地向后端提供指令来饱和它。这可能由许多原因造成,所有这些原因最终都与机器码在内存中的布局有关,这些原因会以奇怪的方式影响程序性能。移除未使用的代码、交换“if”分支,甚至改变函数声明的顺序都有可能会导致性能的提高或恶化。

CPU前端(CPU Front-End)

在机器码被转换为指令和让CPU理解程序员想要做什么之前,CPU首先需要经历的两个重要阶段:获取和解码。

获取阶段,CPU简单地从主内存中加载固定大小的字节块,这些块中包含指令的二进制编码。在x86结构的CPU上,这个块的大小通常是32字节,在不同架构的机器上可能会有所不同。一个很重要的细节就是这个块必须对齐:意味着块的地址必须是其大小的倍数(在我们的例子中是32B)。

接下来是解码阶段:CPU查看这些字节块,丢弃指令指针之前的内容,并将剩下的内容分割成指令。机器指令使用可变数量的字节进行编码:像inc rax这样简单且非常常见的指令占用一个字节,而某些带有编码常量和行为前缀的不常见指令,可能占用多达15个字节。因此,从32字节块中可能解码出可变数量的指令,但不会超过特定于机器的限制,称为解码宽度。在我的CPU(Zen 2)上,解码宽度是4,这意味着在每个周期中,最多可以解码4条指令并传递到下一个阶段。

这些阶段以流水线方式工作:如果CPU能够预测(或预测)它接下来需要哪个指令块,那么获取阶段就不会等待当前块中的最后一条指令被解码,而是立即加载下一个块。

代码对齐

当其他条件相同时,编译器通常更喜欢较短机器码的指令,因为这样可以在单个32B的获取块中放入更多的指令,同时也会减少二进制文件的大小。但有时可能反过来更好,因为获取到的指令块必须对齐。

比如,我们需要执行一个从32B对齐块的最后一个字节开始的指令序列。可能能够在不需要额外延迟的情况下执行第一条指令,但对于后续指令,我们必须等待一个额外的周期来执行另一个指令获取。如果代码块在32B边界上对齐,那么最多可以同时解码并执行4条指令(除非它们特别长或相互依赖)。

考虑到这一点,编译器有时会执行看似多余的优化:它们有时更喜欢具有较长机器码的指令,甚至插入不做任何事情的虚拟指令1,以便将关键跳转位置对齐到合适的边界上。

在GCC中,可以使用-falign-labels=n标志来指定特定的对齐策略,如果想更具选择性,可以用 -function、-loops 或 -jumps 替换 -labels。在开启 -O2 和 -O3 优化级别时,会默认启用不设置特定对齐的策略,这种情况下编译器会使用(通常合理的)特定于机器的默认值。

指令缓存

指令的存储和获取的内存系统,和数据内存系统大致相同,除了用单独的指令缓存替换掉较低层的缓存(因为我们不希望随机的数据读取将处理它的指令代码挤掉)。

在以下情况下,指令缓存至关重要:

  1. 我们不知道接下来要执行哪些指令,并需要以低延迟获取下一个字节块,
  2. 正在执行一长串冗长但快速处理的指令,并需要较高的带宽。

因此,对于具有大量机器码的程序,内存系统可能成为瓶颈。这种情况限制了我们之前讨论的一些典型优化技术的适用性:

  • 内联函数并不总是最佳的,因为它降低了代码共享并增加了二进制文件的大小,需要更多的指令缓存。
  • 展开循环只在一定程度上有益,即使在编译时已知迭代次数:在某个时刻,CPU必须从主内存中获取指令和数据,在这种情况下,它很可能受到内存带宽的限制。
  • 大量的代码对齐增加了二进制文件的大小,同样需要更多的指令缓存。与缺失缓存并等待指令从主内存中获取相比,多花一个周期进行指令获取是一个较小的代价。

另一个方法是,把频繁使用的指令序列放在同一缓存行和内存页面上,这样可以提高缓存局部性。为了提高指令缓存的利用率,应该将热代码与热代码组合在一起,将冷代码与冷代码组合在一起,并尽可能删除死代码(未使用的代码)。如果想进一步探索这个想法,请查看Facebook的二进制优化和布局工具(Binary Optimization and Layout Tool),该工具最近已经被合并到LLVM中。

不均等分支(Unequal Branches)

我们来看下面一个例子,我们需要一个计算整数区间长度的辅助函数。它接受两个参数,x和y,但为了方便,这个区间可能是[x,y],或[y,x],取决于x和y的大小。在C语言中,我们可能会像下面这样写:

int length(int x, int y) {
    if (x > y)
        return x - y;
    else
        return y - x;
}

在x86架构的汇编中,实现它的方式有很多种,会显著影响性能。让我们从尝试将这段代码直接映射到汇编开始:

length:
    cmp  edi, esi
    jle  less
    ; x > y
    sub  edi, esi
    mov  eax, edi
done:
    ret
less:
    ; x <= y
    sub  esi, edi
    mov  eax, esi
    jmp  done

尽管初始的C代码中,if和else的两个执行分支看起来非常对称,汇编版本却不是。这导致了一个有趣的点,即一个分支会比另一个稍微快一点执行:如果 x > y,则CPU只需要执行 cmp 和 ret 之间的5条指令,如果函数对齐,这些指令都会一次性获取;而在 x <= y 的情况下,需要两次额外的跳转。

如果假设x > y 的情况不太可能发生(为什么会有人计算反向区间的长度呢?),更像是几乎从不发生的异常。我们可以检测这种情况,并简单地交换 x 和 y:

int length(int x, int y) {
    if (x > y)
        swap(x, y);
    return y - x;
}

汇编代码将如下进行,就像通常对于没有else的if模式一样:

length:
    cmp  edi, esi
    jle  normal     ; 如果 x <= y,不需要交换,我们可以跳过 xchg
    xchg edi, esi
normal:
    sub  esi, edi
    mov  eax, esi
    ret

现在指令总长度是6,从8减少。但它仍然没有为我们假设的情况完全优化:如果我们认为 x > y 从不发生,那么在加载从不执行的 xchg edi, esi 指令时就是在浪费。我们可以通过将其移到正常执行路径之外来解决这个问题:

length:
    cmp  edi, esi
    jg   swap
normal:
    sub  esi, edi
    mov  eax, esi
    ret
swap:
    xchg edi, esi
    jmp normal

这种技术在一般处理异常情况时非常方便,在高级代码中,我们可以给编译器一个提示,表明某个分支比另一个更可能:

int length(int x, int y) {
    if (x > y) [[unlikely]]
        swap(x, y);
    return y - x;
}

当我们知道一个分支很少被采用时,这种优化才有益。不是这种情况时,有很多比代码布局更好的方法来促使编译器避免任何分支——在这种情况下,通过使用特殊的“条件移动”指令替换它,大致对应于三元表达式 (x > y ? y - x : x - y) 或调用 abs(x - y):

length:
    mov   edx, edi
    mov   eax, esi
    sub   edx, esi
    sub   eax, edi
    cmp   edi, esi
    cmovg eax, edx  ; "mov if edi > esi"
    ret

消除分支也是一个相当重要的话题,我们将后面的文章中更详细地讨论它。

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表