INC指令与ADD 1:有什么区别吗?

56

来自Ira Baxter在“为什么INC和DEC指令不影响Carry Flag(CF)?”的回答中

现在,我大多数情况下会避免使用INCDEC,因为它们会做出部分条件代码更新,这可能会导致流水线中出现奇怪的停顿,而ADD/SUB则不会。 因此,在不影响情况的地方(大多数情况下),我使用ADD/SUB以避免停顿。 我只有在需要尽可能缩小代码的情况下才使用INC/DEC ,例如,适合缓存行的大小,其中一个或两个指令的大小足以产生足够大的差异。 这可能是无意义的微观优化,但我对编码习惯非常老派。

我想问一下为什么使用INC会导致流水线停顿,而ADD却不会?毕竟,ADDINC都会更新标志寄存器。唯一的区别是,INC不更新CF。 但是为什么这很重要呢?


3
@HansPassant: 现在P4已经不相关了,所以这种说法是错误的。Intel和AMD的CPU确实会分别重命名不同的标志位(我认为你说的就是虚拟化),因此inc/dec对于旧值EFLAGS没有假依赖。只是优化手册还没有更新。 - Peter Cordes
3
ADD 1比INC快吗?x86 - phuclv
2个回答

89
更新:Alder Lake上的效能核心Gracemont,并以单个uop运行inc reg,但每个时钟周期只有1次,而add reg, 1则为每个时钟周期4次(https://uops.info/)。这可能是对FLAGS的错误依赖,就像P4一样;uops.info的测试没有尝试添加一个打破依赖关系的指令。除了TL:DR之外,我还没有更新其他部分的答案。
更新2:一份2021年的消息来源声称,冰湖处理器无法将inc/dec(或带有内存操作数的指令)与jcc宏融合。如果这是真的,在循环底部等情况下,应该使用sub ecx, 1 / jnz代替传统的dec ecx / jnz。但Agner FoguiCA都表示ICL仍然可以融合inc/dec。可能在2019年和2021年之间的微码更新中发生了变化,但在Tiger Lake上的测试表明它不起作用。
TL:DR/现代CPU的建议:大概使用add;英特尔Alder Lake的E-cores适用于“通用”调优,似乎运行inc较慢。
除了Alder Lake和早期的Silvermont系列,使用inc,除非目标是内存;这在主流的英特尔或任何AMD上都没问题(例如像gcc的-mtune=core2-mtune=haswell-mtune=znver1)。在英特尔P6/SnB系列上,inc memadd多一个微操作;加载无法微融合。
如果你关心Silvermont系列(包括Xeon Phi中的KNL以及一些上网本、Chromebook和NAS服务器),可能要避免使用inc。在64位代码中,add 1只增加1个额外字节,在32位代码中增加2个字节。但这不是性能灾难(只是局部使用了1个额外ALU端口,不会产生错误依赖或大的停顿),所以如果你对SMont不太关心,就不用担心它。
写CF而不是保持不变可能在其他周围的代码中有用,这些代码可能从CF dep-breaking中受益,例如移位操作。请参见下文。
如果您想要进行增加/减少操作而不触及任何标志位,lea eax, [rax+1]运行效率高,并且与add eax, 1具有相同的代码大小。(通常比add/inc使用更少的执行端口,因此当破坏FLAGS不是问题时,add/inc更好。https://agner.org/optimize/
在现代CPU上,除了间接代码大小/解码效果之外,add 永远不会比 inc 慢(但通常也不会更快),因此出于代码大小的原因,你应该优先选择 inc。特别是如果这个选择在同一个二进制文件中重复多次(例如,如果你是一个编译器开发者)。 inc 可以节省 1 字节(64位模式)或 2 字节(32位模式下的操作码 0x40..F 的 inc r32/dec r32 短格式,被重新用作 x86-64 的 REX 前缀)。这在总代码大小中占据了很小的比例差异。这有助于指令缓存命中率、iTLB命中率和需要从磁盘加载的页面数量。 inc 的优点包括:
  • code-size直接
  • 不使用立即数可以对Sandybridge系列产生uop缓存效应,这可能抵消add的更好微融合。(参见{{link1:Agner Fog在他的微体系结构指南中Sandybridge部分的表9.1。)性能计数器可以轻松测量发射阶段uop,但很难测量如何打包到uop缓存和uop缓存读取带宽效应。
  • 在某些情况下,保持CF不变是有利的,尤其是在可以在inc之后无需停顿即可读取CF的CPU上。(对于Nehalem及更早版本则不适用。)

在现代CPU中有一个例外:Silvermont/Goldmont/Knight's Landing 在分配/重命名(也称为发出)阶段以1个uop高效地解码inc/dec,但扩展为2个。额外的uop合并部分标志。由于合并标志uop创建的依赖链, inc吞吐量每个时钟只有1个,而独立add r32, imm8为0.5c(或0.33c Goldmont)

与P4不同,寄存器结果对标志没有错误依赖(见下文),因此当没有任何东西使用标志结果时,乱序执行可以将标志合并从延迟关键路径中移除。(但是乱序执行窗口要比Haswell或Ryzen等主流CPU小得多。)在大多数情况下,将inc作为2个单独的uop运行对于Silvermont来说可能是一种胜利;大多数x86指令在不读取它们的情况下写入所有标志位,打破这些标志依赖链。

SMont/KNL在解码和分配/重命名之间有一个队列(参见Intel的优化手册,图16-2),因此在发出期间扩展到2个微操作可以填充解码停顿引起的气泡(例如单操作数mulpshufb指令,从解码器产生多于1个微操作并导致3-7个周期的微码停顿)。或者在Silvermont上,只需具有超过3个前缀(包括转义字节和强制前缀)的指令,例如REX + 任何SSSE3或SSE4指令。但请注意,有一个约28个微操作的循环缓冲区,因此小循环不会受到这些解码停顿的影响。

inc/dec 不是唯一的指令,解码为1但发出为2的指令: push/popcall/ret,以及具有3个组件的lea也是如此。KNL的AVX512 gather指令也是如此。来源:Intel的优化手册,17.1.2 乱序引擎(KNL)。这只是一个小的吞吐量惩罚(有时甚至不是,如果其他因素成为更大的瓶颈),所以通常仍然可以使用inc进行“通用”调优。


Intel的优化手册仍然推荐在一般情况下使用add 1而不是inc,以避免部分标志位停顿的风险。但由于Intel的编译器默认不这样做,未来的CPU很可能不会像P4那样在所有情况下都使inc变慢。

Clang 5.0和Intel的ICC 17(在Godbolt上)在优化速度(-O3)时确实使用inc,而不仅仅是为了减小体积。 -mtune=pentium4使它们避免使用inc/dec,但默认的-mtune=generic对P4的重视程度不高。

ICC17 -xMIC-AVX512(相当于gcc的-march=knl)避免了使用inc,这在一般情况下对Silvermont / KNL来说可能是个不错的选择。但是使用inc通常不会导致性能灾难,所以在大多数代码中,特别是当标志结果不是关键路径的一部分时,仍然适合使用inc/dec进行“通用”调优。
除了Silvermont之外,这基本上是来自Pentium4的过时优化建议。在现代CPU上,只有在实际读取一个不是由最后一条写入任何标志位的指令写入的标志位时才会出现问题。例如,在BigInteger的adc循环中(需要保留CF以便使用add不会破坏代码)。
add指令会写入EFLAGS寄存器中的所有条件标志位。寄存器重命名使得写入操作变得容易,适用于乱序执行:参见写后写和写后读的危险。add eax, 1和add ecx, 1可以并行执行,因为它们彼此完全独立。(即使是Pentium4也将条件标志位与其他EFLAGS位分开重命名,因为即使add指令也不会修改中断使能位和许多其他位。)
在P4上,incdec取决于所有标志位的先前值,因此它们不能与彼此或先前设置标志位的指令并行执行。(例如:add eax, [mem] / inc ecx会使inc等待add之后执行,即使add的加载未命中缓存。)这被称为伪依赖。部分标志位写入通过读取旧的标志位值,更新除CF以外的位,然后写入完整的标志位。
所有其他乱序执行的x86 CPU(包括AMD的CPU),会单独重命名标志位的不同部分,因此在内部对除CF以外的所有标志位进行只写更新。(来源:Agner Fog的微架构指南)。只有少数指令,如adccmc,真正读取然后写入标志位。但是shl r, cl也是如此(见下文)。
对于英特尔P6/SnB微架构家族,add dest, 1优于inc dest的情况:
  • 内存目标:在英特尔Core2和SnB系列上,add [rdi], 1可以将存储和加载+加法融合在一起,因此它是2个融合域uop / 4个非融合域uop。
    inc [rdi]只能将存储融合在一起,所以是3F / 4U。
    根据Agner Fog的表格,AMD和Silvermont运行内存目标的incadd相同,作为单个宏操作/uop。

但要注意,add [label], 1需要一个32位地址和一个8位立即数来执行相同的uop。

在进行变量计数的移位/旋转之前,为了打破对标志位的依赖并避免部分标志位合并:由于不幸的CISC历史原因,shl reg, cl对标志位有输入依赖,如果移位计数为0,则必须保持它们不变。
在Intel SnB系列上,变量计数的移位需要3个微操作(与Core2/Nehalem相比增加了1个)。据我所知,其中两个微操作读/写标志位,一个独立的微操作读取regcl,并写入reg。这是一种奇怪的情况,它具有更好的延迟(1个周期+不可避免的资源冲突),而吞吐量(1.5个周期)只能在与打破对标志位依赖的指令混合使用时达到最大吞吐量。(我在Agner Fog的论坛上发表了更多相关内容)。尽可能使用BMI2的shlx指令;它只需要1个微操作,并且计数可以在任何寄存器中。
无论如何,在变量计数的shl之前,inc(写入标志位但不修改CF)会使其对最后一次写入CF的内容产生错误依赖,并且在SnB/IvB上可能需要额外的uop来合并标志位。
Core2/Nehalem甚至可以避免对标志位的错误依赖:Merom以近乎每个时钟周期两次移位的速度运行一个由6个独立的shl reg,cl指令组成的循环,当cl=0或cl=13时性能相同。任何超过每个时钟周期1次的速度都证明了对标志位没有输入依赖。
我尝试了使用shl edx, 2和shl edx, 0(立即计数移位)的循环,但在Core2、HSW或SKL上没有看到dec和sub之间的速度差异。我不清楚AMD的情况。
更新:在Intel P6系列上的良好移位性能是以一个巨大的性能陷阱为代价的,你需要避免:当一条指令依赖于移位指令的标志结果时,前端将停滞直到该指令被撤销。(来源:Intel的优化手册,(3.5.2.6节:部分标志寄存器停滞))。所以shr eax, 2 / jnz在Intel Sandybridge之前的处理器上对性能来说相当灾难!如果你关心Nehalem及更早的处理器,请使用shr eax, 2 / test eax,eax / jnz。Intel的示例清楚地说明了这适用于立即计数移位,而不仅仅是count=cl

基于英特尔Core微架构的处理器[即Core 2及更高版本],移位操作数为1由特殊硬件处理,以避免部分标志寄存器停顿。

实际上,Intel指的是没有立即数的特殊操作码,它通过隐式的1进行移位。我认为在编码shr eax,1的两种方式之间存在性能差异,使用短编码(使用原始8086操作码D1 /5)会产生一个只写(部分)标志结果,但较长的编码(C1 /5, imm8,带有立即数1)直到执行时才检查其立即数是否为0,但不跟踪乱序机制中的标志输出。

由于循环遍历位是常见的,但是循环遍历每2个位(或任何其他步幅)非常罕见,所以这似乎是一个合理的设计选择。这解释了为什么编译器喜欢对移位的结果进行test而不是直接使用shr的标志结果。

更新:对于SnB系列的可变计数移位,Intel的优化手册中提到:

3.5.1.6 Variable Bit Count Rotation and Shift

In Intel microarchitecture code name Sandy Bridge, The “ROL/ROR/SHL/SHR reg, cl” instruction has three micro-ops. When the flag result is not needed, one of these micro-ops may be discarded, providing better performance in many common usages. When these instructions update partial flag results that are subsequently used, the full three micro-ops flow must go through the execution and retirement pipeline, experiencing slower performance. In Intel microarchitecture code name Ivy Bridge, executing the full three micro-ops flow to use the updated partial flag result has additional delay.

Consider the looped sequence below:

loop:
   shl eax, cl
   add ebx, eax
   dec edx ; DEC does not update carry, causing SHL to execute slower three micro-ops flow
   jnz loop

The DEC instruction does not modify the carry flag. Consequently, the SHL EAX, CL instruction needs to execute the three micro-ops flow in subsequent iterations. The SUB instruction will update all flags. So replacing DEC with SUB will allow SHL EAX, CL to execute the two micro-ops flow.


术语

部分标志位停顿发生在读取标志位时,如果有的话。P4从不出现部分标志位停顿,因为它们永远不需要合并。相反,它会出现伪依赖。

一些答案/评论混淆了术语。他们描述了一个伪依赖,但却称之为部分标志位停顿。这是由于只写入了一些标志位而导致的减速,但术语“部分标志位停顿”是指在Sandy Bridge之前的英特尔硬件上发生的部分标志位写入必须合并的情况。Intel Sandy Bridge系列CPU会插入额外的微操作来合并标志位而不会停顿。Nehalem和更早的处理器会停顿约7个周期。我不确定在AMD CPU上的惩罚有多大。

(请注意,部分寄存器的惩罚并不总是与部分标志位相同,请参见下文)。

### Partial flag stall on Intel P6-family CPUs:
bigint_loop:
    adc   eax, [array_end + rcx*4]   # partial-flag stall when adc reads CF 
    inc   rcx                        # rcx counts up from negative values towards zero
    # test rcx,rcx  # eliminate partial-flag stalls by writing all flags, or better use add rcx,1
    jnz
# this loop doesn't do anything useful; it's not normally useful to loop the carry-out back to the carry-in for the same accumulator.
# Note that `test` will change the input to the next adc, and so would replacing inc with add 1

在其他情况下,例如先进行部分标志写入再进行完整标志写入,或者仅读取由inc写入的标志,都是可以的。在SnB系列CPU上,{{link1:inc/dec甚至可以与jcc宏融合,与add/sub一样}}。
在P4之后,英特尔基本放弃了试图让人们重新编译使用-mtune=pentium4或修改手写汇编代码以避免严重瓶颈的尝试。(针对特定微架构的调优始终存在,但P4在弃用了许多在先前CPU上表现良好的功能后有些不寻常,因此在现有二进制文件中很常见。)P4希望人们使用类似RISC的x86子集,并且还为JCC指令提供分支预测提示作为前缀。(它还有其他严重问题,例如追踪缓存不够好,弱解码器导致追踪缓存未命中时性能较差。更不用说高频率时钟的整体理念遇到了功耗密度限制。)
当英特尔放弃P4(NetBurst微架构)后,他们回归到了P6系列设计(Pentium-M / Core2 / Nehalem),这些设计继承了早期P6系列CPU(PPro到PIII)的部分标志位/部分寄存器处理方式,而这些早期设计在NetBurst失误之前就已存在。(并非P4的所有方面都是不好的,一些想法在Sandybridge中重新出现,但总体上,NetBurst被广泛认为是一个错误。)一些非常CISC的指令仍然比多指令替代方案慢,例如enterloopbt [mem], reg(因为reg的值会影响使用的内存地址),但这些在旧的CPU中都很慢,所以编译器已经避免使用它们。
Pentium-M甚至改进了对部分寄存器的硬件支持(降低合并惩罚)。在Sandybridge中,英特尔保留了部分标志和部分寄存器重命名,并在需要合并时使其更加高效(插入无或最小停顿的合并微操作)。SnB进行了重大的内部变化,被认为是一个新的微架构系列,尽管它继承了很多来自Nehalem的东西,以及一些来自P4的思想。(但请注意,SnB的解码微操作缓存不是一个跟踪缓存,因此它是对NetBurst的跟踪缓存试图解决的解码吞吐量/功耗问题的非常不同的解决方案。)
例如,inc alinc ah可以在P6/SnB系列CPU上并行运行,但之后读取eax需要合并。
PPro/PIII在读取完整寄存器时会停顿5-6个周期。Core2/Nehalem在插入用于部分寄存器的合并微操作时只会停顿2或3个周期,但部分标志位仍然会有较长的停顿时间。
SnB在不停顿的情况下插入了一个合并微操作,就像对标志位一样。英特尔的优化指南表示,将AH/BH/CH/DH合并到更宽的寄存器中,插入合并微操作需要整个发射/重命名周期,期间不能分配其他微操作。但对于low8/low16,合并微操作是“流程的一部分”,因此似乎不会导致额外的前端吞吐量惩罚,除了占据发射/重命名周期中的4个槽位之一。
在IvyBridge(或至少Haswell)中,英特尔放弃了对low8和low16寄存器的部分寄存器重命名,只保留了对high8寄存器(AH/BH/CH/DH)的重命名。读取high8寄存器会增加额外的延迟。此外,setcc al与Nehalem及之前的版本(可能包括Sandybridge)相比,在rax的旧值上存在虚假依赖。有关详细信息,请参阅this HSW/SKL partial-register performance Q&A
(我之前声称Haswell可以合并AH而不需要uop,但这是错误的,也不是Agner Fog的指南所说的。我浏览得太快了,不幸地在许多评论和其他帖子中重复了我的错误理解。)
AMD CPU和Intel Silvermont不会对部分寄存器(除了标志位)进行重命名,因此mov al, [mem]在eax的旧值上存在虚假依赖。(好处是当稍后读取完整寄存器时,没有部分寄存器合并的减速现象。)
通常情况下,只有在您的代码实际上依赖于inc不会触碰CF行为时,使用add而不是inc才能使您的代码在AMD或主流Intel上运行更快。也就是说,通常情况下,add只有在它会破坏您的代码时才有帮助,但请注意上面提到的shl情况,其中指令读取标志位,但通常您的代码并不关心这一点,因此这是一个虚假的依赖关系。
如果您确实希望保持CF不变,那么SnB系列之前的CPU在部分标志位停顿方面存在严重问题,但在SnB系列上,CPU合并部分标志位的开销非常低,因此在针对这些CPU时,最好继续使用incdec作为循环条件的一部分,并进行一定程度的展开。(有关详细信息,请参阅我之前提供的BigInteger adc问答链接)。如果您不需要根据结果进行分支,可以使用lea进行算术运算而不影响标志位,这可能会很有用。
Skylake没有部分标志合并成本
更新:Skylake根本没有部分标志合并uops:CF只是与FLAGS的其余部分分开的一个单独的寄存器。需要同时使用两个部分的指令(如cmovbe)会分别读取两个输入。这使得cmovbe成为一个2-uop指令,但大多数其他cmovcc指令在Skylake上是1-uop。参见什么是部分标志停顿?
adc仅仅读取CF,因此在Skylake上可以是单一uop,在同一个循环中与inc或dec没有任何交互。
(待办事项:重写答案的前面部分。)

3
有趣的是,可变移位在Core2时期曾经是1微操作码并且单周期,这似乎违背了英特尔通常的2操作数/微操作码规则,所以我想知道它是如何工作的,以及为什么他们放弃了它。 - harold
1
Godbolt 刚刚添加了 ICC 16 和 17,它们仍然会发出 inc - phuclv
@harold: 在core2上测试表明,shl reg, cl的吞吐量几乎可以达到每个时钟周期0.5次,即使没有任何东西破坏标志依赖性。因此它不是一个3输入uop。我怀疑它是否会推测cl为零/非零,但我不知道他们怎样才能让它工作。可能是一些超级奇怪的东西,与SnB的更改不兼容。嗯,SnB切换到了PRF,但我想知道标志是否占用整数寄存器文件条目?如果标志值仍然保存在ROB中,因为位数不多,我也不会感到惊讶。 - Peter Cordes
甚至包括 shr r, 2 / jnz,因为这适用于具有立即计数的移位操作。(具有隐式计数的操作码无条件地写入标志,因此始终可以使用标志结果。) - Peter Cordes
1
@MaximMasiutin:在P6系列处理器上,读取额外的寄存器可能会导致指令在发射阶段因其寄存器源尚未“入飞”而出现寄存器读取停顿。此外,在英特尔P6 / SnB系列处理器上,add [rip + rel32],imm不能在解码器中微融合(RIP相对+ imm始终存在问题),这使得它与 inc 相同,但对于其他寻址模式,除了代码大小之外,我不认为任何CPU有区别。但是,RIP相对只适用于单个静态变量,许多使用多个增量(如直方图)的用例涉及不同的地址。 - Peter Cordes
显示剩余4条评论

5
根据指令的CPU实现,部分寄存器更新可能会导致停顿。根据Agner Fog's optimization guide, page 62
“由于历史原因,INC和DEC指令不会更改进位标志,而其他算术标志则会被写入。这会导致对先前标志值的错误依赖,并且需要额外的μop。为避免这些问题,建议始终使用ADD和SUB代替INC和DEC。例如,INC EAX应替换为ADD EAX,1。”
另请参见第83页的“部分标志停顿”和第100页的“部分标志停顿”。

5
这段话来自 Pentium4 章节。P4 试图让所有软件改为使用 add r32, 1 而非 inc,而不是像 P6(PPro/PIII)一样实现硬件对不同标志位进行重命名。对于不能在 P4 上运行的代码来说,这并不相关,因为其他 CPU 可以通过硬件处理它。 - Peter Cordes
4
这是一个错误的依赖关系。P4不存在部分标志暂停,因为它从未必须合并不同部分的更改。相反,每个修改部分标志的指令都依赖于旧标志。 - Peter Cordes

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