函数调用循环比空循环快

时间:2017-08-01 15:54:45

标签: c performance assembly x86 fasm

我将一些程序集与一些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 <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提供预热的机会,而不仅仅是测量的时间长度没有显着影响,也没有改变调用顺序或单独运行
  • 长时间运行不会影响比例,例如,运行时间为162.168131.578秒的运行时间延长1000倍

此外,在修改汇编代码以对齐字节后,我测试了给函数集添加了一个额外的偏移量,并得出了一些更奇怪的结论。这是更新的代码:

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字节,这是我找到的。

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

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

2 个答案:

答案 0 :(得分:4)

更新: 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 optimization2013 blog post表示所有Sandybridge系列CPU都存在此效果

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

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

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

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

这似乎是前端之间没有排队等待很多uops的交互,导致后端从推送到弹出的存储转发具有较低的延迟。

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

测试代码:perf stat -r4 shell循环构建&amp;使用每个不同的偏移量分析asm ​​

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 是一种在重定向到日志文件时记录命令及其输出的便捷方法。

asm-link是一个运行(set -x)的脚本,然后对结果运行yasm -felf32 -Worphan-labels -gdwarf2 call-tight-loop.asm "$@" && ld -melf_i386 -o call-tight-loop call-tight-loop.o

NASM / YASM Linux测试程序(组装成一个完整的静态二进制文件,运行循环然后退出,这样你就可以分析整个程序。)OP的FASM源的直接端口,没有优化ASM。

objdumps -drwC -Mintel

快速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 <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对堆栈指针的更新,但the stack engine handles those updates with zero latency。 (英特尔自Pentium-M以来,自K10以来的AMD,根据Agner Fog's microarch pdf,所以我假设您的CPU有一个,即使您没有说明您运行测试的CPU微体系结构。)

额外的ret / call仍然需要执行,但是无序执行可以使关键路径指令以最大吞吐量运行。由于这包括商店的延迟 - 从ret的推/弹+ 1周期加载转发,这在任何CPU上都不是高吞吐量,并且令人惊讶的是前端可以永远是任何对齐的瓶颈。

根据Agner Fog的说法,

dec - &gt; push延迟是Skylake的5个周期,所以在这个问题上你的循环每6个周期最多只运行一次。  这是运行popcall指令的无序执行的充足时间。 Agner列出了每{3}个ret的最大吞吐量,每1个周期一个call。或者在AMD Bulldozer,2和2上。他的表格没有列出ret / call对的吞吐量,所以IDK是否可以重叠。在AMD Bulldozer上,ret的存储/重新加载延迟为8个周期。我认为它与推/弹相同。

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

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

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

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

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

这真的是其中之一&#34;不要这样做&#34; case:内联微小函数,而不是从非常紧凑的循环中调用它们。

如果idq.mite_uops / push是一个非循环计数器的寄存器,您可能会看到不同的结果。这会将push / pop从循环计数器中分离出来,因此有两个独立的依赖链。它应该加速call和no_call版本,但也许不同。它可能只会使前端瓶颈变得更加明显。

如果你pop但是push edx,你应该会看到巨大的加速,因此推/弹指令不会形成循环携带的依赖链。那么额外的pop eax / call肯定会成为瓶颈。

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

答案 1 :(得分:0)

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