增量编译

2016 年 9 月 8 日 · Michael Woerister

我记得在湾区聚会的 1.0 周年演讲中,Aaron Turon 谈到 Dropbox 到目前为止对在生产环境中使用 Rust 感到非常满意。他说,“核心团队一直在定期与他们联系问他们,你知道,你们需要什么?他们的答案始终是:更快的编译速度 ...”对于经验丰富的 Rust 用户来说,这引起了听众会意的轻笑并不奇怪。提高编译时间实际上是 Rust 达到 1.0 版本后的主要开发重点——尽管到目前为止,为实现这一目标所做的大部分工作都集中在编译器内部奠定架构基础,我们才开始慢慢看到实际的结果。

其中一个基于这些基础构建的项目,并且应该有助于提高典型工作流程的编译时间的是增量编译。增量编译避免在重新编译 crate 时重复工作,这将最终导致更快的编辑-编译-调试周期。

今天,我们宣布增量编译的alpha 版本,这标志着该功能开发的一个重要里程碑:自从去年年底开始实施以来,所有基本组件首次到位,大部分基础工作已经完成。您可以在编译器的 nightly 版本中试用它

$ rustc -Zincremental=<path> ./main.rs

这将以增量模式启动编译器,使用您提供的 <path> 作为增量编译缓存目录。我们还在开发一个 cargo 子命令,使其更顺畅,让您只需编写 cargo incremental build。当然,一旦运行稳定,增量编译将通过默认的 cargo build 命令提供。

话虽如此,增量编译尚未准备好用于生产环境:您可能会看到崩溃,您可能会看到编译时间没有实际减少的情况,最重要的是,我们仍然必须编写大量的回归测试,以确保增量编译的程序始终正确——因此,在真正重要的地方还不要使用它。然而,在接下来的几周和几个月里,我们的重点将是从正确性的角度使实现变得非常可靠,您将看到该功能的效率持续、逐步地提高,直到它能够改变您的开发体验。

这篇博客文章将介绍增量编译首先为什么有用以及何时有用,它的实现是如何工作的,它当前的开发状态是什么,最后是未来的计划以及如果您有兴趣,如何做出贡献。

首先,为什么需要增量编译?

程序员的大部分时间都花在编辑-编译-调试工作流程中

  • 您进行小的更改(通常在单个模块甚至函数中),
  • 您让编译器将代码转换为二进制文件,最后
  • 运行程序或一系列单元测试,以查看更改的结果。

之后,它又回到第一步,根据前一次迭代中获得的知识进行下一次小的更改。这个基本的反馈循环是我们日常工作的核心。我们希望等待编译器生成可执行程序的时间尽可能短——理想情况下,足够短,以至于不需要耗时且令人紧张的心理上下文切换:您希望能够继续工作,保持专注。毕竟,在 rustc 自举时,只有那么多退步的乐趣可以获得。

增量编译是一种利用在常规编程工作流程中编译之间几乎没有变化的事实的方式:两次编译会话之间所做的大多数(如果不是全部)更改只会对输出二进制文件中的机器代码产生局部影响,而程序的其余部分,与源代码级别一样,最终将完全相同,一模一样。增量编译旨在保留尽可能多的未更改部分,同时仅重做那些实际必须重做的工作量。

如何使某些东西“增量”?

我们已经听说,以增量方式计算某些东西意味着仅更新计算输出中那些需要适应计算输入中给定更改的部分。我们可以采用的一种基本策略是将一个大的计算(如编译程序)视为许多较小的、相互关联的计算的组合,这些计算相互构建。这些较小的计算中的每一个都将产生一个中间结果,该结果可以缓存,并有望在以后的迭代中重用,从而使我们不必再次重新计算该特定的中间结果。

让我们看一个代数中的简单示例,使事情更加具体。让我们看看以增量方式计算 a+b×c 形式的表达式意味着什么。这将包括用一组 abc 的值计算表达式一次,然后在 a 的值不同的情况下再次计算表达式。第一次,a 将为 1b 将为 2c 将为 3

Initial Computation of a+b×c

假设我们在每个步骤都“保存”了中间结果,也就是说,我们记住了 b×c6a+b×c7。现在,在第二轮中,如果我们把 a 的值改为 4,我们想知道 a+b×c 是什么。当我们重新计算表达式的值时,我们看到我们已经知道 b×c = 6,所以我们不必再次执行该计算,而是可以直接跳到 (a = 4) + (b×c = 6)。因此,我们只用一步而不是两步计算了表达式的值,从而避免了整个繁琐的乘法运算。

Updating the Computation

让我们看看这个方案如何转化为编译器。

增量编译器

我们在 Rust 编译器中选择实现增量的方式很简单:增量编译会话以与批量编译会话相同的顺序执行完全相同的步骤。但是,当控制流到达要计算一些非平凡中间结果的点时,它将尝试从磁盘上的增量编译缓存中加载该结果。如果缓存中有有效的条目,编译器可以跳过计算特定的数据。让我们看一下不同编译阶段及其产生的中间结果的(简化)概述

