在 nightly 版本中使用并行前端加速编译

2023 年 11 月 9 日 · Nicholas Nethercote 代表 并行 Rustc 工作组

Rust 编译器的前端现在可以使用并行执行来显著减少编译时间。要尝试它,请使用 -Z threads=8 选项运行 nightly 编译器。此功能目前是实验性的,我们的目标是在 2024 年将其发布在稳定版编译器中。

请继续阅读以了解为什么需要并行前端以及它的工作原理,或者直接跳到如何使用它部分。

编译时间和并行性

Rust 编译时间一直是一个令人关注的问题。编译器性能工作组多年来一直在不断提高编译器性能。例如,在 2023 年的前 10 个月中,编译时间的平均减少了 13%,峰值内存使用量减少了 15%,二进制大小减少了 7%,这是通过我们的性能测试套件测量的。

但是,目前编译器已经过大量优化,很难找到新的改进点。没有剩余的唾手可得的成果。

但有一个很大但高悬的成果:并行性。当前的 Rust 编译器用户可以从两种并行性中受益,而新添加的并行前端增加了第三种并行性。

现有的进程间并行性

当您编译 Rust 程序时,Cargo 会启动多个 rustc 进程,并行编译多个 crate。这效果很好。尝试使用 -j1 标志编译一个大型 Rust 程序以禁用此并行化,它将比平时花费更长的时间。

如果使用 Cargo 的 --timings 标志进行构建,您可以可视化此并行性,该标志会生成一个图表,显示如何编译 crate。下图显示了在具有 28 个虚拟核心的机器上构建 ripgrep 时的时序图。

cargo build --timings output when compiling ripgrep

有 60 条水平线,每条线代表一个不同的进程。它们的持续时间从几分之一秒到几秒不等。它们中的大多数是 rustc,少数橙色的线是构建脚本。前二十个进程都同时启动。这是可能的,因为相关 crate 之间没有依赖关系。但是,在图表的下方,随着 crate 依赖项的增加,并行性会降低。尽管编译器由于一个名为流水线编译的功能,可以部分重叠编译依赖的 crate,但在编译结束时发生的并行执行要少得多,这对于大型 Rust 程序来说很典型。进程间并行性不足以充分利用许多核心。为了获得更快的速度,我们需要每个进程内的并行性。

现有的进程内并行性:后端

编译器分为两部分:前端和后端。

前端执行许多操作,包括解析、类型检查和借用检查。直到本周,它还不能使用并行执行。

后端执行代码生成。它以称为“代码生成单元”的块生成代码,然后 LLVM 并行处理这些代码。这是一种粗粒度的并行性。

我们可以可视化串行前端和并行后端之间的差异。下图显示了一个名为Samply的分析器在测量 rustc 执行 Cargo 中最终 crate 的发布构建时的输出。该图像叠加了标记,指示前端和后端的执行。

Samply output when compiling Cargo, serial

每条水平线代表一个线程。主线程标记为“rustc”,并显示在底部。它在大多数执行过程中都很忙。其他 16 个线程是 LLVM 线程,标记为“opt cgu.00”到“opt cgu.15”。有 16 个线程,因为 16 是发布构建的默认代码生成单元数。

有几点值得注意。

  • 前端执行需要 10.2 秒。
  • 后端执行需要 6.2 秒,其中 LLVM 线程运行了 5.9 秒。
  • 并行代码生成非常有效。想象一下,如果所有这些 LLVM 都一个接一个地执行!
  • 即使有 16 个 LLVM 线程,也绝不会同时执行所有 16 个线程,尽管这是在具有 28 个核心的机器上运行的。(峰值是 14 或 15。)这是因为主线程会串行地将其内部代码表示形式 (MIR) 转换为 LLVM 的代码表示形式 (LLVM IR)。这对于每个代码生成单元来说都需要短暂的时间,并解释了代码生成线程左侧的阶梯形状。这里有一些改进的空间。
  • 前端完全是串行的。这里有很大的改进空间。

新的进程内并行性:前端

前端现在能够并行执行。它使用 Rayon 使用细粒度并行性执行编译任务。许多数据结构通过互斥锁和读写锁进行同步,在适当的地方使用原子类型,并且许多前端操作都是并行的。通过修改代码中相对较少的关键点来完成并行性的添加。绝大多数前端代码不需要更改。

当启用并行前端并配置为使用八个线程时,我们在编译与之前相同的示例时会获得以下 Samply 配置文件。

Samply output when compiling Cargo, parallel

