增量编译

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×c6 并且 a+b×c7。现在,在第二轮中,我们想知道如果我们将 a 的值更改为 4a+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) = <bits of the obj-file>

所以,到目前为止,这似乎很简单:与其第二次计算某物,不如直接从缓存中加载值。但是,当我们需要找出是否实际上可以使用缓存中的值,或者是否由于某些输入更改而必须重新计算它时,事情就会变得棘手。

依赖图

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

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

Dependency Graph of a+b×c

如您所见,我们有输入 abc 的节点,以及中间结果 b×ca+b×c 的节点。边缘应该不足为奇:从 b×cb 有一个边缘,从 b×cc 有一个边缘,因为这些是我们计算 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 编译器在优化和代码生成阶段花费了大部分时间。因此,如果至少可以跳过代码库的一部分的此阶段,那么这里可以对编译时间产生最大的影响。

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

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

性能指标

以下是一些关于当前实现如何在各种情况下表现的数字。首先,让我们看一下板条箱的 100% 目标文件可以重复使用的最佳情况。这可能发生在更改多板条箱项目中的一个板条箱时,下游板条箱需要重建,但实际上没有受到影响。

Normalized Incremental Compilation Build Times

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

最后一列显示了在没有任何实际更改的情况下重建板条箱所需的时间。对于编译器在优化方面花费大量时间的板条箱,例如 syntex-syntaxregex,收益可能很大:增量重建只需要完整重建所需时间的约 22%(对于 syntex-syntax),regex 仅为 16%,而 all.rs 测试用例futures-rs 板条箱不到 10%。

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

下图显示了对 regex 板条箱进行的各种更改对增量重建时间的影响。

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 频道 中询问。