Rust 编译器的前端现在可以使用并行执行来显著减少编译时间。要尝试它,请使用 -Z threads=8
选项运行夜间版编译器。此功能目前处于实验阶段,我们计划在 2024 年将其发布到稳定版编译器中。
继续阅读以了解为什么需要并行前端以及它是如何工作的,或者直接跳到 如何使用它 部分。
编译时间和并行性
Rust 编译时间一直是一个长期关注的问题。 编译器性能工作组 几年来一直在不断改进编译器性能。例如,在 2023 年的前 10 个月,编译时间的平均减少量为 13%,峰值内存使用量减少了 15%,二进制文件大小减少了 7%,这些数据来自我们的性能测试套件。
然而,目前编译器已经过高度优化,新的改进很难找到。没有低垂的果实了。
但有一个大而高悬的果实:并行性。当前的 Rust 编译器用户受益于两种并行性,而新推出的并行前端则增加了第三种并行性。
现有的进程间并行性
当您编译 Rust 程序时,Cargo 会启动多个 rustc 进程,并行编译多个板条箱。这很有效。尝试使用 -j1
标志编译大型 Rust 程序以禁用此并行化,它将比正常情况花费更长时间。
如果您使用 Cargo 的 --timings
标志构建,您可以可视化这种并行性,它会生成一个图表,显示板条箱是如何编译的。下图显示了在具有 28 个虚拟核心的机器上构建 ripgrep 时的时序图。
有 60 条水平线,每条线代表一个不同的进程。它们的持续时间从几分之一秒到几秒不等。大多数是 rustc,少数橙色的是构建脚本。前 20 个进程都同时启动。这是可能的,因为相关板条箱之间没有依赖关系。但在图表中更下方,随着板条箱依赖关系的增加,并行性降低了。尽管由于一项名为 流水线编译 的功能,编译器可以稍微重叠依赖板条箱的编译,但在编译结束时,并行执行会减少很多,对于大型 Rust 程序来说,这很常见。进程间并行性不足以充分利用多个核心。为了获得更高的速度,我们需要每个进程内部的并行性。
现有的进程内并行性:后端
编译器分为两部分:前端和后端。
前端执行许多操作,包括解析、类型检查和借用检查。在本周之前,它无法使用并行执行。
后端执行代码生成。它以称为“代码生成单元”的块生成代码,然后 LLVM 并行处理这些代码。这是一种粗粒度并行性。
我们可以可视化串行前端和并行后端之间的区别。下图显示了名为 Samply 的分析器在 rustc 执行 Cargo 中最终板条箱的发布构建时的输出。图像叠加了标记,指示前端和后端执行。
每条水平线代表一个线程。主线程标记为“rustc”,显示在底部。它在大部分执行过程中都很繁忙。其他 16 个线程是 LLVM 线程,标记为“opt cgu.00”到“opt cgu.15”。有 16 个线程,因为 16 是发布构建的默认代码生成单元数量。
有几件事值得注意。
- 前端执行需要 10.2 秒。
- 后端执行需要 6.2 秒,LLVM 线程在其中运行了 5.9 秒。
- 并行代码生成非常有效。想象一下,如果所有这些 LLVM 线程一个接一个地执行!
- 尽管有 16 个 LLVM 线程,但在任何时候,尽管这在具有 28 个核心的机器上运行,但并非所有 16 个线程都在同时执行。(峰值为 14 或 15。)这是因为主线程将它的内部代码表示(MIR)串行转换为 LLVM 的代码表示(LLVM IR)。这对于每个代码生成单元都需要很短的时间,并解释了代码生成线程左侧的阶梯形状。这里有一些改进的空间。
- 前端完全是串行的。这里有很多改进的空间。
新的进程内并行性:前端
前端现在能够并行执行。它使用 Rayon 使用细粒度并行性执行编译任务。许多数据结构通过互斥锁和读写锁进行同步,原子类型在适当的地方使用,许多前端操作被并行化。并行性的添加是通过修改代码中相对较少的关键点来完成的。绝大多数前端代码不需要更改。
当启用并行前端并配置为使用 8 个线程时,我们在编译与之前相同的示例时获得了以下 Samply 配置文件。
同样,有几件事值得注意。
- 前端执行需要 5.9 秒(从 10.2 秒下降)。
- 后端执行需要 5.3 秒(从 6.2 秒下降),LLVM 线程在其中运行了 4.9 秒(从 5.9 秒下降)。
- 有 7 个额外的标记为“rustc”的线程在前端运行。前端时间减少表明它们相当有效,但线程利用率参差不齐,8 个线程都有一段时间的空闲期。这里有很大的改进空间。
- 8 个 LLVM 线程同时启动。这是因为 8 个“rustc”线程并行创建了 8 个代码生成单元的 LLVM IR。(对于其中 7 个线程来说,这是他们在后端执行的唯一工作。)之后,阶梯效应又回来了,因为只有一个“rustc”线程执行 LLVM IR 生成,而 7 个或更多 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 频道 上开始讨论。我们特别想知道您关心的代码的性能影响。
未来工作
我们正在努力提高并行前端的性能。如上图所示,前端线程利用率还有提升空间。我们还在解决多线程模式中剩余的错误。
我们的目标是在 2024 年稳定 -Z threads
选项,并在稳定版本中默认以多线程模式运行并行前端。
致谢
并行前端已经开发了很长时间。它是由 @Zoxc 启动的,他也在几年内完成了大部分工作。在一段时间的停滞之后,该项目在今年由 @SparrowLii 重新启动,他领导了将该项目发布的努力。并行 Rustc 工作组的其他成员也参与了审查和其他活动。感谢所有参与的人。