循环中调用函数比空循环快

16
我是一个有用的助手,可以翻译文本。
我将一些汇编代码与一些 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

它们不同是非常令人惊讶的,但这不是我要问的问题。(相关问题在此
我要问的问题是,在第二次运行中,带有函数调用的循环比没有函数调用的循环更快,为什么调用函数的成本显然是负面的。
编辑:只是提一下评论中尝试过的一些事情:
  • 在编译后的字节码中,函数调用没有被优化掉。
  • 将函数和循环的对齐方式调整到从4到64字节边界上并没有加速no_call,尽管某些对齐方式确实减慢了normal_call
  • 让CPU/操作系统有机会预热,通过多次而不仅仅是一次调用函数,对测量时间的长度没有明显影响,改变调用的顺序或单独运行也没有影响
  • 运行更长时间不会影响比率,例如运行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)

我不确定如何解释它,但这是我目前发现的。
另外一个我注意到的事情是,如果从两个函数中删除 push ecxpop ecx,输出结果会变成:
30
125

这表示那是其中最昂贵的部分。堆栈对齐两次都是相同的,因此这不是差异的原因。我最好的猜测是硬件在推送后期望调用或类似操作进行优化,但我不知道是否存在这样的情况。

1
@Eugene Sh.你有什么其他的推荐吗? - rtpax
2
嗯,仔细想想我认为 clock 是可以的。试着查看编译后的 C 代码生成的汇编内容。而且从链接顺序很重要这个事实来判断,似乎正在进行一些链接时优化。 - Eugene Sh.
1
大多数跳转落地的地址(jne @b 的目标)很重要。不幸的是,您没有明确命名它们。no_callnormal_call 只使用一次,因此在那里有任何未对齐的惩罚并不重要(远远超出了 clock 计时的[不]精度)。由于 normal_function 被广泛调用,因此将其对齐可能也有所帮助。通常 4 或 8 边界就足够了,但可以自由尝试高达 64(我认为现代缓存行长度为 32B?但 64 肯定足够应付任何情况)。 - Ped7g
2
另一个影响结果的因素可能是负载下 CPU 频率的动态变化,也许无调用循环被理解为空闲循环,CPU+OS 会降低频率。虽然我认为 CPU 中不太可能有这样复杂的代码分析,但你跳过了热身阶段,操作系统可能需要一段时间才能检测到 100% 的 CPU 核心使用率并提高功率,所以最好先运行一次未计时的 no_call + normal_call,以使 CPU 频率升高并使缓存状态对两种变体(预缓存)相似。 - Ped7g
1
@rtpax - 我在 Visual Studio / Windows 上尝试了相同的代码。我添加了一个零,将 iter equ 1000000000 更改为运行10倍长。两个函数的运行时间约为1.55秒。我尝试在循环之前使用 align 16,但没有显著的差异。整个程序适合代码缓存,这可能是为什么对齐没有帮助的原因。 - rcgldr
显示剩余19条评论
2个回答

5
更新:Skylake的存储/重新加载延迟只有3个时钟周期,但前提是时间安排得当。在存储转发依赖链中自然间隔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左右。请参见Agner Fog论坛上的详细信息。

更新2: 在没有优化编译的情况下添加冗余赋值可以加速代码,以及2013年的博客文章表明所有Sandybridge系列CPU都存在这种影响

Skylake上的连续存储-前向延迟(最坏情况)比之前的微架构好1个周期,但是当负载无法立即执行时,可变性相似。


使用正确的(误)对齐方式,循环中额外的call实际上可以帮助Skylake观察从push到pop的较低存储转发延迟。我使用YASM和perf计数器(Linux perf stat -r4)重现了这一点。(我听说在Windows上使用perf计数器不太方便,而且我也没有Windows开发机。幸运的是,操作系统并不真正相关于答案;任何人都应该能够使用VTune或其他工具在Windows上重现我的性能计数器结果。)
在问题指定的位置进行align 128后,我看到了偏移量为0..10、37、63-74、101和127的更快时间。L1I缓存行为64B,uop-cache关注32B边界。看起来与64B边界的对齐相关才是最重要的。
无调用循环始终稳定为5个周期,但“call”循环可以从通常几乎完全为5个周期的迭代中降至4个周期。我在offset=38处看到了比平时慢的表现(每次迭代5.68+-8.3%个周期)。根据“perf stat -r4”(进行4次运行并平均),其他点也有小的故障,如5.17c+-3.3%。
这似乎是前端不会排队太多uops,导致后端将push到pop的存储转发延迟较低的相互作用。
我不知道重复使用同一地址进行存储转发是否会使其变慢(已经执行了多个存储地址uop,这些uop与相应的存储数据uop之前),或者其他情况。

测试代码:使用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

