Link Time Optimizations:编译器优化的一种新方法

Johnny's Software Lab Johnny's Software Lab 我们帮助您交付快速的软件

菜单

Link Time Optimizations:编译器优化的一种新方法

发布于 2020年5月27日2023年2月7日 作者 Ivica Bogosavljević 发布在 内存占用, 性能, 工具链和性能4 回复 即将举行的矢量化研讨会 _AVX矢量化研讨会:4个半天,5月26日至5月29日,_美国东部时间上午11点至下午3点/美国西海岸时间上午8点至中午12点/欧洲中部时间下午5点至晚上9点NEON矢量化研讨会:待定,发送电子邮件至info@johnnysswlab.com以表达兴趣更多信息…

每个 C/C++ 开发者在其职业生涯早期都会经历一个顿悟时刻:发现编译器中的优化选项。我永远不会忘记,我发现 GCC 编译器提供了 -O0 优化级别用于常规调试,而 -O3 用于快速发布代码。我只需要使用 -O3 编译我的程序,一切都会运行得更快,而不需要我付出任何努力。

多年过去了,我相信每个人都以类似的方式编译他们的代码:我们使用编译器,通过特定的优化选项(-O0-O3)编译我们的 .c.cpp 文件,以获得目标文件(.o)。然后,链接器获取所有目标文件并将它们合并为一个大的可执行文件或库。

在这个故事中,链接器是最后一块拼图,与编译器所做的工作相比,它的工作要简单得多。但是,以这种方式做事,我们会错过一些重要的优化机会。所以,女士们先生们,请允许我解释。

目录:

  1. 动机
  2. Link Time Optimizations
  3. 实验
  4. 总结
  5. 进一步阅读

动机

正如我之前所说,编译器对代码进行了许多优化。请允许我介绍其中的两个:内联和循环合并。看看下面的代码:

int find_min(const int* array, const int len) {
  int min = a[0];
  for (int i = 1; i < len; i++) {
    if (a[i] < min) { min = a[i]; }
  }
  return min;
}
int find_max(const int* array, const int len) {
  int max = a[0];
  for (int i = 1; i < len; i++) {
    if (a[i] > max) { max = a[i]; }
  }
  return max;
}
void main() {
  int* array, len, min, max;
  initialize_array(array, &len);
  min = find_min(array, len);
  max = find_max(array, len);
  ...
}

这是一个非常简单的代码,它初始化一个数组,然后找到它的最小值和最大值。请注意函数 find_minfind_max,并注意它们非常相似。当我们编译程序的优化版本时,编译器可能会 内联 函数 find_minfind_max。内联只是意味着删除对这些函数的调用,而是将它们的函数体复制到调用位置。完成内联后,编译器会看到 find_minfind_max 都循环相同的范围,并且它们之间没有数据依赖关系,因此它决定进行 循环合并。它将来自 find_minfind_max 的循环合并为单个循环。这减少了 CPU 需要做的工作量,并且还增加了 数据缓存 的利用率。因此,优化后的版本可能如下所示:

void main() {
  int* array, len, min, max;
  initialize_array(array, &len);
  min = a[0]; max = a[0]; // 编译器内联了 find_min 和 find_max
  for (int i = 0; i < len; i++) { // 编译器将两个循环合并为一个
    if (a[i] < min) { min = a[i]; }
    if (a[i] > max) { max = a[i]; }
  }
  ...
}

在优化版本中,对函数 find_minfind_max 的调用已经完全消失。这节省了一些调用时间。此外,编译器合并了来自 find_minfind_max 的循环,现在我们只循环一次。

仅这两种优化就有利于性能。但是,如果我们讨论的所有内容都发生在 mainfind_minfind_max 都在同一个编译单元中(在 C 和 C++ 中,编译单元是一个文件,因为编译器逐个文件地工作)。

