现代CPU调优5性能分析方法

5 性能分析方法

当您正在进行高级优化时,例如将更好的算法集成到应用程序中,通常很容易看出性能是否提高,因为基准测试结果通常很明显。从性能分析的角度来看,2 倍、3 倍等大幅提速相对明显。当你从程序中删除大量计算时,你会期望看到运行时间的明显差异。

但同样,在某些情况下,当你看到执行时间的微小变化,比如 5% 时,你却不知道它来自哪里。仅凭计时或吞吐量测量无法解释性能上升或下降的原因。在这种情况下,我们需要深入了解程序是如何执行的。在这种情况下,我们就需要进行性能分析,以了解我们观察到的速度变慢或变快的根本原因。

性能分析类似于侦探工作。要解开性能之谜,就需要收集所有可能的数据并尝试形成假设。一旦有了假设,就需要设计一个实验来证明或推翻它。在找到线索之前,可能会反反复复好几次。就像一个好侦探一样,你要尽可能多地收集证据来证实或反驳你的假设。一旦有了足够的线索,你就可以对观察到的行为做出令人信服的解释。

刚开始处理性能问题时,您可能只有测量数据,例如代码更改前后的数据。根据这些测量结果,你得出的结论是程序变慢了 X%。如果你知道程序变慢发生在某个提交之后,这可能已经为你提供了足够的信息来解决问题。但如果你没有很好的参考点,那么导致速度变慢的可能原因就无穷无尽了,你需要收集更多的数据。收集此类数据最常用的方法之一是对应用程序进行剖析并查看热点。本章将介绍这种方法以及其他几种收集数据的方法,这些方法已被证明在性能工程中非常有用。

下一个问题来了: “有哪些可用的性能数据以及如何收集这些数据?堆栈的硬件层和软件层都有跟踪性能事件并在程序运行时记录这些事件的功能。在这里,硬件指的是执行程序的 CPU,软件指的是操作系统、程序库、应用程序本身以及用于分析的其他工具。通常情况下,软件栈提供时间、上下文切换次数和页面故障等高级指标,而 CPU 则监控缓存未命中、分支预测错误和其他 CPU 相关事件。根据您要解决的问题,有些指标比其他指标更有用。因此,这并不意味着硬件指标总能为我们提供更精确的程序执行概览。它们只是不同而已。有些指标,比如上下文切换次数,CPU 无法提供。性能分析工具(如 Linux perf)可以同时使用操作系统和 CPU 的数据。

性能工程师可能会使用数百种数据源。本章主要介绍硬件级信息的收集。我们将介绍一些最常用的性能分析技术:代码代码插桩、跟踪、特性分析、采样和 Roofline 模型。我们还将讨论静态性能分析技术和编译器优化报告,这些技术无需运行实际应用程序。

5.1 代码插桩 (Code Instrumentation)

可能最早发明的性能分析方法就是代码工具。这是一种在程序中插入额外代码以收集特定运行时信息的技术。下面最简单的示例:在函数开头插入 printf 语句,以指示是否调用了该函数。然后,运行程序并计算输出中出现 “foo 被调用 ”的次数。也许世界上每个程序员在其职业生涯的某个阶段都至少做过一次这样的事情。

int foo(int x) {
+ printf("foo is called\n");
  // function body...
}

行首的加号表示该行是添加的,在原始代码中并不存在。一般来说,插桩代码并不是要推送到代码库中,而是用于收集所需的数据,之后可以删除。

下例提供了一个更有趣的代码插桩示例。在这个编造的代码示例中,函数 findObject 在地图上搜索具有某些属性 p 的对象的坐标。所有对象最终都会被找到。函数 getNewCoords 返回作为参数提供的较大区域内的新坐标。函数 findObj 返回当前坐标 c 找到正确对象的置信度。如果置信度高于阈值,我们会调用 zoomIn 来找到更精确的对象位置。否则,我们将获取搜索区域内的新坐标,以便下次尝试搜索。
工具代码由两个类组成:直方图和增量器。前者跟踪我们感兴趣的变量值及其出现频率,然后在程序结束后打印直方图。后者只是一个辅助类,用于将数值推送到直方图对象中。在这一假设场景中,我们添加了插桩,以了解在找到对象之前放大的频率。变量 inc.tripCount 计算循环在退出前的迭代次数,变量 inc.zoomCount 计算。

+ struct histogram {
+   std::map<uint32_t, std::map<uint32_t, uint64_t>> hist;
+   ~histogram() {
+     for (auto& tripCount : hist)
+       for (auto& zoomCount : tripCount.second)
+         std::cout << "[" << tripCount.first << "][" 
+                   << zoomCount.first << "] :  " 
+                   << zoomCount.second << "\n";
+   }
+ };
+ histogram h;

+ struct incrementor {
+   uint32_t tripCount = 0;
+   uint32_t zoomCount = 0;
+   ~incrementor() {
+        h.hist[tripCount][zoomCount]++;
+   }
+ };

Coords findObject(const ObjParams& p, Coords c, float searchRadius) {
+ incrementor inc;
  while (true) {
+   inc.tripCount++;  
    float match = findObj(c, p);
    if (exactMatch(match))
      return c;   
    if (match > threshold) {
      searchRadius = zoomIn(c, searchRadius);
+     inc.zoomCount++;
    }
    c = getNewCoords(searchRadius);
  }
  return c;
}

我们缩小搜索区域的次数(调用 zoomIn)。我们总是希望 inc.zoomCount 小于或等于 inc.tripCount。
findObject 函数会在各种输入情况下被多次调用。下面是我们在运行仪器程序后可能观察到的输出结果:

// [tripCount][zoomCount]: occurences
[7][6]:  2
[7][5]:  6
[7][4]:  20
[7][3]:  156
[7][2]:  967
[7][1]:  3685
[7][0]:  251004
[6][5]:  2
[6][4]:  7
[6][3]:  39
[6][2]:  300
[6][1]:  1235
[6][0]:  91731
[5][4]:  9
[5][3]:  32
[5][2]:  160
[5][1]:  764
[5][0]:  34142

方括号中的第一个数字是循环的行程计数,第二个数字是我们在同一循环中进行的 zoomIns 次数。列号后的数字是该数字组合的出现次数。
例如,有两次我们观察到 7 次循环迭代和 6 次zoomIns,有 251004 次循环运行了 7 次迭代但没有zoomIns,以此类推。然后,你可以绘制数据图以获得更好的可视化效果,或者采用其他统计方法,但我们可以得出的主要结论是,zoomIns 并不频繁。调用 findObject 的总次数约为 40 万次;我们可以通过对直方图中的所有桶求和来计算。如果我们将所有 zoomCount 不为零的水桶相加,得出的结果约为 10k;这就是 zoomIn 函数被调用的次数。因此,每调用一次 zoomIn,我们就要调用 40 次 findObject 函数。

本书后面几章将举例说明如何利用这些信息进行优化。在我们的案例中,我们得出结论:findObj 经常找不到对象。这意味着循环的下一次迭代将尝试使用新坐标来查找对象,但仍在同一搜索区域内。了解到这一点后,我们可以尝试进行一些优化: 1)并行运行多个搜索,如果其中任何一个搜索成功,则同步运行;2)预先计算当前搜索区域的某些内容,从而消除 findObj 内部的重复工作;3)编写一个软件流水线,调用 getNewCoords 生成下一组所需的坐标,并从内存中预取相应的地图位置。本书第二部分将更深入地探讨其中的一些技术。
当你需要具体了解程序的执行情况时,代码插桩可以提供非常详细的信息。它允许我们跟踪程序中每个变量的任何信息。
在优化大段代码时,使用这种方法往往能获得最佳的洞察力,因为你可以使用一种自上而下的方法(检测主函数,然后深入到其 callees)来更好地理解应用程序的行为。通过代码工具,开发人员可以观察应用程序的架构和流程。这种技术对于处理不熟悉代码库的人员尤其有帮助。
代码插桩技术在视频游戏和嵌入式开发等实时场景的性能分析中得到了广泛应用。有些剖析器将工具与其他技术(如跟踪或采样)相结合。我们将在第 7.7 节中介绍一种名为 Tracy 的混合剖析器。
虽然代码插桩在很多情况下都很强大,但它并不能从操作系统或 CPU 的角度提供代码执行的任何信息。例如,它无法提供进程调入和调出执行的频率(操作系统已知)或发生分支错误预测的次数(CPU 已知)。插桩代码是应用程序的一部分,拥有与应用程序本身相同的权限。它在用户空间运行,无法访问内核。

