添加这些 NOP 的不是我,而是一个汇编程序。它非常愚蠢,不支持对齐选项 (BASM) - 只有一个选项 - 边界大小。
我不知道“BASM”是什么,我在网上也找不到任何关于它的引用(this 除外,它显然不是 x86),但如果它不支持多字节 NOP ,你真的需要一个不同的汇编程序。这只是英特尔和 AMD 架构手册中的基本内容,年。 Gnu 汇编器可以为 ALIGN 指令执行此操作,Microsoft 的 MASM 也可以。开源的 NASM 和 YASM 汇编器也支持这一点,并且它们中的任何一个都可以轻松地集成到任何现有的构建系统中。
我所说的多字节 NOP 是指以下内容,您可以在 AMD 和 Intel 处理器手册中找到:
Length | Mnemonic | Opcode Bytes
---------|-------------------------------------------|-------------------------------------
1 byte | NOP | 90
2 bytes | 66 NOP | 66 90
3 bytes | NOP DWORD [EAX] | 0F 1F 00
4 bytes | NOP DWORD [EAX + 00H] | 0F 1F 40 00
5 bytes | NOP DWORD [EAX + EAX*1 + 00H] | 0F 1F 44 00 00
6 bytes | 66 NOP DWORD [EAX + EAX*1 + 00H] | 66 0F 1F 44 00 00
7 bytes | NOP DWORD [EAX + 00000000H] | 0F 1F 80 00 00 00 00
8 bytes | NOP DWORD [EAX + EAX*1 + 00000000H] | 0F 1F 84 00 00 00 00 00
9 bytes | 66 NOP DWORD [EAX + EAX*1 + 00000000H] | 66 0F 1F 84 00 00 00 00 00
两家制造商提供的序列建议在 9 个字节后略有不同,但这么长的 NOP ……并不是很常见。并且可能并不重要,因为带有过多前缀的极长 NOP 指令无论如何都会降低性能。这些一直可以追溯到 Pentium Pro,因此它们在今天得到了普遍的支持。
Agner Fog 对多字节 NOP 有这样的看法:
多字节 NOP 指令的操作码为0F 1F + 一个虚拟内存操作数。多字节 NOP 指令的长度可以通过在虚拟内存操作数中添加 1 或 4 个字节的位移和一个 SIB 字节以及添加一个或多个 66H 前缀来调整。过多的前缀可能会导致较旧的微处理器出现延迟,但在大多数处理器上至少有两个前缀是可以接受的。以这种方式可以构造不超过 10 个字节的任何长度的 NOP,前缀不超过两个。如果处理器可以处理多个前缀而不会受到惩罚,那么长度可以达到 15 个字节。
所有多余的/多余的前缀都会被忽略。当然,优势在于许多较新的处理器对多字节 NOP 的解码率较低,因此效率更高。它们将比一系列 1 字节 NOP (0x90) 指令更快。
也许比多字节 NOP 更好的对齐方式是使用较长形式的指令,这些指令已经在代码中使用。这些较长的编码不再需要执行(它们只影响解码带宽),因此它们比 NOP 更快/更便宜。例如:
- 使用 mod-reg-r/m 字节形式的指令,例如
INC、DEC、PUSH、POP 等,而不是短版本
- 使用更长的等效指令,例如
ADD 代替 INC 或 LEA 代替 MOV。
- 编码较长形式的立即数操作数(例如,32 位立即数而不是符号扩展的 8 位立即数)
- 添加 SIB 字节和/或不必要的前缀(例如,长模式下的操作数大小、段和 REX)
Agner Fog 的手册详细介绍并提供了这些技术的示例。
我不知道有任何汇编程序会自动为您进行这些转换/优化(出于显而易见的原因,汇编程序会选择最短的版本),但它们通常有一个严格的模式,您可以在其中强制使用特定的编码,或者您可以手动发出指令字节。无论如何,您只能在对性能高度敏感的代码中执行此操作,而工作实际上会得到回报,因此大大限制了所需工作的范围。
我想知道添加这些 NOP 所带来的额外代码大小和额外执行时间是否物有所值。
一般来说,不会。虽然数据对齐非常重要并且本质上是免费的(尽管二进制文件的大小),但代码对齐的重要性要小得多。在紧密循环中的某些情况下,它可以产生显着的差异,但这仅在您的代码中的热点中很重要,您的分析器已经识别了这些热点,然后您可以执行操作以在必要时手动对齐代码。否则,我不会担心。
对齐函数是有意义的,因为它们之间的填充字节永远不会执行(而不是在这里使用 NOP,你会经常看到 INT 3 或无效指令,如 UD2),但我不会理所当然地调整所有分支目标在函数内。仅在已知的关键内部循环中执行此操作。
Agner Fog 一如既往地谈到这一点,并且说得比我好:
大多数微处理器以对齐的 16 字节或 32 字节块的形式获取代码。如果一个重要的子程序入口或跳转标签碰巧接近一个 16 字节块的末尾,那么微处理器在获取该代码块时只会得到几个有用的代码字节。在解码标签后的第一条指令之前,它可能也必须获取接下来的 16 个字节。这可以通过将重要的子程序入口和循环入口对齐 16 来避免。对齐 8 将确保在第一次取指时可以加载至少 8 个字节的代码,如果指令很小,这可能就足够了。如果子例程是关键热点的一部分并且前面的代码不太可能在相同的上下文中执行,我们可以按缓存行大小(通常为 64 字节)对齐子例程条目。
代码对齐的一个缺点是一些缓存空间在对齐的代码条目之前丢失到空白空间。
在大多数情况下,代码对齐的影响很小。所以我的建议是只在最关键的情况下对齐代码,比如关键的子程序和关键的最内层循环。
对齐子例程条目很简单,只需在子例程条目之前放置任意数量的NOP,以使地址可根据需要被 8、16、32 或 64 整除。汇编器使用ALIGN 指令执行此操作。插入的NOP 不会降低性能,因为它们永远不会被执行。
对齐循环条目的问题更大,因为前面的代码也被执行了。最多可能需要 15 个NOP 来将循环条目对齐 16。这些NOP 将在进入循环之前执行,这将花费处理器时间。使用更长的指令比使用大量单字节NOP 更有效。最好的现代汇编程序会这样做,并使用像 MOV EAX,EAX 和
LEA EBX,[EBX+00000000H] 填充 ALIGN nn 语句之前的空格。 LEA 指令特别灵活。可以通过不同地添加一个 SIB 字节、一个段前缀和一个或四个零字节的偏移量来给出类似 LEA EBX,[EBX] 的任何长度的指令,从 2 到 8。不要在 32 位模式下使用两字节偏移,因为这会减慢解码速度。并且不要使用多个前缀,因为这会减慢旧英特尔处理器的解码速度。
使用MOV RAX,RAX和LEA RBX,[RBX+0]之类的伪NOP作为填充符的缺点是对寄存器有错误的依赖,会占用执行资源。最好使用多字节 NOP 指令,可以调整到所需的长度。多字节 NOP 指令适用于所有支持条件移动指令的处理器,即 Intel PPro、P2、AMD Athlon、K7 及更高版本。
对齐循环条目的另一种方法是将前面的指令编码为比必要更长的方式。在大多数情况下,这不会增加执行时间,但可能会增加指令获取时间。
他还继续展示了通过移动前面的子例程条目来对齐内部循环的另一种方法的示例。这有点尴尬,即使在最好的汇编程序中也需要进行一些手动调整,但它可能是最优化的机制。同样,这仅在热路径上的关键内部循环中很重要,您可能已经在其中进行了挖掘和微优化。
有趣的是,我已经对正在优化的代码进行了多次基准测试,但并没有发现对齐循环分支目标有什么好处。例如,我正在编写一个优化的strlen 函数(Gnu 库有,但微软没有),并尝试将主内循环的目标对齐在 8 字节、16 字节和 32 字节边界上。这些都没有太大的不同,尤其是与我在重写代码时取得的其他重大性能进步相比时。
请注意,如果您不针对特定处理器进行优化,您可能会疯狂地尝试寻找最佳的“通用”代码。当谈到对齐对速度的影响时,things can vary wildly。糟糕的对齐策略通常比没有对齐策略更糟糕。
二次幂边界总是一个好主意,但这很容易实现,无需任何额外的努力。同样,不要忽视对齐,因为它可能很重要,但出于同样的原因,不要执着于尝试对齐每个分支目标。
在最初的 Core 2(Penryn 和 Nehalem)微架构上,对齐曾经是一个更大的问题,其中严重的解码瓶颈意味着,尽管问题宽度为 4,但您很难保持其执行单元忙碌。随着在 Sandy Bridge 中引入 µop 缓存(Pentium 4 中为数不多的好功能之一,最终被重新引入 P6 扩展系列),前端吞吐量显着增加,这变得不那么简单了。问题。
坦率地说,编译器也不擅长进行这些类型的优化。 GCC 的-O2 开关意味着-falign-functions、-falign-jumps、-falign-loops 和-falign-labels 开关,默认首选项为在8 字节边界上对齐。这是一种非常直率的方法,而且里程数各不相同。正如我在上面链接的那样,关于禁用这种对齐并使用紧凑代码是否实际上可以提高性能的报告各不相同。此外,您将看到编译器所做的最好的事情就是插入多字节 NOP。我还没有看到使用较长形式的指令或为了对齐目的而大幅重新排列代码的方法。所以我们还有很长的路要走,这是一个非常难以解决的问题。 Some people are working on it,但这只是表明问题的真正棘手程度:“指令流中的微小变化,例如插入单个 NOP 指令,可能会导致显着的性能差异,并具有暴露的效果” (请注意,虽然很有趣,但那篇论文来自早期的 Core 2 天,正如我之前提到的,它遭受了比大多数人更多的错位惩罚。我不是确定你是否会在今天的微架构上看到同样巨大的改进,但我不能肯定地说,因为我还没有进行测试。也许谷歌会雇用我,我可以发表另一篇论文?)
Skylake 在一个周期内可以执行多少个 1 字节 NOP?其他与 Intel 兼容的处理器(如 AMD)呢?我不仅对 Skylake 感兴趣,还对其他微架构感兴趣。执行 15 个 NOP 的序列可能需要多少个周期?
可以通过查看 Agner Fog 的 instruction tables 并搜索 NOP 来回答此类问题。我不会费心将他的所有数据提取到这个答案中。
不过,一般来说,只要知道 NOP 不是免费的即可。尽管它们不需要执行单元/端口,但它们仍然必须像任何其他指令一样通过管道运行,因此它们最终会受到处理器问题(和/或退役)宽度的限制。这通常意味着您可以在每个时钟执行 3 到 5 个 NOP。
NOP 仍然会占用 µop 缓存中的空间,这意味着代码密度和缓存效率会降低。
在许多方面,您可以将NOP 视为等同于XOR reg, reg 或MOV,由于寄存器重命名而在前端被省略。