如果 find_minfind_max 定义在 find.c 中,而 main 定义在 main.c 中,则编译器将无法执行优化。当编译器编译 main.c 时,它仅通过它们的签名来了解 find_minfind_max。它知道这两个函数存在,但它不知道它们内部是什么样的,也不知道它们的地址。它将留下占位符来调用这两个函数,稍后链接器将使用实际地址解析对这两个函数的调用。如果编译器和链接器以这种方式工作,他们就会错过重要的优化潜力。

您可能已经猜到:为什么链接器不做一些优化?链接器知道多个编译单元,当然可以进行优化。那么,女士们先生们,请允许我介绍:

Link Time Optimizations

简介

Link Time Optimizations(简称 LTO)就是这样,在链接期间完成的优化。与传统的编译器相比,链接器知道所有编译单元,并且可以优化更多。链接器所做的最重要的优化是 内联代码局部性改进。如前所述,内联只是意味着将函数体复制到调用位置,而不是直接调用该函数。如果合理,链接器会内联来自其他编译单元的函数。当函数被内联时,链接器有机会进行其他优化,类似于编译器编译单个文件时所做的优化。

常规编译器会尝试找出哪些函数经常执行,哪些函数很少执行。根据这一点,它们将经常执行的函数在内存中彼此靠近移动,以改善 代码局部性。此外,如果函数 A() 调用函数 B(),则出于相同的原因,将它们在内存中彼此靠近放置是有意义的。更好的局部性意味着更少的页面错误1 和更快的代码。链接器更清楚地了解来自不同编译单元的函数如何相互调用,因此可以将它们放置在内存中,以便优化以获得更好的代码局部性。

LTO 的结果是一个二进制文件,通常比原始文件快几个百分点,并且小几个百分点。这并不多,但对于对性能敏感的程序来说,可能意义重大。

但是,LTO 也有缺点。使用 LTO 进行编译和链接要慢得多,并且使用更多的内存,并且这种方式的可扩展性不好。对于大型项目,例如 Chrome 浏览器或 Firefox,它需要一台具有大量内存的专用机器才能链接。原因很简单,因为链接器现在必须将所有编译单元都保存在内存中。

此外,启用 LTO 的总编译时间更长,我认为这是我们尚未看到其广泛采用的原因之一(开发人员不是有耐心的人)。启用 LTO 非常简单,稍后我们将在一些项目上启用它并衡量结果。

底层原理

您可以跳过此部分并移至下一章,因为这对于使用 LTO 并不重要。

那么 LTO 是如何工作的呢?在 LTO 之前,链接器仅负责链接。链接器将获取所有生成的目标文件,对于每个文件,它们将解析对其他目标文件中函数的调用,创建一个导出的函数表,最后将可执行文件输出到磁盘。

现在,有了 LTO,链接器承担了编译器曾经拥有的大量职责,即优化。常见的对象文件包含二进制代码,但是不可能对二进制代码进行 LTO。因此,需要启用 LTO 运行编译器才能生成可用于 LTO 的对象文件。当以 LTO 模式启动编译器时,它会在特殊的 .lto 节中写入代码的 中间表示。中间表示是编译器进行优化的东西;现在,当优化步骤转移到链接阶段时,此信息对于链接器也是必要的。

编译器和链接器之间的界限变得模糊。如果有一天它消失了,而我们只剩下一个工具,我不会感到惊讶。

如何启用 link time optimizations?

LTO 已在编译器上得到支持很长时间了;已经有八年历史的 GCC 4.7 支持它,我假设所有其他流行的编译器都同时支持 LTO。

要启用 LTO,请按照以下简单步骤操作:

现在,您可以像往常一样编译您的程序。除非您使用的是非常旧版本的编译器,否则您不应该在此处遇到任何问题。

实验

现在让我们开始动手吧。我已经对 ProjectX2,我正在从事的商业项目进行了一些测试,所以我将讲述我使用它的经验。我们要调查的第二件事是 LTO 对用于媒体文件操作的 ffmpeg 开源软件的影响。

