为什么在x86上代码应该对齐到偶地址边界?

26

我正在学习Kip Irvine的"Assembly Language for x86 Processors, sixth edition",并且非常喜欢它。

我刚刚在下面的段落中读到了NOP助记符:

有时编译器和汇编程序员使用它[NOP]将代码对齐到偶数地址边界。

给出的示例是:

00000000   66 8B C3   mov ax, bx
00000003   90         nop
00000004   8B D1      mov edx, ecx

该书中提到:

x86处理器被设计为可以更快地从双字地址加载代码和数据。

我的问题是:这个原因是因为针对该书所提到的x86处理器(32位),CPU的字长为32位,因此它可以一次性加载带有NOP指令的指令并处理它们吗?如果是这样,我假设一个具有四字字长的64位处理器会使用五个字节的假想代码加上一个nop来实现这一点?

最后,在编写代码后,我应该通过使用NOP进行调整以进行优化,还是像文本所暗示的那样,编译器(在我这种情况下,是MASM)会为我完成这项工作?


9
现代处理器架构的所有信息都在http://www.agner.org/optimize/上。指令所需的对齐方式与字长无关,对于现代英特尔处理器而言,它是16个字节。我不想扫兴,但你不应该相信一本书对“x86处理器”的性能做出笼统的陈述。每个型号都有不同的特点。 - Pascal Cuoq
谢谢你的评论!你没有破坏我的乐趣 - 乐趣在于学习,我从你这里学到了更多!我也会去查看那个网站。 - Scott Davies
这本书看起来非常过时。16位x86真的很古老,说实话,我不认为教授这些东西有什么价值,即使是为了教育目的。也许可以作为一个反例,展示如何“不”设计处理器/汇编语言。 - Gunther Piez
3个回答

26

当代码在单词(对于8086)或DWORD(80386及更高版本)边界上执行时,它会更快地执行,因为处理器获取整个(D)word。因此,如果指令未对齐,则加载时会出现停顿。

然而,并不是每个指令都可以进行DWORD对齐。我想你可以这样做,但那样会浪费空间,处理器也必须执行NOP指令,这会抵消任何对指令对齐的性能提升。

实际上,将代码对齐到 DWORD(或其他)边界只有在指令是分支指令的目标时才有帮助,编译器通常会对函数的第一条指令进行对齐,但不会对可以通过落空到达的分支目标进行对齐。例如:

MyFunction:
    cmp ax, bx
    jnz NotEqual
    ; ... some code here
NotEqual:
    ; ... more stuff here

编译器通常会对MyFunction进行对齐,因为它是一个分支目标(通过call到达),但是不会对NotEqual进行对齐,因为这样做会插入NOP指令,而这些指令在跳转时必须执行。这会增加代码大小并使流程穿越变得更慢。

如果你只是学习汇编语言,我建议不要过于担心这种可以给你带来微小性能提升的事情。先把代码写出来让它正常工作。然后进行性能分析,如果在查看配置文件数据后认为有必要,则对函数进行对齐。

汇编程序一般不会自动对其进行对齐。


2
谢谢您的回复!是的,我同意 - 我现在会坚持基础知识,但无法抵制优化的想法。这是迷人的东西! - Scott Davies
总的来说,这是一个非常出色的答案。在汇编层面上进行分析并不总是相关的,因为如果你必须采用这种方法,那么你可能已经对一些 C 或 C++ 代码进行了分析,并发现了需要解决的问题,这才导致了最初的汇编。你可以(而且确实应该)使用 rdtsc 指令(读取时间戳计数器)对需要量化基本性能水平但尚未完成的代码进行计时,然后计算差异。这仅适用于 Pentium MMX 及以上版本和 32 位模式。 - Olof Forshell
2
@Scott Davies:当你使用汇编语言编程时思考优化并不是错误的。很可能你这么做是因为你想要一些优化。但需要注意的是,这本书中给出的优化技巧在25年前是正确的,但现在已经过时甚至是错误的。即使它应该在16位模式下运行,你也不想用nops填充指令以使它们停留在一个偶数地址上。如果你想阅读一些有用、让人着迷的东西,我强烈推荐访问agner.org的优化手册。 - Gunther Piez
1
编译器仍然会对循环顶部(因为NOP将在每N个循环迭代中执行一次)以及函数入口点进行对齐。但是,除非它是一个没有穿透的块,否则通常不会对“if”/“else”的分支目标进行对齐。现代x86 CPU在从I-cache获取时更加高效,这也减少了如果目标接近16字节提取块的末尾可能产生的惩罚。(https://agner.org/optimize/)当然,Sandybridge / Zen中的uop缓存也使得代码密度在大多数情况下比对齐更有用,除非您不希望热循环从32字节块的末尾开始。 - Peter Cordes

8

由于(16位)处理器只能从偶地址的内存中获取值,这是由于其特殊的布局:它被分为两个每个1字节的“银行”,因此数据总线的一半连接到第一个银行,另一半连接到另一个银行。现在,假设这些银行是对齐的(就像我的图片一样),处理器可以获取在同一“行”上的值。

  bank 1   bank 2
+--------+--------+
|  8 bit | 8 bit  |
+--------+--------+
|        |        |
+--------+--------+
| 4      | 5      | <-- the CPU can fetch only values on the same "row"
+--------+--------+
| 2      | 3      |
+--------+--------+
| 0      | 1      |
+--------+--------+
 \      / \      /
  |    |   |    |
  |    |   |    |

 data bus  (to uP)

现在,由于这种获取限制,如果CPU被迫获取位于奇地址上的值(假设为3),它必须先获取2和3的值,然后获取4和5的值,丢弃2和5的值,然后合并4和3的值(你提到了x86,它具有小端内存布局)。
这就是为什么最好将代码(和数据!)放在偶数地址上。
PS:在32位处理器上,代码和数据应该对齐在可被4整除的地址上(因为有4个银行)。
希望我表达清楚。 :)

