函数调用循环比空循环快

rtp*_*pax 15 c performance x86 assembly 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
Run Code Online (Sandbox Code Playgroud)

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;
}
Run Code Online (Sandbox Code Playgroud)

我得到的结果令人惊讶.首先,速度取决于我链接的顺序.如果我链接为gcc intern.o extern.o,则典型输出为

162
181
Run Code Online (Sandbox Code Playgroud)

但是以相反的顺序链接gcc extern.o intern.o,我的输出更像是:

162
130
Run Code Online (Sandbox Code Playgroud)

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

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

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

  • 在编译的字节码中,函数调用没有被优化掉.
  • 将函数和循环的对齐调整为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
Run Code Online (Sandbox Code Playgroud)

我不得不手动(并且非移植)强制64字节对齐,因为FASM不支持可执行部分超过4字节对齐,至少在我的机器上.按offset字节偏移程序,这是我发现的.

if (20 <= offset mod 128 <= 31) then we get an output of (approximately):

162
131

else

162 (+/- 10)
162 (+/- 10)
Run Code Online (Sandbox Code Playgroud)

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

编辑2:

我注意到另一件事是,如果你删除push ecx,并pop ecx从两者的功能,输出变为

30
125
Run Code Online (Sandbox Code Playgroud)

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

Pet*_*des 5

更新:Skylake 存储/重新加载延迟低至 3c,但前提是时机正确。存储转发依赖链中涉及的自然间隔 3 个或更多周期的连续加载将经历更快的延迟(例如,imul eax,eax循环中有 4 个,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 上的背靠背(最坏情况)存储转发延迟比之前的 uarch 好 1 个周期,但无法立即执行负载时的可变性是相似的。


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

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

无调用循环始终是稳定的 5 个循环,但call循环可以从通常的几乎恰好 5 个循环降低到每次迭代 4c。在 offset=38(每次迭代 5.68 +- 8.3% 周期)时,我看到了比平常慢的性能。根据perf stat -r4(进行 4 次运行并取平均值),其他点存在小故障,例如 5.17c +- 3.3% 。

这似乎是前端之间的交互,没有在前面排队这么多 uop,导致后端从 push 到 pop 的存储转发延迟较低。

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


测试代码: bashshell 循环以使用每个不同的偏移量来构建和分析 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
Run Code Online (Sandbox Code Playgroud)

(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 源的直接端口,没有对 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
Run Code Online (Sandbox Code Playgroud)

快速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% )
Run Code Online (Sandbox Code Playgroud)

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

您推送/弹出循环计数器,因此除了callandret指令(和cmp/ jcc)之外的所有内容都是涉及循环计数器的关键路径循环携带依赖链的一部分。

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

额外的call/ret仍然需要执行,但乱序执行可以保持关键路径指令以其最大吞吐量运行。由于这包括从 push/pop + 1 个周期的 store->load forwarding 的延迟dec,这在任何 CPU 上都不是高吞吐量,并且令人惊讶的是前端可能成为任何对齐的瓶颈。

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

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

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

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

此外,无条件分支总是以 uop-cache 行结束。有可能在与andnormal_function:相同的自然对齐的 32B 机器代码块中,代码块可能不适合 uop 缓存。(对于单个 32B 块 x86 代码,只有 3 个 uop-cache 行可以缓存解码的 uops)。但这并不能解释 no_call 循环出现问题的可能性,因此您可能没有在英特尔 SnB 系列微架构上运行。calljne

(更新,是的,循环有时主要从传统解码 ( idq.mite_uops) 运行,但通常不完全dsb2mite_switches.penalty_cycles是。 通常是 ~8k,并且可能只发生在定时器中断上。call循环运行得更快的运行似乎与 lower 相关idq.mite_uops,但它是对于 offset=37 的情况,100M 迭代花费了 401M 周期,仍然是 34M +- 63%。)

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


如果您push/pop一个寄存器而不是您的循环计数器,您可能会看到不同的结果。这会将 push/pop 与循环计数器分开,因此会有 2 个单独的依赖链。它应该加快 call 和 no_call 版本的速度,但可能不一样。它只会使前端瓶颈更加明显。

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


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