ProjectX

ProjectX 是一个用 C++ 编写的中型项目。没有专门的团队负责工具或性能3,这与我们通常在大多数中型商业项目中看到的情况相符。它使用 GCC 4.9.4 编译,没有 LTO。系统的有一个组件对性能至关重要,我测量了该组件的性能如何随 LTO 变化。我选择了一个测试,因为测试速度很慢,并且重复了 10 次。这是结果:

阶段| 没有 LTO| 有 LTO| 差异 ---|---|---|--- 测试运行时间| 151.7 秒| 138.7 秒| +9.2% 二进制文件大小 – libA – libB – libC – libD| 362kB 1698kB 31.6MB 1204kB| 292kB 1394kB 23.8MB 1144kB| +24% +21.8% +32.7% +5.2% 编译单个 .cpp 文件的时间| 20.2 秒| 214.2 秒| 慢 10.6 倍 链接 libC(18 个 .o 文件)的时间| 1.6 秒| 63.9 秒| 慢 39.9 倍 链接 libC 的内存消耗| 119MB| 709MB| 多 5.95 倍 LTO 与非 LTO 编译之间的比较

如上表所示,测试运行时间减少了 9.2%,二进制文件大小平均减少了 20%。

但是,编译所需的资源急剧增加。编译一个 .cpp 文件慢了 10 倍,链接慢了 40 倍,链接所需的内存消耗高了近 6 倍。请注意,GCC 4.9 真的很旧,LTO 只是在该版本中得到了正确的实现。让我们尝试下一个实验,也许我们会更幸运。

ffmpeg

ffmpeg 是一个用于处理音频和视频的著名库。我们使用了来自网站的 ffmpeg 4.2.3,并使用 GCC 8 和 CLANG 9 编译了它。ffmpeg 的 configure 脚本有一个启用 LTO 的选项,所以我们就是这么做的。ffmpeg 使用一些手写的汇编程序,我们禁用了它,以便测量 LTO 对优化 C 代码的影响。我们使用的配置行是:

./configure --enable-lto --disable-inline-asm --disable-x86asm

在 GCC 上启用 LTO 很简单。但是,在 CLANG 上,我们需要调整 configure 命令行以强制它使用 LLD 链接器4。我们还想尝试 ThinLTO,这是 CLANG 的一项功能,它承诺与常规 LTO 相同的性能提升,并且编译时间与 LTO 相当。

为了测试性能,我们要求 ffmpeg 将具有 h264 视频和 aac 音频的 mpegts 容器转换为具有 mpeg4 视频和 ac3 音频的 matroska 容器。输入文件为 708MB 大,输出为 349MB。这是我们的测量结果:

阶段| GCC 8 no-LTO| GCC 8 LTO| CLANG 9 no-LTO| CLANG 9 LTO ---|---|---|---|--- 转换时间| 1198 秒| 1188 秒| 884 秒| 891 秒 ffmpeg 大小| 17.1MB| 17.7MB| 18.7MB| 20.5MB 编译时间(make -j4)| 626 秒| 1369 秒| 646 秒| 1112 秒 编译 ffmpeg.cc| 5.5 秒| 1.1 秒| 4.9 秒| 2.6 秒 链接 ffmpeg 时间| 13.1 秒| 1132 秒| 7.7 秒| 368 秒 链接 ffmpeg 内存| 287MB| 381MB| 203MB| 988MB LTO 与非 LTO 在各个方面的比较

我期待一些其他的东西,但是事实是顽固的。我们从上表看到的首先是,与 GCC 相比,CLANG 生成的二进制文件速度更快,无论是非 LTO 还是 LTO。CLANG 的版本比 GCC 晚一年,但差异仍然显着。GCC 生成的二进制文件更小,对于两个编译器,LTO 二进制文件都比非 LTO 二进制文件大。

