增量编译

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 值计算第二次。第一次计算时,a1b2c3

Initial Computation of a+b×c

假设我们在每一步都“保存”了中间结果,也就是说,我们记住了 b×c6a+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。之后,如果分析没有发现任何错误,codegen 阶段会将程序的 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 缓存的值就不能再被依赖了。

编译器中的依赖跟踪

在增量模式下编译时,我们总是构建所生成数据的依赖图:每次写入某些数据(如目标文件)时,我们都会记录我们在执行此操作时访问了哪些其他数据片段。

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

Dependency Graph of Compilation Data

然后,该依赖图与它所描述的缓存条目一起存储在增量编译缓存目录中。

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

Using the Dependency Graph to Validate the Incremental Compilation Cache

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

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

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

性能数据*

让我们看看到目前为止我们所学到的一些启示

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

换句话说,增量编译的有效性对被编译程序的结构和所做的更改非常敏感。修改源代码中的单个字符很可能导致整个增量编译缓存失效。不过,通常这种更改很少见,大多数情况下只需要重新编译程序的一小部分。

当前的实现状态

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

Relative Cost of Compilation Phases

如您所见,Rust 编译器将其大部分时间花在优化和 codegen 阶段。因此,如果该阶段至少可以跳过部分代码库,那么就可以在此处实现对编译时间的最大影响。

考虑到这一点,我们还可以给出增量编译初始版本可以节省多少时间的 上限:如果编译器在编译您的 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 上的跟踪 issue并留下评论!

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

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