函数调用循环比空循环快

我将一些程序集与一些c链接起来测试函数调用的成本,使用以下程序集和c源代码(分别使用fasm和gcc)

部件:

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  #include  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 

他们是不同的是非常令人惊讶,但不是我问的问题。 ( 相关问题在这里 )

我问的问题是,在第二次运行中,如果函数调用的循环比没有函数调用的循环更快,那么调用函数的代价显然是负的。

编辑:只是提到评论中尝试的一些事情:

  • 在编译的字节码中,函数调用没有被优化掉。
  • 将函数和循环的对齐调整为4到64字节边界的所有内容并没有加速no_call,尽管某些对齐确实减慢了normal_call
  • 通过多次调用函数而不是仅仅调用一次函数给CPU / OS提供预热的机会对测量的时间长度没有明显的影响,也没有改变调用的顺序或单独运行
  • 运行较长时间不会影响比例,例如运行1000次,我的运行时间为162.168131.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字节对齐,至少在我的机器上。 通过offset字节offset程序,这是我发现的。

 if (20 <= offset mod 128 <= 31) then we get an output of (approximately): 162 131 else 162 (+/- 10) 162 (+/- 10) 

不知道该怎么做,但这是我到目前为止所发现的

编辑2:

我注意到的另一件事是,如果从两个函数中删除push ecxpop ecx ,输出就会变为

 30 125 

这表明这是其中最昂贵的部分。 堆栈对齐两次都是相同的,因此这不是差异的原因。 我最好的猜测是,不知何故,硬件经过优化,可以在推送或类似的东西后进行调用,但我不知道有类似的东西。

更新: Skylake存储/重新加载延迟低至3c ,但前提是 时机正确 。 存储转发依赖链中涉及的自然间隔3个或更多周期的连续负载将经历更快的延迟(例如,4个imul eax,eax循环中的imul eax,eaxmov [rdi], eax / mov eax, [rdi]每次迭代只需要循环计数从12到15个循环。)但是当允许加载比这更加密集时,会遇到某种类型的争用,每次迭代会得到大约4.5个循环。 非整数平均吞吐量也是一个很大的线索,有一些不寻常的东西。

我看到32B向量的效果相同(最好的情况是6.0c,背靠背6.2到6.9c),但128b向量总是在5.0c左右。 查看Agner Fog论坛的详细信息 。

Update2: 添加冗余分配可在没有优化的情况下编译时加快代码速度, 2013年博客文章表明所有Sandybridge系列CPU都存在这种影响

Skylake的背对背(最坏情况)存储转发延迟比之前的搜索更好1个周期,但是当负载无法立即执行时的可变性是相似的。


通过正确(错误)对齐,循环中的额外call实际上可以帮助Skylake观察从推送到弹出的较低存储转发延迟。 我能够使用perf stat -r4使用perf计数器(Linux perf stat -r4 )重现这一点。 (我听说在Windows上使用perf计数器不太方便,而且我还没有Windows开发机器。幸运的是,操作系统与答案无关;任何人都应该能够重现我的计时器结果在Windows上使用VTune或其他东西。)

在问题中指定的位置处align 128在offset = 0..10,37,63-74,101和127处看到了更快的时间 。 L1I缓存行为64B,uop-cache关注32B边界。 看起来相对于64B边界的对齐是最重要的。

无调用循环总是稳定的5个循环,但是call循环每次迭代可以从通常的几乎完全5个循环下降到4c。 我看到偏移= 38时的性能比平常慢(每次迭代5.68 + – 8.3%周期)。 根据perf stat -r4 (进行4次运行和平均),在其他点有小故障,如5.17c + – 3.3%。

它似乎是前端之间的交互,没有排队前面那么多uops,导致后端从推送到弹出的存储转发具有更低的延迟。

IDK如果重复使用相同的地址进行存储转发会使速度变慢(多个存储地址uops已经在相应的存储数据uop之前执行),或者是什么。


测试代码:使用bash shell循环来构建和分析每个不同偏移量的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的脚本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 

快速call运行的示例输出:

 + asm-link -m32 -d call-tight-loop.asm -DFUNC=normal_call -DOFFSET=3 ... 080480d8 : 80480d8: c3 ret ... 08048113 : 8048113: b9 00 e1 f5 05 mov ecx,0x5f5e100 08048118 : 8048118: 51 push ecx 8048119: e8 ba ff ff ff call 80480d8  804811e: 59 pop ecx 804811f: 49 dec ecx 8048120: 83 f9 00 cmp ecx,0x0 8048123: 75 f3 jne 8048118  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% ) 