这种技术的一个更重要的缺点是,每当有新的东西(比如说另一个变量)需要检测时,就需要重新编译。这会成为一种负担,并增加分析时间。不幸的是,这种方法还有其他缺点。由于您通常关心的是应用程序中的热点路径,因此您需要对代码中性能关键部分的内容进行检测。在热路径中注入仪器代码很容易导致整体基准测试速度降低 2 倍。切记不要对插桩序进行基准测试。通过检测代码,您会改变程序的行为,因此您可能无法看到与之前相同的效果。
所有上述情况都会增加实验之间的间隔时间,消耗更多的开发时间,这也是工程师们现在不经常手动检测代码的原因。不过,编译器仍在广泛使用自动代码检测。编译器能够自动检测整个程序(第三方库除外),以收集有关执行情况的有趣统计数据。最广为人知的自动探测用例是代码覆盖率分析和配置文件引导优化(参见第 11.7 节)。
在谈到插桩时,有必要提及二进制插桩技术。二进制工具的原理与此类似,但它是针对已构建的可执行文件而非源代码进行的。二进制工具有两种类型:静态(提前完成)和动态(程序执行时按需插入工具代码)。动态二进制工具的主要优点是不需要重新编译程序和重新链接。此外,使用动态检测,可以将检测量限制在感兴趣的代码区域,而不是检测整个程序。
二进制工具在性能分析和调试中非常有用。英特尔Pin是最常用的二进制工具之一。Pin 会在出现有趣事件时拦截程序的执行,并从程序中的这一点开始生成新的仪器代码。这样就能收集各种运行时信息。英特尔 SDE: Software Development Emulator 软件开发仿真器是建立在 Pin 基础上的最流行的工具之一。另一个著名的二进制工具名为 DynamoRIO, 二进制插桩工具的作用:

  • 指令计数和函数调用计数;
  • 指令组合分析;
  • 拦截应用程序中的函数调用和任何指令的执行;
  • 内存强度和占用空间(参见第 7.8.3 节)。

与代码检测一样,二进制检测只检测用户级代码,速度可能非常慢。

5.2 跟踪(Tracing)

跟踪在概念上与插桩非常相似,但又略有不同。代码插桩假定用户可以完全访问其应用程序的源代码。另一方面,跟踪则依赖于现有的工具。例如,strace 工具能让我们跟踪系统调用,可视为 Linux 内核的工具。英特尔处理器跟踪工具(Intel PT,见附录 C)可以记录处理器执行的指令,可视为 CPU 的工具。跟踪可从预先进行了适当检测且不会发生变化的组件中获取。跟踪通常被用作一种黑盒方法,即用户不能修改应用程序的代码,但又想深入了解程序正在做什么。

下面提供了一个使用 Linux strace 工具跟踪系统调用的示例,它显示了运行 git status 命令时的前几行输出。
通过使用 strace 跟踪系统调用,我们可以知道每次系统调用的时间戳(最左边一列)、退出状态(在 = 符号之后)以及每次系统调用的持续时间(在角括号中)。

