LLVM 项目博客

LLVM 项目新闻和来自实战的细节

弥合差距:Rust 与 C/C++ 之间的跨语言 LTO

链接时优化 (LTO) 是 LLVM 实现全程序优化的方式。跨语言 LTO 是 Rust 编译器中的一项新功能,它允许 LLVM 的链接时优化在混合的 C/C++/Rust 代码库中执行。它也是一项完美结合了 Rust 编程语言和 LLVM 编译器平台各自优势的功能。
  • Rust 由于没有语言运行时,并且具有底层访问能力,几乎可以无缝地集成到现有的 C/C++ 代码库中,
  • 而 LLVM 作为一个语言无关的底层基础,提供了一个共同基础,在这里特定代码段所用到的源语言不再重要。
那么,跨语言 LTO 到底做了什么?有两个答案。
  • 从技术角度来看,它允许代码库在不考虑实现语言边界的情况下进行优化,使得重要的优化(例如函数内联)能够跨编译单元执行,即使例如其中一个编译单元是用 Rust 编写的,而另一个是用 C++ 编写的。
  • 从心理角度来看,这同样重要,它有助于减轻许多注重性能的开发人员在处理在不同源语言中实现的函数之间频繁跳转的软件时,可能会产生的效率低下感。
由于 Firefox 是一个大型的、对性能敏感的代码库,其中有相当一部分是用 Rust 编写的,因此跨语言 LTO 已经成为 Firefox 开发人员长期的愿望清单中的一个项目。因此,Mozilla 的低级工具团队承担了在 Rust 编译器中实现它的任务。

为了解释跨语言 LTO 的工作原理,回顾一下 LLVM 世界中传统的编译和“常规”链接时优化是如何工作的是有帮助的。


背景 - LLVM 编译管道的鸟瞰图

Clang 和 Rust 编译器都遵循类似的编译流程,在某种程度上,这个流程是由 LLVM 制定的。
  1. 编译器前端为每个编译单元生成一个 LLVM 位码模块 (.bc)。在 C 和 C++ 中,每个源文件将生成一个编译单元。在 Rust 中,每个包至少会被翻译成一个编译单元。

    .c --clang--> .bc

    .c --clang--> .bc


    .rs --+
    |
    .rs --+--rustc--> .bc
    |
    .rs --+

  2. 在下一步中,LLVM 的优化管道将独立地优化每个 LLVM 模块。

    .c --clang--> .bc --LLVM--> .bc (opt)

    .c --clang--> .bc --LLVM--> .bc (opt)


    .rs --+
    |
    .rs --+--rustc--> .bc --LLVM--> .bc (opt)
    |
    .rs --+

  3. 然后,LLVM 将每个模块降低为机器码,这样我们就能为每个模块获得一个目标文件。

    .c --clang--> .bc --LLVM--> .bc (opt) --LLVM--> .o

    .c --clang--> .bc --LLVM--> .bc (opt) --LLVM--> .o


    .rs --+
    |
    .rs --+--rustc--> .bc --LLVM--> .bc (opt) --LLVM--> .o
    |
    .rs --+

  4. 最后,链接器将接收目标文件的集合,并将它们链接成一个二进制文件。

    .c --clang--> .bc --LLVM--> .bc (opt) --LLVM--> .o ------+
    |
    .c --clang--> .bc --LLVM--> .bc (opt) --LLVM--> .o ------+
    |
    +--ld--> bin
    .rs --+ |
    | |
    .rs --+--rustc--> .bc --LLVM--> .bc (opt) --LLVM--> .o --+
    |
    .rs --+

如果没有使用任何类型的 LTO,这就是常规的编译流程。如您所见,每个编译单元都是独立优化的。优化器不知道其他编译单元中函数的定义,因此无法将它们内联或基于它们的实际行为做出其他类型的决策。为了允许跨编译单元边界进行内联和优化,LLVM 支持链接时优化。


LLVM 中的链接时优化

LTO 背后的基本原理是将 LLVM 的一些优化过程推迟到链接阶段。为什么要推迟到链接阶段?因为这是整个程序(即所有编译单元)都同时可用时的管道中的一个点,因此跨编译单元边界进行优化成为可能。通过使用一个指向链接器的插件,可以在链接阶段执行 LLVM 工作。