(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源代码,没有对汇编进行任何优化。

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对堆栈指针的更新,但堆栈引擎处理这些更新时具有零延迟。(自Pentium-M以来的英特尔,K10以来的AMD,根据Agner Fog的微体系结构PDF,因此我假设您的CPU有一个,即使您没有提及您在哪个CPU微体系结构上运行测试。)

额外的call/ret仍需要执行,但乱序执行可以使关键路径指令以最大吞吐量运行。由于这包括来自push/pop的存储器-加载转发的延迟+ dec 1个周期,因此在任何CPU上都不具有高吞吐量,并且前端可能会成为瓶颈是一个意外。

push->pop延迟在Skylake上为5个时钟周期,根据Agner Fog的说法,在该微架构上,您的循环每6个周期最多只能运行一次。这足以让乱序执行运行callret指令。 Agner列出了call的最大吞吐量为每3个周期1个,ret为每1个周期1个。或者在AMD Bulldozer上,分别为2和2。他的表格没有列出call/ret对的吞吐量,因此我不知道它们是否可以重叠。在AMD Bulldozer上,使用mov进行存储/重新加载的延迟为8个周期。我认为使用push/pop的延迟也大致相同。

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

猜测每个可能的微架构细节太复杂了,因此请告诉我们您测试的CPU。

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

此外,无条件分支总是结束uop-cache行。可能会出现这样的情况,即normal_function:calljne在自然对齐的32B机器代码块中,但代码块不适合于uop缓存。(单个x86代码的32B块只能缓存3条解码uop)。但这并不能解释no_call循环可能出现问题的可能性,因此您可能没有在Intel SnB系列微体系结构上运行。
(更新,是的,该循环有时主要从遗留解码(idq.mite_uops)运行,但通常不是完全独占的。dsb2mite_switches.penalty_cycles通常为~8k,并且可能仅在计时器中断时发生。 call循环运行更快的运行似乎与较低的idq.mite_uops相关,但对于偏移量= 37的情况,100M次迭代花费了401M周期,仍然为34M +- 63%。)
这真的是那些“不要这样做”的情况之一:内联微小函数而不是从非常紧密的循环内部调用它们。

如果您push/pop一个不同于循环计数器的寄存器,您可能会看到不同的结果。这将分离push/pop和循环计数器,因此将有两个单独的依赖链。这应该加速调用和no_call版本,但可能不是完全相等的。它可能只是使前端瓶颈更加明显。

如果您push edxpop eax,则应该会看到巨大的加速,因此push/pop指令不形成循环传递的依赖关系链。然后额外的call/ret肯定会成为瓶颈。


附注: dec ecx 已经设置了您所需的ZF,因此您可以只使用 dec ecx / jnz。 此外,cmp ecx,0test ecx,ecx 不够高效(代码大小更大,无法在许多CPU上进行宏融合)。 无论如何,这与您两个循环的相对性能的问题完全无关。 (您在函数之间没有ALIGN指令意味着更改第一个函数将更改第二个函数中的循环分支的对齐方式,但您已经探索了不同的对齐方式。)


1
不知何故,我总能在滚动屏幕查看作者之前就知道这是你的答案 :) (也许是因为在页面滚动的过程中发生了很多良好的学习)。 - David C. Rankin
根据David Kanter的说法,"store-address" uops将地址写入存储缓冲区。 - Peter Cordes
1
@PeterCordes - 我发现Skylake的存储转发延迟低至3个时钟周期,即使它们是“连续的”,只要它们的时间/间隔正确。例如,循环mov rcx,[rsp-8];mov [rsp-8],rcx;times 9 nop;dec rdi;jne .top在我的Skylake上每次迭代运行3个时钟周期,并且每个循环有一个存储转发。如果您删除nops,则速度会变慢。 - BeeOnRope
1
你还可以通过依赖指令来间隔它们,而不是使用nop - 如果负载使用地址寄存器精确间隔3个周期,例如使用一系列的add rsp, 0,这也可行。我猜发生的事情是,如果store“准备就绪”,它可以立即转发到load,但如果load尝试得太早,则必须重试,而重试要么不会每个周期发生,要么会与存储所需的资源竞争。对于较慢的“过早”情况,port4(存储)uops显示了4.5倍于预期计数的奇怪现象,好像存储正在重试一样。 - BeeOnRope
主要(延迟)测试不一定涉及任何存储/重新加载并行操作:仅涉及一个加载/存储对,这可能会在迭代中串行执行(因为它们通过内存具有依赖关系)。我看到的结果类似于这样。后面的列是分派到端口2、3、4、7的微操作数。端口4的数字是奇怪的:显示的数字比预期高约4.3倍。我不知道这是否是“真实的”或者只是性能计数器的怪癖。但是存储重试是很奇怪的吧?我会期望加载... - BeeOnRope
显示剩余8条评论

0
每次调用 normal_function 和从其返回时,除了第一次之外,都将被正确预测,因此我不希望看到由于调用存在而导致的时间差异。因此,你看到的所有时间差异(无论更快或更慢)都是由其他影响(如评论中提到的那些影响)而不是你实际尝试测量的代码差异造成的。

即使分支预测正确,也可能导致指令获取延迟。如果循环体不那么慢,您将看到更大的影响。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接