# strace -tt -T -- git status
08:48:08.432163 execve("/usr/bin/git", ["git", "status"], 0x7fffffb9c560 /* 24 vars */) = 0 <0.001054>
08:48:08.433978 brk(NULL)               = 0x5a15bffda000 <0.000014>
08:48:08.434498 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f1b82aa8000 <0.000021>
08:48:08.434664 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory) <0.000019>
08:48:08.434923 openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 <0.000033>
08:48:08.435008 fstat(3, {st_mode=S_IFREG|0644, st_size=71351, ...}) = 0 <0.000019>
08:48:08.435089 mmap(NULL, 71351, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f1b82a96000 <0.000033>
08:48:08.435197 close(3)                = 0 <0.000017>
08:48:08.435270 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpcre2-8.so.0", O_RDONLY|O_CLOEXEC) =
...

跟踪的开销取决于我们试图跟踪的具体内容。例如,如果我们跟踪一个很少进行系统调用的程序,那么在 strace 下运行它的开销将接近于零。另一方面,如果我们跟踪一个严重依赖系统调用的程序,开销可能会非常大,例如 100 倍。此外,由于跟踪不会跳过任何样本,因此会产生大量数据。为弥补这一不足,跟踪工具提供了过滤器,可将数据收集限制在特定时间片或特定代码段。与插桩类似,跟踪也可用于探索系统中的异常情况。例如,您可能想确定应用程序在 10 秒钟无响应期间发生了什么。正如您稍后将看到的,采样方法并非为此而设计,但通过跟踪,您可以了解导致程序无响应的原因。例如,通过英特尔 PT,您可以重建程序的控制流,准确了解执行了哪些指令。

跟踪对调试也非常有用。它的基本特性使 “记录和重放 ”成为可能。Mozilla rr调试器就是这样一个工具,它可以记录和重放进程,支持向后单步等。大多数跟踪工具都能为事件加上时间戳,这样我们就能找到事件与当时发生的外部事件之间的关联。也就是说,当我们观察到程序出现故障时,可以查看应用程序的跟踪记录,并将故障与当时整个系统中发生的事件联系起来。

5.3 收集性能监控事件(Performance Monitoring Events)

性能监控计数器(PMC Performance Monitoring Counters)是一种非常重要的底层性能分析工具。它们可以提供有关程序执行的独特信息。PMC 通常有两种使用模式: “计数 “或 ”采样"。计数模式主要用于计算第 4.9 节讨论的各种性能指标。采样模式用于查找热点,我们将很快讨论这一点。
计数模式的原理非常简单:我们要计算程序运行时某些性能监控事件的总数。PMC 在 “自顶向下微体系结构分析”(TMA)方法中得到了广泛应用,我们将在第 6.1 节详细介绍该方法。下图展示了从程序开始到结束的性能事件计数过程。

图中概述的步骤大致代表了典型分析工具对性能事件的计数过程。perf stat 工具也有类似的过程,可用于统计各种硬件事件,如指令数、周期数、缓存未命中数等。下面是 perf stat 输出的示例:

# perf stat -- ls
code  my.script  openpbs-23.06.06  slurm-8.out  snap  test.sh  t.out  v23.06.06

 Performance counter stats for 'ls':

              0.52 msec task-clock                       #    0.691 CPUs utilized
                 0      context-switches                 #    0.000 /sec
                 0      cpu-migrations                   #    0.000 /sec
                96      page-faults                      #  183.560 K/sec
         2,035,732      cycles                           #    3.892 GHz
         2,029,699      instructions                     #    1.00  insn per cycle
           375,710      branches                         #  718.390 M/sec
            11,642      branch-misses                    #    3.10% of all branches
                        TopdownL1                 #     29.8 %  tma_backend_bound
                                                  #     11.2 %  tma_bad_speculation
                                                  #     36.3 %  tma_frontend_bound
                                                  #     22.7 %  tma_retiring

       0.000756947 seconds time elapsed

       0.000790000 seconds user
       0.000000000 seconds sys

这些数据可能非常有用。首先,它能让我们迅速发现一些异常情况,如分支预测错误率过高或 IPC 过低。此外,当你修改了代码,并想验证修改是否提高了性能时,这些数据也会派上用场。查看相关事件可能会帮助你证明或拒绝代码变更。perf stat 实用程序可用作轻量级基准包装器。它可以作为性能调查的第一步。有时可以立即发现异常,从而节省分析时间。
使用 perf list 可以查看可用事件名称的完整列表:

# perf list

List of pre-defined events (to be used in -e or -M):

  branch-instructions OR branches                    [Hardware event]
  branch-misses                                      [Hardware event]
  bus-cycles                                         [Hardware event]
  cache-misses                                       [Hardware event]
  cache-references                                   [Hardware event]
  cpu-cycles OR cycles                               [Hardware event]
  instructions                                       [Hardware event]
  ref-cycles                                         [Hardware event]
  alignment-faults                                   [Software event]
  bpf-output                                         [Software event]
  cgroup-switches                                    [Software event]
  context-switches OR cs                             [Software event]
  cpu-clock                                          [Software event]
  cpu-migrations OR migrations                       [Software event]
  dummy                                              [Software event]
  emulation-faults                                   [Software event]
  major-faults                                       [Software event]
  minor-faults                                       [Software event]
  page-faults OR faults                              [Software event]
  task-clock                                         [Software event]
  duration_time                                      [Tool event]
  user_time                                          [Tool event]
  system_time                                        [Tool event]

cpu:
  L1-dcache-loads OR cpu/L1-dcache-loads/
  L1-dcache-load-misses OR cpu/L1-dcache-load-misses/
  L1-dcache-stores OR cpu/L1-dcache-stores/
...

现代 CPU 有数百个可观察到的性能事件。要记住所有这些事件及其含义非常困难。了解何时使用特定事件更是难上加难。这就是为什么我一般不建议手动收集特定事件,除非你真的知道自己在做什么。相反,我建议使用英特尔 VTune Profiler 等工具,它们可以自动收集所需的事件来计算各种指标。

由于访问 PMC 需要 root 访问权限,而在虚拟化环境中运行的应用程序通常不具备 root 访问权限,因此性能事件并非在每个环境中都可用。对于在公共云中执行的程序,如果虚拟机(VM)管理器没有将 PMU 编程接口正确地暴露给客户机,那么直接在客户机容器中运行基于 PMU 的剖析器就不会产生有用的输出结果。因此,基于 CPU 性能监控计数器的剖析器在虚拟化和云环境中并不能很好地工作Du 等人,2010 ,尽管情况正在改善。VMware® 是首批启用虚拟性能监控计数器(vPMC virtual Performance Monitoring Counters )的虚拟机管理器之一。AWS EC2云也为专用主机启用了PMC。

5.3.1 多路复用和扩展事件

有时候,我们需要同时对多个不同的事件进行计数。然而,一个计数器一次只能计数一个事件。这就是 PMU 包含多个计数器的原因(在英特尔最新的 Golden Cove 微体系结构中,有 12 个可编程 PMC,每个硬件线程 6 个)。即便如此,固定计数器和可编程计数器的数量也并不总是足够的。自上而下的微体系结构分析(TMA Top-down Microarchitecture Analysis)方法需要在程序的单次执行中收集多达 100 个不同的性能事件。现代 CPU 没有这么多计数器,这就是多路复用发挥作用的地方。

如果需要收集的事件数量超过可用 PMC 的数量,分析工具就会使用时间多路复用,让每个事件都有机会访问监控硬件。

采用多路复用后,一个事件不会一直被测量,而是只在部分时间内被测量。运行结束时,剖析工具需要根据启用的总时间缩放原始计数:最终计数 = 原始计数 × (运行时间/启用时间) 。

假设在剖析过程中,我们可以在三个时间间隔内测量组 1 中的一个事件。每个测量时间间隔持续 100 毫秒(启用时间)。程序运行时间为 500 毫秒(运行时间)。经测量,该计数器的事件总数为 10,000 个(原始计数)。因此,最终计数需要按以下方式缩放:最终计数 = 10,000 × (500ms/(100ms × 3)) = 16,666

这是在整个运行过程中对事件进行测量时的估计计数。重要的是要明白,这仍然是一个估计值,而不是实际计数。在长时间间隔内执行相同代码的稳定工作负载上,可以安全地使用多路复用和缩放。但是,如果程序经常在不同的热点(即不同的阶段)之间跳转,就会出现盲点,从而在缩放过程中引入错误。为避免缩放,可将事件数减少到不超过可用物理 PMC 的数量。不过,您必须多次运行基准才能测量所有事件。

5.3.2 使用标记 API

在某些情况下,我们可能会对分析特定代码区域而非整个应用程序的性能感兴趣。这种情况可能是您正在开发一段新代码,并希望只关注该代码。当然,您也希望跟踪优化进度并捕获更多性能数据,以便在优化过程中对您有所帮助。大多数性能分析工具都提供了特定的标记 API,可以让您做到这一点。下面是两个例子:

  • Intel VTune 有 __itt_task_begin / __itt_task_end 函数。
  • AMD uProf 有 amdProfileResume / amdProfilePause 函数。
    这种混合方法结合了仪器和性能事件计数的优点。与测量整个程序不同,标记 API 允许我们将性能统计归因于代码区域(循环、函数)或功能片段(远程过程调用 (RPC)、输入事件等)。您所获得的数据质量很容易证明您的努力是值得的。例如,在调查仅在特定类型的 RPC 中发生的性能错误时,可以仅针对该类型的 RPC 启用监控。
    下面我们将提供一个使用 libpfm4 的基本示例,libpfm4 是用于收集性能监控事件的流行 Linux 库之一。它建立在 Linux perf_events 子系统之上,可让您直接访问性能事件计数器。perf_events 子系统相当低级,因此 libpfm4 软件包在此非常有用,因为它既添加了一个用于识别 CPU 上可用事件的发现工具,也是原始 perf_event_open 系统调用的封装库。下面的代码列表展示了如何使用 libpfm4 来检测 C-Ray基准的渲染函数。
+#include <perfmon/pfmlib.h>
+#include <perfmon/pfmlib_perf_event.h>
...
/* render a frame of xsz/ysz dimensions into the provided framebuffer */
void render(int xsz, int ysz, uint32_t *fb, int samples) {
   ...
+  pfm_initialize();
+  struct perf_event_attr perf_attr;
+  memset(&perf_attr, 0, sizeof(perf_attr));
+  perf_attr.size = sizeof(struct perf_event_attr);
+  perf_attr.read_format = PERF_FORMAT_TOTAL_TIME_ENABLED | 
+                          PERF_FORMAT_TOTAL_TIME_RUNNING | PERF_FORMAT_GROUP;
+   
+  pfm_perf_encode_arg_t arg;
+  memset(&arg, 0, sizeof(pfm_perf_encode_arg_t));
+  arg.size = sizeof(pfm_perf_encode_arg_t);
+  arg.attr = &perf_attr;
+   
+  pfm_get_os_event_encoding("instructions", PFM_PLM3, PFM_OS_PERF_EVENT_EXT, &arg);
+  int leader_fd = perf_event_open(&perf_attr, 0, -1, -1, 0);
+  pfm_get_os_event_encoding("cycles", PFM_PLM3, PFM_OS_PERF_EVENT_EXT, &arg);
+  int event_fd = perf_event_open(&perf_attr, 0, -1, leader_fd, 0);
+  pfm_get_os_event_encoding("branches", PFM_PLM3, PFM_OS_PERF_EVENT_EXT, &arg);
+  event_fd = perf_event_open(&perf_attr, 0, -1, leader_fd, 0);
+  pfm_get_os_event_encoding("branch-misses", PFM_PLM3, PFM_OS_PERF_EVENT_EXT, &arg);
+  event_fd = perf_event_open(&perf_attr, 0, -1, leader_fd, 0);
+
+  struct read_format { uint64_t nr, time_enabled, time_running, values[4]; };
+  struct read_format before, after;

  for(j=0; j<ysz; j++) {
    for(i=0; i<xsz; i++) {
      double r = 0.0, g = 0.0, b = 0.0;
+     // capture counters before ray tracing
+     read(event_fd, &before, sizeof(struct read_format));

      for(s=0; s<samples; s++) {
        struct vec3 col = trace(get_primary_ray(i, j, s), 0);
        r += col.x;
        g += col.y;
        b += col.z;
      }
+     // capture counters after ray tracing
+     read(event_fd, &after, sizeof(struct read_format));

+     // save deltas in separate arrays
+     nanosecs[j * xsz + i] = after.time_running - before.time_running;
+     instrs  [j * xsz + i] = after.values[0] - before.values[0];
+     cycles  [j * xsz + i] = after.values[1] - before.values[1];
+     branches[j * xsz + i] = after.values[2] - before.values[2];
+     br_misps[j * xsz + i] = after.values[3] - before.values[3];

      *fb++ = ((uint32_t)(MIN(r * rcp_samples, 1.0) * 255.0) & 0xff) << RSHIFT |
              ((uint32_t)(MIN(g * rcp_samples, 1.0) * 255.0) & 0xff) << GSHIFT |
              ((uint32_t)(MIN(b * rcp_samples, 1.0) * 255.0) & 0xff) << BSHIFT;
  } }
+ // aggregate statistics and print it
  ...
}

在本代码示例中,我们首先初始化 libpfm 库,然后配置性能事件和读取这些事件的格式。在 C-Ray 基准中,渲染函数只被调用一次。在您自己的代码中,请注意不要多次初始化 libpfm。

然后,我们选择要分析的代码区域。在我们的例子中,它是一个内部带有跟踪函数调用的循环。我们在该代码区域周围使用两个读取系统调用来捕获循环前后的性能计数器值。然后,我们保存脱钩值,以便稍后处理。在本例中,我们通过计算平均值、第 90 百分位数和最大值对其进行了汇总(代码未显示)。在英特尔 Alder Lake 处理器上运行,得到的输出结果如下所示。读取某个线程的计数器时,数值仅针对该线程。可以选择包含归属于该线程的内核代码。

$ ./c-ray-f -s 1024x768 -r 2 -i sphfract -o output.ppm
Per-pixel ray tracing stats:
                      avg         p90         max
-------------------------------------------------
nanoseconds   |      4571 |      6139 |     25567
instructions  |     71927 |     96172 |    165608
cycles        |     20474 |     27837 |    118921
branches      |      5283 |      7061 |     12149
branch-misses |        18 |        35 |       146

请记住,我们的仪器测量的是每个像素的光线跟踪统计数据。用平均值乘以像素数(1024x768),就能大致得出程序的总统计信息。在这种情况下,运行 perf stat 并比较我们收集到的 C-Ray 性能事件的总体统计数据,是一种很好的理智检查方法。
C-Ray 基准主要强调 CPU 内核的浮点运算性能,这通常不会导致测量结果出现较大差异,换句话说,我们希望所有测量结果都非常接近。然而,我们看到的情况并非如此,p90 值是平均值的 1.33 倍,而最大值比平均值慢 5 倍。这里最有可能的解释是,对于某些像素,算法遇到了转角情况,执行了更多的指令,因此运行时间更长。
不过,通过研究源代码或扩展插桩来捕捉 “慢 ”像素的更多数据,以确认假设总是有好处的。
在我们的示例中,额外的检测代码会导致 17% 的开销,这对于本地实验来说还算可以,但如果在生产中运行,开销就相当大了。大多数大型分布式系统都希望开销小于 1%,对于某些系统来说,5% 的开销也是可以忍受的,但用户不太可能对 17% 的减速感到满意。插桩的开销至关重要,尤其是在生产环境。
开销可以用单位时间或工作(RPC、数据库查询、循环迭代等)的发生率来计算。如果在我们的系统中,读取系统调用大约需要 1.6 微秒的 CPU 时间,而我们为每个像素调用两次(外循环迭代),那么每个像素的开销就是 3.2 微秒的 CPU 时间。
有许多策略可以降低开销。
一般来说,插桩调用应始终有固定成本,例如确定性系统调用,而不是列表遍历或动态内存分配。否则会干扰测量。仪器代码有三个逻辑部分:收集信息、存储信息和报告信息。为了降低第一部分(收集)的开销,我们可以降低采样率,例如,对每 10 个 RPC 进行采样,跳过其余的。对于长期运行的应用程序,可以采用相对便宜的随机取样方法来监控性能,即随机选择每次取样要监控的 RPC。这些方法牺牲了收集的准确性,但仍能很好地估计整体性能特征,而且开销极低。
对于第二和第三部分(存储和聚合),建议只收集、处理和保留了解系统性能所需的数据。通过使用 “在线 ”算法计算平均值、方差、最小值、最大值和其他指标,可以避免在内存中存储每个样本。这将大大减少仪器的内存占用。例如,可使用 Knuth 的在线方差算法计算方差和标准差。一个好的实现使用不到 50 字节的内存。

对于较长的例程,可以在开始和结束时收集计数器,并在中间收集部分计数器。在连续运行过程中,可以二进制搜索例程中性能最差的部分,并对其进行优化。重复上述操作,直到去掉所有性能最差的部分。如果尾部延迟是主要问题,那么在特别慢的运行中发出日志信息可以提供有用的见解。

在我们的示例中,虽然 CPU 有 6 个可编程计数器,但我们同时收集了 4 个事件。您可以打开其他组,启用不同的事件集。内核会选择不同的组同时运行。time_enabled 和 time_running 字段表示多路复用。它们都表示以纳秒为单位的持续时间。time_enabled 字段表示事件组已启用多少纳秒。time_running(运行时间)字段表示在启用的时间内收集了多少事件。如果同时启用了两个事件组,而这两个事件组在硬件计数器上无法同时显示,那么两个事件组的运行时间可能会趋近于 time_running = 0.5 * time_enabled。
同时捕获多个事件可以计算我们在第 4 章中讨论过的各种指标。例如,捕获 INSTRUCTIONS_RETIRED 和 UNHALTED_CLOCK_CYCLES 就可以测量 IPC。通过比较 CPU 周期(UNHALTED_CORE_CYCLES)和固定频率参考时钟(UNHALTED_REFERENCE_CYCLES),我们可以观察到频率缩放的影响。通过请求消耗的 CPU 周期(UNHALTED_CORE_CYCLES,仅在线程运行时计数)并将其与挂钟时间进行比较,可以检测线程何时未运行。此外,我们还可以对数字进行归一化处理,以获得每秒/时钟/指令的事件发生率。例如,通过测量 MEM_LOAD_RETIRED.L3_MISS 和 INSTRUCTIONS_RETIRED,我们可以得到 L3MPKI 指标。如您所见,这提供了很大的灵活性。
对事件进行分组的重要特性是,计数器将在同一读取系统调用下以原子方式提供。这些原子捆绑非常有用。
首先,它允许我们将每个组内的事件关联起来。例如,假设我们测量了某个代码区域的 IPC,发现它非常低。在这种情况下,我们可以将两个事件(指令和周期)与第三个事件(如 L3 缓存未命中)配对,以检查该事件是否导致了我们正在处理的低 IPC 问题。如果不是,我们可以使用其他事件继续进行因子分析。其次,事件分组有助于在工作负载具有不同阶段时减少偏差。由于组内的所有事件都是在同一时间测量的,因此它们总是捕捉到相同的阶段。
在某些情况下,插桩可能会成为功能或特性的一部分。例如,开发人员可以实施一种仪器逻辑,用于检测 IPC 的下降(例如,当有一个繁忙的同级硬件线程在运行时)或 CPU 频率的下降(例如,由于负载过重导致系统节流)。发生这种情况时,应用程序会自动推迟低优先级工作,以补偿暂时增加的负载。

5.4 采样

采样是最常用的性能分析方法。人们通常将其与查找程序中的热点联系起来。广义地说,抽样有助于找到代码中导致某些性能事件发生次数最多的地方。如果我们想找到热点,问题可以重新表述为 “找到代码中消耗 CPU 周期最多的地方"。人们经常使用剖析profiling这一术语来描述技术上所谓的采样(sampling)收集数据的技术,包括取样、代码工具、跟踪等。

最简单的取样剖析器就是调试器。事实上,你可以通过以下方法识别热点:a) 在调试器下运行程序;b) 每 10 秒暂停程序;c) 记录程序停止的位置。如果多次重复 b) 和 c),就能从收集到的样本中绘制出直方图。停止次数最多的代码行将是程序中最热的地方。当然,这并不是查找热点的有效方法,我们也不建议这样做。这只是为了说明概念。不过,这只是对真实剖析工具工作原理的简化描述。现代剖析器每秒能收集数千个样本,因此能非常准确地估计出程序中最热的地方。
与调试器的示例一样,每采集到一个新样本,就会中断被分析程序的执行。中断时,剖析器会收集程序状态快照,即一个样本。每个样本收集的信息可能包括中断时执行的指令地址、寄存器状态、调用堆栈(参见第 5.4.3 节)等。收集的样本存储在转储文件中,可用于显示程序中最耗时的部分、调用图等。

