x86的MOV真的可以"free"吗?为什么我根本不能重现这个?
Can x86's MOV really be "free"? Why can't I reproduce this at all?
我经常看到有人声称 MOV 指令在 x86 中可以自由使用,因为寄存器重命名。
对于我来说,我无法在单个测试用例中验证这一点。我尝试的每个测试用例都会揭穿它。
例如,这是我用 Visual C++ 编译的代码:
#include <limits.h>
#include <stdio.h>
#include <time.h>
int main(void)
{
unsigned int k, l, j;
clock_t tstart = clock();
for (k = 0, j = 0, l = 0; j < UINT_MAX; ++j)
{
++k;
k = j; // <-- comment out this line to remove the MOV instruction
l += j;
}
fprintf(stderr, "%d ms\n", (int)((clock() - tstart) * 1000 / CLOCKS_PER_SEC));
fflush(stderr);
return (int)(k + j + l);
}
这将为循环生成以下汇编代码(您可以随意生成它;您显然不需要 Visual C++):
LOOP:
add edi,esi
mov ebx,esi
inc esi
cmp esi,FFFFFFFFh
jc LOOP
现在我 运行 这个程序好几次了,当删除 MOV 指令时,我观察到一个非常一致的 2% 的差异:
Without MOV With MOV
1303 ms 1358 ms
1324 ms 1363 ms
1310 ms 1345 ms
1304 ms 1343 ms
1309 ms 1334 ms
1312 ms 1336 ms
1320 ms 1311 ms
1302 ms 1350 ms
1319 ms 1339 ms
1324 ms 1338 ms
那么是什么原因呢?为什么不是 MOV "free"?这个循环对于 x86 来说太复杂了吗?
是否有单个示例可以证明 MOV 像人们声称的那样是免费的?
如果是这样,它是什么?如果不是,为什么每个人都声称 MOV 是免费的?
这里有两个小测试,我认为它们最终显示了移动消除的证据:
__loop1:
add edx, 1
add edx, 1
add ecx, 1
jnc __loop1
对比
__loop2:
mov eax, edx
add eax, 1
mov edx, eax
add edx, 1
add ecx, 1
jnc __loop2
如果mov
向依赖链添加一个循环,预计第二个版本每次迭代大约需要 4 个循环。在我的 Haswell 上,每次迭代都需要大约 2 个周期,如果没有移动消除,这是不可能发生的。
Register-copy 对于 front-end 永远不会免费,只是在 issue/rename 阶段从 back-end 中实际执行(零延迟)中删除 CPUs:
- 用于 XMM 向量寄存器的 AMD Bulldozer 系列,不是整数。
- 用于整数和 XMM 向量寄存器的 AMD Zen 系列。 (以及 Zen2 及更高版本中的 YMM)
(有关 BD / Zen 1 中 YMM 的 low/high 一半的详细信息,请参阅 Agner Fog's 微架构指南)
- 用于整数和向量寄存器的 Intel Ivy Bridge 及更高版本(MMX 除外)
- Intel Goldmont 及更高版本 low-power CPUs:XMM 和整数
(包括 Alder Lake E-cores integer/XMM 但不包括 YMM)
- 不是 Intel Ice Lake:禁用微代码更新 register-renaming 作为解决错误的一部分,用于 general-purpose 整数 reg。 XMM/YMM/ZMM 重命名仍然有效。 Tiger Lake 也受到影响,但 Rocket Lake 或 Alder Lake 没有受到影响 P-cores.
uops.info results for mov r32, r32
- 注意 CPUs 的延迟为 0。请注意,它们在 Ice Lake 和 Tiger Lake 上显示 latency=1,因此它们在微代码更新后重新测试。
你的实验
问题中循环的吞吐量不依赖于 MOV 的延迟,或者(在 Haswell 上)不使用执行的好处单位.
front-end 的循环仍然只有 4 微指令发送到 out-of-order back-end。 (mov
仍然需要被 out-of-order back-end 跟踪,即使它不需要执行单元,但是 cmp/jc
macro-fuses 变成一个 uop) .
Intel CPUs 因为 Core 2 的问题宽度为每时钟 4 微指令,所以 mov
不会阻止它在(接近)每个时钟上执行一个迭代哈斯韦尔。在 Ivybridge 上它也会 运行 每个时钟一个(mov-elimination),但在 Sandybridge 上 不会 (没有 mov-elimination)。 在 SnB 上,每 1.333c 周期大约有一个迭代器,这在 ALU 吞吐量上是瓶颈,因为 mov
总是需要一个 。 (SnB/IvB 只有三个 ALU 端口,而 Haswell 有四个)。
请注意,重命名阶段的特殊处理对于 x87 FXCHG(将 st0
与 st1
交换)的时间比 MOV 长得多。 Agner Fog 在 PPro/PII/PIII(first-gen P6 核心)上将 FXCHG 列为 0 延迟。
题中的循环有两个互锁的依赖链(add edi,esi
依赖于EDI和循环计数器ESI),这使得它对不完美的调度更加敏感。由于 seemingly-unrelated 指令,与理论预测相比减速 2% 并不罕见,指令顺序的微小变化会造成这种差异。要 运行 每个迭代正好 1c,每个循环都需要 运行 一个 INC 和一个 ADD。由于所有 INC 和 ADD 都依赖于先前的迭代,因此 out-of-order 执行无法在一个周期内赶上 运行ning 两个。更糟糕的是,ADD 依赖于上一个循环中的 INC,这就是我所说的“互锁”的意思,因此在 INC dep 链中丢失一个循环也会使 ADD dep 链停止。
此外,predicted-taken b运行ches 只能在端口 6 上 运行,因此 任何端口 6 不执行 cmp/jc 的周期是吞吐量损失周期。每当 INC 或 ADD 在端口 6 上窃取一个周期而不是在端口 0、1 或 5 上窃取 运行ning 时,就会发生这种情况。IDK 如果这是罪魁祸首,或者如果在 INC/ADD dep 链中丢失周期他们自己是问题所在,或者两者兼而有之。
添加额外的 MOV 不会增加任何 execution-port 压力,假设它被 100% 消除,但它确实阻止了 运行 前进的 front-end back-end 个执行单位。 (循环中的 4 个微指令中只有 3 个需要一个执行单元,而您的 Haswell CPU 可以 运行 在其 4 个 ALU 端口中的任何一个上进行 INC 和 ADD:0、1、5 和 6。所以瓶颈是:
- front-end 每个时钟 4 微指令的最大吞吐量。 (没有 MOV 的循环只有 3 微码,所以 front-end 可以 运行 提前)。
- taken-branch 每个时钟一个吞吐量。
- 涉及
esi
的依赖链(每个时钟 1 个 INC 延迟)
- 涉及
edi
的依赖链(每个时钟增加1个延迟,并且还依赖于上一次迭代的INC)
没有 MOV,front-end 可以每个时钟 4 次发出循环的三个微指令,直到 out-of-order back-end 满为止。 (AFAICT, lsd.cycles_4_uops
的性能计数器确认它在发出任何 uops 时主要以 4 个为一组发出。)
。该决定基于跟踪调度程序(又名保留站,RS)中每个端口的微指令数的计数器。当 RS 中有很多 uops 等待执行时,这很有效并且通常应该避免将 INC 或 ADD 调度到端口 6。而且我想也避免了安排 INC 和 ADD,这样时间就会从这些 dep 链中的任何一个中丢失。但是,如果 RS 为空或 nearly-empty,计数器将不会阻止 ADD 或 INC 在端口 6 上窃取一个周期。
我以为我在做某事g 在这里,但是任何 sub-optimal 调度应该让 front-end 赶上并保持 back-end 满。我不认为我们应该期望 front-end 会在管道中引起足够多的气泡来解释低于最大吞吐量 2% 的下降,因为微小循环应该 运行 从循环缓冲区以非常一致的 4每时钟吞吐量。也许还有其他事情正在发生。
mov
消除的好处的真实例子。
我使用 lea
构建了一个每个时钟只有一个 mov
的循环,创建了一个完美的演示,其中 MOV-elimination 100% 成功,或者 0% 的时间 mov same,same
来演示产生的延迟瓶颈。
由于macro-fuseddec/jnz
是涉及循环计数器的依赖链的部分,不完善的调度无法延迟. 这不同于 cmp/jc
每次迭代都从 critical-path 依赖链中“分叉”的情况。
_start:
mov ecx, 2000000000 ; each iteration decrements by 2, so this is 1G iters
align 16 ; really align 32 makes more sense in case the uop-cache comes into play, but alignment is actually irrelevant for loops that fit in the loop buffer.
.loop:
mov eax, ecx
lea ecx, [rax-1] ; we vary these two instructions
dec ecx ; dec/jnz macro-fuses into one uop in the decoders, on Intel
jnz .loop
.end:
xor edi,edi ; edi=0
mov eax,231 ; __NR_exit_group from /usr/include/asm/unistd_64.h
syscall ; sys_exit_group(0)
在 Intel SnB-family 上,LEA 在寻址模式下具有一个或两个组件 运行s,延迟为 1c(参见 http://agner.org/optimize/, and other links in the x86 标签 wiki)。
我在 Linux 上构建并 运行 这是一个静态二进制文件,所以 user-space perf-counters 整个过程只测量循环,启动/关闭可忽略不计高架。 (与将 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% )
正如预期的那样,循环 运行s 1G 次(branches
~= 10 亿次)。超过 2G 的“额外”111k 周期是其他测试中也存在的开销,包括没有 mov
的测试。它不是来自 mov-elimination 的偶尔失败,但它确实随着迭代计数而扩展,因此它不仅仅是启动开销。这可能来自定时器中断,因为 IIRC Linux perf
在处理中断时不会乱用 perf-counters,只是让它们继续计数。 (perf
虚拟化硬件性能计数器,因此即使线程在 CPU 之间迁移,您也可以获得 per-process 计数。)此外,计时器中断共享相同物理内核的同级逻辑内核会有点扰乱事情。
瓶颈是涉及循环计数器的 loop-carried 依赖链。 1G 迭代器的 2G 周期是每次迭代 2 个时钟,或每次递减 1 个时钟。这证实了 dep 链的长度是 2 个周期。 这只有在 mov
具有零延迟 时才有可能。 (我知道这并不能证明没有其他瓶颈。它实际上只能证明延迟是最多 2个周期,如果您不相信我关于延迟是唯一瓶颈的断言。有一个 resource_stalls.any
性能计数器,但它没有很多选项来分解哪个微体系结构资源已耗尽。)
循环有 3 个 fused-domain 微指令:mov
、lea
和 . The 3G uops_issued.any
count confirms that: It counts in the fused domain, which is all of the pipeline from decoders to retirement, except for the scheduler (RS) and execution units. (macro-fused instruction-pairs stay as single uop everywhere. It's only for micro-fusion of stores or ALU+load that 1 fused-domain uop in the ROB 跟踪两个未fused-domain 微指令的进度。)
2G uops_executed.thread
(unfused-domain) 告诉我们所有的 mov
微指令都被淘汰了(即由 issue/rename 阶段处理,并放在 ROB 中already-executed 状态)。它们仍然占用 issue/retire 带宽,在 uop 缓存中占用 space,以及 code-size。它们在 ROB 中占用 space,限制了 out-of-order window 大小。 A mov
指令永远不会免费。除了延迟和执行端口之外,还有许多可能的微架构瓶颈,最重要的通常是 front-end.
的 4-wide 问题率
在 Intel CPUs 上,零延迟通常比不需要执行单元更重要,尤其是在有 4 个 ALU 端口的 Haswell 和更高版本中。 (但其中只有 3 个可以处理向量微指令,所以 non-eliminated 向量移动更容易成为瓶颈,特别是在没有很多负载或存储占用 front-end 带宽的代码中(每个 4 fused-domain 微指令时钟)远离 ALU uops。此外,将 uops 调度到执行单元并不完美(首先更像是 oldest-ready),因此不在关键路径上的 uops 可以从关键路径窃取周期。)
如果我们将 nop
或 xor edx,edx
放入循环中,它们也会发出但不会在 Intel SnB-family CPUs 上执行。
Zero-latency mov-elimination 可用于 zero-extending 从 32 位到 64 位,以及 8 位到 64 位。().
没有mov-elimination
目前所有支持mov-elimination的CPU不支持mov same,same
,所以为[=367选择不同的寄存器=] 从 32 到 64 位的整数,或者 vmovdqa xmm,xmm
到 zero-extend 到 YMM 在极少数情况下是必要的。 (除非你 需要 寄存器中的结果已经存在。跳到不同的寄存器并返回通常更糟。)在 Intel 上,这同样适用于 movzx eax,al
例如. (AMD Ryzen 没有 mov-eliminate movzx。)Agner Fog 的指令 tables 显示 mov
作为 always 在 Ryzen 上被淘汰,但我猜他意味着它不会像在 Intel 上那样在两个不同的 regs 之间失败。
我们可以利用这个限制来创造一个有意击败它的micro-benchmark。
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% )
这次拍1G迭代需要3G循环,因为现在依赖链的长度是3个循环。
fused-domain uop 计数没有改变,仍然是 3G。
改变的是现在 unfused-domain uop 计数与 fused-domain 相同。所有的 uops 都需要一个执行单元; none 的 mov
指令被删除,因此它们都向 loop-carried dep 链添加了 1c 延迟。
(当有 micro-fused 微指令时,例如 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% )
现在我们将 loop-carried 深度链的延迟降低到 2 个周期。
什么都没有消除。
我在 3.9GHz i7-6700k Skylake 上进行了测试。对于所有 perf 事件,我在 Haswell i5-4210U 上得到相同的结果(在 1G 计数的 40k 以内)。这与同一系统上的 re-running 的误差幅度大致相同。
请注意,如果我 运行 perf
作为 root1,并且计算 cycles
而不是 cycles:u
(user-space only),它测量的 CPU 频率正好是 3.900 GHz。 (IDK 为什么 Linux 仅在重新启动后立即服从 bios-settings 的最大涡轮增压,但如果我将其闲置几分钟则降至 3.9GHz。Asus Z170 Pro Gaming mobo,Arch Linux 与内核 4.10.11-1-ARCH。看到与 Ubuntu 相同的事情。从 /etc/rc.local
向每个 /sys/devices/system/cpu/cpufreq/policy[0-9]*/energy_performance_preference
写入 balance_performance
修复它,但是写入 balance_power
使其稍后再次降回 3.9GHz。)
1:更新:作为 运行ning sudo perf
的更好替代方案,我在 /etc/syctl.d/99-local.conf
中设置了 sysctl kernel.perf_event_paranoid = 0
你应该在 AMD Ryzen 上得到相同的结果,因为它可以消除整数 mov
。 AMD Bulldozer-family 只能消除 xmm 寄存器副本。 (根据 Agner Fog 的说法,ymm
寄存器副本是消除的 low-half 和高半部分的 ALU 运算。)
例如,AMD Bulldozer 和 Intel Ivybridge 可以维持每个时钟 1 个的吞吐量
movaps xmm0, xmm1
movaps xmm2, xmm3
movaps xmm4, xmm5
dec
jnz .loop
但英特尔 Sandybridge 无法消除移动,因此它会在 3 个执行端口的 4 个 ALU 微处理器上出现瓶颈。如果它是 pxor xmm0,xmm0
而不是 movaps,SnB 也可以维持每个时钟的一次迭代。 (但是 Bulldozer-family 不能,因为 xor-zeroing 在 AMD 上仍然需要一个执行单元,即使它独立于寄存器的旧值。而且 Bulldozer-family 对于 PXOR 只有 0.5c 吞吐量.)
mov-elimination
的限制
连续两条相关的 MOV 指令暴露了 Haswell 和 Skylake 之间的差异。
.loop:
mov eax, ecx
mov ecx, eax
sub ecx, 2
jnz .loop
Haswell:较小的 run-to-run 可变性(1.746 至 1.749 c / iter),但这是典型的:
1,749,102,925 cycles:u # 2.690 GHz
4,000,000,212 instructions:u # 2.29 insn per cycle
1,000,000,208 branches:u # 1538.062 M/sec
3,000,079,561 uops_issued_any:u # 4614.308 M/sec
1,746,698,502 uops_executed_core:u # 2686.531 M/sec
745,676,067 lsd_cycles_4_uops:u # 1146.896 M/sec
并非所有 MOV 指令都被消除:每次迭代的 2 条指令中约有 0.75 条使用了执行端口。每个执行而不是被消除的 MOV 都会给 loop-carried dep 链增加 1c 的延迟,所以 uops_executed
和 cycles
非常相似并不是巧合。所有微指令都是单个依赖链的一部分,因此不可能存在并行性。 cycles
总是比 uops_executed
高 5M,无论 run-to-run 变化如何,所以我猜其他地方只用了 5M 周期。
Skylake:比 HSW 结果更多 table,而且更多 mov-elimination:每 2 个中只有 0.6666 个 MOV 需要一个执行单元。
1,666,716,605 cycles:u # 3.897 GHz
4,000,000,136 instructions:u # 2.40 insn per cycle
1,000,000,132 branches:u # 2338.050 M/sec
3,000,059,008 uops_issued_any:u # 7014.288 M/sec
1,666,548,206 uops_executed_thread:u # 3896.473 M/sec
666,683,358 lsd_cycles_4_uops:u # 1558.739 M/sec
在 Haswell 上,lsd.cycles_4_uops
占了所有微指令。 (0.745 * 4 ~= 3)。因此,几乎在发出任何 uops 的每个周期中,都会发出一个完整的 4 组(来自 loop-buffer。我可能应该查看一个不关心它们来自哪里的不同计数器,比如 uops_issued.stall_cycles
计算没有发出 uops 的周期)。
但在 SKL 上,0.66666 * 4 = 2.66664
小于 3,因此在某些周期中 front-end 发出的指令少于 4 微指令。 (通常它会停止,直到 out-of-order back-end 中有空间发出完整的 4 组,而不是发出 non-full 组)。
很奇怪,我不知道确切的微体系结构限制是什么。由于循环只有 3 微指令,因此 4 微指令中的每个 issue-group 都超过了一个完整的迭代。因此一个问题组最多可以包含 3 个依赖 MOV。也许 Skylake 的设计有时会打破它,以允许更多 mov-elimination?
更新:实际上这对于 Skylake 上的 3-uop 循环是正常的。 uops_issued.stall_cycles
显示 HSW 和 SKL 发出一个简单的 3 uop 循环,没有 mov-elimination 与他们发出这个循环的方式相同。因此,更好的 mov-elimination 是 side-effect 出于某些其他原因拆分问题组。 (这不是瓶颈,因为 taken b运行ches 的执行速度不能超过每时钟 1 个,无论它们发出的速度有多快)。我仍然不知道为什么 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 # 3.897 GHz
5,000,000,185 instructions:u # 2.14 insn per cycle
1,000,000,181 branches:u # 1669.905 M/sec
4,000,061,152 uops_issued_any:u # 6679.720 M/sec
2,333,374,781 uops_executed_thread:u # 3896.513 M/sec
1,000,000,942 lsd_cycles_4_uops:u # 1669.906 M/sec
所有微指令以 4 为一组发出。任何连续的 4 微指令组都将恰好包含两个 MOV 微指令,它们是要消除的候选者。因为它显然成功地消除了某些 ccles,IDK 为什么它不能总是这样做。
Intel's optimization manual 表示尽早覆盖 mov-elimination 的结果可以释放微体系结构资源,因此它可以更频繁地成功,至少 movzx
是这样。请参见 示例 3-23。 Re-ordering 提高 Zero-Latency MOV 指令有效性的序列。
所以也许它是用 limited-size table 的 ref-counts 进行内部跟踪的?当不再需要物理寄存器文件条目作为原始体系结构寄存器的值时,如果仍然需要它作为 mov 目标的值,则必须阻止释放物理寄存器文件条目。尽快释放 PRF 条目是关键,因为 PRF size can limit the out-of-order window 小于 ROB 大小。
我尝试了 Haswell 和 Skylake 上的示例,发现 mov-elimination 实际上在执行此操作时确实工作了更多的时间,但它实际上在总周期中稍微慢了一点,而不是更快。该示例旨在展示 IvyBridge 的优势,它可能在其 3 个 ALU 端口上存在瓶颈,但 HSW/SKL 仅在 dep 链中的资源冲突上存在瓶颈,并且似乎不需要 ALU 端口来实现更多movzx
条指令。
另请参阅 了解更多关于 mov-elimination 如何工作以及它是否适用于 xchg eax, ecx
的研究和猜测。 (实际上 xchg reg,reg
在 Intel 上是 3 个 ALU 微指令,但在 Ryzen 上是 2 个消除微指令。有趣的是猜测英特尔是否可以更有效地实现它。)
顺便说一句,作为 Haswell 勘误表的解决方法,Linux 在启用超线程时不提供 uops_executed.thread
,仅提供 uops_executed.core
。另一个核心肯定一直处于空闲状态,甚至没有定时器中断,because I took it offline with echo 0 > /sys/devices/system/cpu/cpu3/online
。不幸的是,在内核的 perf
驱动程序 (PAPI) 决定在启动时启用 HT 之前,这无法完成,而我的戴尔笔记本电脑没有禁用 HT 的 BIOS 选项。所以我无法让 perf
在该系统上一次使用所有 8 个硬件 PMU 计数器,只有 4 个。:/
我经常看到有人声称 MOV 指令在 x86 中可以自由使用,因为寄存器重命名。
对于我来说,我无法在单个测试用例中验证这一点。我尝试的每个测试用例都会揭穿它。
例如,这是我用 Visual C++ 编译的代码:
#include <limits.h>
#include <stdio.h>
#include <time.h>
int main(void)
{
unsigned int k, l, j;
clock_t tstart = clock();
for (k = 0, j = 0, l = 0; j < UINT_MAX; ++j)
{
++k;
k = j; // <-- comment out this line to remove the MOV instruction
l += j;
}
fprintf(stderr, "%d ms\n", (int)((clock() - tstart) * 1000 / CLOCKS_PER_SEC));
fflush(stderr);
return (int)(k + j + l);
}
这将为循环生成以下汇编代码(您可以随意生成它;您显然不需要 Visual C++):
LOOP:
add edi,esi
mov ebx,esi
inc esi
cmp esi,FFFFFFFFh
jc LOOP
现在我 运行 这个程序好几次了,当删除 MOV 指令时,我观察到一个非常一致的 2% 的差异:
Without MOV With MOV
1303 ms 1358 ms
1324 ms 1363 ms
1310 ms 1345 ms
1304 ms 1343 ms
1309 ms 1334 ms
1312 ms 1336 ms
1320 ms 1311 ms
1302 ms 1350 ms
1319 ms 1339 ms
1324 ms 1338 ms
那么是什么原因呢?为什么不是 MOV "free"?这个循环对于 x86 来说太复杂了吗?
是否有单个示例可以证明 MOV 像人们声称的那样是免费的?
如果是这样,它是什么?如果不是,为什么每个人都声称 MOV 是免费的?
这里有两个小测试,我认为它们最终显示了移动消除的证据:
__loop1:
add edx, 1
add edx, 1
add ecx, 1
jnc __loop1
对比
__loop2:
mov eax, edx
add eax, 1
mov edx, eax
add edx, 1
add ecx, 1
jnc __loop2
如果mov
向依赖链添加一个循环,预计第二个版本每次迭代大约需要 4 个循环。在我的 Haswell 上,每次迭代都需要大约 2 个周期,如果没有移动消除,这是不可能发生的。
Register-copy 对于 front-end 永远不会免费,只是在 issue/rename 阶段从 back-end 中实际执行(零延迟)中删除 CPUs:
- 用于 XMM 向量寄存器的 AMD Bulldozer 系列,不是整数。
- 用于整数和 XMM 向量寄存器的 AMD Zen 系列。 (以及 Zen2 及更高版本中的 YMM)
(有关 BD / Zen 1 中 YMM 的 low/high 一半的详细信息,请参阅 Agner Fog's 微架构指南) - 用于整数和向量寄存器的 Intel Ivy Bridge 及更高版本(MMX 除外)
- Intel Goldmont 及更高版本 low-power CPUs:XMM 和整数
(包括 Alder Lake E-cores integer/XMM 但不包括 YMM) - 不是 Intel Ice Lake:禁用微代码更新 register-renaming 作为解决错误的一部分,用于 general-purpose 整数 reg。 XMM/YMM/ZMM 重命名仍然有效。 Tiger Lake 也受到影响,但 Rocket Lake 或 Alder Lake 没有受到影响 P-cores.
uops.info results formov r32, r32
- 注意 CPUs 的延迟为 0。请注意,它们在 Ice Lake 和 Tiger Lake 上显示 latency=1,因此它们在微代码更新后重新测试。
你的实验
问题中循环的吞吐量不依赖于 MOV 的延迟,或者(在 Haswell 上)不使用执行的好处单位.
front-end 的循环仍然只有 4 微指令发送到 out-of-order back-end。 (mov
仍然需要被 out-of-order back-end 跟踪,即使它不需要执行单元,但是 cmp/jc
macro-fuses 变成一个 uop) .
Intel CPUs 因为 Core 2 的问题宽度为每时钟 4 微指令,所以 mov
不会阻止它在(接近)每个时钟上执行一个迭代哈斯韦尔。在 Ivybridge 上它也会 运行 每个时钟一个(mov-elimination),但在 Sandybridge 上 不会 (没有 mov-elimination)。 在 SnB 上,每 1.333c 周期大约有一个迭代器,这在 ALU 吞吐量上是瓶颈,因为 mov
总是需要一个 。 (SnB/IvB 只有三个 ALU 端口,而 Haswell 有四个)。
请注意,重命名阶段的特殊处理对于 x87 FXCHG(将 st0
与 st1
交换)的时间比 MOV 长得多。 Agner Fog 在 PPro/PII/PIII(first-gen P6 核心)上将 FXCHG 列为 0 延迟。
题中的循环有两个互锁的依赖链(add edi,esi
依赖于EDI和循环计数器ESI),这使得它对不完美的调度更加敏感。由于 seemingly-unrelated 指令,与理论预测相比减速 2% 并不罕见,指令顺序的微小变化会造成这种差异。要 运行 每个迭代正好 1c,每个循环都需要 运行 一个 INC 和一个 ADD。由于所有 INC 和 ADD 都依赖于先前的迭代,因此 out-of-order 执行无法在一个周期内赶上 运行ning 两个。更糟糕的是,ADD 依赖于上一个循环中的 INC,这就是我所说的“互锁”的意思,因此在 INC dep 链中丢失一个循环也会使 ADD dep 链停止。
此外,predicted-taken b运行ches 只能在端口 6 上 运行,因此 任何端口 6 不执行 cmp/jc 的周期是吞吐量损失周期。每当 INC 或 ADD 在端口 6 上窃取一个周期而不是在端口 0、1 或 5 上窃取 运行ning 时,就会发生这种情况。IDK 如果这是罪魁祸首,或者如果在 INC/ADD dep 链中丢失周期他们自己是问题所在,或者两者兼而有之。
添加额外的 MOV 不会增加任何 execution-port 压力,假设它被 100% 消除,但它确实阻止了 运行 前进的 front-end back-end 个执行单位。 (循环中的 4 个微指令中只有 3 个需要一个执行单元,而您的 Haswell CPU 可以 运行 在其 4 个 ALU 端口中的任何一个上进行 INC 和 ADD:0、1、5 和 6。所以瓶颈是:
- front-end 每个时钟 4 微指令的最大吞吐量。 (没有 MOV 的循环只有 3 微码,所以 front-end 可以 运行 提前)。
- taken-branch 每个时钟一个吞吐量。
- 涉及
esi
的依赖链(每个时钟 1 个 INC 延迟) - 涉及
edi
的依赖链(每个时钟增加1个延迟,并且还依赖于上一次迭代的INC)
没有 MOV,front-end 可以每个时钟 4 次发出循环的三个微指令,直到 out-of-order back-end 满为止。 (AFAICT,lsd.cycles_4_uops
的性能计数器确认它在发出任何 uops 时主要以 4 个为一组发出。)
我以为我在做某事g 在这里,但是任何 sub-optimal 调度应该让 front-end 赶上并保持 back-end 满。我不认为我们应该期望 front-end 会在管道中引起足够多的气泡来解释低于最大吞吐量 2% 的下降,因为微小循环应该 运行 从循环缓冲区以非常一致的 4每时钟吞吐量。也许还有其他事情正在发生。
mov
消除的好处的真实例子。
我使用 lea
构建了一个每个时钟只有一个 mov
的循环,创建了一个完美的演示,其中 MOV-elimination 100% 成功,或者 0% 的时间 mov same,same
来演示产生的延迟瓶颈。
由于macro-fuseddec/jnz
是涉及循环计数器的依赖链的部分,不完善的调度无法延迟. 这不同于 cmp/jc
每次迭代都从 critical-path 依赖链中“分叉”的情况。
_start:
mov ecx, 2000000000 ; each iteration decrements by 2, so this is 1G iters
align 16 ; really align 32 makes more sense in case the uop-cache comes into play, but alignment is actually irrelevant for loops that fit in the loop buffer.
.loop:
mov eax, ecx
lea ecx, [rax-1] ; we vary these two instructions
dec ecx ; dec/jnz macro-fuses into one uop in the decoders, on Intel
jnz .loop
.end:
xor edi,edi ; edi=0
mov eax,231 ; __NR_exit_group from /usr/include/asm/unistd_64.h
syscall ; sys_exit_group(0)
在 Intel SnB-family 上,LEA 在寻址模式下具有一个或两个组件 运行s,延迟为 1c(参见 http://agner.org/optimize/, and other links in the x86 标签 wiki)。
我在 Linux 上构建并 运行 这是一个静态二进制文件,所以 user-space perf-counters 整个过程只测量循环,启动/关闭可忽略不计高架。 (与将 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% )
正如预期的那样,循环 运行s 1G 次(branches
~= 10 亿次)。超过 2G 的“额外”111k 周期是其他测试中也存在的开销,包括没有 mov
的测试。它不是来自 mov-elimination 的偶尔失败,但它确实随着迭代计数而扩展,因此它不仅仅是启动开销。这可能来自定时器中断,因为 IIRC Linux perf
在处理中断时不会乱用 perf-counters,只是让它们继续计数。 (perf
虚拟化硬件性能计数器,因此即使线程在 CPU 之间迁移,您也可以获得 per-process 计数。)此外,计时器中断共享相同物理内核的同级逻辑内核会有点扰乱事情。
瓶颈是涉及循环计数器的 loop-carried 依赖链。 1G 迭代器的 2G 周期是每次迭代 2 个时钟,或每次递减 1 个时钟。这证实了 dep 链的长度是 2 个周期。 这只有在 mov
具有零延迟 时才有可能。 (我知道这并不能证明没有其他瓶颈。它实际上只能证明延迟是最多 2个周期,如果您不相信我关于延迟是唯一瓶颈的断言。有一个 resource_stalls.any
性能计数器,但它没有很多选项来分解哪个微体系结构资源已耗尽。)
循环有 3 个 fused-domain 微指令:mov
、lea
和 uops_issued.any
count confirms that: It counts in the fused domain, which is all of the pipeline from decoders to retirement, except for the scheduler (RS) and execution units. (macro-fused instruction-pairs stay as single uop everywhere. It's only for micro-fusion of stores or ALU+load that 1 fused-domain uop in the ROB 跟踪两个未fused-domain 微指令的进度。)
2G uops_executed.thread
(unfused-domain) 告诉我们所有的 mov
微指令都被淘汰了(即由 issue/rename 阶段处理,并放在 ROB 中already-executed 状态)。它们仍然占用 issue/retire 带宽,在 uop 缓存中占用 space,以及 code-size。它们在 ROB 中占用 space,限制了 out-of-order window 大小。 A mov
指令永远不会免费。除了延迟和执行端口之外,还有许多可能的微架构瓶颈,最重要的通常是 front-end.
在 Intel CPUs 上,零延迟通常比不需要执行单元更重要,尤其是在有 4 个 ALU 端口的 Haswell 和更高版本中。 (但其中只有 3 个可以处理向量微指令,所以 non-eliminated 向量移动更容易成为瓶颈,特别是在没有很多负载或存储占用 front-end 带宽的代码中(每个 4 fused-domain 微指令时钟)远离 ALU uops。此外,将 uops 调度到执行单元并不完美(首先更像是 oldest-ready),因此不在关键路径上的 uops 可以从关键路径窃取周期。)
如果我们将 nop
或 xor edx,edx
放入循环中,它们也会发出但不会在 Intel SnB-family CPUs 上执行。
Zero-latency mov-elimination 可用于 zero-extending 从 32 位到 64 位,以及 8 位到 64 位。(
没有mov-elimination
目前所有支持mov-elimination的CPU不支持mov same,same
,所以为[=367选择不同的寄存器=] 从 32 到 64 位的整数,或者 vmovdqa xmm,xmm
到 zero-extend 到 YMM 在极少数情况下是必要的。 (除非你 需要 寄存器中的结果已经存在。跳到不同的寄存器并返回通常更糟。)在 Intel 上,这同样适用于 movzx eax,al
例如. (AMD Ryzen 没有 mov-eliminate movzx。)Agner Fog 的指令 tables 显示 mov
作为 always 在 Ryzen 上被淘汰,但我猜他意味着它不会像在 Intel 上那样在两个不同的 regs 之间失败。
我们可以利用这个限制来创造一个有意击败它的micro-benchmark。
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% )
这次拍1G迭代需要3G循环,因为现在依赖链的长度是3个循环。
fused-domain uop 计数没有改变,仍然是 3G。
改变的是现在 unfused-domain uop 计数与 fused-domain 相同。所有的 uops 都需要一个执行单元; none 的 mov
指令被删除,因此它们都向 loop-carried dep 链添加了 1c 延迟。
(当有 micro-fused 微指令时,例如 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% )
现在我们将 loop-carried 深度链的延迟降低到 2 个周期。
什么都没有消除。
我在 3.9GHz i7-6700k Skylake 上进行了测试。对于所有 perf 事件,我在 Haswell i5-4210U 上得到相同的结果(在 1G 计数的 40k 以内)。这与同一系统上的 re-running 的误差幅度大致相同。
请注意,如果我 运行 perf
作为 root1,并且计算 cycles
而不是 cycles:u
(user-space only),它测量的 CPU 频率正好是 3.900 GHz。 (IDK 为什么 Linux 仅在重新启动后立即服从 bios-settings 的最大涡轮增压,但如果我将其闲置几分钟则降至 3.9GHz。Asus Z170 Pro Gaming mobo,Arch Linux 与内核 4.10.11-1-ARCH。看到与 Ubuntu 相同的事情。从 /etc/rc.local
向每个 /sys/devices/system/cpu/cpufreq/policy[0-9]*/energy_performance_preference
写入 balance_performance
修复它,但是写入 balance_power
使其稍后再次降回 3.9GHz。)
1:更新:作为 运行ning sudo perf
的更好替代方案,我在 /etc/syctl.d/99-local.conf
kernel.perf_event_paranoid = 0
你应该在 AMD Ryzen 上得到相同的结果,因为它可以消除整数 mov
。 AMD Bulldozer-family 只能消除 xmm 寄存器副本。 (根据 Agner Fog 的说法,ymm
寄存器副本是消除的 low-half 和高半部分的 ALU 运算。)
例如,AMD Bulldozer 和 Intel Ivybridge 可以维持每个时钟 1 个的吞吐量
movaps xmm0, xmm1
movaps xmm2, xmm3
movaps xmm4, xmm5
dec
jnz .loop
但英特尔 Sandybridge 无法消除移动,因此它会在 3 个执行端口的 4 个 ALU 微处理器上出现瓶颈。如果它是 pxor xmm0,xmm0
而不是 movaps,SnB 也可以维持每个时钟的一次迭代。 (但是 Bulldozer-family 不能,因为 xor-zeroing 在 AMD 上仍然需要一个执行单元,即使它独立于寄存器的旧值。而且 Bulldozer-family 对于 PXOR 只有 0.5c 吞吐量.)
mov-elimination
的限制连续两条相关的 MOV 指令暴露了 Haswell 和 Skylake 之间的差异。
.loop:
mov eax, ecx
mov ecx, eax
sub ecx, 2
jnz .loop
Haswell:较小的 run-to-run 可变性(1.746 至 1.749 c / iter),但这是典型的:
1,749,102,925 cycles:u # 2.690 GHz
4,000,000,212 instructions:u # 2.29 insn per cycle
1,000,000,208 branches:u # 1538.062 M/sec
3,000,079,561 uops_issued_any:u # 4614.308 M/sec
1,746,698,502 uops_executed_core:u # 2686.531 M/sec
745,676,067 lsd_cycles_4_uops:u # 1146.896 M/sec
并非所有 MOV 指令都被消除:每次迭代的 2 条指令中约有 0.75 条使用了执行端口。每个执行而不是被消除的 MOV 都会给 loop-carried dep 链增加 1c 的延迟,所以 uops_executed
和 cycles
非常相似并不是巧合。所有微指令都是单个依赖链的一部分,因此不可能存在并行性。 cycles
总是比 uops_executed
高 5M,无论 run-to-run 变化如何,所以我猜其他地方只用了 5M 周期。
Skylake:比 HSW 结果更多 table,而且更多 mov-elimination:每 2 个中只有 0.6666 个 MOV 需要一个执行单元。
1,666,716,605 cycles:u # 3.897 GHz
4,000,000,136 instructions:u # 2.40 insn per cycle
1,000,000,132 branches:u # 2338.050 M/sec
3,000,059,008 uops_issued_any:u # 7014.288 M/sec
1,666,548,206 uops_executed_thread:u # 3896.473 M/sec
666,683,358 lsd_cycles_4_uops:u # 1558.739 M/sec
在 Haswell 上,lsd.cycles_4_uops
占了所有微指令。 (0.745 * 4 ~= 3)。因此,几乎在发出任何 uops 的每个周期中,都会发出一个完整的 4 组(来自 loop-buffer。我可能应该查看一个不关心它们来自哪里的不同计数器,比如 uops_issued.stall_cycles
计算没有发出 uops 的周期)。
但在 SKL 上,0.66666 * 4 = 2.66664
小于 3,因此在某些周期中 front-end 发出的指令少于 4 微指令。 (通常它会停止,直到 out-of-order back-end 中有空间发出完整的 4 组,而不是发出 non-full 组)。
很奇怪,我不知道确切的微体系结构限制是什么。由于循环只有 3 微指令,因此 4 微指令中的每个 issue-group 都超过了一个完整的迭代。因此一个问题组最多可以包含 3 个依赖 MOV。也许 Skylake 的设计有时会打破它,以允许更多 mov-elimination?
更新:实际上这对于 Skylake 上的 3-uop 循环是正常的。 uops_issued.stall_cycles
显示 HSW 和 SKL 发出一个简单的 3 uop 循环,没有 mov-elimination 与他们发出这个循环的方式相同。因此,更好的 mov-elimination 是 side-effect 出于某些其他原因拆分问题组。 (这不是瓶颈,因为 taken b运行ches 的执行速度不能超过每时钟 1 个,无论它们发出的速度有多快)。我仍然不知道为什么 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 # 3.897 GHz
5,000,000,185 instructions:u # 2.14 insn per cycle
1,000,000,181 branches:u # 1669.905 M/sec
4,000,061,152 uops_issued_any:u # 6679.720 M/sec
2,333,374,781 uops_executed_thread:u # 3896.513 M/sec
1,000,000,942 lsd_cycles_4_uops:u # 1669.906 M/sec
所有微指令以 4 为一组发出。任何连续的 4 微指令组都将恰好包含两个 MOV 微指令,它们是要消除的候选者。因为它显然成功地消除了某些 ccles,IDK 为什么它不能总是这样做。
Intel's optimization manual 表示尽早覆盖 mov-elimination 的结果可以释放微体系结构资源,因此它可以更频繁地成功,至少 movzx
是这样。请参见 示例 3-23。 Re-ordering 提高 Zero-Latency MOV 指令有效性的序列。
所以也许它是用 limited-size table 的 ref-counts 进行内部跟踪的?当不再需要物理寄存器文件条目作为原始体系结构寄存器的值时,如果仍然需要它作为 mov 目标的值,则必须阻止释放物理寄存器文件条目。尽快释放 PRF 条目是关键,因为 PRF size can limit the out-of-order window 小于 ROB 大小。
我尝试了 Haswell 和 Skylake 上的示例,发现 mov-elimination 实际上在执行此操作时确实工作了更多的时间,但它实际上在总周期中稍微慢了一点,而不是更快。该示例旨在展示 IvyBridge 的优势,它可能在其 3 个 ALU 端口上存在瓶颈,但 HSW/SKL 仅在 dep 链中的资源冲突上存在瓶颈,并且似乎不需要 ALU 端口来实现更多movzx
条指令。
另请参阅 xchg eax, ecx
的研究和猜测。 (实际上 xchg reg,reg
在 Intel 上是 3 个 ALU 微指令,但在 Ryzen 上是 2 个消除微指令。有趣的是猜测英特尔是否可以更有效地实现它。)
顺便说一句,作为 Haswell 勘误表的解决方法,Linux 在启用超线程时不提供 uops_executed.thread
,仅提供 uops_executed.core
。另一个核心肯定一直处于空闲状态,甚至没有定时器中断,because I took it offline with echo 0 > /sys/devices/system/cpu/cpu3/online
。不幸的是,在内核的 perf
驱动程序 (PAPI) 决定在启动时启用 HT 之前,这无法完成,而我的戴尔笔记本电脑没有禁用 HT 的 BIOS 选项。所以我无法让 perf
在该系统上一次使用所有 8 个硬件 PMU 计数器,只有 4 个。:/