对于前端来说,寄存器复制从未是免费的,只有在以下CPU的问题/重命名阶段中被消除(零延迟)后端执行时才会被消除:
- AMD Bulldozer家族适用于XMM向量寄存器,不适用于整数。
- AMD Zen家族适用于整数和XMM向量寄存器。(Zen2及更高版本还包括YMM)
(有关BD / Zen 1中YMM低/高半部分的详细信息,请参见Agner Fog's微体系结构指南)
- Intel Ivy Bridge及更高版本适用于整数和向量寄存器(除了MMX)
- Intel Goldmont及更高版本的低功耗CPU:XMM和整数
(包括Alder Lake E-cores整数/XMM但不包括YMM)
- 不适用于Intel Ice Lake:一个微码更新禁用了通用整数寄存器的寄存器重命名,以解决错误问题。 XMM/YMM/ZMM重命名仍然有效。 Tiger Lake也受到影响,但Rocket Lake或Alder Lake P-cores不受影响。
uops.info结果为mov r32, r32
- 请注意,在其有效的CPU上延迟=0。 请注意,它们在Ice Lake和Tiger Lake上显示延迟=1,因此它们在微码更新后重新测试。
你的实验
问题中循环的吞吐量不取决于MOV的延迟,或(在Haswell上)不使用执行单元的好处。
该循环仍然只有4个uops可以发出到乱序后端。(即使mov不需要执行单元,它仍然需要由乱序后端跟踪,但cmp/jc会宏观融合成一个uop)。
自Core 2以来,英特尔CPU的问题宽度为每个时钟周期4个uops,因此在Haswell上,mov
不会阻止其以接近每个时钟周期的速度执行一次迭代。它也将在Ivybridge上以每个时钟周期一次的速度运行(通过mov消除),但不会在Sandybridge上运行(没有mov消除)。在SnB上,它大约每1.333个周期运行一次,瓶颈在ALU吞吐量上,因为mov总是需要一个。(SnB/IvB只有三个ALU端口,而Haswell有四个)。
请注意,在重命名阶段对x87 FXCHG(交换
st0
和
st1
)进行特殊处理已经存在很长时间,比MOV更久。 Agner Fog将FXCHG列为PPro / PII / PIII(第一代P6核心)上的0延迟。
问题中的循环有两个交织的依赖链(
add edi,esi
依赖于 EDI 和循环计数器 ESI),这使得它更容易受到不完美调度的影响。与理论预测相比,因为看似无关的指令而导致的2%减速并不罕见,指令顺序的小变化就可以造成这种差异。为了每次迭代正好运行1c,每个周期都需要运行一个INC和一个ADD。由于所有的INC和ADD都依赖于前一次迭代,乱序执行不能通过在单个周期内运行两个来追赶上去。更糟糕的是,ADD依赖于前一个周期的INC,这就是我所说的“交织”,所以在INC dep链中失去一个周期也会使ADD dep链停滞。
此外,预测分支只能在port6上运行,因此
任何port6没有执行cmp / jc的周期都是吞吐量损失的周期。每当INC或ADD窃取port6上的一个周期而不是在端口0、1或5上运行时,就会发生这种情况。我不知道这是否是罪魁祸首,还是INC / ADD dep链本身失去了周期的问题,或者两者都有可能。
增加额外的MOV指令不会增加任何执行端口的压力,假设它被完全消除,但它会阻止前端超越后端执行单元。(循环中只有3个4个uops需要执行单元,您的Haswell CPU可以在其4个ALU端口0、1、5和6上运行INC和ADD。因此瓶颈是:
- 前端每个时钟最大吞吐量为4个uops。(没有MOV的循环只有3个uops,因此前端可以超前运行)。
- 每个时钟的taken-branch吞吐量为1个。
- 涉及esi的依赖链(每个时钟的INC延迟)
- 涉及edi的依赖链(每个时钟的ADD延迟,并且还依赖于前一次迭代的INC)
没有MOV指令,前端可以每个时钟周期发出循环的三个uops,直到乱序后端满为止。 (据我所知,它在循环缓冲区(Loop Stream Detector:LSD)中“展开”微小循环,因此具有ABC uops的循环可以以ABCA BCAB CABC ...模式发出。 lsd.cycles_4_uops
的性能计数器证实,当它发出任何uops时,它主要以4个一组发出。)
Intel CPU将uops分配给端口,当它们进入乱序后端时。这个决策基于计数器来跟踪调度器(也称为预约站,RS)中每个端口的已有uops数量。当RS中有大量uops等待执行时,这种方法很有效,并且通常应该避免将INC或ADD调度到端口6。我想这也避免了将INC和ADD调度在时间上从这两个dep链中损失。但是如果RS为空或接近空,计数器将无法阻止ADD或INC从端口6窃取一个周期。
我认为我在这里找到了一些线索,但任何次优的调度都应该让前端赶上并保持后端满负荷。我不认为我们应该期望前端引起足够多的流水线气泡来解释低于最大吞吐量2%的下降,因为微小的循环应该以非常一致的4个时钟吞吐量从循环缓冲区运行。也许还有其他问题。
消除mov
带来的益处的真实示例。
我使用lea
构建了一个循环,每个时钟周期只有一个mov
,创建了一个完美的演示,其中MOV-elimination成功率为100%,或者使用 mov same,same
的成功率为0%,以展示产生的延迟瓶颈。
由于宏合并的dec / jnz
是涉及循环计数器的依赖链的一部分,因此不完美的调度无法延迟它。这与cmp / jc
“分叉”关键路径依赖链的情况不同,后者会在每次迭代中发生。
_start:
mov ecx, 2000000000
align 16
.loop:
mov eax, ecx
lea ecx, [rax-1]
dec ecx
jnz .loop
.end:
xor edi,edi
mov eax,231
syscall
在英特尔Snb-family上,使用一个或两个组件的LEA寻址模式运行时延为1c(请参见
http://agner.org/optimize/以及
x86标签wiki中的其他链接)。
我在Linux上将其构建并作为静态二进制文件运行,因此用户空间的perf计数器仅测量具有可忽略的启动/关闭开销的循环。 (与将perf-counter查询放入程序本身相比,
perf stat
非常容易)
$ yasm -felf64 -Worphan-labels -gdwarf2 mov-elimination.asm && ld -o mov-elimination mov-elimination.o &&
objdump -Mintel -drwC mov-elimination &&
taskset -c 1 ocperf.py stat -etask-clock,context-switches,page-faults,cycles,instructions,branches,uops_issued.any,uops_executed.thread -r2 ./mov-elimination
Disassembly of section .text:
00000000004000b0 <_start>:
4000b0: b9 00 94 35 77 mov ecx,0x77359400
4000b5: 66 66 2e 0f 1f 84 00 00 00 00 00 data16 nop WORD PTR cs:[rax+rax*1+0x0]
00000000004000c0 <_start.loop>:
4000c0: 89 c8 mov eax,ecx
4000c2: 8d 48 ff lea ecx,[rax-0x1]
4000c5: ff c9 dec ecx
4000c7: 75 f7 jne 4000c0 <_start.loop>
00000000004000c9 <_start.end>:
4000c9: 31 ff xor edi,edi
4000cb: b8 e7 00 00 00 mov eax,0xe7
4000d0: 0f 05 syscall
perf stat -etask-clock,context-switches,page-faults,cycles,instructions,branches,cpu/event=0xe,umask=0x1,name=uops_issued_any/,cpu/event=0xb1,umask=0x1,name=uops_executed_thread/ -r2 ./mov-elimination
Performance counter stats for './mov-elimination' (2 runs):
513.242841 task-clock:u (msec) # 1.000 CPUs utilized ( +- 0.05% )
0 context-switches:u # 0.000 K/sec
1 page-faults:u # 0.002 K/sec
2,000,111,934 cycles:u # 3.897 GHz ( +- 0.00% )
4,000,000,161 instructions:u # 2.00 insn per cycle ( +- 0.00% )
1,000,000,157 branches:u # 1948.396 M/sec ( +- 0.00% )
3,000,058,589 uops_issued_any:u # 5845.300 M/sec ( +- 0.00% )
2,000,037,900 uops_executed_thread:u # 3896.865 M/sec ( +- 0.00% )
0.513402352 seconds time elapsed ( +- 0.05% )
如预期的那样,循环运行了10亿次(
branches
~= 10亿)。超过20亿的额外111k周期是其他测试中也存在的开销,包括没有
mov
的测试。它不是由于mov消除偶尔失败引起的,但它确实随着迭代计数而增加,因此它不仅仅是启动开销。可能是由于定时器中断,因为如果我没记错的话,Linux
perf
在处理中断时不会干扰性能计数器,而是让它们继续计数。(
perf
虚拟化硬件性能计数器,因此即使线程跨CPU迁移,也可以获得每个进程的计数。)此外,共享同一物理核心的兄弟逻辑核心上的定时器中断也会稍微干扰一下事情。
瓶颈是涉及循环计数器的循环依赖链。每1G迭代需要2G个周期,即每次递减需要1个时钟周期。这证实了依赖链的长度为2个周期。
只有当mov
没有延迟时才可能实现这一点。(我知道这并不能证明没有其他瓶颈。如果您不相信我的断言,即延迟是唯一的瓶颈,它只能
证明延迟
最多为2个周期。有一个
resource_stalls.any
性能计数器,但它没有太多选项来分解哪个微架构资源已经耗尽。)
这个循环有3个融合域uop: mov
, lea
, 和 宏融合的 dec/jnz
。3G的 uops_issued.any
计数证实了这一点:它在融合域中计数,即从解码器到退役的所有管道,除了调度器(RS)和执行单元。(宏融合指令对在任何地方都保持单个uop。只有对存储器或ALU+load进行微融合时,ROB中的一个融合域uop跟踪两个未融合域uop的进度。)
2G uops_executed.thread
(未融合域)告诉我们所有的mov
uop都被消除了(即由发行/重命名阶段处理,并以已执行状态放置在ROB中)。它们仍然占用发行/退役带宽、uop缓存中的空间和代码大小。它们占用ROB中的空间,限制乱序窗口大小。mov
指令从来不是免费的。除了延迟和执行端口之外,存在许多可能的微架构瓶颈,其中最重要的通常是前端的4宽度问题率。
在英特尔CPU上,零延迟通常比不需要执行单元更为重要,特别是在Haswell及以后的处理器中,其中有4个ALU端口。 (但只有其中的3个可以处理向量uop,因此未消除的向量移动更容易成为瓶颈,特别是在没有许多负载或存储占据前端带宽(每时钟周期4个融合域uop)的代码中将前端带宽从ALU uop中取走。此外,对执行单元进行uop调度并非完美(更像是最旧准备就绪),因此不在关键路径上的uop可以从关键路径中窃取周期)。
如果我们在循环中加入
nop
或者
xor edx,edx
,它们也会被发出但不会在Intel SnB系列CPU上执行。
零延迟的mov消除对于从32位扩展到64位以及从8位扩展到64位非常有用。(
movzx eax, bl
被消除了,movzx eax, bx
没有)。
没有mov消除
所有支持mov消除的当前CPU都不支持对mov same,same
进行消除,因此为32到64位的整数选择不同的寄存器进行零扩展,或者在罕见情况下使用vmovdqa xmm,xmm
进行零扩展到YMM。(除非你需要结果在它已经在的寄存器中。来回跳到另一个寄存器并返回通常更糟糕。) 在英特尔上,对于例如movzx eax,al
也适用相同的规则。(AMD Ryzen不会移动消除movzx。) Agner Fog的指令表显示mov
总是在Ryzen上被消除,但我想他的意思是它不能像在英特尔上那样在两个不同的寄存器之间失败。
我们可以利用这个限制来创建一个故意击败它的微基准。
mov ecx, ecx # CPUs can't eliminate mov same,same
lea ecx, [rcx-1]
dec ecx
jnz .loop
3,000,320,972 cycles:u # 3.898 GHz ( +- 0.00% )
4,000,000,238 instructions:u # 1.33 insn per cycle ( +- 0.00% )
1,000,000,234 branches:u # 1299.225 M/sec ( +- 0.00% )
3,000,084,446 uops_issued_any:u # 3897.783 M/sec ( +- 0.00% )
3,000,058,661 uops_executed_thread:u # 3897.750 M/sec ( +- 0.00% )
这需要3G个周期来完成1G次迭代,因为依赖链的长度现在是3个周期。
融合域uop计数没有变化,仍为3G。
改变的是未融合域uop计数与融合域相同。所有的uops都需要一个执行单元;没有一个mov指令被消除,所以它们都给循环担负的依赖链增加了1个周期的延迟。
(当有微型融合uops时,比如 add eax,[rsi],uops_executed计数可能会高于uops_issued。但我们现在没有这种情况。)
完全没有mov
:
lea ecx, [rcx-1]
dec ecx
jnz .loop
2,000,131,323 cycles:u # 3.896 GHz ( +- 0.00% )
3,000,000,161 instructions:u # 1.50 insn per cycle
1,000,000,157 branches:u # 1947.876 M/sec
2,000,055,428 uops_issued_any:u # 3895.859 M/sec ( +- 0.00% )
2,000,039,061 uops_executed_thread:u # 3895.828 M/sec ( +- 0.00% )
现在我们的循环依赖链路延迟降至2个周期。
没有任何东西被消除。
我在一台3.9GHz i7-6700k Skylake上进行了测试。对于所有perf事件,我在Haswell i5-4210U上获得了相同的结果(在1G计数中相差不到40k)。这大约是在同一系统上重新运行的误差范围。
请注意,如果我以root身份运行perf,并且计算周期而不是cycles:u(仅用户空间),它会将CPU频率测量为恰好3.900 GHz。(我不知道为什么Linux只在重新启动后遵从max turbo的bios设置,但是如果我让它闲置几分钟,它就会降到3.9 GHz。Asus Z170 Pro Gaming主板,带有内核4.10.11-1-ARCH的Arch Linux。在Ubuntu上也看到了同样的事情。从/etc/rc.local向/sys/devices/system/cpu/cpufreq/policy[0-9]*/energy_performance_preference写入balance_performance可以修复它,但是编写balance_power会使它稍后再次降至3.9GHz。)
1:更新:作为运行sudo perf
的更好选择,我在/etc/syctl.d/99-local.conf
中设置了sysctl kernel.perf_event_paranoid = 0
你应该在 AMD Ryzen 上获得相同的结果,因为它可以消除整数
mov
。AMD Bulldozer 家族只能消除 xmm 寄存器副本。(根据 Agner Fog 的说法,
ymm
寄存器副本是消除低半部分和高半部分的 ALU 操作。)
例如,AMD Bulldozer 和 Intel Ivybridge 可以每个时钟周期维持 1 的吞吐量。
movaps xmm0, xmm1
movaps xmm2, xmm3
movaps xmm4, xmm5
dec
jnz .loop
但是Intel Sandybridge不能消除操作,因此它会在3个执行端口中的4个ALU uops上出现瓶颈。如果使用
pxor xmm0,xmm0
而不是movaps,则SnB也可以每个时钟周期维持一个迭代。(但Bulldozer系列不能,因为在AMD上,xor-zeroing仍需要执行单元,即使与寄存器的旧值无关。而且Bulldozer系列的PXOR吞吐量只有0.5c。)
移动指令消除的局限性
连续两个依赖MOV指令会暴露Haswell和Skylake之间的差异。
.loop:
mov eax, ecx
mov ecx, eax
sub ecx, 2
jnz .loop
Haswell:次要的运行到运行的变化(1.746到1.749 c/iter),但这是典型的:
1,749,102,925 cycles:u
4,000,000,212 instructions:u
1,000,000,208 branches:u
3,000,079,561 uops_issued_any:u
1,746,698,502 uops_executed_core:u
745,676,067 lsd_cycles_4_uops:u
不是所有的MOV指令都被消除了:每次迭代中约有0.75个2个指令需要执行端口。每个未被消除的MOV指令都会增加1c的延迟到循环传递的依赖链中,因此和非常相似并非巧合。所有的uops都是单一依赖链的一部分,因此没有并行性可言。无论运行到运行的变化如何,始终比高约5M,因此我猜还有5M个周期被用在其他地方。
Skylake:结果比HSW更稳定,并且更多的MOV被消除:每2个指令中只有0.6666个指令需要执行单元。
1,666,716,605 cycles:u
4,000,000,136 instructions:u
1,000,000,132 branches:u
3,000,059,008 uops_issued_any:u
1,666,548,206 uops_executed_thread:u
666,683,358 lsd_cycles_4_uops:u
在Haswell架构中,
lsd.cycles_4_uops
占据了所有的uops。(0.745 * 4 ~= 3)。因此,在几乎每个发出任何uops的周期中,都会发出一个完整的4组(从循环缓冲区)。我可能应该看一下不关心它们来自哪里的其他计数器,比如
uops_issued.stall_cycles
来计算没有发出uops的周期。
但是在SKL架构中,
0.66666 * 4 = 2.66664
小于3,因此在某些周期中,前端发出的uops少于4个。(通常会停顿,直到乱序后端有足够的空间发出完整的4组,而不是发出不完整的组)。
这很奇怪,我不知道确切的微架构限制是什么。由于循环只有3个uops,每个4个uops的发布组都超过了完整迭代。因此,一个发布组可以包含多达3个依赖性MOV。也许Skylake有时设计成打破这一点,以允许更多的mov消除?
更新: 实际上,Skylake上的3-uop循环是正常的。
uops_issued.stall_cycles
显示HSW和SKL以与此相同的方式发出简单的3 uop loop,没有mov-elimination。因此,更好的mov-elimination是由于出于某种其他原因而分裂发布组的副作用。(这不是瓶颈,因为无论如何,条件分支无法比每个时钟周期执行的速度更快)。我仍然不知道为什么SKL不同,但我不认为这是什么值得担心的事情。
在一个不那么极端的情况下,SKL和HSW是相同的,两者都无法消除每2个MOV指令中的0.3333:
.loop:
mov eax, ecx
dec eax
mov ecx, eax
sub ecx, 1
jnz .loop
2,333,434,710 cycles:u
5,000,000,185 instructions:u
1,000,000,181 branches:u
4,000,061,152 uops_issued_any:u
2,333,374,781 uops_executed_thread:u
1,000,000,942 lsd_cycles_4_uops:u
所有的uop都是以4个为一组进行发布。任何连续的4个uop中将包含恰好两个MOV uop,这些uop可能会被消除。既然它在某些周期内成功地消除了两个uop,那我不知道为什么它不能总是这样做。
英特尔优化手册指出,尽早覆盖mov-elimination的结果可以释放微架构资源,使其更容易成功,至少对于movzx
是如此。请参见示例3-23。重新排序序列以提高零延迟MOV指令的有效性。
因此,也许它在内部通过有限大小的引用计数表进行跟踪?如果它作为mov目标的值仍然需要,那么必须有一些东西阻止物理寄存器文件条目在不再需要作为原始体系结构寄存器的值时被释放。尽早释放PRF条目非常重要,因为PRF大小可能会限制乱序窗口小于ROB大小。
我尝试了Haswell和Skylake上的示例,并发现在这样做时mov-elimination确实更多地起作用,但总周期实际上略慢而不是更快。该示例旨在展示IvyBridge的好处,它可能会在其3个ALU端口上出现瓶颈,但HSW/SKL只会在dep链中的资源冲突上出现瓶颈,并且似乎不需要一个ALU端口来执行更多的movzx指令。
另请参见
为什么在现代Intel架构中XCHG reg,reg是3个微操作指令?以获取有关mov-elimination如何工作以及它是否适用于xchg eax,ecx的更多研究和猜测。(实际上,在Intel上,xchg reg,reg是3个ALU uops,但在Ryzen上则是2个消除uops。有趣的是猜测Intel是否可以更有效地实现它。)
顺便提一下,由于Haswell芯片上的一个错误,当启用超线程时,Linux不提供uops_executed.thread
,只提供uops_executed.core
。另一个内核在整个过程中肯定是空闲的,甚至没有计时器中断 因为我使用echo 0 > /sys/devices/system/cpu/cpu3/online
将其离线了。不幸的是,在内核的perf
驱动程序(PAPI)决定启用HT之前无法完成此操作,并且我的Dell笔记本电脑没有BIOS选项可以禁用HT。因此,在该系统上我无法让perf
同时使用所有8个硬件PMU计数器,而只能使用其中4个 :/。