5.4.1 用户模式和基于硬件事件的采样

采样可通过两种不同模式进行,即使用用户模式或基于硬件事件的采样 (EBS event-based sampling)。用户模式采样是一种纯软件方法,将代理库嵌入到剖析应用程序中。代理为应用程序中的每个线程设置一个操作系统定时器。定时器到期时,应用程序会收到代理处理的 SIGPROF 信号。EBS 使用硬件 PMC 触发中断。特别是 PMU 的计数器溢出功能,我们稍后将讨论。

用户模式采样只能用于识别热点,而 EBS 可用于涉及 PMC 的其他分析类型,如缓存遗漏采样、自顶向下微架构分析(见第 6.1 节)等。与 EBS 相比,用户模式采样会产生更高的运行时开销。当采样间隔为 10ms 时,用户模式采样的平均开销约为 5%,而 EBS 的开销不到 1%。由于 EBS 的开销较小,因此可以使用更高的采样率,从而获得更准确的数据。不过,用户模式采样产生的要分析的数据更少,处理时间也更短。

5.4.2 寻找热点

在本节中,我们将讨论将 PMC 与 EBS 配合使用的机制。下图展示了 PMU 的计数器溢出功能,该功能用于触发性能监控中断 (PMI),也称为 SIGPROF。 在基准测试开始时,我们配置要采样的事件。对周期进行采样是许多剖析工具的默认设置,因为我们想知道程序的大部分时间都花在了哪里。不过,这并不一定是严格的规则;我们可以对任何性能事件进行采样。例如,如果我们想知道程序在哪个位置发生了最多的 L3 缓存未命中,我们就会对相应的事件(即 MEM_LOAD_RETIRED.L3_MISS)进行采样。

