News

Kotlin 增量编译的新方式

Read this post in other languages:
English, 한국어

在 Kotlin 1.7.0 中,我们针对跨模块依赖项中的项目更改重做了增量编译。 新方式解除了此前对增量编译的限制。 现在,在依赖的非 Kotlin 模块内进行更改时增量编译会受到支持,并且它与 Gradle 构建缓存兼容。 对编译避免的支持也得到了改进。 这些改进都减少了必要全模块和文件重新编译的次数,让整体编译更加迅速。

增量编译的新方案目前处于实验性阶段,仅支持 Gradle 构建系统中的 JVM 后端。

试用新方式

基准

如果您使用 Gradle 构建缓存或经常在非 Kotlin Gradle 模块中进行更改,我们认为您会从新方式中感受到最显著的改进。 以下基准测试结果在 Kotlin 项目中的 kotlin-gradle-plugin 模块上测得:

如何启用

在 gradle.properties 文件中设置以下选项即可使用新方式进行增量编译:

kotlin.incremental.useClasspathSnapshot=true

对于增量编译,稳定性和可靠性至关重要。 因此,我们希望您愿意报告您在使用此编译方案时遭遇的问题或奇怪行为。

有时,增量编译的问题会在失败发生几轮后显现,因此您可能需要构建报告来跟踪更改和编译的历史记录。 这样做也有助于提供可重现的错误报告。

在后台

编译避免和增量编译

如果您熟悉 Kotlin 快速编译Gradle 中的增量 Java 编译和 Gradle 中的编译避免的技巧,则可以跳过本节。

快速编译的一大核心是应用程序二进制接口 (ABI)。 如果两个类在用作编译类路径时可互换,则它们具有相同的 ABI。

查看以下示例 Java 类:

这些类具有相同的 ABI。 private1() 和 private2() 方法在编译期间对其他类均不可见。 方法体也不影响其他类的编译。 因此,这些版本的 JavaClass 在编译期间可以互换。

Gradle 可以跟踪 Java ABI 中的更改。 因此,如果依赖项的更改不会影响 ABI,则纯 Java 编译任务的状态将保持最新状态。 此功能即编译避免,在 Gradle 3.4 中引入,并带来了显著性能改进。

由于 Kotlin ABI 包含更多信息(例如内联函数的主体),我们不能依赖当前 Gradle 中实现的 ABI 比较。 这就需要在每次依赖项更改后启动 Kotlin 编译器。

加快编译速度的另一种方式是仅重新编译受影响的文件。 这个概念即增量编译。 假设编译类路径有一些 ABI 更改。 那么,处理这个问题的最佳方式是什么? 通常,类路径中的 ABI 更改仅影响模块中的一部分文件。 Kotlin 编译器会保存所编译的类之间的依赖关系。 因此,在后续编译期间,可以仅查找和重新编译受 ABI 更改影响的类。

如果重新编译的类的 ABI 也被更改,则可以在 ABI 中找到受新更改影响的类并重复编译。 这个操作有些复杂。 部分文件或类应始终共同编译(例如,多文件类或 sealed 接口及其继承者)。 我们还应该跟踪在编译时计算的常量,但是此主题不在当前重点范围之内。

跟踪跨模块依赖项中的更改

以下示例解释了我们如何跟踪跨模块依赖项中的 ABI 更改。 在这里,模块 B 依赖于模块 A。首个完整构建在修订 1 中调用。 应用修订 2 后,模块 B 的编译被调用。 此操作在修订 3 中重复。

历史记录文件

首先,考虑当前默认方式。 Kotlin 编译器可以保存 ABI 中的更改并生成类文件。 这是您可能已经在构建目录中发现的 `build-history.bin` 文件。

在修订 1 中,执行的是以下操作:

  • 完全构建模块 A,因为没有先前的状态。
  • 将信息保存到模块 A 的历史记录文件中。
  • 完全构建模块 B,因为没有先前的状态。
  • 编译的类之间的所有依赖关系都保存在模块 B 中。

当然,还有其他阶段。 例如,为模块 B 保存历史记录文件,但为了清楚起见,我们在这里不包含这些阶段。