对于两个编译器,启用 LTO 后,编译时间都会加倍。对于两个编译器,LTO 的链接时间都很长,在 GCC 上慢了 86 倍,在 CLANG 上慢了 48 倍。

这项实验表明,至少对于 ffmpeg 而言,启用 LTO 并不能带来预期的加速。

总结

最初的假设是,开启 LTO 后,速度会提高几个百分点,二进制文件大小会减少几个百分点,并且编译和链接会花费更多的时间和需要更多的内存。

在 ProjectX 上,使用了一个有几年历史的工具链,确实发生了这种情况。ffmpeg 讲述了一个完全不同的故事。我认为发生的事情是 ffmpeg 已经针对速度进行了高度优化;开发人员将所有的小函数都移动到了头文件中,以便编译器可以轻松地内联它们。此外,ffmpeg 是用 C 编写的,与 C++ 相比,C 更容易保持低开销。大部分优化潜力已经被使用,链接器无法做更多的事情。

我认为,对于大多数尚未针对速度进行积极优化的项目,尝试一下 LTO 是有意义的。我希望看到 LTO 相关的速度和二进制文件大小的承诺在大多数情况下都能实现。但最终,是否应启用 LTO 的最终指标是您特定项目的性能数据。

您是否需要讨论项目中的性能问题?或者您可能想要为自己或您的团队提供 矢量化培训联系我们 或者在 LinkedIn , Twitter 或者 Mastodon 上关注我们,并在新内容可用时立即收到通知。

进一步阅读

使用 GCC Link Time Optimization 优化现实世界的应用程序 LLVM 博客:ThinLTO:可扩展和增量 LTO Honza Hubička 的博客:GCC 9:链接时和过程间优化改进

  1. 当您的程序调用另一个函数或尝试访问内存的另一部分时,但内存的这一部分尚未从磁盘加载时,会发生页面错误。在这种情况下,操作系统需要从磁盘加载丢失的部分,这会导致程序速度降低 []
  2. ProjectX 是一个虚构的名字,因为我不允许透露该项目的详细信息 []
  3. 大型软件项目,例如 Chromium 或 Firefox,有专门的团队负责调整系统性能。 []
  4. LLD 链接器是 LLVM 提供的链接器;LLVM 也提供了 CLANG。此链接器可以开箱即用地理解 CLANG 中间表示,并且可以链接启用 LTO 的对象文件 []

标签: c c++ clang 嵌入式系统 gcc 通用系统 高性能系统 link time optimizations linker performance thinlto WHOPR

文章导航

← 通过更好地使用数据缓存来加快程序运行速度 MOSH – 当网络状况不佳时,可替代 SSH 的简单方法 →

4 条评论 / 在下面添加您的评论

  1. Charles Van Noland 说: 2021 年 11 月 5 日凌晨 1:27 我看到链接时优化为代码提供了显着的加速(至少使用 GCC),但这并不是普遍的。这取决于代码是什么样的,它做什么以及它如何做。我还没有看到由于 LTO 导致代码运行速度变慢的情况,所以就是这样。 回复
  2. Mladen 说: 2022 年 4 月 13 日上午 8:04 有什么缺点吗?这种优化会出错吗?我们如何才能意识到这一点? 回复 1. Ivica Bogosavljević 说: 2022 年 4 月 13 日上午 8:15 原则上,这些优化不应使代码更快。当然,编译器/链接器中可能存在一些错误,导致性能下降,但在这种情况下,您应该提交错误报告。 回复
  3. tlanyan 说: 2024 年 3 月 5 日上午 10:39 我想知道使用 Clang 编译 ProjectX 时,与 GCC 相比,是否存在性能差距? 回复

发表评论 取消回复

您的电子邮件地址不会被公开。必填字段已标记 * 评论 * 名称 * 电子邮件 * 网站 保存我的姓名、电子邮件和网站在此浏览器中,以便下次发表评论时使用。 Δ

喜欢你读的东西吗? 关注我们!

LinkedInTwitter