初始化寄存器后,我们开始计数并让基准运行。由于我们配置了 PMC 来计算周期,因此它将在每个周期内递增。
最终,它将溢出。当寄存器溢出时,硬件将触发 PMI。剖析工具被配置为捕获 PMI,并有一个处理 PMI 的中断服务例程(ISR Interrupt Service Routine)。我们在 ISR 中执行多个步骤:首先,禁止计数;然后,记录计数器溢出时 CPU 执行的指令;然后,将计数器重置为 N,并恢复基准测试。
现在,让我们回到 N 值。利用该值,我们可以控制获得新中断的频率。假设我们想要更精细的粒度,每 100 万个周期采样一次。为此,我们可以将计数器设置为(无符号)-1,000,000,这样它就会在每 100 万个周期后溢出。这个值也被称为采样后值。
我们要多次重复这一过程,以建立足够的样本集合。如果我们稍后汇总这些样本,就能绘制出程序中最热位置的直方图,就像下面 Linux perf 记录/报告输出中显示的那样。这为我们提供了按降序(热点)排序的程序功能开销明细。下图是对 Phoronix 测试套件中的 x264基准进行采样的示例:

$ time -p perf record -F 1000 -- ./x264 -o /dev/null --slow --threads 1 ../Bosphorus_1920x1080_120fps_420_8bit_YUV.y4m
[ perf record: Captured and wrote 1.625 MB perf.data (35035 samples) ]
real 36.20 sec
$ perf report -n --stdio
# Samples: 35K of event 'cpu_core/cycles/'
# Event count (approx.): 156756064947
# Overhead  Samples  Shared Object  Symbol                                                     
# ........  .......  .............  ........................................
  7.50%     2620     x264           [.] x264_8_me_search_ref
  7.38%     2577     x264           [.] refine_subpel.lto_priv.0
  6.51%     2281     x264           [.] x264_8_pixel_satd_8x8_internal_avx2
  6.29%     2212     x264           [.] get_ref_avx2.lto_priv.0
  5.07%     1787     x264           [.] x264_8_pixel_avg2_w16_sse2
  3.26%     1145     x264           [.] x264_8_mc_chroma_avx2
  2.88%     1013     x264           [.] x264_8_pixel_satd_16x8_internal_avx2
  2.87%     1006     x264           [.] x264_8_pixel_avg2_w8_mmx2
  2.58%      904     x264           [.] x264_8_pixel_satd_8x8_avx2
  2.51%      882     x264           [.] x264_8_pixel_sad_16x16_sse2
  ...

Linux perf 收集了 35,035 个样本,这意味着有相同数量的进程中断。我们还使用了 -F 1000,将采样率设置为每秒 1000 个样本。这与 36.2 秒的总体运行时间大致吻合。请注意,Linux perf 提供了总运行周期的大致数字。如果用它除以采样次数,我们将得到 156756064947 个周期/35035 个采样 =每个样本消耗 450 万个周期。也就是说,Linux perf 将 N 设置为大约 4500000,每秒就能采集 1000 个样本。Linux perf 可以根据实际 CPU 频率动态调整 N。
当然,对我们来说最有价值的是按每个函数的样本数量排序的热点列表。知道了最热门的函数之后,我们可能还想再深入研究一下:每个函数内部有哪些热门代码部分?要查看内联函数的剖析数据以及特定源代码区域生成的汇编代码,我们需要使用调试信息(-g 编译器标志)构建应用程序。
Linux perf 没有丰富的图形支持,因此查看源代码的热点部分不是很方便,但还是可以做到的。Linux perf 将源代码与生成的程序集混合在一起,如下所示:

# snippet of annotating source code of 'x264_8_me_search_ref' function
$ perf annotate x264_8_me_search_ref --stdio
Percent | Source code & Disassembly of x264 for cycles:ppp 
----------------------------------------------------------
  ...
        :                 bmx += square1[bcost&15][0];   <== source code
  1.43  : 4eb10d:  movsx  ecx,BYTE PTR [r8+rdx*2]        <== corresponding machine code
        :                 bmy += square1[bcost&15][1];
  0.36  : 4eb112:  movsx  r12d,BYTE PTR [r8+rdx*2+0x1]
        :                 bmx += square1[bcost&15][0];
  0.63  : 4eb118:  add    DWORD PTR [rsp+0x38],ecx
        :                 bmy += square1[bcost&15][1];
  ...

大多数带有图形用户界面(GUI)的剖析器(如 Intel VTune Profiler)都能并排显示源代码和相关程序集。此外,还有一些工具可以通过类似 Intel VTune 和其他工具的丰富图形界面,将 Linux perf 原始数据的输出可视化。你将在第 7 章中看到更多细节。
采样可以很好地统计程序的执行情况,但这种技术的缺点之一是存在盲点,不适合检测异常行为。每个样本都代表了程序执行过程中的一部分聚合视图。聚合并不能为我们提供足够的细节,让我们了解该时间间隔内到底发生了什么。我们无法放大样本以了解更多执行的细微差别。当我们将时间间隔压缩成样本时,就会丢失有价值的信息,而且对于分析持续时间很短的事件也毫无用处。
例如,对一个会对网络数据包做出反应的程序(如股票交易软件)进行剖析可能信息量不大,因为它会将大部分样本归因于繁忙的等待循环。增加采样间隔,例如每秒采样 1000 次以上,可能会获得更好的图像,但可能仍然不够。作为一种解决方案,您应该使用跟踪,因为它不会跳过感兴趣的事件。

5.4.3 收集调用栈

通常在采样时,我们可能会遇到程序中最热的函数被多个函数调用的情况。下图是这种情况的一个示例。剖析工具的输出可能会显示 foo 是程序中最热的函数之一,但如果它有多个调用者,我们就想知道哪个调用者调用 foo 的次数最多。这是应用程序中出现 memcpy 或 sqrt 等库函数热点的典型情况。要了解某个函数成为热点的原因,我们需要知道是程序控制流图 (CFG) 中的哪条路径造成的。

分析 foo 所有调用者的源代码可能非常耗时。我们只想关注那些导致 foo 成为热点的调用者。换句话说,我们要找出程序 CFG 中最热门的路径。剖析工具通过在收集性能样本时捕获进程的调用堆栈和其他信息来实现这一目的。然后,对所有收集到的堆栈进行分组,让我们看到通往特定函数的最热路径。

在 Linux perf 中收集调用堆栈有三种方法:

    1. 帧指针(perf record --call-graph fp)。它要求使用 --fno-omit-frame-pointer 构建二进制文件。从历史上看,帧指针(RBP 寄存器)被用于调试,因为它能让我们在不弹出堆栈中所有参数的情况下获取调用堆栈(也称为 “堆栈解卷”)。
      帧指针可以立即显示返回地址。帧指针能以非常低廉的成本实现堆栈解卷,从而减少剖析开销,不过,仅为此目的就需要消耗一个额外的寄存器。在架构寄存器数量较少的时候,使用帧指针在运行时性能方面代价高昂。如今,Linux 社区正在重新使用帧指针,因为它提供了质量更好的调用堆栈和较低的剖析开销。
    1. DWARF 调试信息(perf record --call-graph dwarf)。它要求二进制文件在构建时包含 DWARF 调试信息 (-g)。它还可以通过堆栈解卷过程获取调用堆栈,但这种方法比使用帧指针更昂贵。
    1. 英特尔最后分支记录(LBR)。这种方法利用了硬件特性,使用以下命令即可访问: perf record --call-graph lbr。
      它通过解析 LBR 堆栈(一组硬件寄存器)来获取调用堆栈。生成的调用图没有前两种方法生成的调用图深。有关 LBR 调用栈模式的更多信息,请参见第 6.2 节。