修订 2 中的操作:

  • 以增量方式构建模块 A。
  • 将模块 A 中 ABI 没有更改的信息保存到模块 A 的历史记录文件中。请注意,方法体不影响 ABI。
  • 收集模块 B 的依赖项更改。 ABI 也可能没有更改。
  • 完成模块 B 的编译任务,因为没有要重新编译的文件。

修订 3 中的操作:

  • 以增量方式构建模块 A。
  • 将 A.doA 中的更改添加到相应的历史记录文件中。
  • 分析模块 B 中的依赖项更改,找到更改的方法:A.doA
  • 将类 B 标记为重新编译,如内部存储的依赖映射所述。

优势

  • 这非常有效,无需保存编译类路径或比较类路径。

缺点

  • 在修订 2 中,Gradle 未处理输入的最新状态。 我们花了些时间启动 Kotlin 编译器。
  • 使修订可重定位的开销相当大。 因此,增量编译与 Gradle 构建缓存不兼容。
  • 如果模块 A 不生成历史记录文件(例如,在外部库中),则此方式不适用。

如果使用构建报告,可能有以下重建原因:DEP_CHANGE_HISTORY_IS_NOT_FOUND 和 DEP_CHANGE_HISTORY_NO_KNOWN_BUILDS。 它们与这些缺点有关。

跟踪编译类路径

现在,我们的替代方式需要在 Kotlin 编译器的每个调用上存储编译类路径的 ABI。 此方式还需要在每次编译时比较交叉路径。 这些操作相当繁重,需要大量优化才能实现可接受的性能:

可能的优化

(a) 仅保留编译期间实际使用的类路径 ABI 的部分。

(b) 仅从类路径提取可被编译器使用的 ABI 部分。

(c) 在生产者端生成 ABI 以及类文件。

(d) 缓存提取的 ABI。

其中一些选项互不兼容。 例如,选项 (b) 和 (c) 无法同时实现。 在某些情况下,最佳方式取决于构建系统提供的功能。 它还很大程度上取决于用例:

  • 在大型库上添加依赖项时,如果在一个模块中只使用一个类,那么选项 (b) 更有效(下方左图)。
  • 如果是在大量模块中添加相似依赖项并在其中的多个模块内使用依赖项中的几乎所有类,则使用选项 (d) 并缓存通用依赖项的计算 ABI 更有效。

我们对多个开源项目的估计证明,最有效的方式是对每个依赖项执行一次 ABI 提取并缓存执行结果。

新方式使用 Gradle 工件转换进行 ABI 提取。 这使结果可缓存且完全可重定位。 使用远程构建缓存时,从库依赖项提取 ABI 的繁重工作很可能不会在您的机器上执行。 只是下载此工件。

接下来请看先前示例的三个修订在新方式下的编译方式。

在修订 1 中,将执行两个模块的完全构建,因为没有先前的状态。 这里没有变化。 与先前的方案不同,我们还存储了模块 B 的编译类路径的快照。

在修订 2 中,Kotlin 编译器为模块 A 生成不同的字节码,但工件转换产生的结果相同。 Gradle 将模块 B 的所有输入标记为“最新”。 无需额外操作。 构建链被中断,我们更快获得结果。

在修订 3 中,模块 A 产生不同的输出,工件转换产生不同的 ABI,导致模块 B 的编译被触发。 在模块 B 编译期间,需要执行以下步骤:

  • 比较先前存储的类路径快照和新的快照,生成更改的 ABI 列表。 唯一的区别是 A.doA 方法。
  • 将 class B 标记为重新编译,如内部存储的依赖映射所述。
  • 存储模块 B 的编译类路径的快照。
  • 重新编译模块 B 中的 class B,因为其类型取决于更改后的 A.doA

后续规划

我们将稳定这一方式,计划实现对其他后端(例如 JS)和构建系统的支持。

提出您的反馈意见

希望您愿意在项目中试用新的增量编译。 如果您有任何反馈或遇到问题,请在我们的问题跟踪器中报告。 谢谢!

致谢

我们非常感谢外部贡献者提供的巨大帮助:Ivan GavrilovicHung NguyenCédric Champeau 等。

本博文英文原作者:

Sue

Andrey Uskov

Discover more