以下是 LTO 的具体实现方式:
  • 编译器将每个编译单元翻译成 LLVM 位码(即跳过降低为机器码的步骤),
     
  • 链接器通过 LLVM 链接器插件知道如何像读取常规目标文件一样读取 LLVM 位码模块,以及
     
  • 链接器再次通过 LLVM 链接器插件合并它遇到的所有位码模块,然后在进行实际链接之前运行 LLVM 优化过程。
有了这些功能,新的编译流程,其中 LTO 针对 C++ 代码启用,看起来像这样。

.c --clang--> .bc --LLVM--> .bc (opt) ------------------+ - - +
| |
.c --clang--> .bc --LLVM--> .bc (opt) ------------------+ - - +
| |
+-ld+LLVM--> bin
.rs --+ |
| |
.rs --+--rustc--> .bc --LLVM--> .bc (opt) --LLVM--> .o -+
|
.rs --+

如您所见,我们的 Rust 代码仍然编译成常规的目标文件。因此,Rust 代码对于链接时发生的优化来说是透明的。然而,从图中可以看出,更改这一点并不难,对吧?


跨语言链接时优化

实现跨语言 LTO 从概念上来说很简单,因为这项功能是建立在巨人的肩膀上的。由于 Rust 编译器使用 LLVM,因此所有重要的构建块都随时可用。最终的图表看起来与您的预期非常相似,rustc 发出经过优化的 LLVM 位码,LLVM 链接器插件将它与其他模块一起合并到 LTO 过程中。

.c --clang--> .bc --LLVM--> .bc (opt) ---------+
|
.c --clang--> .bc --LLVM--> .bc (opt) ---------+
|
+-ld+LLVM--> bin
.rs --+ |
| |
.rs --+--rustc--> .bc --LLVM--> .bc (opt) -----+
|
.rs --+

尽管如此,实现生产就绪的版本仍然是一项重大的时间投资。在弄清楚所有东西如何组合在一起之后,主要挑战在于让 Rust 编译器生成与 Clang 生成的位码以及链接器插件可以接受的位码兼容的 LLVM 位码。我们遇到的问题中的一部分包括:
  • Rust 编译器和 Clang 都是基于 LLVM 的,但它们可能使用的是不同版本的 LLVM。由于 Rust 的 LLVM 版本通常与特定的 LLVM 版本不匹配,而是 LLVM 存储库中的任意修订版本,因此情况变得更加复杂。我们了解到,所有参与的 LLVM 版本必须非常接近,才能使一切正常运行。Rust 编译器的文档现在提供了有关 Rust 和 Clang 不同版本之间的兼容性表
     
  • Rust 编译器默认情况下对同一个包的所有编译单元执行一种特殊的 LTO 形式,称为ThinLTO,然后将它们传递给链接器。然而,我们很快就发现,当尝试对已经过 ThinLTO 过程的模块执行另一轮 ThinLTO 时,LLVM 链接器插件会发生段错误。我们认为,没问题,我们指示 Rust 编译器在为跨语言情况编译时禁用它自己的 ThinLTO 过程,事实的确如此,一切都很好 - 直到几周后,即使 ThinLTO 仍然被禁用,段错误又神秘地返回了。

    我们注意到,问题只出现在一个特定的、可能是无害的设置中:同样需要进行两轮 LTO,这次第一轮是在 rustc 中的常规 LTO 过程,然后将它的输出输入到链接器插件中的 ThinLTO 中。这种设置虽然计算量大,但很受欢迎,因为它可以生成更快的代码,并在 Rust 端允许进行更好的死代码消除。理论上,它应该工作正常。然而,不知何故,rustc 生成了符号名称,这些符号名称显然已经过 ThinLTO 的混淆处理,即使我们多次检查确认 ThinLTO 已针对 Rust 被禁用。当问题持续存在,而我们逐渐耗尽了如何进一步调试这个问题的想法时,我们开始质疑自己对 LLVM 内部工作原理的理解。

    当我们发现 Rust 的预编译标准库仍然会启用 ThinLTO 时,您可以想象我们头上出现了一个象征性的灯泡,无论我们对测试使用什么编译器设置,都是如此。标准库(包括它的 LLVM 位码表示)是在 Rust 的二进制分发版中作为一部分编译的,因此它总是使用 Rust 的构建服务器中的设置进行编译。然后,我们在 rustc 中的本地完整 LTO 过程会将这个有问题的位码拉入输出模块中,进而导致链接器插件再次崩溃。从那时起,ThinLTO 默认情况下就关闭libstd
     
  • 在进行上述修复之后,我们成功地使用跨语言 LTO 编译了整个 Firefox。不幸的是,我们发现没有真正进行跨语言优化。Clang 和 rustc 都在生成 LLVM 位码,而 LLD 生成了功能完备的 Firefox 二进制文件,但当查看机器码时,即使是简单的函数也没有跨语言边界内联。经过数天的调试(不幸的是,当时我们没有意识到LLVM 的优化备注),事实证明,Clang 在所有函数上都发出一个 target-cpu 属性,而 rustc 没有,这导致 LLVM 拒绝内联机会。

    为了防止该功能因类似原因在将来默默地倒退,我们在扩展 Rust 编译器的测试框架和 CI 上花费了大量的精力。它现在能够编译并运行一个兼容版本的 Clang,并使用它来执行跨语言 LTO 的端到端测试,确保小函数确实会跨语言边界内联。