下面是一个使用 LBR 在程序中收集调用堆栈的示例。通过查看输出结果,我们可以知道 55% 的时间 foo 是由 func1 调用的,33% 的时间是由 func2 调用的,11% 的时间是由 fun3 调用的。我们可以清楚地看到 foo 的调用者之间的开销分布,现在可以把注意力集中在程序 CFG 中最热的一条边上,即 func1 → foo,但我们或许也应该关注一下 func2 → foo 这一条边。

$ perf record --call-graph lbr -- ./a.out
$ perf report -n --stdio --no-children
# Samples: 65K of event 'cycles:ppp'
# Event count (approx.): 61363317007
# Overhead       Samples  Command  Shared Object     Symbol
# ........  ............  .......  ................  ......................
    99.96%         65217  a.out    a.out             [.] foo
            |
             --99.96%--foo
                       |
                       |--55.52%--func1
                       |          main
                       |          __libc_start_main
                       |          _start
                       |
                       |--33.32%--func2
                       |          main
                       |          __libc_start_main
                       |          _start
                       |
                        --11.12%--func3
                                  main
                                  __libc_start_main
                                  _start

使用英特尔 VTune Profiler 时,在配置分析时选中相应的 “收集堆栈 ”框,即可收集调用堆栈数据。使用命令行界面时,指定 -knob enable-stack-collection=true 选项。

5.5 Roofline 性能模型

Roofline 性能模型是一种面向吞吐量的性能模型,在高性能计算领域得到广泛应用。它于 2009 年在加州大学伯克利分校开发Williams 等人,2009。该模型中的 “topline ”一词表示应用程序的性能不能超过机器的能力。程序中的每个函数和每个循环都受限于机器的计算能力或内存带宽。这一概念如图所示。应用程序的性能总会受到某个 “屋顶线 ”函数的限制。

应用程序的最高性能受限于峰值 FLOPS(水平线)与平台带宽乘以算术强度(对角线)之间的最小值。

硬件有两个主要限制:计算速度(峰值计算性能,FLOPS)和数据移动速度(峰值内存带宽,GB/s)。应用程序的最高性能受限于 FLOPS 峰值(水平线)与平台带宽乘以算术强度(对角线)之间的最小值。屋顶线图显示了两个应用程序 A 和 B 的性能与硬件限制之间的关系。应用程序 A 的算术强度较低,其性能受到内存带宽的限制,而应用程序 B 的计算强度较高,不会受到内存瓶颈的影响。与此类似,A 和 B 可以代表程序中的两个不同功能,并具有不同的性能特征。Roofline 性能模型考虑到了这一点,可以在同一图表上显示应用程序的多个功能和循环。不过,请记住,Roofline 性能模型主要适用于计算密集型循环较少的 HPC 应用程序。我不建议将其用于通用应用程序,如编译器、网络浏览器或数据库。
算术强度是浮点运算(FLOPs)88 与字节数之间的比率,可以为程序中的每个循环计算。让我们来计算下面中代码的算术强度。在最内层的循环体中,我们有一个浮点加法和一个乘法,因此有 2 个 FLOP。此外,我们还进行了三次读操作和一次写操作;因此,我们传输了 4 个操作 * 4 个字节 = 16 个字节。该代码的算术强度为 2 / 16 = 0.125。算术强度是 “屋顶线 ”图表上的 X 轴,而 Y 轴则用来衡量特定程序的性能。

void matmul(int N, float a[][2048], float b[][2048], float c[][2048]) {
    #pragma omp parallel for
    for(int i = 0; i < N; i++) {
        for(int j = 0; j < N; j++) {
            for(int k = 0; k < N; k++) {
                c[i][j] = c[i][j] + a[i][k] * b[k][j];
            }
        }
    }
}

加快应用程序性能的传统方法是充分利用机器的 SIMD 和多核功能。通常,我们需要对多个方面进行优化:
矢量化、内存和线程。屋顶线方法可以帮助评估应用程序的这些特性。在屋顶线图上,我们可以绘制标量单核、SIMD 单核和 SIMD 多核性能的理论最大值(见下图)。这将使我们了解提高应用程序性能的空间。如果我们发现自己的应用程序属于计算约束型(即算术强度高),并且低于标量单核性能峰值,我们就应该考虑强制矢量化(见第 9.4 节),并将工作分配给多个线程。相反,如果应用的算术强度较低,我们就应该设法改善内存访问(参见第 8 章)。使用 “屋顶线 ”模型优化性能的最终目标是将图表上的点向上移动。
矢量化和线程化会使点向上移动,而通过提高算术强度来优化内存访问则会使点向右移动,也有可能提高性能。
理论最大值(顶线)通常在设备规格书中列出,很容易查找。此外,理论最大值还可以根据所使用机器的特性计算出来。通常,只要知道机器的参数,就不难计算。对于英特尔酷睿 i5-8259U 处理器,使用 AVX2 和 2 Fused Multiply 时的最大 FLOPS(单精度浮点运算)数为添加 (FMA) 单元的计算公式为:

添加 (FMA) 单元的计算公式为

我用于实验的英特尔 NUC 套件 NUC8i5BEH 的最大内存带宽计算如下。请记住,DDR 技术允许每次内存访问传输 64 位或 8 字节。

Empirical Roofline ToolIntel Advisor 等自动化工具能够通过运行一组准备好的基准,根据经验确定理论最大值。如果计算可以重复使用高速缓存中的数据,就有可能获得更高的 FLOP 速率。
Roofline 可以通过为每一级内存层次结构引入专用的屋顶线来考虑这一点。
确定硬件限制后,我们就可以开始根据屋顶线评估应用程序的性能。英特尔顾问会自动绘制屋顶线图,并为特定循环的性能优化提供提示。下图是英特尔顾问生成的屋顶线图示例。请注意,屋顶线图采用对数刻度。下图使用 Clang 10 编译器在配备 8GB 内存的英特尔 NUC 套件 NUC8i5BEH 上对矩阵乘法 “之前 ”和 “之后 ”版本进行的 Roofline 分析。

Roofline 方法通过在同一图表上绘制 “之前 ”和 “之后 ”的点来跟踪优化进度。因此,这是一个迭代过程,可指导开发人员帮助其应用程序充分利用硬件功能。上图显示对对上面代码进行以下两处修改后的性能提升:

  • 交换最内层的两个循环(交换行 4 和 5)。这样可以实现高速缓冲存储器访问(参见第 8 章)。
  • 使用 AVX2 指令启用最内层循环的自动矢量化。
    总之,Roofline 性能模型有助于
  • 识别性能瓶颈。
  • 指导软件优化。
  • 确定何时完成优化。
  • 评估相对于机器能力的性能。

5.6 静态性能分析

如今,我们拥有大量的静态代码分析工具。对于 C 和 C++ 语言,我们有 Clang static analyzer、Klocwork、Cppcheck 等著名工具。这些工具旨在检查代码的正确性和语义。同样,有些工具也试图解决代码性能方面的问题。静态性能分析器不执行程序,也不对程序进行剖析。相反,它们会模拟代码在真实硬件上的执行情况。静态预测性能几乎是不可能的,因此这类分析有很多局限性。

首先,静态分析 C/C++ 代码的性能是不可能的,因为我们不知道它将被编译成何种机器代码。因此,静态性能分析适用于汇编代码。其次,静态分析工具模拟工作负载,而不是执行工作负载。这显然非常缓慢,因此不可能对整个程序进行静态分析。相反,工具会截取一段汇编代码,并尝试预测它在真实硬件上的表现。用户应选择特定的汇编指令(通常是一个小循环)进行分析。因此,静态性能分析的范围非常狭窄。

