用CMP reg,0和OR reg,reg来测试一个寄存器是否为零?

21

使用以下代码有执行速度差异吗:

cmp al, 0
je done

以及以下内容:

or al, al
jz done

我知道 JE 和 JZ 指令是相同的,同时使用 OR 可以减少一个字节的大小。然而,我也关心代码速度。逻辑运算符似乎比 SUB 或 CMP 更快,但我只是想确认一下。这可能是大小和速度之间的权衡,或者是双赢(当然,代码将更加难懂)。


9
英特尔优化手册指出:使用寄存器的自身测试而不是将寄存器与零进行比较,这样可以避免编码零,因此这基本上只是大小问题。宏操作融合也适用于两者。快速查看Agner Fog表格表明,在大多数CPU上,CMP和OR的速度相同。 - Jester
1
@Jester:OR无法与任何东西宏合并。旧的CPU(Core2)只能将有符号比较与test宏合并,而不能与cmp宏合并。AMD CPU只能宏合并cmptest,而不能宏合并还写入寄存器的操作。 - Peter Cordes
2个回答

37

是的,性能上是有区别的。

比较一个寄存器是否为零的最佳选择是test reg, reg。它可以像cmp reg, 0一样设置FLAGS,而且速度至少和其他方式一样快1,并且代码大小更小。

(当ZF已经由设置reg的指令适当设置时,最好直接跳转、setcc或cmovcc。例如,普通循环的底部通常看起来像dec ecx/jnz .loop_top。大多数x86整数指令“根据结果设置标志”,包括ZF=1如果输出是0。)

或 reg,reg在任何现有的x86 CPU上都无法与宏融合JCC合并为单个uop,并且会增加后续读取reg的任何内容的延迟,因为它将该值重新写入寄存器。 cmp的缺点通常只是代码大小。

注1:可能有一些例外,但仅限于过时的P6系列CPU(Intel Nehalem以前,2011年被Sandybridge系列替代)。 请参见下面有关通过将相同的值重写到寄存器中以避免寄存器读取停顿的方法。 其他微架构系列没有这样的停顿,而且使用test总是更优。


FLAGS 的结果与 test reg,reg / and reg,reg / or reg,reg 相同,
除了 AF 标志位之外,在所有情况下都等同于 cmp reg, 0,因为

  • CF = OF = 0,因为 test/and 总是这样做,对于 cmp 来说,减去零不会溢出或进位。
  • ZFSFPF 根据结果(即 reg)设置:test 的结果为 reg&regcmp 的结果为 reg - 0
在执行完test后,AF未定义,但根据cmp的结果进行设置。我忽略它,因为它非常模糊:唯一读取AF的指令是ASCII调整打包BCD指令,例如AASlahf/pushf
当然,您可以检查除reg == 0(ZF)之外的其他条件,例如通过查看SF来测试负有符号整数。但有趣的事实是,在某些CPU上,经过cmp后,有符号小于条件jljs更有效率。它们在与零比较后是等效的,因为OF=0,所以l条件(SF!=OF)等效于SF
每个可以 宏熔丝 TEST/JL 的CPU也可以宏熔丝TEST/JS,甚至是Core 2。但在CMP byte [mem], 0之后,始终使用JL而不是JS来分支符号位,因为Core 2无法宏熔丝。 (至少在32位模式下;Core 2在64位模式下根本无法宏熔丝)。
有符号比较条件还允许您执行类似 jlejg的操作,同时查看ZF以及SF!= OF。

test的编码比带有立即数0的cmp更短,除了cmp al, imm8这种特殊情况仍然是两个字节。

即使在这种情况下,由于宏融合的原因(在Core2上与jle等一起使用),以及完全没有立即数可能会通过留出另一个指令可以借用的空间来帮助uop缓存密度(SnB-family),所以test更可取。


将测试/jcc的宏融合成一个单独的uop在解码器中