Compiler Phases and their By-Products

首先,编译器将源代码解析为抽象语法树 (AST)。然后,AST 经过分析阶段,该阶段会生成类型信息和每个函数的MIR。之后,如果分析未发现任何错误,代码生成阶段会将程序的 MIR 版本转换为其机器代码版本,从而为每个源代码级别的模块生成一个目标文件。在最后一步中,所有目标文件链接在一起,形成最终的输出二进制文件,该二进制文件可能是库或可执行文件。

与上面我们的代数示例进行比较,AST 的部分对应于 abc,也就是说,它们是我们增量计算的输入,它们决定了我们在编译过程中需要更新的内容。另一方面,类型信息、MIR 和目标文件的部分是我们的中间结果,也就是说,它们对应于我们为 b×ca+b×c 存储的增量计算缓存条目。在代数示例中,缓存条目看起来像 b×c = 6,在编译器的情况下,它看起来像 translate_to_obj_file(mir1, mir2, mir3) = <目标文件的位>

因此,到目前为止,这看起来很简单:不要第二次计算某些东西,只需从缓存中加载值即可。但是,当我们需要找出是否真的可以使用缓存中的值,或者由于某些输入已更改,我们是否必须重新计算它时,事情会变得棘手。

依赖关系图

有一种正式的方法可以用来以直接的方式建模计算的中间结果及其各自的“最新性”:依赖关系图。它看起来像这样:每个输入和每个中间结果都表示为有向图中的节点。另一方面,图中的表示哪个中间结果或输入可以对其他一些中间结果产生影响

让我们回到我们的代数示例,看看这在实践中是什么样子

Dependency Graph of a+b×c

如您所见,我们有输入 abc 的节点,以及中间结果 b×ca+b×c 的节点。边不应该令人惊讶:从 b×cb 和到 c 各有一条边,因为这些是我们在计算 b×c 时需要读取的值。对于 a+b×c,情况完全相同。顺便说一句,请注意,上面的图是一棵树,仅仅是因为它建模的计算具有树的形式。通常,依赖关系图是有向无环图,如果我们向计算中添加另一个中间结果 b×c+c,情况也会如此

Example of a non-tree Dependency Graph

使这种数据结构真正有用的原因是,我们可以向它提出“如果 X 发生变化,Y 是否仍然是最新的?”形式的问题。我们只需获取代表 Y 的节点,并通过传递地跟踪所有源自 Y 的边来收集 Y 所依赖的所有输入。如果这些输入中的任何一个发生变化,我们为 Y 缓存的值就不能再依赖了。

编译器中的依赖关系跟踪

以增量模式编译时,我们始终构建生成数据的依赖关系图:每次写入某些数据(如目标文件)时,我们都会记录在执行此操作时正在访问的其他数据。

这里的重点是记录。在任何时候,编译器都会跟踪它当前正在处理的哪个数据(它在后台的线程局部内存中执行此操作)。这是依赖关系图当前活动节点。相反,为了计算活动节点的值而需要读取的数据也会被跟踪:它通常已经位于某种容器中(例如哈希表),该容器需要调用查找方法才能访问特定条目。我们通过使这些查找方法透明地创建依赖关系图中的边来充分利用这一事实:每当访问条目时,我们知道它正在被读取,并且我们知道它被读取用于什么(当前活动节点)。这为我们提供了依赖关系边的两端,我们可以简单地将其添加到图中。在编译会话结束时,我们所有的数据都很好地链接在一起,大部分是自动完成的

Dependency Graph of Compilation Data

然后,这个依赖关系图会和它所描述的缓存条目一起存储在增量编译缓存目录中。

在后续编译会话开始时,我们会通过将输入(=AST 节点)与之前的版本进行比较来检测哪些输入发生了更改。给定该图和已更改的输入集,我们可以轻松找到所有不再是最新的缓存条目,并将其从缓存中删除。

Using the Dependency Graph to Validate the Incremental Compilation Cache

任何在缓存验证阶段幸存下来的内容都可以在当前编译会话期间安全地重用。

我们采用的自动依赖跟踪方法有几个好处。由于它内置于编译器的内部 API 中,它将随着编译器更改而保持更新,并且很难意外忘记使用它。如果仍然忘记正确使用它(例如,在某些地方没有声明正确的活动节点),那么结果是一个过于保守,但仍然“正确”的依赖关系图:它将对重用率产生负面影响,但不会导致错误地重用一些过时的数据。

另一方面,该系统不会尝试预测或计算依赖关系图的样子,它只是跟踪。另一方面,我们(尚未编写的)大部分回归测试给出给定程序的依赖关系图应该是什么样子的描述。这确保了实际的图和参考图是通过不同的方法得到的,从而降低了编译器和测试用例对相同但错误的值达成一致的风险。

“更快!高达 15% 或更多。”*

让我们看看到目前为止我们所学到的一些含义

  • 依赖关系图反映了源代码部分和输出二进制文件部分之间的实际依赖关系。
  • 如果存在一些可从许多中间结果访问的输入节点,例如,几乎每个函数都使用的中心数据类型,那么更改该数据类型的定义将意味着必须从头开始编译所有内容,这是无法避免的。