静态性能分析器的输出相当低级,通常将执行分解为 CPU 周期。通常,开发人员将其用于对关键代码区域进行细粒度调整,在该区域中,每个 CPU 周期都很重要。

  • 静态分析与动态分析

静态工具: 它们不运行实际代码,而是尝试模拟执行,尽可能多地保留微体系结构细节。由于不运行代码,它们无法进行实际测量(执行时间、性能计数器)。这样做的好处是,你不需要真正的硬件,就可以在不同世代的 CPU 上模拟代码。另一个好处是,您无需担心结果的一致性:静态分析仪将始终为您提供确定的输出结果,因为模拟(与在真实硬件上的执行相比)不存在任何偏差。静态工具的缺点在于,它们通常无法预测和模拟现代 CPU 内部的一切:它们所基于的模型可能存在缺陷和局限性。静态性能分析仪的例子有 UICA91llvm-mca

动态工具:它们基于在真实硬件上运行代码,并收集执行过程中的各种信息。这是证明任何性能假设的唯一 100% 可靠方法。但缺点是,收集 PMC 等低级性能数据通常需要一定的访问权限。要编写一个好的基准并测量你想测量的东西并非易事。最后,您还需要过滤噪音和噪声。最后,还需要过滤噪声和各种副作用。nanoBenchuarch-bench 是动态微体系结构性能分析工具的两个例子。这里有更多的静态和动态微体系结构性能分析工具。

5.6.1 案例研究:使用 UICA 优化 FMA 吞吐量

开发人员经常问的问题之一是:"最新的处理器有 10+ 的吞吐量?“最新的处理器有 10 多个执行单元,我该如何编写代码才能让它们一直忙个不停?这的确是最难解决的问题之一。有时,这需要在显微镜下观察程序是如何运行的。UICA 模拟器就是这样一个显微镜,它可以帮助你深入了解代码是如何在现代处理器中运行的。

让我们来看代码。我有意让示例尽可能简单。当然,实际代码通常比这更复杂。代码用浮点数值 B 对数组 a 的每个元素进行缩放,并将乘积累加为总和。右图是 Clang-16 在使用 -O3 -ffast-math -march=core-avx2 编译时生成的循环机器码。

float foo(float * a, float B, int N){  │ .loop:
  float sum = 0;                       │  vfmadd231ps ymm2, ymm1, ymmword [rdi + rsi]
  for (int i = 0; i < N; i++)          │  vfmadd231ps ymm3, ymm1, ymmword [rdi + rsi + 32]
    sum += a[i] * B;                   │  vfmadd231ps ymm4, ymm1, ymmword [rdi + rsi + 64]
  return sum;                          │  vfmadd231ps ymm5, ymm1, ymmword [rdi + rsi + 96]
}                                      │  sub rsi, -128
                                       │  cmp rdx, rsi
                                       │  jne .loop

这是一个还原循环,即我们需要将所有乘积相加,最后返回一个浮点数值。这段代码的写法是,在 sum 上有一个循环携带依赖关系。在累加前一个乘积之前,不能覆盖 sum。要实现并行化,一种聪明的方法是使用多个累加器,最后将它们累加起来。因此,我们可以用 sum1 来累加偶数迭代的结果,用 sum2 来累加奇数迭代的结果,而不是单个 sum。
Clang-16 就是这样做的:它有 4 个矢量(ymm2-ymm5),每个矢量有 8 个浮点累加器,另外它还使用 FMA 将乘法和加法合并为一条指令。常数 B 被广播到 ymm1 寄存器中。-ffast-math选项允许编译器重新关联浮点运算;我们将在第9.4.96节讨论该选项如何帮助优化代码。让我们一探究竟。我们将清单 5.5 中的汇编代码段移植到 UICA 中并进行了仿真。在撰写本文时,UICA 尚不支持 Alder Lake(基于 Golden Cove 的英特尔第 12 代处理器),因此我们在最新的 Rocket Lake(基于 Sunny Cove 的英特尔第 11 代处理器)上运行了它。
虽然架构不同,但本实验所暴露的问题在两种架构上都同样明显。模拟结果如图所示。这是一个流水线图,与我们在第 3 章中展示的类似。我们跳过了前两次迭代,只显示了迭代 2 和 3(最左列 “It”)。此时执行达到稳定状态,所有后续迭代看起来都非常相似。
UICA 是实际 CPU 流水线的一个非常简化的模型。例如,你可能会注意到指令获取和解码阶段缺失了。此外,UICA 也没有考虑缓存未命中和分支预测错误的情况,因此它假定所有内存访问总是命中 L1 缓存,分支预测总是正确,而我们知道现代处理器的情况并非如此。同样,这与我们的实验无关,因为我们仍然可以利用仿真结果找到改进代码的方法。
你能看出性能问题吗?让我们来看看图表。首先,每 96 个元素中就有一个元素是 “a”,而不是 “B”。这是程序员的疏忽,但希望编译器将来能处理好这个问题。

UICA 流水线图 I = issued, r = ready for dispatch, D = dispatched, E = executed, R = retired.

FMA 指令分为两个 µop:一个是进入端口 {2,3} 的加载 µop,另一个是可以进入端口 {0,1} 的 FMA µop。负载 µop 的延迟时间为 5 个周期:从周期 7 开始,到周期 11 结束。FMA µop 的延迟时间为 4 个周期:从周期 15 开始,到周期 18 结束。如图所示,所有 FMA µops 都依赖于负载 µops: FMA µops 总是在相应的负载 µop 结束后启动。现在,在周期 6 找到两个 r 单元,它们已准备好被分派,但 Rocket Lake 只有两个负载端口,而且在同一周期内这两个端口都已被占用。因此,这两个负载将在下一个周期发出。

该循环对 ymm2-ymm5 有四个交叉迭代依赖关系。指令 2 中写入 ymm2 的 FMA µop 不能在上一次迭代的指令 1 执行完毕之前开始执行。请注意,指令 2 中的 FMA µop 是在指令 1 执行完毕后的第 18 周期派发的。指令 1 和指令 2 之间存在数据依赖关系。您还可以在其他 FMA 指令中观察到这种模式。那么,你会问 "问题出在哪里?请看图像的右上角。

在每个周期中,我们添加了执行的 FMA µops 数量(UICA 不会打印)。结果是 1,2,1,0,1,2,1,.....,即平均每个周期执行一个 FMA µoop。
大多数最新的英特尔处理器都有两个 FMA 执行单元,因此每个周期可以执行两个 FMA µOP。因此,我们只利用了可用 FMA 执行吞吐量的一半。图中清楚地显示了这一差距,因为每隔四个周期就没有 FMA 执行。正如我们之前发现的那样,由于 FMA µops 的输入(ymm2-ymm5)尚未准备就绪,因此无法派发 FMA µops。
要将 FMA 执行单元的利用率从 50%提高到 100%,我们需要将累加器数量翻倍,从 4 个增加到 8 个,从而有效地将循环展开 2 倍。我们将有 8 个独立的数据流链,而不是 4 个。我不会在这里展示解卷版本的仿真结果,你可以自己进行实验。
相反,让我们通过在真实硬件上运行这两个版本来证实假设。
顺便说一句,验证总是个好主意,因为像 UICA 这样的静态性能分析器并不是精确的模型。下面,我们将展示在 Alder Lake 处理器上运行的两个 nanoBench 测试的输出结果。该工具使用提供的汇编指令(-asm 选项)创建基准内核。读者可以在 nanoBench 文档中查找其他参数的含义。左侧的原始代码在 4 个周期内执行 4 条指令,而改进后的版本可以在 4 个周期内执行 8 条指令。现在我们可以确定我们已经最大限度地提高了 FMA 的执行吞吐量,因为右边的代码让 FMA 单元一直处于忙碌状态。

在英特尔酷睿 i7-1260P (Alder Lake) 上运行

指令退役 8.00 内核周期 4.00 作为经验法则,在这种情况下,循环必须以 T * L 的系数展开,其中 T 是指令的吞吐量,L 是指令的延迟。在我们的例子中,由于 Alder Lake 上 FMA 的吞吐量为 2,而 FMA 的延迟为 4 个周期,因此我们应将其展开 2 * 4 = 8,以实现 FMA 端口的最大利用率。这样就创建了 8 个可独立执行的数据流链。
值得一提的是,在实际应用中,你并不总能看到 2 倍的速度提升。这只能在 UICA 或 nanoBench 等理想化环境中实现。在实际应用中,即使你最大限度地提高了 FMA 的执行吞吐量,但最终的缓存缺失和其他流水线危险可能会阻碍其收益。当出现这种情况时,缓存未命中的影响会超过 FMA 端口利用率未达到最佳状态的影响,这很容易导致 5% 的速度提升令人失望。不过不用担心,你还是做对了。
最后,让我们提醒您,UICA 或其他静态性能分析器并不适合分析大段代码。但它们非常适合用于探索微架构效应。此外,它们还能帮助您建立 CPU 工作原理的心智模型。UICA 的另一个重要用例是查找循环中的关键依赖链,这在 Easyperf 博客的一篇文章中有所描述。

5.7 编译器优化报告

如今,软件开发在很大程度上依赖于编译器进行性能优化。编译器在加快软件速度方面发挥着至关重要的作用。大多数开发人员将优化代码的工作交给编译器,只有当他们发现有机会改进编译器无法完成的工作时,才会进行干预。可以说,这是一种很好的默认策略。但是,当你希望尽可能获得最佳性能时,这种策略就不太管用了。如果编译器未能执行关键的优化,比如对循环进行矢量化呢?你怎么知道呢?幸运的是,所有主流编译器都提供优化报告,我们现在就来讨论一下。

假设你想知道一个临界循环是否解卷。如果是解卷,解卷因子是多少?有一个很难知道的方法:研究生成的汇编指令。遗憾的是,并不是所有人都能自如地阅读汇编语言。如果函数很大,调用了其他函数,或者有很多也被矢量化的循环,或者编译器为同一个循环创建了多个版本,那么阅读起来就会特别困难。大多数编译器(包括 GCC、Clang、Intel 编译器和 MSVC98)都提供了优化报告,以检查对特定代码进行了哪些优化。

下例为未被 clang 16.0 向量化的循环示例。

void foo(float* __restrict__ a, 
         float* __restrict__ b, 
         float* __restrict__ c,
         unsigned N) {
  for (unsigned i = 1; i < N; i++) {
    a[i] = c[i-1]; // value is carried over from previous iteration
    c[i] = b[i];
  }
}

要在 Clang 编译器中发布优化报告,需要使用 -Rpass* 标志:

$ clang -O3 -Rpass-analysis=.* -Rpass=.* -Rpass-missed=.* a.c -c
a.c:5:3: remark: loop not vectorized [-Rpass-missed=loop-vectorize]
  for (unsigned i = 1; i < N; i++) {
  ^
a.c:5:3: remark: unrolled loop by a factor of 8 with run-time trip count [-Rpass=loop-unroll]
  for (unsigned i = 1; i < N; i++) {
  ^

通过查看上面的优化报告,我们可以发现该循环没有被矢量化,而是被展开了。
对于开发人员来说,要识别循环依赖关系并非易事。c[i-1] 加载的值取决于上一次迭代的存储(。通过手动展开循环的前几次迭代,可以发现这种依赖关系:

// iteration 1
  a[1] = c[0];
  c[1] = b[1]; // writing the value to c[1]
// iteration 2
  a[2] = c[1]; // reading the value of c[1]
  c[2] = b[2];

如果我们对进行矢量化,将导致在数组 a 中写入错误的值。假定 CPU SIMD 单元每次可以处理四个浮点数,我们将得到可以用以下伪代码表示的代码:

// iteration 1
a[1..4] = c[0..3]; // oops!, a[2..4] get wrong values
c[1..4] = b[1..4];
...

上面代码不能矢量化,因为循环内部的操作顺序很重要。可以通过对调第 6 行和第 7 行来修正此示例。这不会改变代码的语义,因此是完全合法的修改。或者,也可以通过将循环拆分成两个独立的循环来改进代码。这样做会使循环开销增加一倍,但矢量化带来的性能提升将抵消这一缺点。

void foo(float* __restrict__ a, 
         float* __restrict__ b, 
         float* __restrict__ c,
         unsigned N) {
  for (unsigned i = 1; i < N; i++) {
    c[i] = b[i];
    a[i] = c[i-1];
  }
}

在优化报告中,我们现在可以看到该循环已成功矢量化:

$ clang -O3 -Rpass-analysis=.* -Rpass=.* -Rpass-missed=.* a.c -c
a.cpp:5:3: remark: vectorized loop (vectorization width: 8, interleaved count: 4) [-Rpass=loop-vectorize]
  for (unsigned i = 1; i < N; i++) {
  ^

这只是使用优化报告的一个示例;我们将在第 9.4.2 节中提供更多示例,讨论如何发现矢量化机会。
编译器优化报告可以帮助您发现遗漏的优化机会,并了解遗漏的原因。此外,编译器优化报告还有助于测试假设。编译器通常会根据成本模型分析来决定某种转换是否有益。但编译器并不总能做出最优选择。一旦在报告中发现关键的优化缺失,就可以尝试通过修改源代码或以 #pragma、属性、编译器内置等形式向编译器提供提示来加以纠正。与往常一样,请在实际环境中测量验证您的假设。

编译器报告可能相当庞大,每个源代码文件都会生成单独的报告。有时,在输出文件中找到相关记录可能成为一项挑战。我们应该提到,最初这些报告的设计明确供编译器编写者用于改进优化过程。多年来,已经出现了一些工具,使它们更易于应用程序开发人员访问和操作。最值得注意的是 opt-vieweroptview2。此外,Compiler Explorer 网站还为基于 LLVM 的编译器提供了“优化输出”工具,当您将鼠标悬停在源代码相应行上时,它会报告执行的转换。所有这些工具都帮助可视化基于 LLVM 的编译器成功的和失败的代码转换。

在链接时优化 (LTO)模式中,某些优化是在链接阶段进行的。要同时从编译和链接阶段生成编译器报告,应向编译器和链接器传递专用选项。更多信息,请参见 LLVM “备注 ”指南。
英特尔® ISPC 编译器采用了略有不同的方式来报告缺失的优化。它会对编译成相对低效代码的代码结构发出警告。无论如何,编译器优化报告都应该是您工具箱中的重要工具之一。它们可以快速检查针对特定热点进行了哪些优化,并查看一些重要的优化是否失败。通过编译器优化报告,我发现了很多改进的机会。

5.8 本章小结

  • 延迟和吞吐量通常是衡量程序性能的最终指标。在寻求改进方法时,我们需要获得更多有关应用程序执行方式的详细信息。硬件和软件都能提供用于性能监控的数据。
  • 代码工具能让我们跟踪程序中的许多事情,但在开发和运行时都会造成相对较大的开销。虽然大多数开发人员都没有手动检测代码的习惯,但这种方法仍适用于自动化流程,例如配置文件引导优化(PGO)。
  • 跟踪在概念上与仪表化类似,对于探索系统中的异常情况非常有用。跟踪使我们能够捕捉到事件的整个序列,每个事件都附有时间戳。
  • 性能监控计数器是一种非常重要的底层性能分析工具。它们通常有两种使用模式: “计数 “或 ”采样"。计数模式主要用于计算各种性能指标。
  • 采样跳过程序执行的大部分时间,只采样一次,以代表整个时间间隔。尽管如此,采样通常会产生足够精确的分布。最著名的采样应用案例是查找程序中的热点。采样是最流行的分析方法,因为它不需要重新编译程序,运行时开销也很小。
  • 一般来说,计数和采样的运行时开销都很低(通常低于 2%)。一旦开始在不同事件之间进行多路复用,计数就会变得越来越昂贵(5%-15% 的开销),而采样则会随着采样频率的增加而变得越来越昂贵[Nowak & Bitzes,2014]。
  • 屋顶线性能模型是一种面向吞吐量的性能模型,在高性能计算(HPC)领域得到广泛应用。它根据硬件限制可视化应用程序的性能。屋顶线模型有助于识别性能瓶颈、指导软件优化并跟踪优化进度。
  • 有一些工具可以静态分析代码的性能。这类工具模拟一段代码,而不是执行它。这种方法有很多限制和约束,但你会得到一份非常详细和低级的报告。
  • 编译器优化报告有助于发现编译器优化的缺失。
posted @   磁石空杯  阅读(25)  评论(0编辑  收藏  举报
努力加载评论中...
点击右上角即可分享
微信分享提示