部件:
format ELF public no_call as "_no_call" public normal_call as "_normal_call" section '.text' executable iter equ 100000000 no_call: mov ecx, iter @@: push ecx pop ecx dec ecx cmp ecx, 0 jne @b ret normal_function: ret normal_call: mov ecx, iter @@: push ecx call normal_function pop ecx dec ecx cmp ecx, 0 jne @b ret
c来源:
#include <stdio.h> #include <time.h> extern int no_call(); extern int normal_call(); int main() { clock_t ct1, ct2; ct1 = clock(); no_call(); ct2 = clock(); printf("\n\n%d\n", ct2 - ct1); ct1 = clock(); normal_call(); ct2 = clock(); printf("%d\n", ct2 - ct1); return 0; }
我得到的结果令人惊讶.首先,速度取决于我链接的顺序.如果我链接为gcc intern.o extern.o,则典型输出为
162 181
但是以相反的顺序链接gcc extern.o intern.o,我的输出更像是:
162 130
他们是不同的是非常令人惊讶,但不是我问的问题. (relevant question here)
我问的问题是,在第二次运行中,如果函数调用的循环比没有函数调用的循环快,那么调用函数的代价显然是负的.
编辑:
只是提一下评论中尝试的一些事情:
>在编译的字节码中,函数调用未被优化掉.
>将函数和循环的对齐调整为4到64字节边界的所有内容并没有加速no_call,尽管某些对齐确实减慢了normal_call
>通过多次调用函数给CPU / OS提供预热的机会,而不是仅仅调用一次,对测量的时间长度没有明显的影响,也没有改变调用顺序或单独运行
>运行较长时间不会影响比率,例如运行1000次,我的运行时间为162.168和131.578秒
另外,在修改汇编代码以对齐字节之后,我测试了给函数集添加了一个额外的偏移量,并得出了一些更奇怪的结论.这是更新的代码:
format ELF public no_call as "_no_call" public normal_call as "_normal_call" section '.text' executable iter equ 100000000 offset equ 23 ; this is the number I am changing times offset nop times 16 nop no_call: mov ecx, iter no_call.loop_start: push ecx pop ecx dec ecx cmp ecx, 0 jne no_call.loop_start ret times 55 nop normal_function: ret times 58 nop normal_call: mov ecx, iter normal_call.loop_start: push ecx call normal_function pop ecx dec ecx cmp ecx, 0 jne normal_call.loop_start ret
我不得不手动(并且非移植)强制64字节对齐,因为FASM不支持可执行部分超过4字节对齐,至少在我的机器上.通过偏移字节偏移程序,这是我发现的.
if (20 <= offset mod 128 <= 31) then we get an output of (approximately): 162 131 else 162 (+/- 10) 162 (+/- 10)
不知道该怎么做,但这是我到目前为止所发现的
编辑2:
我注意到的另一件事是,如果从两个函数中删除push ecx和pop ecx,输出就会变为
30 125
这表明这是其中最昂贵的部分.堆栈对齐两次都是相同的,因此这不是差异的原因.我最好的猜测是,不知何故硬件经过优化,可以在推送或类似的东西后进行调用,但我不知道有什么类似的东西.
更新:Skylake存储/重新加载延迟低至3c,但前提是时机正确.存储转发依赖链中涉及的自然间隔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左右.见details on Agner Fog’s forum.
Update2:Adding a redundant assignment speeds up code when compiled without optimization和2013 blog post表示所有Sandybridge系列CPU都存在此效果.
Skylake的背对背(最坏情况)存储转发延迟比之前的搜索更好1个周期,但是当负载无法立即执行时的可变性是相似的.
通过正确(错误)对齐,循环中的额外调用实际上可以帮助Skylake观察从推送到弹出的较低存储转发延迟.我能够使用YASM使用perf计数器(Linux perf stat -r4)重现这一点. (我听说在Windows上使用perf计数器不太方便,而且我还没有Windows开发机器.幸运的是,操作系统与答案无关;任何人都应该能够重现我的计时器结果在Windows上使用VTune或其他东西.)
我在问题中指定的位置处对齐128后,在offset = 0..10,37,63-74,101和127处看到了更快的时间. L1I缓存行为64B,uop-cache关注32B边界.看起来相对于64B边界的对齐是最重要的.
无调用循环始终是稳定的5个循环,但是调用循环每次迭代可以从通常的几乎完全5个循环下降到4c.我看到偏移= 38时的性能比平常慢(每次迭代5.68 – 8.3%循环).根据perf stat -r4(进行4次运行和平均),在其他点有小故障,如5.17c – 3.3%.
它似乎是前端之间没有排队等待很多uops的交互,导致后端具有较低的延迟,用于从推送到流行的存储转发.
IDK如果重复使用相同的地址进行存储转发会使速度变慢(多个存储地址uops已经在相应的存储数据uop之前执行),或者是什么.
测试代码:bash shell loop to build&使用每个不同的偏移量分析asm:
(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
子shell中的(set -x)是一种在重定向到日志文件时记录命令及其输出的便捷方法.
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源的直接端口,没有对asm的优化.
CPU p6 ; YASM directive. For NASM, %use smartalign. section .text iter equ 100000000 %ifndef OFFSET %define OFFSET 0 %endif align 128 ;;offset equ 23 ; this is the number I am changing 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 ; __NR_exit from /usr/include/asm/unistd_32.h xor ebx,ebx int 0x80 ; sys_exit(0), 32-bit ABI
快速呼叫运行的示例输出:
+ 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更新堆栈指针,但是the stack engine handles those updates with zero latency.(英特尔自Pentium-M,AMD自K10以来,根据Agner Fog’s microarch pdf,所以我假设你的CPU有一个,甚至虽然你没有说过你运行测试的CPU微体系结构.)
额外的调用/返回仍然需要执行,但是无序执行可以使关键路径指令以最大吞吐量运行.由于这包括存储的延迟 – >从推/弹1循环到dec的负载转发,这不是任何CPU上的高吞吐量,并且令人惊讶的是前端可能成为任何对齐的瓶颈.
根据Agner Fog的说法,push-gt; pop延迟在Skylake上是5个周期,所以在你的循环中,你的循环最多只能在每6个循环中运行一次.
这是运行调用和返回指令的无序执行的充足时间. Agner列出了每3个循环调用一次的最大吞吐量,并且每1个循环返回一个.或者在AMD Bulldozer,2和2.他的表中没有列出关于呼叫/报警对的吞吐量的任何信息,所以IDK是否可以重叠.在AMD Bulldozer上,mov的存储/重载延迟为8个周期.我认为推送/弹出大致相同.
似乎循环顶部的不同对齐(即no_call.loop_start :)导致前端瓶颈.调用版本每次迭代有3个分支:调用,ret和循环分支.请注意,ret的分支目标是调用后的指令.这些中的每一个都可能破坏前端.由于您在实践中看到实际的减速,我们必须看到每个分支超过1个周期延迟.或者对于no_call版本,单个提取/解码泡沫比大约6个周期更糟,导致实际浪费的周期将uop发布到核心的无序部分.那真是怪了.
猜测每个可能的uarch的实际微体系结构细节是太复杂了,所以让我们知道你测试的CPU.
我会提到虽然Skylake循环中的push / pop阻止它从循环流检测器发出,但每次都必须从uop缓存中重新获取. Intel’s optimization manual说,对于Sandybridge来说,循环中不匹配的推/弹会阻止它使用LSD.这意味着它可以将LSD用于具有平衡推/弹的循环.在我的测试中,Skylake的情况并非如此(使用lsd.uops性能计数器),但我还没有看到是否有任何改变,或者SnB是否也是如此.
此外,无条件分支始终结束uop-cache行.使用normal_function可能:在与调用和jne相同的自然对齐的32B机器代码块中,可能代码块不适合uop缓存. (只有3个uop-cache行可以为单个32B的x86代码块缓存解码的uop).但这并不能解释no_call循环出现问题的可能性,因此您可能无法在Intel SnB系列微体系结构上运行.
(更新,是的,循环有时主要来自遗留解码(idq.mite_uops),但通常不是唯一的.dsb2mite_switches.penalty_cycles通常是~8k,可能只发生在定时器中断.调用循环运行得更快的运行似乎与较低的idq.mite_uops相关联,但对于偏移= 37的情况,它仍然是34M-63%,其中100M迭代需要401M周期.)
这实际上是“不要那样做”的情况之一:内联微小的函数,而不是从非常紧密的循环中调用它们.
如果您按下/弹出除循环计数器之外的寄存器,您可能会看到不同的结果.这会将push / pop与循环计数器分开,因此会有2个独立的依赖链.它应该加速call和no_call版本,但也许不同.它可能只会使前端瓶颈变得更加明显.
你应该看到一个巨大的加速,如果你推edx但pop eax,所以推/弹指令不会形成循环携带的依赖链.那么额外的呼叫/撤销肯定会成为瓶颈.
旁注:dec ecx已经按照你想要的方式设置ZF,所以你可以使用dec ecx / jnz.此外,cmp ecx,0
is less efficient than test ecx,ecx
(更大的代码大小,无法在尽可能多的CPU上进行宏熔合).无论如何,与你的两个循环的相对性能问题完全无关. (你在函数之间缺少一个ALIGN指令意味着改变第一个会改变第二个循环分支的对齐,但你已经探索了不同的对齐.)