是的,性能有所不同。
比较寄存器与零的最佳选择是test reg, reg。它以与 cmp reg,0 相同的方式设置 FLAGS, 并且至少与任何其他方式一样快1,并且代码大小更小。
(更好的是ZF 已经被设置reg 的指令正确设置,因此您可以直接分支、setcc 或 cmovcc。例如,the bottom of a normal loop 通常看起来像dec ecx / @987654353 @. 大多数 x86 整数指令“根据结果设置标志”,如果输出为 0,则包括 ZF=1。)。
or reg,reg 不能将 macro-fuse 和 JCC 放入任何现有 x86 CPU 上的单个 uop 中,并为以后读取 reg 的任何内容增加延迟,因为它将值重写到寄存器中。 cmp 的缺点通常只是代码大小。
脚注 1:可能有一个例外,但仅限于过时的 P6 系列 CPU(Intel 直至 Nehalem,2011 年被 Sandybridge 系列取代)。有关通过将相同的值重写到寄存器中来避免寄存器读取停顿的信息,请参见下文。其他微架构家族没有这样的摊位,而且or 永远比test 更胜一筹。
test reg,reg/and reg,reg/or reg,reg 的FLAGS 结果在所有情况下都与cmp reg, 0 相同(AF 除外),因为:
-
CF = OF = 0 因为test/and 总是这样做,而对于cmp 因为减零不能溢出或进位。
-
ZF, SF, PF 根据结果设置(即reg):reg&reg 用于测试,reg - 0 用于 cmp。
(AF 在test 之后未定义,但根据cmp 的结果设置。我忽略它,因为它真的很模糊:读取 AF 的唯一指令是 ASCII-adjust packed-packed-BCD 指令比如AAS和lahf/pushf。)
您当然可以检查reg == 0 (ZF) 以外的条件,例如通过查看 SF 来测试负符号整数。但有趣的事实:jl,带符号的小于条件,在 cmp 之后的某些 CPU 上比 js 更有效。与零比较后它们是等价的,因为 OF=0,所以 l 条件 (SF!=OF) 等价于 SF。
每个可以macro-fuse TEST/JL 的 CPU 也可以宏融合 TEST/JS,甚至是 Core 2。但是在 CMP byte [mem], 0 之后,总是使用 JL 而不是 JS 在符号位上分支,因为 Core 2 不能宏-保险丝。 (至少在 32 位模式下;Core 2 在 64 位模式下根本无法进行宏融合)。
签名比较条件还允许您执行 jle or jg 之类的操作,同时查看 ZF 和 SF!=OF。
test 比cmp 编码更短,立即为 0,在所有情况下,除了仍然是两个字节的 cmp al, imm8 特殊情况。
即便如此,由于宏融合的原因,test 更可取(在 Core2 上使用 jle 和类似的),并且因为完全没有立即数可以通过留下另一个指令可以借用的插槽来帮助 uop-cache 密度如果它需要更多空间(SnB 系列)。
将 test/jcc 宏融合到解码器中的单个 uop
Intel 和 AMD CPU 中的解码器可以在内部宏融合 test 和 cmp 以及一些条件分支指令到单个比较和分支操作中。当发生宏融合时,这为您提供每个周期 5 条指令的最大吞吐量,而没有宏融合时为 4 条。 (适用于 Core2 以后的 Intel CPU。)
最近的 Intel CPU 可以宏融合一些指令(如 and 和 add/sub)以及 test 和 cmp,但 or 不是其中之一。 AMD CPU 只能将 test 和 cmp 与 JCC 合并。请参阅x86_64 - Assembly - loop conditions and out of order,或直接参考Agner Fog's microarch docs 了解哪个 CPU 可以宏熔断什么的详细信息。 test 可以在 cmp 不能的某些情况下进行宏融合,例如js。
几乎所有简单的 ALU 运算(按位布尔运算、加/减运算等)都在一个周期内运行。它们在通过乱序执行管道跟踪它们时都有相同的“成本”。英特尔和 AMD 使用晶体管来制造快速执行单元,以便在单个周期内添加/删除/任何内容。是的,按位OR 或AND 更简单,并且可能使用的功率略低,但仍然不能比一个时钟周期快。
or reg, reg 将另一个延迟周期添加到依赖链中,以用于后续需要读取寄存器的指令。它是操作链中的x |= x,它会导致您想要的价值。
您可能认为额外的寄存器写入还需要额外的物理寄存器文件 (PRF) 条目,而不是 test,但情况可能不是。 (有关 PRF 容量对无序执行的影响的更多信息,请参阅 https://blog.stuffedcow.net/2013/05/measuring-rob-capacity/。
test 必须在某处产生其 FLAGS 输出。至少在 Intel Sandybridge 系列 CPU 上,当一条指令产生一个寄存器和一个 FLAGS 结果时,它们会一起存储在同一个 PRF 条目中。 (来源:我认为是一项英特尔专利。这是凭记忆进行的,但似乎是一个明显合理的设计。)
像cmp 或test 这样仅 产生 FLAGS 结果的指令也需要一个 PRF 条目作为其输出。大概这稍微更糟:旧的物理寄存器仍然“活动”,被引用为由一些旧指令写入的体系结构寄存器的值的持有者。现在架构 EFLAGS(或更具体地说,分别重命名的 CF 和 SPAZO 标志组)指向由重命名器更新的 RAT(寄存器分配表)中的这个新物理寄存器。当然,下一条写 FLAGS 的指令将覆盖它,一旦所有读者都读取并执行了 PR,就可以释放该 PR。这不是我在优化时考虑的事情,我认为在实践中往往并不重要。
P6 系列寄存器读取停止:可能向上 or reg,reg
P6 系列 CPU(PPro / PII 到 Nehalem)具有有限数量的寄存器读取端口,用于发布/重命名阶段从永久寄存器文件中读取“冷”值(不是从运行中指令转发的) ,但最近写入的值可直接从 ROB 获得。不必要地重写寄存器可以使其再次存在于转发网络中,以帮助避免寄存器读取停顿。 (见Agner Fog's microarch pdf)。
故意重写具有相同值的寄存器以使其保持“热”实际上可能是对 P6 上一些周围代码情况的优化。早期的 P6 系列 CPU 根本无法进行宏融合,因此使用 and reg,reg 而不是 test 甚至不会错过这一点。但是 Core 2(在 32 位模式下)和 Nehalem(在任何模式下)可以宏融合 test/jcc,所以你错过了。
(and 等效于 P6 系列上的 or,但如果您的代码曾经在 Sandybridge 系列 CPU 上运行,则不会那么糟糕:它可以宏融合 and/jcc 但不能or/jcc。寄存器的 dep 链中的额外延迟周期仍然是 P6 的劣势,特别是如果涉及它的关键路径是主要瓶颈。)
P6 系列现在已经非常过时了(Sandybridge 在 2011 年取代了它),Core 2 之前的 CPU(Core、Pentium M、PIII、PII、PPro)非常已经过时并进入了逆向计算领域,尤其是对于性能很重要的任何事情。您可以在优化时忽略 P6 系列,除非您有特定的目标机器(例如,如果您有一台老旧的 Nehalem Xeon 机器),或者您正在为剩下的少数用户调整编译器的 -mtune=nehalem 设置。
如果您要在 Core 2 / Nehalem 上调整某些东西以加快速度,请使用 test,除非分析表明寄存器读取停顿在特定情况下是一个大问题,并且使用 and 实际上可以解决它。
在早期的 P6 系列中,and reg,reg 可能可以作为您的默认代码生成选项,前提是该值不是有问题的循环承载的 dep 链的一部分,但稍后会读取。或者,如果是,但还有一个特定的寄存器读取停顿,您可以使用 and reg,reg 修复。
如果您只想测试完整寄存器的低 8 位,test al,al 避免写入部分寄存器,在 P6 系列上,部分寄存器与完整 EAX/RAX 分开重命名。 or al,al 更糟糕的是,如果您稍后阅读 EAX 或 AX: partial-register stall on P6-family。(Why doesn't GCC use partial registers?)
不幸or reg,reg成语的历史
or reg,reg 成语可能来自 8080 ORA A,正如 in a comment 指出的那样。
8080's instruction set 没有test 指令,因此您根据值设置标志的选择包括ORA A 和ANA A。 (请注意,A 寄存器目标已包含在这两条指令的助记符中,并且没有对不同寄存器进行或操作的指令:除了mov,它是一个单地址机器,而大多数情况下8086 is a 2-address machine说明。)
8080 ORA A 是通常的首选方式,因此当人们移植他们的 asm 源代码时,这种习惯可能会延续到 8086 汇编编程中。 (或使用自动工具;8086 was intentionally designed for easy / automatic asm-source porting from 8080 code。)
这个坏习惯继续被初学者盲目使用,大概是由那些在当天学习并传递它的人教的,没有考虑无序执行的明显关键路径延迟缺点。 (或者其他更微妙的问题,比如没有宏观融合。)
Delphi's compiler reportedly uses or eax,eax,这在当时(在 Core 2 之前)可能是一个合理的选择,假设寄存器读取停顿比延长 dep 链对于接下来读取它更重要。 IDK 如果这是真的,或者他们只是在使用古老的成语而不考虑它。
不幸的是,当时的编译器编写者不知道未来,因为 and eax,eax 在 Intel P6 系列上的性能与 or eax,eax 完全相同,但在其他 uarches 上的性能较差,因为 and 可以宏融合在桑迪布里奇家族。 (参见上面的 P6 部分)。
内存中的值:可能使用cmp 或将其加载到注册表中。
要测试内存中的值,您可以cmp dword [mem], 0,但 Intel CPU 不能宏融合同时具有立即数和内存操作数的标志设置指令。如果你要在分支的一侧使用比较后的值,你应该 mov eax, [mem] / test eax,eax 或其他东西。如果不是,则任何一种方式都是 2 个前端 uop,但这是代码大小和后端 uop 数量之间的权衡。
虽然请注意某些寻址模式won't micro-fuse either on SnB-family: RIP-relative + immediate 不会在解码器中进行微融合,或者索引寻址模式将在 uop-cache 之后取消分层。无论哪种方式都会导致cmp dword [rsi + rcx*4], 0 / jne 或 [rel some_static_location] 的 3 个融合域微指令。
在 i7-6700k Skylake 上(通过性能事件 uops_issued.any 和 uops_executed.thread 进行测试):
-
mov reg, [mem](或movzx)+test reg,reg / jnz在融合域和非融合域中都有2 uop,无论寻址模式如何,或者movzx而不是mov。无需微熔;进行宏熔断。
-
cmp byte [rip+static_var], 0 + jne。 3个融合,3个未融合。 (前端和后端)。 RIP 相对 + 直接组合可防止微融合。它也不会宏观融合。代码量更小,但效率更低。
-
cmp byte [rsi + rdi], 0(索引地址模式)/jne 3 个已融合,3 个未融合。解码器中的微熔丝,但在问题/重命名时未层压。不会进行宏融合。
-
cmp byte [rdi + 16], 0 + jne 2 个融合的,3 个未融合的 uop。由于简单的寻址模式,确实发生了 cmp load+ALU 的微融合,但立即阻止了宏融合。与负载 + 测试 + jnz 差不多:更小的代码大小,但多了 1 个后端 uop。
如果您在寄存器中有0(如果您想比较布尔值,则为1),您可以使用cmp [mem], reg / jne 获得更少的微指令,低至1 个融合域, 2未融合。但是 RIP 相对寻址模式仍然没有宏融合。
编译器倾向于使用 load + test/jcc,即使以后不使用该值。
您也可以使用test dword [mem], -1 测试内存中的值,但不要这样做。由于test r/m16/32/64, sign-extended-imm8 不可用,因此对于大于字节的任何内容,它的代码大小都比cmp 差。 (我认为设计理念是,如果您只想测试寄存器的低位,只需 test cl, 1 而不是 test ecx, 1,并且像 test ecx, 0xfffffff0 这样的用例非常罕见,不值得花操作码。特别是因为该决定是针对 16 位代码的 8086 做出的,它只是 imm8 和 imm16 之间的区别,而不是 imm32。)
(我写的是 -1 而不是 0xFFFFFFFF,所以它与 byte 或 qword 相同。~0 将是另一种写法。)
相关: