每一个程序员都应该了解的内存知识-Part7

内存分析工具

** 注:这篇文章是使用 Kimi AI 进行翻译的**

7 内存性能工具

有多种工具可供程序员使用,以帮助他们理解程序的缓存和内存使用情况。现代处理器拥有可以利用的性能监控硬件。有些事件难以精确测量,因此也有模拟的空间。当涉及到更高级的功能时,有特殊工具可以监控进程的执行。我们将介绍大多数 Linux 系统上可用的一组常用工具。

7.1 内存操作分析

分析内存操作需要硬件的协作。单独的软件可以收集一些信息,但这要么是粗粒度的,或者仅仅是模拟。第 7.2 节和第 7.5 节将展示模拟的示例。这里我们将专注于可测量的内存效应。

在 Linux 上,oprofile 提供了对性能监控硬件的访问。Oprofile 提供了首次在 [continuous] 中描述的持续分析功能;它执行统计学上的系统级分析,并具有易于使用的界面。Oprofile 绝不是使用处理器性能测量功能的唯一方法;Linux 开发者正在开发 pfmon,这可能在某个时候被广泛部署,也值得在这里描述。

oprofile 提供的界面简单且最小化,但也是相当低级的,即使使用了可选的 GUI。用户必须在处理器可以记录的事件中进行选择。处理器的架构手册描述了这些事件,但通常需要对处理器本身有广泛的了解才能解释数据。另一个问题是收集到的数据的解释。性能测量计数器是绝对值,并且可以任意增长。对于给定的计数器,多高才算太高?

这个问题的部分答案是避免查看绝对值,而是将多个计数器相互关联。处理器可以监控多个事件;然后可以检查收集到的绝对值的比率。这给出了不错的、可比较的结果。通常,除数是处理时间的度量,时钟周期数或指令数。作为程序性能的初步尝试,仅通过这两个数字本身的关系是有用的。

图 7.1 显示了各种工作集大小的简单随机“Follow”测试案例的每条指令所需周期数(CPI)。对于大多数 Intel 处理器,收集这些信息的事件名称是 CPU_CLK_UNHALTEDINST_RETIRED。正如名称所暗示的,前者计算 CPU 的时钟周期,后者计算指令的数量。我们看到的图像类似于我们使用的每个列表元素的周期数测量。对于小的工作集大小,比率是 1.0 或甚至更低。这些测量是在 Intel Core 2 处理器上进行的,该处理器是多标量的,可以同时处理多个指令。对于不受内存带宽限制的程序,比率可以显著低于 1.0,但在这种情况下,1.0 已经相当不错了。

一旦 L1d 不再足够大以容纳工作集,CPI 跳升到略低于 3.0。请注意,CPI 比率平均了所有指令访问 L2 的惩罚,而不仅仅是内存指令。使用列表元素数据的周期数,可以计算出每个列表元素需要多少条指令。如果甚至 L2 缓存也不够,CPI 比率跳升到超过 20。这些是预期的结果。

但是,性能测量计数器应该提供更多关于处理器内部发生了什么的洞察。为此我们需要考虑处理器实现的细节。在本文档中,我们关注的是缓存处理细节,因此我们必须查看与缓存相关的事件。这些事件、它们的名称以及它们的计数是特定于处理器的。这是 oprofile 目前难以使用的地方,无论用户界面多么简单:用户必须自己弄清楚性能计数器的细节。在第 10 节中,我们将看到有关一些处理器的详细信息。

对于 Core 2 处理器,要查找的事件是 L1D_REPLDTLB_MISSESL2_LINES_IN。后者可以测量所有未命中以及由指令而不是硬件预取引起的未命中。随机“Follow”测试的结果可以在图 7.2 中看到。

图 7.2 显示了使用退休指令数(INST_RETIRED)计算的所有比率。这意味着也计算了不触及内存的指令,这意味着实际上遭受缓存未命中的触及内存的指令数量甚至比图中显示的还要高。

L1d 未命中在所有其他未命中中占据主导地位,因为对于 Intel 处理器来说,L2 未命中意味着由于使用包容性缓存,也会发生 L1d 未命中。处理器有 32k 的 L1d,因此我们如预期地看到,当工作集大小大约在那个大小时(除了列表数据结构之外,还有其他使用缓存的情况,这意味着增长发生在 16k 和 32k 标记之间),L1d 速率从零上升。有趣的是,硬件预取可以在工作集大小达到并包括 64k 时,将未命中率保持在 1%。在此之后,L1d 速率急剧上升。

L2 未命中率在 L2 耗尽之前一直保持为零;由于 L2 的其他用途引起的少数未命中对数字影响不大。一旦超过 L2 的大小(221 字节),未命中率上升。重要的是要注意,L2 需求未命中率非零。这表明硬件预取器没有加载所有后来指令所需的缓存行。这是预期的,访问的随机性阻止了完美的预取。将此与图 7.3 中顺序读取的数据进行比较。

图 7.3 显示了 L2 需求未命中率基本上为零(请注意,此图的比例与图 7.2 不同)。对于顺序访问情况,硬件预取器工作得非常好:几乎所有的 L2 缓存未命中都是由预取器引起的。L1d 和 L2 未命中率相同这一事实表明,所有 L1d 缓存未命中都由 L2 缓存处理,没有进一步的延迟。这是所有程序的理想情况,但当然,几乎永远无法实现。

两个图中的第四行是 DTLB 未命中率(Intel 为代码和数据分别有单独的 TLB,DTLB 是数据 TLB)。对于随机访问情况,DTLB 未命中率是显著的,并且导致了延迟。有趣的是,DTLB 惩罚在 L2 未命中之前就开始了。对于顺序访问情况,DTLB 成本基本上为零。

回到第 6.2.1 节中的矩阵乘法示例和第 9.1 节中的示例代码,我们可以使用三个更多的计数器。SSE_PRE_MISSSSE_PRE_EXECLOAD_HIT_PRE 计数器可以用来查看软件预取的效果如何。如果运行第 9.1 节中的代码,我们得到以下结果:

描述 比率
有用的 NTA 预取 2.84%
晚期 NTA 预取 2.65%

低有用的 NTA(非临时对齐)预取比率表明,许多预取指令被执行用于已经加载的缓存行,因此不需要工作。这意味着处理器浪费时间解码预取指令并查找缓存。不过,也不能太严厉地评判代码。很大程度上取决于使用的处理器的缓存大小;硬件预取器也起作用。

低晚期 NTA 预取比率是误导的。该比率意味着 2.65% 的所有预取指令发出得太晚了。需要数据的指令在数据能够预取到缓存之前就已经执行了。必须记住,只有 2.84%+2.65%=5.5% 的预取指令有任何用处。在有用的 NTA 预取指令中,有 48% 没有及时完成。因此,代码可以进一步优化:

  • 大多数预取指令并不需要。
  • 预取指令的使用可以调整以更好地匹配硬件。

留给读者作为练习,确定对可用硬件的最佳解决方案。确切的硬件规格起着重要作用。在 Core 2 处理器上,SSE 算术操作的延迟是 1 个周期。旧版本有 2 个周期的延迟,这意味着硬件预取器和预取指令有更多的时间来引入数据。

要确定可能需要预取的地方——或者预取是不必要的——可以使用 opannotate 程序。它列出了程序的源代码或汇编代码,并显示了识别事件的指令。请注意,有两个模糊的来源:

  1. Oprofile 执行随机分析。为了避免过多地减慢系统的运行速度,只有每 N 个事件(其中 N 是每个事件的阈值,有强制的最小值)被记录。可能会有导致 100 个事件的行,但它们可能根本不会出现在报告中。

  2. 并非所有事件都被准确记录。例如,记录特定事件时的指令计数器可能是错误的。处理器是多标量的,很难给出 100% 正确的答案。然而,一些处理器上的一些事件是准确的。

注释列表对于确定预取信息很有用。每个事件都带有指令指针;因此,还可以精确定位程序中的其他热点。许多 INST_RETIRED 事件的来源被频繁执行,值得调整。报告许多缓存未命中的位置可能需要一个预取指令来避免缓存未命中。

可以在没有硬件支持的情况下测量的一种事件是页面错误。操作系统负责解决页面错误,在这些场合,它也会计算它们。它区分两种类型的页面错误:

  • 次要页面错误 这些是匿名(即,不是文件支持的)页面的页面错误,这些页面尚未使用,对于副本上写时的页面,以及其内容已经在某个内存中的其他页面。

  • 主要页面错误 解决它们需要访问磁盘以检索文件支持(或交换出的)数据。

显然,主要页面错误比次要页面错误要昂贵得多。但后者也不便宜。在任何情况下,都需要进入内核,必须找到一个新的页面,页面必须被清除或用适当的数据填充,并且必须相应地修改页面表树。最后一个步骤需要与读取或修改页面表树的其他任务同步,这可能会引入进一步的延迟。

获取页面错误计数信息的最简单方法是使用 time 工具。注意:使用真正的工具,而不是 shell 内置命令。输出可以在图 7.4 中看到。


图 7.4:time 实用程序的输出

这里有趣的部分是最后一行。time 工具报告了一个主要和 335 个次要页面错误。确切的数字会变化;特别是,立即重复运行可能会显示现在根本没有主要页面错误。如果程序执行相同的操作,并且环境中没有任何变化,那么总页面错误计数将保持稳定。

程序启动是一个特别敏感的阶段,因为使用每个页面都会产生页面错误;可见的效果(特别是对于 GUI 应用程序)是使用的页面越多,程序开始工作所需的时间就越长。在第 7.5 节中,我们将看到一种专门测量这种效果的工具。

在幕后,time 工具使用了 rusage 功能。wait4 系统调用在父进程等待子进程终止时填充一个 struct rusage 对象;这正是 time 工具所需要的。但进程也可以请求有关其自身资源使用情况的信息(这就是 rusage 名称的由来)或其已终止子进程的资源使用情况。

1
2
#include <sys/resource.h>
int getrusage(__rusage_who_t who, struct rusage *usage)

who 参数指定请求信息的进程。目前,定义了 RUSAGE_SELFRUSAGE_CHILDREN。子进程的资源使用情况在每个子进程终止时累积。这是一个总值,而不是单个子进程的使用情况。存在允许请求特定线程信息的提议,因此我们可能在不久的将来看到 RUSAGE_THREADrusage 结构被定义为包含各种指标,包括执行时间、发送的 IPC 消息数、使用的内存以及页面错误数。后者信息可在结构的 ru_minfltru_majflt 成员中获取。

试图确定程序因页面错误而失去性能的程序员可以定期请求信息,然后将返回的值与先前的结果进行比较。

从外部,如果请求者具有必要的权限,信息也是可见的。伪文件 /proc/<PID>/stat,其中 <PID> 是我们感兴趣的进程的进程 ID,包含在第十到第十四个字段中的页面错误数。它们是进程及其子进程的累积次要和主要页面错误的对。

7.2 模拟 CPU 缓存

虽然缓存工作的技术描述相对容易理解,但实际程序如何使用缓存却不容易看出。程序员并不直接关心地址值,无论是绝对的还是相对的。地址部分由链接器确定,在运行时由动态链接器和内核确定。生成的汇编代码预计将使用所有可能的地址工作,而在源语言中,甚至没有绝对地址值的提示。因此,可能很难了解程序如何使用内存。{ 当靠近硬件编程时,这可能有所不同,但这不关系到正常编程,无论如何,只能对特殊地址如内存映射设备才有可能。}

像 oprofile(如第 7.1 节所述)这样的 CPU 级分析工具可以帮助理解缓存使用。生成的数据对应于实际硬件,如果不需要细粒度收集,可以相对快速地收集。一旦需要更细粒度的数据,oprofile 就不再可用了;线程将不得不被频繁中断。此外,要查看程序在不同处理器上的内存行为,实际上必须拥有这样的机器并在它们上面执行程序。这有时(经常)是不可能的。一个例子是图 3.8 中的数据。要使用 oprofile 收集这些数据,将需要 24 台不同的机器,其中许多机器并不存在。

该图中的数据是使用缓存模拟器收集的。这个程序,cachegrind,使用最初是为了检查程序中的内存处理相关问题而开发的 valgrind 框架。valgrind 框架模拟程序的执行,并在此过程中允许各种扩展,如 cachegrind,钩入执行框架。cachegrind 利用此功能拦截所有内存地址的使用;然后它模拟给定大小、缓存行大小和关联性的 L1i、L1d 和 L2 缓存的操作。

要使用该工具,必须使用 valgrind 作为包装器运行程序:

1
valgrind --tool=cachegrind command arg

在这种最简单的形式中,程序 command 在模拟三个缓存时使用参数 arg 执行,这些缓存的大小和关联性对应于它正在运行的处理器的大小和关联性。程序运行时打印出的一部分输出显示在标准错误上;它包含如图 7.5 所示的总缓存使用情况的统计信息。


图 7.5:cachegrind 汇总输出

提供了指令的总数和内存引用的次数,以及它们在 L1i/L1d 和 L2 缓存中产生的未命中次数、未命中率等信息。该工具甚至能够将 L2 访问分为指令访问和数据访问,并且所有数据缓存的使用都分为读访问和写访问。

当模拟缓存的细节被改变并且比较结果时,情况变得更加有趣。通过使用 -I1-D1-L2 参数,可以指示 cachegrind 忽略处理器的缓存布局,并使用命令行指定的布局。例如:

1
valgrind --tool=cachegrind --L2=8388608,8,64 command arg

这将模拟一个具有 8MB 容量、8路集合关联和 64 字节缓存行大小的 L2 缓存。请注意,-L2 选项在命令行上出现在被模拟程序的名称之前。

这并不是 cachegrind 能做的全部。在进程退出之前,cachegrind 会写入一个名为 cachegrind.out.XXXXX 的文件,其中 XXXXX 是进程的 PID。该文件包含了关于每个函数和源文件中缓存使用的摘要信息和详细信息。可以使用 cg_annotate 程序查看这些数据。

这个程序产生的输出包含了进程终止时打印的缓存使用摘要,以及程序中每个函数的缓存行使用的详细摘要。生成这种按函数的数据需要 cg_annotate 能够将地址匹配到函数。这意味着为了获得最佳结果,应该提供调试信息。如果没有调试信息,ELF 符号表也能提供一些帮助,但由于内部符号没有列在动态符号表中,结果不会是完整的。图 7.6 显示了与图 7.5 中相同程序运行的部分输出。

Ir、Dr 和 Dw 列显示的是总的缓存使用情况,而不是缓存未命中,缓存未命中显示在接下来的两列中。这些数据可以用来识别产生最多缓存未命中的代码。首先,人们可能会集中关注 L2 缓存未命中,然后着手优化 L1i/L1d 缓存未命中。

cg_annotate 可以提供更详细的数据。如果提供了源文件的名称,它还会注释(这也是程序名称的由来)源文件的每一行,显示对应于该行的缓存命中和未命中次数。这些信息允许程序员深入到缓存未命中成为问题的确切行。程序界面有点原始:截至撰写本文时,cachegrind 数据文件和源文件必须位于同一目录中。

在这一点上,应该再次指出:cachegrind 是一个模拟器,它不使用来自处理器的测量数据。处理器中的实际缓存实现可能完全不同。cachegrind 模拟了最近最少使用(LRU)替换策略,这对于具有较大关联性的缓存来说可能成本过高。此外,模拟没有考虑上下文切换和系统调用,这两者都可以破坏大量 L2 缓存,并必须清空 L1i 和 L1d。这导致总的缓存未命中数比实际体验中要低。尽管如此,cachegrind 是一个了解程序内存使用情况及其内存问题的好工具。

7.3 测量内存使用情况

知道程序分配了多少内存,以及可能在哪里发生分配,是优化其内存使用的第一步。幸运的是,有一些易于使用的程序可用,甚至不需要重新编译或特别修改程序。

对于第一个工具,称为 massif,只需不剥离编译器可以自动生成的调试信息即可。它提供了随时间累积的内存使用情况的概览。图 7.7 显示了一个示例输出。


图 7.7:Massif 输出

像 cachegrind(第 7.2 节)一样,massif 是一个使用 valgrind 基础设施的工具。它通过以下方式启动:

1
valgrind --tool=massif command arg

这里的 command arg 是被观察的程序及其参数。程序将被模拟,并且所有对内存分配函数的调用都被识别出来。记录调用站点以及时间戳值;新分配的大小将同时添加到整个程序的总数和特定调用站点的总数中。同样适用于释放内存的函数,显然,释放的块的大小将从相应的总和中减去。然后,这些信息可以用来创建一个显示程序生命周期内内存使用的图表,根据请求分配的位置分割每个时间值。在进程终止之前,massif 创建两个文件:massif.XXXXX.txtmassif.XXXXX.ps,其中 XXXXX 在这两种情况下都是进程的 PID。.txt 文件是所有调用站点内存使用情况的摘要,.ps 是图 7.7 中可以看到的内容。

Massif 还可以记录程序的栈使用情况,这有助于确定应用程序的总内存占用。但这并不总是可能的。在某些情况下(一些线程栈或当使用 signalstack 时),valgrind 运行时无法知道栈的限制。在这些情况下,将这些栈的大小添加到总数中也没有太多意义。还有一些其他情况,这样做没有意义。如果程序受到影响,应该使用附加选项 —stacks=no 启动 massif。请注意,这是 valgrind 的一个选项,因此必须在被观察程序的名称之前。

一些程序提供自己的内存分配函数或围绕系统分配函数的包装函数。在第一种情况下,通常遗漏分配;在第二种情况下,记录的调用站点隐藏了信息,因为只记录了包装函数中调用的地址。为此,可以将附加函数添加到分配函数列表中。—alloc-fn=xmalloc 参数将指定函数 xmalloc 也是分配函数,这在 GNU 程序中通常是这种情况。记录对 xmalloc 的调用,但不记录从 xmalloc 内部进行的分配调用。

第二个工具称为 memusage;它是 GNU C 库的一部分。它是 massif 的简化版本(但在 massif 之前很长时间就存在了)。它只记录堆的总内存使用情况(包括可能的 mmap 等调用,如果给出 -m 选项),以及可选的栈。结果可以显示为总内存使用情况随时间的图表,或者作为对分配函数调用的线性显示。图表由 memusage 脚本单独创建,就像 valgrind 一样,必须使用它来启动应用程序:

1
memusage command arg

必须使用 -p IMGFILE 选项指定应在文件 IMGFILE 中生成图表,它将是一个 PNG 文件。收集数据的代码在实际程序本身中运行,它不是像 valgrind 那样的模拟。这意味着 memusage 比 massif 快得多,并且可以在 massif 不太有用的情况下使用。除了总内存消耗外,代码还记录分配大小,程序终止时,它会显示使用的分配大小的直方图。此信息将写入标准错误。

有时,直接调用要观察的程序是不可能的(或不可行)。一个例子是 gcc 的编译器阶段,它由 gcc 驱动程序启动。在这种情况下,必须使用 -n NAME 参数向 memusage 脚本提供要观察的程序的名称。如果观察的程序启动了其他程序,此参数也很有用。如果没有指定程序名称,则将分析所有启动的程序。

massif 和 memusage 两个程序都有其他选项。发现自己需要更多功能的程序员应该首先查阅手册或帮助消息,以确保额外的功能尚未实现。

现在我们知道了如何捕获有关内存分配的数据,有必要讨论如何在内存和缓存使用的背景下解释这些数据。高效动态内存分配的主要方面是线性分配和使用部分的紧凑性。这归结于使预取高效和减少缓存未命中。

一个需要稍后处理任意数量数据的程序可以通过创建一个列表来完成,其中每个列表元素包含一个新的数据项。这种分配方法的开销可能很小(单个链表的一个指针),但使用数据时的缓存效应可能会显著降低性能。

一个问题是,例如,没有保证顺序分配的内存在内存中顺序布局。这有很多可能的原因:

  • 由内存分配器管理的大型内存块内的内存块实际上是从后向前返回的;
  • 一个内存块耗尽,新的一块在地址空间的不同部分开始;
  • 分配请求的大小不同,由不同的内存池提供;
  • 多线程程序中分配的交错。

如果必须预先分配数据以供以后处理,链表方法是明显不好的主意。没有保证(甚至可能性)列表中的连续元素在内存中连续布局。要确保连续分配,内存不能以小块分配。必须使用另一层内存处理;程序员可以轻松实现。另一种选择是使用 GNU C 库中提供的 obstack 实现。这个分配器从系统的分配器请求大块内存,然后任意大小的内存块。除非大型内存块耗尽,否则这些分配始终是顺序的,这取决于请求的分配大小,相当罕见。Obstacks 不是内存分配器的完整替代品,它们释放对象的能力有限。有关详细信息,请参阅 GNU C 库手册。

那么,如何从图表中识别出在这种情况下使用 obstacks(或类似技术)是明智的?如果不查看源代码,无法识别可能的候选变更,但图表可以为搜索提供入口点。如果从同一位置进行许多分配,这可能意味着批量分配可能会有所帮助。在图 7.7 中,我们可以看到在地址 0x4c0e7d5 处的分配是这样一个可能的候选者。从运行开始大约 800ms 到 1800ms,这是唯一增长的区域(除了顶部的绿色区域)。此外,斜率不陡峭,这意味着我们有大量的相对较小的分配。这确实是使用 obstacks 或类似技术候选者。

图表可以显示的另一个问题是分配的总数很高。如果图表不是随时间线性绘制的,而是随分配函数调用次数线性绘制的(这是 memusage 的默认设置),则图表中的缓坡意味着许多小分配。memusage 不会说分配发生在哪里,但与 massif 的输出比较可以说明这一点,或者程序员可能立即识别出来。许多小分配应该合并以实现线性内存使用。

但这还有另一个同样重要的方面:许多分配也意味着管理数据的开销更高。这本身可能不是问题。在 massif 图表中,名为“heap-admin”的红色区域表示这种开销,它相当小。但是,根据 malloc 的实现,这种管理数据是与数据块一起分配的,在同一内存中。对于 GNU C 库中当前的 malloc 实现,就是这种情况:每个分配的块至少有一个 2 个单词的头(32 位平台上为 8 字节,64 位平台上为 16 字节)。此外,由于内存管理的方式,块大小通常比必要的大一些(将块大小四舍五入到特定倍数)。

所有这些都意味着程序使用的内存与仅供分配器用于管理目的的内存交织在一起。我们可能会看到这样的东西:

每个块代表一个内存字,在这段内存中,我们有四个已分配的块。由于块头和填充,开销为 50%。由于头的放置,这自动意味着处理器的有效预取率也降低了高达 50%。如果块顺序处理(以充分利用预取),处理器将读取所有头和填充字到缓存中,即使它们从不打算由应用程序本身读取或写入。只有运行时使用头字,运行时只有在块被释放时才起作用。

现在,有人可能会争论说,实现应该改变,将管理数据放在其他地方。这确实在一些实现中完成了,这可能证明是个好主意。然而,有许多方面需要考虑,安全性不是最不重要的。不管我们是否可能在未来看到变化,填充问题永远不会消失(在忽略头的情况下,示例中数据的填充量为 16%)。只有程序员直接控制分配,这才能避免。当对齐要求发挥作用时,可能仍然存在孔洞,但这也是程序员可以控制的事情。

7.4 改进分支预测

在第 6.2.2 节中,提到了两种通过分支预测和块重排序改进 L1i 用途的方法:使用 __builtin_expect 进行静态预测和基于配置文件的优化(PGO)。正确的分支预测对性能有影响,但在这里我们感兴趣的是内存使用改进。

使用 __builtin_expect(或更好的 likelyunlikely 宏)很简单。定义放在一个中心头文件中,编译器会处理其余的事情。但是有一个小问题:程序员很容易使用 likely,而实际上是 unlikely,反之亦然。即使有人使用像 oprofile 这样的工具来测量不正确的分支预测和 L1i 缺失,这些问题也很难检测到。

有一个简单的方法。第 9.2 节中的代码显示了 likelyunlikely 宏的替代定义,这些宏在运行时积极测量静态预测是否正确或不正确。然后,程序员或测试人员可以检查结果并进行调整。这些测量实际上并不考虑程序的性能,它们只是测试程序员所做的静态假设。更多细节可以在上述引用的节中找到,以及代码。

PGO 在当今的 gcc 中使用起来相当容易。不过,这是一个三步过程,必须满足某些要求。首先,所有源文件必须使用额外的 -fprofile-generate 选项进行编译。这个选项必须传递给所有编译运行和链接程序的命令。混合编译的对象文件,有和没有这个选项的,是可能的,但 PGO 对没有启用它的对象文件没有任何好处。

编译器生成的二进制文件行为正常,只是它明显更大、更慢,因为它记录(并发出)关于分支是否采取的所有种类的信息。编译器还为每个输入文件生成一个带有 .gcno 扩展名的文件。该文件包含与代码中的分支相关的信息。这些信息必须保留以备后用。

一旦程序二进制可用,就应该用它来运行一组代表性的工作负载。无论使用什么工作负载,最终的二进制文件都将被优化以很好地完成此任务。连续运行程序是可能的,通常也是必要的;所有运行都将有助于同一个输出文件。在程序终止之前,程序运行期间收集的数据将被写入带有 .gcda 扩展名的文件中。这些文件是在包含源文件的目录中创建的。程序可以从任何目录执行,二进制文件可以复制,但必须有源代码的目录可用且可写。再次,每个输入源文件都会创建一个输出文件。如果程序运行多次,重要的是之前的运行的 .gcda 文件在源目录中找到,因为否则运行的数据不能累积在一个文件中。

当一组代表性的测试运行完成后,是时候重新编译应用程序了。编译器必须能够在保存源文件的同一目录中找到 .gcda 文件。这些文件不能移动,因为编译器将找不到它们,嵌入在文件中的校验和也不再匹配。对于重新编译,将 -fprofile-generate 参数替换为 -fprofile-use。至关重要的是,源代码不能以任何方式更改,这将改变生成的代码。这意味着:更改空格和编辑注释是没问题的,但添加更多的分支或基本块会使收集的数据无效,编译将失败。

这是程序员所需要做的全部;这是一个相当简单的过程。最重要的是进行测量的代表性测试的选择。如果测试工作负载与程序实际使用的方式不匹配,执行的优化实际上可能会造成更多的伤害而不是好处。因此,通常很难对库使用 PGO。库可以在许多——有时差异很大——的场景中使用。除非用例确实相似,否则通常最好完全依赖于使用 __builtin_expect 的静态分支预测。

关于 .gcno.gcda 文件的一些话。这些是二进制文件,不能立即用于检查。然而,可以使用 gcov 工具(也是 gcc 包的一部分)来检查它们。

这个工具主要用于覆盖率分析(因此得名),但使用的文件格式与 PGO 相同。gcov 工具为每个具有执行代码的源文件生成带有 .gcov 扩展名的输出文件(这可能包括系统头文件)。这些文件是源列表,根据给定给 gcov 的参数,带有分支计数器、概率等注释。

7.5 页面错误优化

在像 Linux 这样的按需分页操作系统上,mmap 调用仅修改页面表。它确保对于文件支持的页面,可以找到底层数据,并且对于匿名内存,在访问时提供初始化为零的页面。在 mmap 调用时,实际上并没有分配内存。{ 如果你想说“错误!”请等一等,稍后将说明有 例外。}

分配部分发生在内存页面首次访问时,无论是通过读取或写入数据,还是通过执行代码。在随后的页面错误响应中,内核接管控制,并使用页面表树确定必须出现在页面上的数据。这种页面错误的解决并不便宜,但它发生在进程使用的每个页面上。

为了最小化页面错误的代价,必须减少使用的页面总数。优化代码大小将有助于这一点。为了减少特定代码路径的成本(例如,启动代码),也可以重新排列代码,以便在该代码路径中,触摸的页面数量最小化。然而,确定正确的顺序并不容易。

作者编写了一个基于 valgrind 工具集的工具来测量页面错误,因为它们发生。不是页面错误的数量,而是它们发生的原因。pagein 工具发出有关页面错误顺序和时间的信息。输出写入名为 pagein.<PID> 的文件,看起来像图 7.8。


图 7.8:pagein 工具的输出

第二列指定了被分页的页面的地址。第三列包含 CD,分别表示它是否是代码或数据页面。第四列指定了自第一个页面错误以来经过的周期数。行的其余部分是 valgrind 尝试为导致页面错误的地址找到名称。地址值本身是正确的,但如果没有调试信息,名称可能不总是准确的。

在图 7.8 中的示例中,执行从地址 0x3000000B50 开始,这迫使地址为 0x3000000000 的页面被分页。不久之后,这个页面之后的页面也被带入;在该页面上调用的函数是 _dl_start。初始代码访问了页面 0x7FF000000 上的一个变量。这发生在第一个页面错误后的 3,320 个周期,并且很可能是程序的第二条指令(就在第一条指令之后的三个字节)。如果查看程序,会注意到这个内存访问有些奇特。问题中的指令是一个 call 指令,它没有显式地加载或存储数据。但它确实在栈上存储了返回地址,这里正是发生的情况。这不是进程的官方栈,而是 valgrind 的内部应用栈。这意味着在解释 pagein 的结果时,重要的是要记住 valgrind 引入了一些工件。

pagein 的输出可以用来确定程序代码中哪些代码序列理想地应该是相邻的。快速查看 /lib64/ld-2.5.so 代码显示,第一条指令立即调用函数 _dl_start,这两个地方在不同的页面上。将代码重新排列,将代码序列移动到同一个页面上,可以避免——或者至少延迟——页面错误。到目前为止,确定最佳代码布局是一个繁琐的过程。由于第二次使用页面并没有被设计为被记录,因此需要使用试错法来查看更改的效果。使用调用图分析,可以猜测可能的调用序列;这可能会帮助加快排序函数和变量的过程。

在非常粗略的层面上,可以通过查看构成可执行文件或 DSO 的对象文件来查看调用序列。从一个或多个入口点(即,函数名)开始,可以计算依赖链。在对象文件级别,这很容易实现。在每一轮中,确定包含所需函数和变量的对象文件。种子集必须明确指定。然后确定那些对象文件中所有未定义的引用,并将它们添加到所需符号的集合中。重复,直到集合稳定。

这个过程的第二步是确定一个顺序。各种对象文件必须组合在一起,以填充尽可能少的页面。作为额外的奖励,没有函数应该跨越页面边界。在所有这一切中的一个复杂性是,为了最好地安排对象文件,必须知道链接器以后会做什么。这里的重要事实是,链接器将按照输入文件(例如,归档文件)和命令行中出现的顺序,将对象文件放入可执行文件或 DSO 中。这给了程序员足够的控制权。

对于那些愿意投入更多时间的人,已经有成功的尝试,使用自动调用跟踪通过 gcc 在使用 -finstrument-functions 选项调用时插入的 __cyg_profile_func_enter__cyg_profile_func_exit 钩子来进行重新排序 [oooreorder]。有关这些 __cyg_* 接口的更多信息,请参阅 gcc 手册。通过创建程序执行的跟踪,程序员可以更准确地确定调用链。在 [oooreorder] 中的结果是启动成本减少了 5%,仅仅通过重新排序函数。主要的好处是减少了页面错误的数量,但 TLB 缓存也起了作用——鉴于在虚拟化环境中,TLB 未命中变得更加昂贵,这是一个越来越重要的作用。

通过结合 pagein 工具的分析和调用序列信息,应该可以优化程序的某些阶段(如启动)以最小化页面错误的数量。

Linux 内核提供了两种额外的机制来避免页面错误。第一种是 mmap 的标志,它指示内核不仅要修改页面表,而且实际上要预先错误映射区域中的所有页面。这可以通过简单地将 MAP_POPULATE 标志添加到 mmap 调用的第四个参数来实现。这将使 mmap 调用的成本显著增加,但是,如果调用映射的所有页面立即被使用,好处可能是巨大的。而不是有一个相当昂贵的页面错误,由于同步要求等的开销,程序将有一个更昂贵的 mmap 调用。使用这个标志有缺点,尽管,在映射的页面中大部分未在调用后不久(或根本不)使用的情况下。未映射的未使用页面显然是浪费时间和内存。立即预先错误并仅在后来使用页面也可能堵塞系统。内存在被使用之前就被分配了,这可能会导致同时出现内存短缺。另一方面,在最坏的情况下,页面简单地被重新用于新的目的(因为它还没有被修改),这不是那么昂贵,但与分配一起,还是增加了一些成本。

MAP_POPULATE 的粒度太粗了。还有第二个可能的问题:这是一种优化;并不关键的是所有页面确实被映射了。

如果系统太忙而无法执行操作,预先错误可以被丢弃。一旦页面真正被使用,程序就会采取页面错误,但这并不比人为地创建资源稀缺更糟糕。另一种选择是使用 posix_madvise 函数的 POSIX_MADV_WILLNEED 建议。这是对操作系统的一个提示,表明程序在不久的将来将需要调用中描述的页面。内核可以自由地忽略该建议,但它也可以预先错误页面。这里的优势在于粒度更细。任何映射地址空间区域中的单个页面或页面范围都可以预先错误。对于在运行时不使用大量数据的内存映射文件,这可能比使用 MAP_POPULATE 有巨大的优势。

除了这些主动减少页面错误数量的方法,还可以采取更被动的方法,这在硬件设计师中很受欢迎。DSO 在地址空间中占用相邻页面,一个页面范围用于代码,一个页面范围用于数据。页面大小越小,容纳 DSO 所需的页面就越多。这反过来意味着页面错误也更多。重要的是,相反的情况也是正确的。对于更大的页面大小,映射(或匿名内存)所需的页面数减少;随之页面错误数量也减少。

大多数架构支持 4k 的页面大小。在 IA-64 和 PPC64 上,64k 的页面大小也很流行。这意味着内存分配的最小单位是 64k。该值必须在编译内核时指定,并且不能动态更改(至少目前不能)。多页面大小架构的 ABI 设计为允许使用任一页面大小运行应用程序。运行时将进行必要的调整,正确编写的程序不会注意到任何问题。更大的页面大小意味着通过部分使用的页面造成更多的浪费,但在某些情况下,这是可以接受的。

大多数架构还支持 1MB 或更大的非常大页面大小。在某些情况下,这样的页面也是有用的,但让所有内存都以这么大的单位分配是没有意义的。物理 RAM 的浪费将太大了。但是非常大的页面有其优势:如果使用巨大的数据集,在 x86-64 上使用 2MB 页面存储将比使用 4k 页面少 511 个页面错误(每个大页面)。这可能产生很大的影响。解决方案是选择性地请求内存分配,即,仅在请求的地址范围内使用巨大的内存页面,并且对于同一进程中的所有其他映射使用正常的页面大小。

非常大的页面大小确实有其代价。由于用于大页面的物理内存必须是连续的,所以可能在一段时间后,由于内存碎片化,可能无法分配这样的页面。为了防止这种情况发生,人们正在研究内存碎片整理和避免碎片化,但这非常复杂。对于大约 2MB 的大页面,所需的 512 个连续页面总是难以获得,除非在系统启动时。这就是为什么当前大页面的解决方案需要使用一个特殊的文件系统 hugetlbfs。这个伪文件系统由系统管理员通过写入应该保留的大页面数量来按需分配。

后来,当程序需要一个大页面时,有多种可能性:

  • 程序可以使用带有 SHM_HUGETLB 标志的 System V 共享内存。
  • 可以实际挂载 hugetlbfs 文件系统,然后程序可以在挂载点下创建一个文件,并使用 mmap 将一个或多个页面映射为匿名内存。

在第一种情况下,不需要挂载 hugetlbfs。请求一个或多个大页面的代码可能如下所示:

1
2
3
key_t k = ftok("some/key/file", 42);
int id = shmget(k, LENGTH, SHM_HUGETLB|IPC_CREAT|SHM_R|SHM_W);
void *a = shmat(id, NULL, 0);

这段代码的关键部分是使用 SHM_HUGETLB 标志和为 LENGTH 选择正确的值,该值必须是系统的大页面大小的倍数。不同的架构有不同的值。使用 System V 共享内存接口有一个讨厌的问题,那就是依赖于键参数来区分(或共享)映射。ftok 接口很容易产生冲突,这就是为什么,如果可能的话,最好使用其他机制。

如果使用 hugetlbfs 文件系统的挂载不是一个问题,那么最好使用它而不是 System V 共享内存。使用特殊文件系统的唯一真正问题是内核必须支持它,而且还没有标准化的挂载点。一旦文件系统被挂载,例如在 /dev/hugetlb,程序可以轻松使用它:

1
2
int fd = open("/dev/hugetlb/file1", O_RDWR|O_CREAT, 0700);
void *a = mmap(NULL, LENGTH, PROT_READ|PROT_WRITE, fd, 0);

通过在 open 调用中使用相同的文件名,多个进程可以共享相同的大页面并协作。也可以使页面可执行,在这种情况下,必须在 mmap 调用中设置 PROT_EXEC 标志。与 System V 共享内存示例一样,LENGTH 的值必须是系统大页面大小的倍数。

一个防御性编写的程序(像所有程序一样)可以使用以下函数在运行时确定挂载点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
char *hugetlbfs_mntpoint(void) {
char *result = NULL;
FILE *fp = setmntent(_PATH_MOUNTED, "r");
if (fp != NULL) {
struct mntent *m;
while ((m = getmntent(fp)) != NULL)
if (strcmp(m->mnt_fsname, "hugetlbfs") == 0) {
result = strdup(m->mnt_dir);
break;
}
endmntent(fp);
}
return result;
}

有关这两种情况的更多信息可以在 kernel source tree 中的 hugetlbpage.txt 文件中找到。该文件还描述了 IA-64 所需的特殊处理。

图 7.9 显示了使用大页面运行随机 Follow 测试的结果,NPAD =0。这是图 3.15 中显示的相同数据,但这次,我们还使用在大页面中分配的内存来测量数据。可以看到性能优势可能是巨大的。对于 220 字节,使用大页面的测试比原来快 57%。这是因为该大小仍然完全适合单个 2MB 页面,因此不会发生 DTLB 未命中。

在此之后,收益最初较小,但随着工作集大小的增加而再次增长。对于 512MB 工作集大小,使用大页面的测试比原来快 38%。大页面测试的曲线在大约 250 个周期处有一个平台。在工作集大小超过 227 字节后,数字再次显著上升。平台的原因是 64 个 TLB 条目对于 2MB 页面覆盖了 227 字节。

正如这些数字所示,使用大工作集大小的成本的很大一部分来自 TLB 未命中。使用本节描述的接口可能会带来巨大的收益。图中的数字很可能是上限,但即使在现实世界的程序中,也显示出显著的加速。数据库是今天使用大页面的程序之一,因为它们使用了大量的数据。

目前没有办法使用大页面来映射文件支持的数据。有兴趣实现这种能力,但迄今为止提出的建议都涉及明确使用大页面,并且依赖于 hugetlbfs 文件系统。这是不可接受的:在这种情况下,大页面的使用必须是透明的。内核可以轻松确定哪些映射是大的,并自动使用大页面。一个大问题是内核并不总是知道使用模式。如果内存,可以作为大页面映射,后来需要 4k 页面粒度(例如,因为使用 mprotect 更改了内存范围的部分保护),大量宝贵的资源,特别是线性物理内存,将被浪费。因此,可能还需要一段时间才能成功实现这种方法。

参考资料


每一个程序员都应该了解的内存知识-Part7
https://ysc2.github.io/ysc2.github.io/2024/04/29/每一个程序员都应该了解的内存知识-Part7/
作者
Ysc
发布于
2024年4月29日
许可协议