【问题标题】:Loop with function call faster than an empty loop函数调用循环比空循环快
【发布时间】:2018-01-08 14:15:44
【问题描述】:

我将一些程序集与一些 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)

我要问的问题是,为什么在第二次运行中,有函数调用的循环比没有函数调用的循环快,调用函数的成本显然是负的。

编辑: 只是提一下在 cmets 中尝试过的一些事情:

  • 在编译的字节码中,函数调用没有被优化掉。
  • 将函数和循环的对齐方式调整为 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 字节,这是我发现的。

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

这表明这是其中最昂贵的部分。堆栈对齐两次都是相同的,所以这不是差异的原因。我最好的猜测是,硬件以某种方式进行了优化,以期待在推送或类似的东西后调用,但我不知道这样的事情

【问题讨论】:

  • @Eugene Sh.你会推荐什么?
  • 好吧,虽然我猜clock 很好。尝试查看编译后的 C 代码的生成程序集。此外,看起来(判断链接顺序很重要)正在发生一些链接时间优化。
  • 大部分跳转到达的地址(jne @b 的目标)很重要。不幸的是,您没有明确命名它们。 no_callnormal_call 仅使用一次,因此任何未对齐的惩罚都不重要(远远超出 clock 计时的 [im] 精度)。并且由于normal_function 被广泛调用,因此对齐它也可能有所帮助。通常 4 或 8 个边界就足够了,但可以随意尝试最多 64 个(我认为现代缓存线长 32B?但 64 对任何事情来说都足够了)。
  • 导致结果倾斜的另一件事可能是负载下 CPU 频率的动态变化,也许 no-call 循环被理解为 idle-loop 并且 CPU+OS 确实切换了频率。下来,虽然我认为这不太可能在 CPU 中进行如此复杂的代码分析。但是您正在跳过预热阶段,操作系统可能需要一段时间才能检测到 100% 的 CPU 内核使用率,然后才能提高功率,所以可能先对 no_call + normal_call 进行一次非时钟运行,两者都可以提高 CPU 频率。并使两种变体的缓存状态相似(预缓存)。
  • @rtpax - 我用 Visual Studio / Windows 尝试了相同的代码。我添加了一个零,更改为iter equ 1000000000 运行时间延长了 10 倍。这两个功能的运行时间约为 1.55 秒。我在循环之前尝试了align 16,但并没有显着不同。整个程序适合代码缓存,这可能是对齐没有帮助的原因。

标签: c performance assembly x86 fasm


【解决方案1】:

更新:Skylake 存储/重新加载延迟低至 3c,但前提是时机正确。自然间隔 3 个或更多周期的存储转发依赖链中涉及的连续负载将经历更快的延迟(例如,循环中有 4 个imul eax,eaxmov [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

更新 2:Adding a redundant assignment speeds up code when compiled without optimization2013 blog post 表示此效果存在于所有 Sandybridge 系列 CPU 上

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


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

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

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

这似乎是前端没有在前面排队那么多 uops 之间的交互,导致后端从 push 到 pop 的 store-forwarding 延迟较低。

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


测试代码: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 "$@" &amp;&amp; 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 <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% )

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

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

您可能希望pop 必须等待call/ret 对堆栈指针的更新,但the stack engine handles those updates with zero latency。 (Intel 自 Pentium-M,AMD 自 K10,根据Agner Fog's microarch pdf,所以我假设你的 CPU 有一个,即使你没有说明你在什么 CPU 微架构上运行测试。)

额外的call/ret仍然需要执行,但是乱序执行可以保持关键路径指令以最大吞吐量运行。由于这包括存储的延迟->从 push/pop 的负载转发 + 1 个周期为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 个周期差,导致在向内核的无序部分发出微指令时实际浪费了一个周期。这很奇怪。

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

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

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

(更新,是的,循环有时主要从旧版解码 (idq.mite_uops) 运行,但通常不是排他性的。dsb2mite_switches.penalty_cycles 通常约为 8k,并且可能仅在计时器中断时发生。call 的运行更快的循环运行似乎与更低的idq.mite_uops 相关,但对于 100M 次迭代花费 401M 周期的 offset=37 情况,它仍然是 34M +- 63%。)

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


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

如果您使用push edxpop eax,您应该会看到巨大的加速,因此推送/弹出指令不会形成循环携带的依赖链。那么多余的call/ret肯定会成为瓶颈。


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

【讨论】:

  • 不知何故,我总是知道这是您的答案之一——甚至在我滚动到足够远的地方看到作者之前。 :)(我猜是因为在页面向下的过程中发生的所有好的学习)
  • @DavidC.Rankin:我认为我有一种相当独特的写作风格(和格式),所以即使除了信息内容之外,这也是一种暗示。在很多答案中,我将一些关键点加粗,以方便人们浏览,而大多数人也不会这样做。
  • 真正有趣的结果是,在某些情况下,存储转发速度可以达到 3 个周期。我可以想到几种可能性:也许“将转发”预测器只能每 N 个周期(其中 N 是 5 或 6 或其他东西)或每 N 个微指令发出一个预测。更可能的可能性是存储转发有两个阶段:搜索存储缓冲区,然后是实际转发。由于地址[rdi] 未修改且不是dep 链的一部分,因此第一部分可以与imul 工作重叠并被隐藏。如果你是背靠背做的,那么它不能与自己重叠。
  • @PeterCordes - 我发现 Skylake 的存储转发延迟低至 3c,即使它们是“背靠背”的,只要它们的时间/间隔正确。例如,循环 mov rcx, [rsp - 8] ; mov [rsp - 8], rcx ; times 9 nop ; dec rdi ; jne .top 在我的 Skylake 上每次迭代运行 3 个循环,每个循环有一个存储转发。如果您删除 nop,它会变得更慢。
  • 您还可以使用相关指令而不是 nops 将它们隔开 - 如果负载恰好隔开 3 个周期,例如在地址寄存器上使用一系列 add rsp, 0,它也可以解决。我猜发生的情况是,如果商店“准备好”,它可以立即转发给加载,但如果加载尝试过早,它必须重试,并且重试不是每个周期都发生,或者它确实和与商店所需的资源竞争。对于较慢的“太早”情况,端口 4(存储)微指令显示 4.5 倍的预期计数,这很奇怪,就好像存储正在重试一样。
【解决方案2】:

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

【讨论】:

  • 即使是正确预测的分支也会导致取指令延迟。如果循环体不是那么慢,您会看到更大的效果。
猜你喜欢
  • 2017-11-29
  • 1970-01-01
  • 2018-02-16
  • 2014-09-23
  • 1970-01-01
  • 1970-01-01
  • 2021-10-01
  • 2019-04-15
相关资源
最近更新 更多