换句话说,增量编译的有效性对正在编译的程序的结构和所做的更改非常敏感。更改源代码中的单个字符很可能会使整个增量编译缓存失效。但通常,这种更改是一种罕见的情况,并且大多数时候只需要重新编译程序的一小部分。

当前实现状态

对于增量编译的第一个初始实现(我们现在称之为 alpha 版本),我们选择专注于缓存目标文件。我们为什么要这样做?让我们再次看一下编译阶段,特别是平均每个阶段花费多少时间

Relative Cost of Compilation Phases

如你所见,Rust 编译器大部分时间都花在优化和代码生成阶段。因此,如果至少可以跳过部分代码库的这个阶段,那么这是可以对编译时间产生最大影响的地方。

考虑到这一点,我们还可以给出此初始版本的增量编译可以节省多少时间的上限:如果编译器在编译你的 crate 时花费 X 秒进行优化,那么增量编译最多可以将编译时间减少 X 秒。

另一个对 alpha 版本的实际有效性有很大影响的领域是依赖跟踪粒度:我们自己决定如何使依赖关系图的粒度精细,而当前的实现使其在某些地方相当粗糙。例如,依赖关系图只知道一个 impl 中所有方法的单个节点。因此,如果仅更改了其中一个方法,编译器会将该 impl所有方法都视为已更改。当然,这意味着将重新编译比严格必要时更多的代码。

性能数据

以下是一些关于当前实现在各种情况下的表现的数据。首先,让我们看一下最佳情况,即可以重用 crate 的 100% 目标文件。当更改多 crate 项目中的一个 crate 并且需要重建下游 crate 但实际上不受影响时,可能会发生这种情况。

Normalized Incremental Compilation Build Times

如你所见,在增量模式下第一次编译 crate 可能比在非增量模式下编译慢。这是因为在激活时,依赖关系跟踪会产生一些额外的成本。请注意,增量编译也可以更快(如 regex crate 的情况)。这是因为增量编译将代码拆分为比常规编译会话更小的优化单元,从而减少了优化时间,但也降低了运行时代码的效率。

最后一列显示了在没有任何更改的情况下重建 crate 所需的时间。对于编译器花费大量时间进行优化的 crate,例如syntex-syntaxregex,增益可能很大:增量重建仅需完整重建的 syntex-syntax 所需时间的约 22%,仅需 regex 的 16%,以及 futures-rs crate 的 all.rs 测试用例的不到 10%。

另一方面,对于像 futures-rs 库这样在编译时产生少量机器代码的 crate,当前版本的增量编译几乎没有区别:它只比从头编译快 3%。

下一个图表显示了对 regex crate 进行的各种更改对增量重建时间的影响

Build Times After Specific Changes

这些数字表明,增量编译的有效性确实很大程度上取决于应用于代码的更改类型。对于具有非常局部影响的更改,我们可以接近最佳重用(如 Error::cause()dfa::write_vari32() 的情况)。如果我们更改了对很多地方产生影响的内容,例如 Compiler::new(),我们可能不会看到编译时间明显减少。但请再次注意,这些测量来自当前的实现状态。它们不反映该功能的全部潜力。

未来计划

alpha 版本代表了 Rust 编译器增量编译的最小端到端实现,因此还有很大的改进空间。关于当前状态的部分已经阐述了我们将沿着提高效率的两个主要方向进行努力

  • 缓存更多中间结果,如 MIR 和类型信息,这将允许编译器跳过越来越多的步骤。

  • 使依赖跟踪更加精确,以便编译器在缓存失效期间遇到更少的误报。

在这两个方向上的改进将使增量编译随着实现的成熟而更加有效。

在正确性方面,我们从一开始就试图谨慎行事,如果我们不确定我们的依赖关系跟踪是否正确,宁愿让编译器重新计算某些内容,但仍然有更多可以做的事情。

  • 我们希望有更多的自动测试,以确保系统的各种基本组件不会退化。这是一个感兴趣的人可以相对轻松地开始贡献的领域,因为一个人只需要理解 Rust 语言和测试框架,而无需了解编译器实现的更复杂内部结构。如果您有兴趣加入,请前往 GitHub 上的跟踪问题并发表评论!

  • 我们正在开发 cargo incremental 工具(实现为 Cargo 子命令,方便安装和使用),它将遍历项目的 git 历史记录,编译源代码的后续版本,并收集关于增量编译与常规编译的效率正确性的数据。如果您有兴趣提供帮助,请考虑参与该工具本身的开发,或下载并在您的项目上运行它。IRC 上的 #rustc 频道目前是获取有关此主题的更多信息的最佳场所。

感谢社区中迄今为止直接或间接为增量编译做出贡献的所有人!如果您有兴趣解决特定的实现问题,请查找带有 A-incr-comp 标签的问题,或者在 IRC 上的 #rustc 频道中询问。