Intel和AMD CPU中的解码器可以将一些条件分支指令与testcmp内部进行宏融合,从而形成单个比较和分支操作。当发生宏融合时,最大吞吐量为每个周期5条指令,而没有宏融合则为4条指令。(对于自Core2以来的Intel CPU。)

近期的英特尔CPU可以将一些指令(如andadd/sub)以及testcmp作为宏融合,但是or不是其中之一。AMD CPU只能将testcmp与JCC合并。详细信息请参见x86_64 - Assembly - loop conditions and out of order或直接参考Agner Fog的微架构文档,了解哪个CPU可以将哪个指令宏融合。在某些情况下,test可以宏融合而cmp则不能,例如使用js
几乎所有简单的ALU操作(按位布尔运算、加/减等)都可以在一个时钟周期内完成。它们在乱序执行流水线中的“成本”相同。英特尔和AMD花费晶体管制造快速执行单元,以便在一个时钟周期内进行加/减/其他操作。是的,按位ORAND更简单,可能使用的功率略低,但仍无法比一个时钟周期更快地运行。

or reg, reg 增加了另一个时钟周期的延迟,对于需要读取寄存器的后续指令的依赖链来说。它是操作链中的 x |= x,这些操作导致你想要的值。


您可能认为额外的寄存器写入会需要一个额外的物理寄存器文件(PRF)条目,与test相比,但这可能不是情况。(有关PRF容量对乱序执行的影响,请参见https://blog.stuffedcow.net/2013/05/measuring-rob-capacity/。) test必须在某个地方产生其FLAGS输出。至少在英特尔Sandybridge系列CPU上,当一条指令产生一个寄存器和一个FLAGS结果时,它们都存储在同一个PRF条目中。(来源:我认为是英特尔专利。这是根据记忆而言,但似乎是一个明显合理的设计。)

cmptest这样产生FLAGS结果的指令还需要一个PRF条目来存储其输出。大概这会稍微糟糕一些:旧的物理寄存器仍然存在,被引用为某个早期指令写入的体系结构寄存器值的持有者。现在,体系结构EFLAGS(更具体地说,是CF和SPAZO标志组)在由重命名器更新的RAT(寄存器分配表)中指向这个新的物理寄存器。当然,下一个写入FLAGS的指令将覆盖它,从而允许该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(在任何模式下)可以宏融合测试/ jcc,因此您会错过这一点。

在P6系列CPU上,andor用于此目的是等效的,但如果您的代码运行在Sandybridge系列CPU上,则后者不太好:它可以将and/jcc进行宏融合,但不能将or/jcc 进行宏融合。寄存器中的依赖链的额外周期仍然是P6上的一个缺点,特别是如果其中涉及的关键路径是主要瓶颈。

P6系列已经非常过时(Sandybridge在2011年替代了它),而Core 2之前的CPU(Core、Pentium M、PIII、PII、PPro)则非常过时,甚至已经步入复古计算领域,尤其是对于任何性能至关重要的事情。优化时,除非您有特定的目标机器(例如,如果您有一个陈旧的Nehalem Xeon机器)或者您正在调整编译器的-mtune = nehalem设置以适应少数仍然存在的用户,否则可以忽略P6系列。

如果您要调整某些东西以在Core 2 / Nehalem上运行更快,请使用test,除非分析显示在特定情况下,寄存器读取停顿是一个大问题,并且使用and实际上可以解决它。

在早期的P6系列中,当值不是问题循环传递依赖链的一部分但稍后需要读取时,and reg,reg 可能是您默认的代码生成选择。或者如果它确实是一个问题,但也有一个特定的寄存器读取停顿,您可以使用 and reg,reg 修复它。
如果您只想测试完整寄存器的低8位,则 test al,al 避免了写入部分寄存器,在P6系列中,这与完整的EAX / RAX分别重命名。如果您稍后读取EAX或AX,则 or al,al 更糟糕:在P6系列上产生部分寄存器停顿。(为什么GCC不使用部分寄存器?)

不幸的or reg,reg习语的历史

or reg,reg习语可能来自于8080的ORA A,正如在评论中指出的

8080的指令集没有test指令,因此您可以选择根据值设置标志的选项包括ORA AANA A。(请注意,对于这两个指令,A寄存器目的地已经内置在助记符中,并且没有将OR运算符写入不同寄存器的指令:它是一个1地址机器,除了mov外,而8086对于大多数指令来说是一个2地址机器。)

8080 ORA A 是通常的做法,因此可以推断人们在移植汇编源代码时将其带入了 8086 汇编程序设计中。 (或使用自动工具; 8086 was intentionally designed for easy / automatic asm-source porting from 8080 code。)

这种不良习惯继续被初学者盲目使用,可能是由过去学习并毫无思考地传授下来的人所教。这种做法明显会对乱序执行的关键路径延迟产生负面影响(或其他更微妙的问题,如无宏融合)。


Delphi的编译器据报道使用了or eax,eax,这可能是一个合理的选择(在Core 2之前),假设寄存器读取停顿比延长下一个读取的dep链更重要。我不知道这是否正确,或者他们只是在不加思考地使用古老的习语。

不幸的是,当时的编译器编写者并不知道未来,因为and eax,eax在Intel P6系列上执行与or eax,eax完全相同,但在其他uarch上不那么糟糕,因为and可以在Sandybridge系列上进行宏融合。(请参见上面的P6部分)。


内存中的值:可以使用cmp或将其加载到寄存器中。

要测试内存中的值,您可以使用cmp dword [mem], 0,但是Intel CPU无法宏观融合同时具有立即和内存操作数的设置标志指令。如果您在分支的一侧使用比较后的值,则应该使用mov eax,[mem] / test eax,eax或类似的东西。否则,两种方式都是2个前端uop,但这是代码大小和后端uop计数之间的权衡。

尽管请注意,某些寻址模式在SnB系列上也不会微聚:RIP相对+立即数在解码器中不会微聚,或者索引寻址模式将在uop缓存之后取消层压。这两种方式都导致cmp dword [rsi + rcx * 4],0 / jne[rel some_static_location]的3个融合域uop。

在i7-6700k Skylake上(使用性能事件uops_issued.anyuops_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进行了微型融合,但是立即数防止了宏融合。与load + test + jnz一样好:代码尺寸更小但需要1个额外的后端uop。
如果寄存器中有一个0(或者一个1用于比较布尔值),您可以使用cmp [mem], reg/jne来进行比较,这样可以减少uops的数量,甚至可以降低到1个融合域和2个未融合域。但是,RIP相对寻址模式仍然无法宏观融合。
即使后面不再使用该值,编译器通常也会使用加载+测试/jcc。
您还可以使用test dword [mem], -1测试内存中的值,但不建议这样做。由于test r/m16/32/64, sign-extended-imm8不可用,因此对于大于字节的任何内容,它的代码大小比cmp更差。 (我认为设计理念是,如果您只想测试寄存器的低位,请使用test cl, 1而不是test ecx, 1,而像test ecx, 0xfffffff0这样的用例很少见,因此不值得花费一个操作码。特别是在8086使用16位代码时,这个决定仅是imm8和imm16之间的区别,而不是imm32。)

(我写的是-1而不是0xFFFFFFFF,这样就可以与byteqword相同。~0是另一种写法。)

相关:


我通常以微操作的数量而非指令来思考问题。一个折叠指令实际上是两个操作,具有两个微操作(算作一个微操作)。在Haswell中,我每个时钟周期执行了六个微操作(或操作),但每个周期只有五条指令。我不知道最大的微操作/时钟周期数是多少,但至少有六个。我想我是说每个周期的操作数更有趣。我并不是真的反对你写的任何东西。 - Z boson
@Zboson:我通常以融合领域 uops 为思考方式。如果涉及到负载/存储,当然也会考虑执行端口,但是往往受限于前端/管线宽度(每个时钟周期4个uops),而不是执行资源。(当然,假设您没有受到 dep chains 或 高速缓存丢失的限制)。我之所以指出每个时钟周期指令数的重要性,是为了解释为什么让宏融合发生如此重要。 - Peter Cordes
1
我认为OR AL,AL的起源可以追溯到8080上的ORA A。由于MSDOS API的最古老部分是模仿CP/M而设计的,以便于移植,我可以想象早期的DOS代码受到了在8080上开始存在的代码的严重影响。 - fvu
1
@MikeB:https://uops.info/是目前最好的来源,具有可靠的自动化测试。对于旧CPU,Agner Fog的指令表通常非常好,并且大多数没有错别字... https://agner.org/optimize/。对于分析指令序列,有Intel的IACA(已经停用)[什么是IACA,如何使用它?](https://dev59.com/kl8e5IYBdhLWcg3wJn2l),以及开源LLVM-MCA https://llvm.org/docs/CommandGuide/llvm-mca.html。 - Peter Cordes
1
@ecm:感谢您的校对!如果我没记错的话,我是想说“即使后面不使用该值”。ADHD 真是讨厌,我在编辑回答的不同部分反复跳来跳去,而不是在一个地方完成一种思路 :P - Peter Cordes
显示剩余3条评论

14

这取决于确切的代码序列、具体的CPU以及其他因素。

or al,al 的主要问题在于它“修改”了 EAX,这意味着后续使用 EAX 的指令可能会停顿,直到此指令完成。请注意,条件分支(jz)也取决于该指令,但是CPU制造商做了大量工作(分支预测和推测执行)来缓解这个问题。同时请注意,在理论上,CPU制造商可以设计一种CPU,识别出在这种特定情况下 EAX 没有被改变,但是有数百个这样的特殊情况,识别其中大多数的好处太小。

cmp al,0 的主要问题在于它稍微大一些,这可能意味着较慢的指令获取/更高的缓存压力,并且(如果它是一个循环)可能意味着代码不再适合某些CPU的“循环缓冲区”。

正如Jester在评论中指出的那样,test al,al 避免了这两个问题- 它比 cmp al,0 更小,并且不会修改 EAX

当然(根据具体的序列),AL 中的值必须来自某处,如果它来自一个适当设置标志的指令,可能可以修改代码以避免稍后再使用另一个指令来设置标志。


AL 中的值来自于 BIOS 中断,因此这并不符合“适当设置标志”的要求... iret 无论如何都会恢复标志。我还考虑了一个使用 lodsbprint 子程序,并检查空终止符,那么 lodsb 是否会根据 AL 中的内容改变标志呢? - sadljkfhalskdjfh
@AnonymousShadow 在这种情况下,您的比较指令的性能微不足道,您不必担心它。BIOS中断最少需要数百个周期,慢速I/O操作可能需要数十亿个周期。 - Ross Ridge
@RossRidge,对于使用LODSB处理大字符串怎么看?无论如何,大小方面都会有所不同,还是用它吧。 - sadljkfhalskdjfh
1
@AnonymousShadow:如果优化代码大小,请使用lodsb。否则,在英特尔CPU(例如Haswell)上,mov al,[esi] / inc esi解码仅需要2个uop而不是3个,因此它可能运行得更快。根据您的循环,您可以使用更复杂的寻址模式来避免指针增量(代码大小较小,但2个寄存器寻址模式无法在英特尔SnB系列上微融合)。请参阅我的答案,了解为什么“test”出于相同的原因更好(由于与分支的宏融合,uops更少)。如果您正在使用setcc来消耗标志,而不是分支,则重要性较小。 - Peter Cordes
2
@Brendan 在汇编语言中,test al,alcmp al,0 占用2个字节。只有当你开始使用另一个寄存器时,它们的大小才会有所不同。 - Sep Roland
显示剩余2条评论

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