性能启动开销:一个执行MOV + SYS_exit的简单静态可执行文件为什么会有这么多停滞周期(和指令)?

11

我试图理解如何衡量性能,并决定编写非常简单的程序:

section .text
    global _start

_start:
    mov rax, 60
    syscall

我使用perf stat ./bin运行了程序,让我惊讶的是stalled-cycles-frontend太高了。

      0.038132      task-clock (msec)         #    0.148 CPUs utilized          
             0      context-switches          #    0.000 K/sec                  
             0      cpu-migrations            #    0.000 K/sec                  
             2      page-faults               #    0.052 M/sec                  
       107,386      cycles                    #    2.816 GHz                    
        81,229      stalled-cycles-frontend   #   75.64% frontend cycles idle   
        47,654      instructions              #    0.44  insn per cycle         
                                              #    1.70  stalled cycles per insn
         8,601      branches                  #  225.559 M/sec                  
           929      branch-misses             #   10.80% of all branches        

   0.000256994 seconds time elapsed

据我理解,stalled-cycles-frontend 表示 CPU 前端需要等待某些操作(例如总线事务)的结果才能完成。

那么在这种最简单的情况下,是什么导致 CPU 前端大部分时间等待呢?

还有 2 次页面错误?为什么?我没有读取任何内存页。


4
你是如何不担心这 47,654 条指令的? :) 不确定 perf 到底计算了什么,但很可能它正在执行的其他任务(比如内核代码?)也导致了进程停顿。 - Jester
3
如果你复制/粘贴了真实代码(以及构建说明),那它本来就不存在。因此,始终使用复制/粘贴而不是重新输入代码。只有在编写虚构的、未在任何地方测试过的代码时,才将代码键入到 SO 中。请记住,在 SO 上发布的代码应该是可行的且已被测试过。 - Peter Cordes
2
@PeterCordes 正是缺少 _start: 标签让我认为正常的初始化已经执行。此外,问题中没有说明如何禁止 libc 链接:如果你没有明确尝试这样做,生成一个没有这些东西的可执行文件就非常困难。无论我的猜测是否正确,我都不会删除我的评论:这个链接真的很好,对其他读者也可能很有趣。 - cmaster - reinstate monica
1
@cmaster:在Linux上,ld foo.o -o foo会生成一个静态可执行文件。而且,动态链接的原因不是依赖于内核头文件(用户空间ABI是稳定的,你不需要不同的glibc来匹配你的内核)。原因是为了避免将libc嵌入到每个二进制文件中,并且glibc的错误修复不需要重新编译所有东西。此外还有通常的内存优势。使用gcc,要想用自定义的_start创建静态可执行文件,请使用gcc foo.o -nostdlib -static。或者,要将libc与您自己的_start动态链接,请使用gcc foo.o -nostartfiles(glibc init函数仍然会运行)。 - Peter Cordes
1
@cmaster:glibc的初始化函数使用与动态加载C++库中构造函数相同的机制运行:libc.so.6在一个 _global_ctors 数组或类似的东西中有必要的初始化函数地址,动态链接器在跳转到 _start 之前调用它们。如果gcc告诉链接器这样做,链接器只会“插入代码来调用 main”(即C运行时启动文件)。如果你运行 gcc -v foo.c,你会看到编译器、汇编器和链接器的命令行(ld通过 collect2 包装器调用,但你可以看到 crt0.ogcc 显式传递)。 - Peter Cordes
显示剩余10条评论
1个回答

2

页面错误包括代码页。

perf stat 包括启动开销。

我不知道 perf 如何开始计数的详细信息,但是它可能必须在内核模式下编程性能计数器,因此它们在 CPU 切换回用户模式时进行计数(在具有 Meltdown 防御的内核上会停顿多个周期,尤其是使 TLB 失效的情况)。

我猜记录的 47,654 条指令中大部分是内核代码。 可能包括页面故障处理程序!

我猜你的进程从未经过用户->内核->用户,整个过程是内核->用户->内核(启动,syscall 调用 sys_exit,然后永远不返回到用户空间),因此除了在 sys_exit 系统调用后在内核内运行时,TLB 永远不会热起来,而且 TLB 缺失不是页面错误,但这可以解释许多停滞周期。

顺便说一下,用户->内核转换本身就会解释大约 150 个停滞周期。 syscall 比高速缓存缺失要快(除了它不是流水线化的,并且实际上刷新整个流水线;即特权级别未重命名)。


使用 perf stat --all-user 仅在用户空间中计数。

或者使用 perf stat -e task-clock,cycles:u,instructions:u,cycles:k,instructions:k 来编程四个计数器,两个计数器仅在用户模式下计算 instructionscycles 事件,另外两个计数器仅在内核模式下计算它们。 (PMU 中有硬件支持,因此对用户空间的计数非常准确,只偏差一个或两个指令。)

另请参见


这只是一个快速的部分答案;我想看到一个能够解释perf stat视为启动/关闭开销包括哪些(可能是内核)指令的答案。同时,你是否可以控制它;我认为x86 perf计数器可以编程为仅在用户模式下计数,或内核+用户模式。 - Peter Cordes
但是如果“perf”启动开销太高……我们如何使用它编写微型/纳米基准测试呢?这真的可能吗? - St.Antario
1
@St.Antario:将你的代码放在一个至少运行50毫秒或更长时间的循环中。我有一个testloop.asm文件和一个可以轻松找到的汇编+链接+perf stat命令行,它使用mov ebp, 1000000000作为循环计数器,用于dec ebp / jnz循环。如果循环中的代码超过几个快速uops,我可以根据需要添加或删除零。通过perf stat -r4,我获得了极好的重复性和准确性,对于运行时间约为0.1到1秒左右的结果,预期结果的精度达到了10k或100k的1部分。请参见https://dev59.com/yFcP5IYBdhLWcg3wlq73. - Peter Cordes
@St.Antario:计时短暂效应的替代方法(例如在代码缓存冷启动和分支预测未训练时运行函数的第一次运行)是禁用Turbo等,使用rdtsc来测量时间。或者,可以使用低级库直接访问它们,而不是使用perf(虚拟化性能计数器)。请参见@BeeOnRope的uarch-bench项目。通过内核模块让您编程计数器,然后可以使用rdpmc从用户空间读取它们(如果启用了该权限)。 - Peter Cordes
或者使用Intel-PT在每个取出的分支上记录时间戳,这样您就可以看到每个循环迭代花费的时间是否有差异。但是与rdtsc一样,它只提供时间,而没有其他有趣的计数器。 - Peter Cordes
显示剩余2条评论

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