更新:Skylake的存储/重新加载延迟只有3个时钟周期,但前提是时间安排得当。在存储转发依赖链中自然间隔3个或更多时钟周期的连续加载将体验到更快的延迟(例如,在循环中有4个"imul eax,eax","mov [rdi],eax" / "mov eax,[rdi]"仅将周期计数从每次迭代的12增加到15个)。但当加载允许比那更密集地执行时,会遭受某种类型的争用,并且每次迭代需要约4.5个时钟周期。非整数平均吞吐量也是有些异常的重要线索。
我看到32B向量的情况相同(最佳情况为6.0c,背靠背6.2至6.9c),但128b向量始终在5.0c左右。请参见Agner Fog论坛上的详细信息。
更新2: 在没有优化编译的情况下添加冗余赋值可以加速代码,以及2013年的博客文章表明所有Sandybridge系列CPU都存在这种影响。
Skylake上的连续存储-前向延迟(最坏情况)比之前的微架构好1个周期,但是当负载无法立即执行时,可变性相似。
使用正确的(误)对齐方式,循环中额外的
call
实际上可以帮助Skylake观察从push到pop的较低存储转发延迟。我使用YASM和perf计数器(Linux
perf stat -r4
)重现了这一点。(我听说在Windows上使用perf计数器不太方便,而且我也没有Windows开发机。幸运的是,操作系统并不真正相关于答案;任何人都应该能够使用VTune或其他工具在Windows上重现我的性能计数器结果。)
在问题指定的位置进行
align 128
后,我看到了偏移量为0..10、37、63-74、101和127的更快时间。L1I缓存行为64B,uop-cache关注32B边界。看起来与64B边界的对齐相关才是最重要的。
无调用循环始终稳定为5个周期,但“call”循环可以从通常几乎完全为5个周期的迭代中降至4个周期。我在offset=38处看到了比平时慢的表现(每次迭代5.68+-8.3%个周期)。根据“perf stat -r4”(进行4次运行并平均),其他点也有小的故障,如5.17c+-3.3%。
这似乎是前端不会排队太多uops,导致后端将push到pop的存储转发延迟较低的相互作用。
我不知道重复使用同一地址进行存储转发是否会使其变慢(已经执行了多个存储地址uop,这些uop与相应的存储数据uop之前),或者其他情况。
测试代码:使用bash
shell循环构建和分析每个不同偏移量的汇编代码:
(set -x; for off in {0..127};do
asm-link -m32 -d call-tight-loop.asm -DFUNC=normal_call -DOFFSET=$off &&
ocperf.py stat -etask-clock,context-switches,cpu-migrations,page-faults:u,cycles,instructions,uops_issued.any,uops_executed.thread,idq.mite_uops,dsb2mite_switches.penalty_cycles -r4 ./call-tight-loop;
done ) |& tee -a call-tight-loop.call.offset-log
(set -x)
在子shell中使用是一个方便的方法,可以将命令及其输出记录到日志文件中。
asm-link
是一个脚本,运行yasm -felf32 -Worphan-labels -gdwarf2 call-tight-loop.asm "$@" && ld -melf_i386 -o call-tight-loop call-tight-loop.o
,然后在结果上运行objdumps -drwC -Mintel
。
NASM / YASM Linux测试程序(汇编为完整的静态二进制文件,运行循环然后退出,因此您可以对整个程序进行分析)。直接移植OP的FASM源代码,没有对汇编进行任何优化。
CPU p6
section .text
iter equ 100000000
%ifndef OFFSET
%define OFFSET 0
%endif
align 128
times OFFSET nop
times 16 nop
no_call:
mov ecx, iter
.loop:
push ecx
pop ecx
dec ecx
cmp ecx, 0
jne .loop
ret
times 55 nop
normal_function:
ret
times 58 nop
normal_call:
mov ecx, iter
.loop:
push ecx
call normal_function
pop ecx
dec ecx
cmp ecx, 0
jne .loop
ret
%ifndef FUNC
%define FUNC no_call
%endif
align 64
global _start
_start:
call FUNC
mov eax,1
xor ebx,ebx
int 0x80
一个快速的call
运行的示例输出:
+ asm-link -m32 -d call-tight-loop.asm -DFUNC=normal_call -DOFFSET=3
...
080480d8 <normal_function>:
80480d8: c3 ret
...
08048113 <normal_call>:
8048113: b9 00 e1 f5 05 mov ecx,0x5f5e100
08048118 <normal_call.loop>:
8048118: 51 push ecx
8048119: e8 ba ff ff ff call 80480d8 <normal_function>
804811e: 59 pop ecx
804811f: 49 dec ecx
8048120: 83 f9 00 cmp ecx,0x0
8048123: 75 f3 jne 8048118 <normal_call.loop>
8048125: c3 ret
...
Performance counter stats for './call-tight-loop' (4 runs):
100.646932 task-clock (msec) # 0.998 CPUs utilized ( +- 0.97% )
0 context-switches # 0.002 K/sec ( +-100.00% )
0 cpu-migrations # 0.000 K/sec
1 page-faults:u # 0.010 K/sec
414,143,323 cycles # 4.115 GHz ( +- 0.56% )
700,193,469 instructions # 1.69 insn per cycle ( +- 0.00% )
700,293,232 uops_issued_any # 6957.919 M/sec ( +- 0.00% )
1,000,299,201 uops_executed_thread # 9938.695 M/sec ( +- 0.00% )
83,212,779 idq_mite_uops # 826.779 M/sec ( +- 17.02% )
5,792 dsb2mite_switches_penalty_cycles # 0.058 M/sec ( +- 33.07% )
0.100805233 seconds time elapsed ( +- 0.96% )
在注意到变量存储转发延迟之前的旧答案
您推送/弹出循环计数器,因此除了call
和ret
指令(以及cmp
/jcc
)之外的所有内容都是关键路径循环传递依赖链中涉及循环计数器的一部分。
您会期望pop
必须等待由call
/ret
对堆栈指针的更新,但堆栈引擎处理这些更新时具有零延迟。(自Pentium-M以来的英特尔,K10以来的AMD,根据Agner Fog的微体系结构PDF,因此我假设您的CPU有一个,即使您没有提及您在哪个CPU微体系结构上运行测试。)
额外的
call
/
ret
仍需要执行,但乱序执行可以使关键路径指令以最大吞吐量运行。由于这包括来自push/pop的存储器-加载转发的延迟+
dec
1个周期,因此在任何CPU上都不具有高吞吐量,并且前端可能会成为瓶颈是一个意外。
push
->pop
延迟在Skylake上为5个时钟周期,根据Agner Fog的说法,在该微架构上,您的循环每6个周期最多只能运行一次。这足以让乱序执行运行call
和ret
指令。 Agner列出了call
的最大吞吐量为每3个周期1个,ret
为每1个周期1个。或者在AMD Bulldozer上,分别为2和2。他的表格没有列出call
/ret
对的吞吐量,因此我不知道它们是否可以重叠。在AMD Bulldozer上,使用mov
进行存储/重新加载的延迟为8个周期。我认为使用push
/pop
的延迟也大致相同。
似乎不同的循环顶部对齐方式(即
no_call.loop_start:
)会导致前端瓶颈。每次迭代,
call
版本有3个分支:调用、返回和循环分支。请注意,
ret
的分支目标是
call
之后的指令。每个分支都有可能干扰前端。由于实际上看到了减速,我们必须看到每个分支超过1个周期的延迟。或者对于no_call版本,单个取/解码气泡比大约6个周期更糟,导致将uops发出到核心的乱序部分中实际浪费一个周期。这很奇怪。
猜测每个可能的微架构细节太复杂了,因此请告诉我们您测试的CPU。
我要提到的是,在Skylake上循环内部的push
/pop
会阻止其从Loop Stream Detector中发出,并且每次都必须重新从uop缓存中获取。Intel's optimization manual表示,对于Sandybridge,循环内不匹配的push/pop会停止使用LSD。这意味着它可以在具有平衡push/pop的循环中使用LSD。在我的测试中,在Skylake上并非如此(使用lsd.uops
性能计数器),但我没有看到任何关于这是否是一个变化,或者SnB实际上也是这样的。
此外,无条件分支总是结束uop-cache行。可能会出现这样的情况,即
normal_function:
与
call
和
jne
在自然对齐的32B机器代码块中,但代码块不适合于uop缓存。(单个x86代码的32B块只能缓存3条解码uop)。但这并不能解释no_call循环可能出现问题的可能性,因此您可能没有在Intel SnB系列微体系结构上运行。
(更新,是的,该循环有时主要从遗留解码(
idq.mite_uops
)运行,但通常不是完全独占的。
dsb2mite_switches.penalty_cycles
通常为~8k,并且可能仅在计时器中断时发生。
call
循环运行更快的运行似乎与较低的
idq.mite_uops
相关,但对于偏移量= 37的情况,100M次迭代花费了401M周期,仍然为34M +- 63%。)
这真的是那些“不要这样做”的情况之一:内联微小函数而不是从非常紧密的循环内部调用它们。
如果您push
/pop
一个不同于循环计数器的寄存器,您可能会看到不同的结果。这将分离push/pop和循环计数器,因此将有两个单独的依赖链。这应该加速调用和no_call版本,但可能不是完全相等的。它可能只是使前端瓶颈更加明显。
如果您push edx
但pop eax
,则应该会看到巨大的加速,因此push/pop指令不形成循环传递的依赖关系链。然后额外的call
/ret
肯定会成为瓶颈。
附注: dec ecx
已经设置了您所需的ZF,因此您可以只使用 dec ecx / jnz
。 此外,cmp ecx,0
比 test ecx,ecx
不够高效(代码大小更大,无法在许多CPU上进行宏融合)。 无论如何,这与您两个循环的相对性能的问题完全无关。 (您在函数之间没有ALIGN
指令意味着更改第一个函数将更改第二个函数中的循环分支的对齐方式,但您已经探索了不同的对齐方式。)
clock
是可以的。试着查看编译后的 C 代码生成的汇编内容。而且从链接顺序很重要这个事实来判断,似乎正在进行一些链接时优化。 - Eugene Sh.jne @b
的目标)很重要。不幸的是,您没有明确命名它们。no_call
和normal_call
只使用一次,因此在那里有任何未对齐的惩罚并不重要(远远超出了clock
计时的[不]精度)。由于normal_function
被广泛调用,因此将其对齐可能也有所帮助。通常 4 或 8 边界就足够了,但可以自由尝试高达 64(我认为现代缓存行长度为 32B?但 64 肯定足够应付任何情况)。 - Ped7gno_call
+normal_call
,以使 CPU 频率升高并使缓存状态对两种变体(预缓存)相似。 - Ped7giter equ 1000000000
更改为运行10倍长。两个函数的运行时间约为1.55秒。我尝试在循环之前使用align 16
,但没有显著的差异。整个程序适合代码缓存,这可能是为什么对齐没有帮助的原因。 - rcgldr