然后将4和5的值丢弃,再将2和5的值连接起来,最后将4和3连接起来。你可以解释一下这是什么意思吗? - Siva Krishna Aleti
假设你想要加载由第三个和第四个字节组成的单词。CPU会在地址2处加载该单词(第一次内存访问),并在地址4处加载该单词(第二次内存访问);存储在地址2和5处的字节将被丢弃,因为它们不需要,而存储在3和4地址处的字节将被合并。 - BlackBear
在某些CPU上,循环的顶部应该对齐到16,如果你不必跳过太多字节,就像GAS.p2align 4,,10,GCC使用(https://godbolt.org/z/3WPGM8feK-它还使用无条件的8字节对齐,这可能不值得)。这个答案是针对没有缓存的CPU(如386)的古老建议,这些CPU只能以2或4字节的块进行代码提取,而不是从I-cache一次提取16或32字节。(自1995年Pentium Pro以来的英特尔)。https://agner.org/optimize/.具有uop缓存和非对齐I-cache提取的现代CPU通常对代码对齐要求更少敏感。 - Peter Cordes
但是,是的,这就是Kip Irvine的旧书所谈论的效果类型,这个答案仅仅忽略了一个要点,即它是过时的建议。 - Peter Cordes

3
问题不仅限于指令获取。很遗憾,程序员早期并没有意识到这一点,并经常因此受到惩罚。x86架构让人们变得懒惰。当转换到其他架构时会变得困难。
这与数据总线的性质有关。例如,当你有一个32位宽的数据总线时,从内存读取是对齐在该边界上的。在这种情况下,低两个地址位通常被忽略,因为它们没有意义。因此,如果您从地址0x02执行32位读取,无论是作为指令获取的一部分还是从内存中读取,都需要两个内存周期。需要从地址0x00读取两个字节和从0x04读取另外两个字节。这将使时间加倍,在指令获取时会阻塞流水线。这种性能下降是戏剧性的,绝不是对数据读取的浪费优化。将数据对齐在自然边界上,并将结构和其他项目调整为这些大小的整数倍,无需任何其他努力即可看到高达两倍的性能提升。同样,即使只计数到10,使用int而不是char作为变量也可以更快。添加nop以对齐分支目标通常不值得努力。不幸的是,x86是可变字长、基于字节的,你经常遭受这些低效率。如果你被困在一个角落里,需要从循环中挤出更多时钟,不仅应该对齐与总线大小匹配的边界(现在是32位或64位),还应该对齐缓存行边界,并尝试将该循环保持在一个或两个缓存行内。同样的故事,例如,如果你有一个地址为0xFFFC的分支目标,如果不在缓存中,就必须获取一个缓存行,这并不意外,但再过一两个指令(四个字节)就需要另一个缓存行。如果目标是0x10000,根据您函数的大小自然而然地,您可能已经在一个缓存行中完成了这项工作。如果这是一个经常调用的函数,并且另一个经常调用的函数在一个足够相似的地址上,这两个函数会互相驱逐,你将运行得慢两倍。这是x86帮助的地方,因为可变指令长度可以将更多的代码打包到缓存行中,比其他常用的架构更好。

在x86和指令获取方面,你真的无法获胜。从指令角度来看,尝试手动调整x86程序往往是徒劳的。不同处理器核心及其微妙差别的数量很多,你可能会在某一台计算机上的某个处理器上取得进展,但是同样的代码会使其他x86处理器在其他计算机上运行速度变慢,有时甚至不到原来的一半。最好是通用高效,稍微有些粗糙,这样它每天都可以在所有计算机上正常运行。数据对齐将在不同处理器和计算机之间显示出改进,但指令对齐则不会。


1
变量指令长度并非全是坏处。熟练的编译器/程序员可以/将使用较短的指令形式,导致更密集的代码,进而卸载代码缓存。要从L1、L2、L3或RAM访问代码或数据,可以使用大约3、10、30和100个时钟周期的停顿成本。从L2中找到的东西到L1会导致额外的7(10-3)个周期。L3(istdo L1&2)17(30-10-3),RAM(istdo caches)67(100-30-10-3)。从这个角度来看,密集的代码是相当不错的。 - Olof Forshell
2
顺便提一下,我们现在已经进入了2019年,处理器在这方面有了巨大的改进:自 Nehalem(2008年)以来,未对齐的读/写操作除非跨越缓存行,否则不会产生额外的成本(即使在这种情况下,情况也因情况而异)。这使得数据对齐变得不那么重要。 - Gabriel Ravier

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