在注意到变量存储转发延迟之前的旧答案

你推/弹你的循环计数器,所以除了callret指令(和cmp / jcc )之外的所有东西都是涉及循环计数器的关键路径循环携带依赖链的一部分。

你会期望pop必须等待通过call / ret更新堆栈指针,但堆栈引擎会以零延迟处理这些更新 。 (根据Agner Fog的microarch pdf ,英特尔自Pentium-M,AMD自K10以来,所以我假设您的CPU有一个,即使您没有说明您运行测试的CPU微体系结构。)

额外的call / ret仍然需要执行,但是无序执行可以使关键路径指令以最大吞吐量运行。 由于这包括存储的延迟 – >从推/弹+ 1周期到负载转发为dec ,这不是任何CPU上的高吞吐量,并且令人惊讶的是前端可能成为任何对齐的瓶颈。

根据Agner Fog的说法,在Skylake上push – > pop延迟是5个周期,所以在你的循环中,你的循环最多只能在每6个循环中运行一次。 这是执行callret指令的无序执行的充足时间。 Agner列出了每3个循环call一次的最大吞吐量,并且每1个循环ret一个。 或者在AMD Bulldozer,2和2上。他的表没有列出任何关于call / ret对的吞吐量,所以IDK是否可以重叠。 在AMD Bulldozer上, mov存储/重载延迟为8个周期。 我认为它与推/弹一样大致相同。

似乎循环顶部的不同对齐(即no_call.loop_start:导致前端瓶颈。 call版本每次迭代有3个分支:调用,ret和循环分支。 请注意, ret的分支目标是call后的指令。 这些都可能破坏前端。 由于您在实践中看到实际的减速,我们必须看到每个分支超过1个周期延迟。 或者对于no_call版本,单个提取/解码泡沫比大约6个周期更糟,导致实际浪费的周期将uop发布到核心的无序部分。 那真是怪了。

猜测每个可能的uarch的实际微体系结构细节是太复杂了,所以让我们知道你测试的CPU是什么。

我会提到虽然Skylake循环中的push / pop阻止它从循环流检测器发出,但每次都必须从uop缓存中重新获取。 英特尔的优化手册说,对于Sandybridge来说,循环中不匹配的推/弹会阻止它使用LSD。 这意味着它可以将LSD用于具有平衡推/弹的循环。 在我的测试中,Skylake的情况并非如此(使用lsd.uops性能计数器),但我还没有看到是否有任何改变,或者SnB是否也是如此。

此外,无条件分支始终结束uop-cache行。 使用normal_function:可能normal_function:在与calljne相同的自然对齐的32B机器代码块中,可能代码块不适合uop缓存。 (只有3个uop-cache行可以为单个32B的x86代码块缓存解码的uop)。 但这并不能解释no_call循环出现问题的可能性,因此您可能无法在Intel SnB系列微体系结构上运行。

(更新,是的,循环有时主要来自遗留解码( idq.mite_uops ),但通常不是唯一的dsb2mite_switches.penalty_cycles通常是~8k,可能只发生在定时器中断。 call循环运行得更快的运行似乎与较低的idq.mite_uops相关idq.mite_uops ,但对于偏移= 37的情况,它仍然是34M + – 63%,其中100M迭代需要401M周期。)

这实际上是“不要那样做”的情况之一:内联微小的函数,而不是从非常紧凑的循环中调用它们。


如果您push / pop除循环计数器之外的寄存器,您可能会看到不同的结果。 这会将push / pop与循环计数器分开,因此会有2个独立的依赖链。 它应该加速call和no_call版本,但也许不会同等。 它可能只会使前端瓶颈变得更加明显。

你应该看到一个巨大的加速,如果你push edxpop eax ,所以push / pop指令不会形成循环携带的依赖链。 然后额外的call / ret肯定会成为瓶颈。


旁注: dec ecx已经按照你想要的方式设置ZF,所以你可以使用dec ecx / jnz 。 此外, cmp ecx,0的效率低于test ecx,ecx (更大的代码大小,并且无法在尽可能多的CPU上进行宏融合)。 无论如何,与你的两个循环的相对性能问题完全无关。 (在函数之间缺少ALIGN指令意味着更改第一个会改变第二个循环分支的对齐,但您已经探索了不同的对齐。)

除了第一次之外,每次都会正确地预测对normal_function的调用及其返回,因此我不希望看到由于调用的存在而导致的时序差异。 因此,您看到的所有时间差异(无论是更快还是更慢)都是由于其他影响(例如评论中提到的影响),而不是您实际尝试测量的代码差异。