同样,有几点值得注意。

  • 前端执行需要 5.9 秒(从 10.2 秒减少)。
  • 后端执行需要 5.3 秒(从 6.2 秒减少),LLVM 线程运行了 4.9 秒(从 5.9 秒减少)。
  • 在前端运行的有七个额外的标记为“rustc”的线程。减少的前端时间表明它们相当有效,但线程利用率不均衡,八个线程都有不活动的时间段。这里有很大的改进空间。
  • 八个 LLVM 线程同时启动。这是因为八个“rustc”线程并行地为八个代码生成单元创建 LLVM IR。(对于其中七个线程,这是他们在后端所做的唯一工作。)之后,阶梯效应会返回,因为只有一个“rustc”线程执行 LLVM IR 生成,而七个或更多 LLVM 线程处于活动状态。如果将前端使用的线程数更改为 16,则阶梯形状将完全消失,尽管在这种情况下,最终执行时间几乎不会改变。

将它们放在一起

长期以来,Rust 编译一直受益于 Cargo 的进程间并行性,以及后端的进程内并行性。现在,它还可以受益于前端的进程内并行性。

您可能想知道进程间并行性和进程内并行性如何交互。如果我们有 20 个并行的 rustc 调用,并且每个调用最多可以运行 16 个线程,那么我们是否会在只有几十个核心的机器上最终得到数百个线程,从而导致操作系统尽力调度它们时的执行效率低下?

幸运的是,不会。编译器使用作业服务器协议来限制它创建的线程数。如果发生大量的进程间并行性,则进程内并行性将受到适当的限制,并且线程数不会超过核心数。

如何使用它

夜间编译器现在已启用并行前端。但是,默认情况下,它以单线程模式运行,不会减少编译时间。

热衷的用户可以使用 -Z threads 选项选择多线程模式。例如

$ RUSTFLAGS="-Z threads=8" cargo build --release

或者,要从 config.toml 文件中选择(对于一个或多个项目),请添加以下行

[build]
rustflags = ["-Z", "threads=8"]

单线程模式是默认模式可能令人惊讶。为什么将前端并行化,然后以单线程模式运行它?答案很简单:谨慎。这是一个巨大的变化!并行前端有很多新代码。单线程模式会执行大多数新代码,但不包括可能影响多线程模式的死锁等线程错误。即使在 Rust 中,并行程序也比串行程序更难正确编写。出于这个原因,并行前端在一段时间内也不会在 beta 版或稳定版中发布。

性能影响

当并行前端以单线程模式运行时,编译时间通常比串行前端慢 0% 到 2%。这应该几乎不明显。

当并行前端以 -Z threads=8 的多线程模式运行时,我们对真实世界代码的测量表明,编译时间最多可以减少 50%,尽管效果差异很大,并且取决于代码及其构建配置的特性。例如,开发构建可能会比发布构建看到更大的改进,因为发布构建通常在后端花费更多时间进行优化。少数情况下,多线程模式下的编译速度比单线程模式下的编译速度慢。这些大多是已经编译速度很快的微型程序。

我们推荐八个线程,因为这是我们测试最多的配置,并且已知可以提供良好的结果。低于八个的值将看到较小的收益,但如果您的硬件少于八个核心,则适合使用。大于八的值将产生递减的收益,甚至可能导致更差的性能。

如果从一个线程到八个线程的 50% 的改进似乎很低,请回想一下上面的解释,即前端仅占编译时间的一部分,并且后端已经是并行的。你无法击败 阿姆达尔定律

多线程模式下的内存使用量会显着增加。我们已经看到高达 35% 的增长。考虑到编译的各个部分(每个部分都需要一定的内存量)现在正在并行执行,这不足为奇。

正确性

单线程模式下的可靠性应该很高。

在多线程模式下,存在一些已知的错误,包括死锁。如果编译挂起,您可能遇到了其中一个错误。

无论使用哪个前端,编译器生成的二进制文件都应该是相同的。任何差异都将被视为错误。

反馈

如果您对并行前端有任何问题,请检查标记为“WG-compiler-parallel”标签的问题。如果您的特定问题与任何现有问题都不匹配,请提交一个新问题。

对于更一般的反馈,请在 wg-parallel-rustc Zulip 频道上开始讨论。我们特别有兴趣了解您关心的代码的性能影响。

未来工作

我们正在努力提升并行前端的性能。正如上面的图表所示,前端的线程利用率仍有提升空间。我们也在解决多线程模式下剩余的错误。

我们的目标是稳定 -Z threads 选项,并在 2024 年的稳定版本中默认以多线程模式运行并行前端。

致谢

并行前端已经开发了很长时间。它由 @Zoxc 发起,他在最初的几年里也完成了大部分工作。经过一段时间的停滞后,该项目今年由 @SparrowLii 重启,他领导了该项目使其得以发布。并行 Rustc 工作组的其他成员也参与了审查和其他活动。非常感谢所有参与者。