这份列表可能还会继续很长一段时间,每个额外的目标平台都会带来新的惊喜需要处理。我们必须通过在每一步都进行回归测试来谨慎地推进,以控制许多移动部件。然而,在这一点上,我们对底层实现充满信心,Firefox 提供了一个大型、复杂、多平台的测试用例,该用例在过去几个月里一直运行良好。


使用跨语言 LTO:一个最小的示例

确切的构建工具调用方式取决于最终是 rustc 还是 Clang 执行链接步骤,以及 Rust 代码是通过 Cargo 还是通过 rustc 直接编译的。Rust 的编译器文档描述了各种情况。其中最简单的一种情况是 rustc 直接生成一个静态库,而 Clang 负责链接,看起来如下:

# Compile the Rust static library, called "xyz"
rustc --crate-type=staticlib -O -C linker-plugin-lto -o libxyz.a lib.rs

# Compile the C code with "-flto"
clang -flto -c -O2 main.c

# Link everything
clang -flto -O2 main.o -L . -lxyz

-C linker-plugin-lto 选项指示 Rust 编译器发出 LLVM 位码,然后可以使用该位码进行“完整”和“精简”LTO。第一次设置可能非常繁琐,因为如前所述,所有参与的编译器和链接器必须是兼容的版本。理论上,大多数主要的链接器都将起作用;在实践中,LLD 似乎是 Linux 上最可靠的一个,Gold 排在第二位,BFD 链接器至少需要是 2.32 版本。在 Windows 和 macOS 上,唯一经过适当测试的链接器分别是 LLD 和 ld64。对于 ld64,Firefox 使用了一个修补版本,因为 rustc 生成的 LLVM 位码喜欢触发这个链接器与 ThinLTO 存在的一个预先存在的问题


结论

跨语言 LTO 已经在 Windows、macOS 和 Linux 上的 Firefox 发布版本中启用数月,Mozilla 低级工具团队对结果感到满意。虽然我们仍然需要努力使该功能的初始设置更简便,但它已经能够从 Firefox 中的 Rust 组件中移除重复的逻辑,因为现在代码可以简单地调用等效的 C++ 实现,并依赖于这些调用被内联。跨语言 LTO 的存在和持续测试将毫无疑问地降低使用 Rust 实现新组件的心理障碍,即使它们与现有的 C++ 代码紧密集成。

跨语言 LTO 自 Rust 编译器 1.34 版本起可用,并与 Clang 8 协同工作。请随时尝试并报告 Rust 错误追踪器中的任何问题。


致谢

我要感谢我的低级工具团队同事 David Major、Eric Rahm 和 Nathan Froyd 的宝贵帮助和鼓励,并感谢 Alex Crichton 在 Rust 方面的辛勤审查。