Kotlin 快速编译背后的黑科技,了解一下~

3,848 阅读16分钟

原文链接:The Dark Secrets of Fast Compilation for Kotlin

前言

快速编译大量代码一向是一个难题,尤其是当编译器必须执行很多复杂操作时,例如重载方法解析和泛型类型推断。 本文主要介绍在日常开发中做一些小改动时,Kotlin编译器是如何加快编译速度的

为什么编译那么耗时?

编译时间长通常有三大原因:

  1. 代码库大小:通常代码码越大,编译耗时越长
  2. 你的工具链优化了多少,这包括编译器本身和你正在使用的任何构建工具。
  3. 你的编译器有多智能:无论是在不打扰用户的情况下计算出许多事情,还是需要不断提示和样板代码

前两个因素很明显,让我们谈谈第三个因素:编译器的智能。 这通常是一个复杂的权衡,在 Kotlin 中,我们决定支持干净可读的类型安全代码。这意味着编译器必须非常智能,因为我们在编译期需要做很多工作。

Kotlin 旨在用于项目寿命长、规模大且涉及大量人员的工业开发环境。

因此,我们希望静态类型安全,能够及早发现错误,并获得精确的提示(支持自动补全、重构和在 IDE 中查找使用、精确的代码导航等)。

然后,我们还想要干净可读的代码,没有不必要的噪音。这意味着我们不希望代码中到处都是类型。 这就是为什么我们有支持 lambda 和扩展函数类型的智能类型推断和重载解析算法等等。 Kotlin 编译器会自己计算出很多东西,以同时保持代码干净和类型安全。

编译器可以同时智能与高效吗?

为了让智能编译器快速运行,您当然需要优化工具链的每一部分,这是我们一直在努力的事情。 除此之外,我们正在开发新一代 Kotlin 编译器,它的运行速度将比当前编译器快得多,但这篇文章不是关于这个的。

不管编译器有多快,在大型项目上都不会太快。 而且,在调试时所做的每一个小改动都重新编译整个代码库是一种巨大的浪费。 因此,我们试图尽可能多地复用之前的编译,并且只编译我们绝对需要的文件。

有两种通用方法可以减少重新编译的代码量:

  • 编译避免:即只重新编译受影响的模块,
  • 增量编译:即只重新编译受影响的文件。

人们可能会想到一种更细粒度的方法,它可以跟踪单个函数或类的变化,因此重新编译的次数甚至少于一个文件,但我不知道这种方法在工业语言中的实际实现,总的来说它似乎没有必要。

现在让我们更详细地了解一下编译避免和增量编译。

编译避免

编译避免的核心思想是:

  • 查找dirty(即发生更改)的文件
  • 重新编译这些文件所属的module
  • 确定哪些其他模块可能会受到更改的影响,重新编译这些模块,并检查它们的ABI
  • 然后重复这个过程直到重新编译所有受影响的模块

从以上步骤可以看出,没有人依赖的模块中的更改将比每个人都依赖的模块(比如util模块)中的更改编译得更快(如果它影响其 ABI),因为如果你修改了util模块,依赖了它的模块全都需要编译

ABI是什么

上面介绍了在编译过程中会检查ABI,那么ABI是什么呢?

ABI 代表应用程序二进制接口,它与 API 相同,但用于二进制文件。本质上,ABI 是依赖模块关心的二进制文件中唯一的部分。

粗略地说,Kotlin 二进制文件(无论是 JVM 类文件还是 KLib)包含declarationbody两部分。其他模块可以引用declaration,但不是所有declaration。因此,例如,私有类和成员不是 ABI 的一部分。

body可以成为 ABI 的一部分吗?也是可以的,比如当我们使用inline时。 同时Kotlin 具有内联函数和编译时常量(const val)。因此如果内联函数的bodyconst val 的值发生更改,则可能需要重新编译相关模块。

因此,粗略地说,Kotlin 模块的 ABIdeclaration、内联body和其他模块可见的const val值组成。

因此检测 ABI 变化的直接方法是

  • 以某种形式存储先前编译的 ABI(您可能希望存储哈希以提高效率)
  • 编译模块后,将结果与存储的 ABI 进行比较:
  • 如果相同,我们就完成了;
  • 如果改变了,重新编译依赖模块。

编译避免的优缺点

避免编译的最大优点是相对简单。

当模块很小时,这种方法确实很有帮助,因为重新编译的单元是整个模块。 但如果你的模块很大,重新编译的耗时会很长。 因此为了尽可能地利用编译避免提升速度,决定了我们的工程应该由很多小模块组成。作为开发人员,我们可能想要也可能不想要这个。 小模块不一定听起来像一个糟糕的设计,但我宁愿为人而不是机器构建我的代码。为了利用编译避免,实际上限制了我们项目的架构。

另一个观察结果是,许多项目都有类似于util的基础模块,其中包含许多有用的小功能。 几乎所有其他模块都依赖于util模块,至少是可传递的。 现在,假设我想添加另一个在我的代码库中使用了 3 次的小实用函数。 它添加到util模块中会导致ABI发生变化,因此所有依赖模块都受到影响,进而导致整个项目都需要重新编译。

最重要的是,拥有许多小模块(每个都依赖于多个其他模块)意味着我的项目的configuration时间可能会变得巨大,因为对于每个模块,它都包含其独特的依赖项集(源代码和二进制文件)。 在 Gradle 中配置每个模块通常需要 50-100 毫秒。 大型项目拥有超过 1000 个模块的情况并不少见,因此总配置时间可能会超过一分钟。 它必须在每次构建以及每次将项目导入 IDE 时都运行(例如,添加新依赖项时)。

Gradle 中有许多特性可以减轻编译避免的一些缺点:例如,可以使用缓存configuration cache。 尽管如此,这里仍有很大的改进空间,这就是为什么在 Kotlin 中我们使用增量编译。

增量编译

增量编译比编译避免更加细粒度:它适用于单个文件而不是模块。 因此,当通用模块的 ABI 发生微小变化时,它不关心模块大小,也不重新编译整个项目。这种方式不会限制用户项目的架构,并且可以加快编译速度

JPS(IntelliJ的内置构建系统)一直支持增量编译。 而Gradle仅支持开箱即用的编译避免。 从 1.4 开始,Kotlin Gradle 插件为 Gradle 带来了一些有限的增量编译实现,但仍有很大的改进空间。

理想情况下,我们只需查看更改的文件,准确确定哪些文件依赖于它们,然后重新编译所有这些文件。

听起来很简单,但实际上准确地确定这组依赖文件非常复杂。

一方面,源文件之间可能存在循环依赖关系,这是大多数现代构建系统中的模块所不允许的。并且单个文件的依赖关系没有明确声明。请注意,如果引用了相同的包和链调用,imports不足以确定依赖关系:对于 A.b.c(),我们最多需要导入 A,但 B 类型的更改也会影响我们。

由于所有这些复杂性,增量编译试图通过多轮来获取受影响的文件集,以下是它的完成方式的概要:

  • 查找dirty(更改)的文件
  • 重新编译它们(使用之前编译的结果作为二进制依赖,而不是编译其他源文件)
  • 检查这些文件对应的ABI是否发生了变化
  • 如果没有,我们就完成了!
  • 如果发生了变化,则查找受更改影响的文件,将它们添加到脏文件集中,重新编译
  • 重复直到 ABI 稳定(这称为“固定点”)

由于我们已经知道如何比较 ABI,所以这里基本上只有两个棘手的地方:

  • 使用先前编译的结果来编译源的任意子集
  • 查找受一组给定的 ABI 更改影响的文件。

这两者都是 Kotlin 增量编译器的功能。 让我们一个一个看一下。

编译脏文件

编译器知道如何使用先前编译结果的子集来跳过编译非脏文件,而只需加载其中定义的符号来为脏文件生成二进制文件。 如果不是为了增量,编译器不一定能够做到这一点:从模块生成一个大二进制文件而不是每个源文件生成一个小二进制文件,这在 JVM 世界之外并不常见。 而且它不是 Kotlin 语言的一个特性,它是增量编译器的一个实现细节。

当我们将脏文件的 ABI 与之前的结果进行比较时,我们可能会发现我们很幸运,不需要再进行几轮重新编译。 以下是一些只需要重新编译脏文件的更改示例(因为它们不会更改 ABI):

  • 注释、字符串文字(const val 除外)等,例如:更改调试输出中的某些内容
  • 更改仅限于非内联且不影响返回类型推断的函数体,例如:添加/删除调试输出,或更改函数的内部逻辑
  • 仅限于私有声明的更改(它们可以是类或文件私有的),例如:引入或重命名私有函数
  • 重新排序函数声明

如您所见,这些情况在调试和迭代改进代码时非常常见。

扩大脏文件集

如果我们不那么幸运并且某些声明已更改,则意味着某些依赖于脏文件的文件在重新编译时可能会产生不同的结果,即使它们的代码中没有任何一行被更改。

一个简单的策略是此时放弃并重新编译整个模块。
这将把所有编译避免的问题都摆在桌面上:一旦你修改了一个声明,大模块就会成为一个问题,而且大量的小模块也有性能成本,如上所述。
所以,我们需要更细化:找到受影响的文件并重新编译它们。

因此,我们希望找到依赖于实际更改的 ABI 部分的文件。
例如,如果用户将 foo 重命名为 bar,我们只想重新编译关心名称 foobar 的文件,而不管其他文件,即使它们引用了此 ABI的其他部分。
增量编译器会记住哪些文件依赖于先前编译中的哪个声明,我们可以使用这种数据,有点像模块依赖图。同样,这不是非增量编译器通常会做的事情。

理想情况下,对于每个文件,我们应该存储哪些文件依赖于它,以及它们关心 ABI 的哪些部分。实际上,如此精确地存储所有依赖项的成本太高了。而且在许多情况下,存储完整签名毫无意义。

我们看一下下面这个例子:

// dirty.kt
// rename this to be 'fun foo(i: Int)'
fun changeMe(i: Int) = if (i == 1) 0 else bar().length

// clean.kt
fun foo(a: Any) = ""
fun bar() =  foo(1)

我们定义两个kt文件 ,dirty.ktclean.kt

假设用户将函数 changeMe 重命名为 foo。 请注意,虽然 clean.kt 没有改变,但 bar() 的主体将在重新编译时改变:它现在将从dirty.kt 调用 foo(Int),而不是从 clean.kt 调用 foo(Any) ,并且它的返回类型 也会改变。

这意味着我们必须重新编译dirty.ktclean.kt。 增量编译器如何发现这一点?

我们首先重新编译更改的文件:dirty.kt。 然后我们看到 ABI 中的某些内容发生了变化:

  • 没有功能 changeMe
  • 有一个函数 foo 接受一个 Int 并返回一个 Int

现在我们看到 clean.kt 依赖于名称 foo。 这意味着我们必须再次重新编译 clean.ktdirty.kt。 为什么? 因为类型不能被信任。

增量编译必须产生与所有代码的完全重新编译相同的结果。
考虑dirty.kt 中新出现的foo 的返回类型。它是推断出来的,实际上它取决于 clean.ktbar 的类型,它是文件之间的循环依赖。
因此,当我们将 clean.kt 添加到组合中时,返回类型可能会发生变化。在这个例子中,我们会得到一个编译错误,但是在我们重新编译 clean.ktdirty.kt 之前,我们不知道它。

Kotlin 增量编译的第一原则:您可以信任的只是名称。

这就是为什么对于每个文件,我们存储它产生的 ABI,以及在编译期间查找的名称(不是完整的声明)。

我们存储所有这些的方式可以进行一些优化。

例如,某些名称永远不会在文件之外查找,例如局部变量的名称,在某些情况下还有局部函数的名称。
我们可以从索引中省略它们。为了使算法更精确,我们记录了在查找每个名称时查阅了哪些文件。为了压缩我们使用散列的索引。这里有更多改进的空间。

您可能已经注意到,我们必须多次重新编译初始的脏文件集。 唉,没有办法解决这个问题:可能存在循环依赖,只有一次编译所有受影响的文件才能产生正确的结果。

在最坏的情况下,增量编译可能会比编译避免做更多的工作,因此应该有适当的启发式方法来防止它。

跨模块的增量编译

迄今为止最大的挑战是可以跨越模块边界的增量编译。

比如说,我们在一个模块中有脏文件,我们做了几轮并在那里到达一个固定点。现在我们有了这个模块的新 ABI,需要对依赖的模块做一些事情。

当然,我们知道初始模块的 ABI 中哪些名称受到影响,并且我们知道依赖模块中的哪些文件查找了这些名称。

现在,我们可以应用基本相同的增量算法,但从 ABI 更改开始,而不是从一组脏文件开始。

如果模块之间没有循环依赖,单独重新编译依赖文件就足够了。但是,如果他们的 ABI 发生了变化,我们需要将更多来自同一模块的文件添加到集合中,并再次重新编译相同的文件。

Gradle 中完全实现这一点是一个公开的挑战。这可能需要对 Gradle 架构进行一些更改,但我们从过去的经验中知道,这样的事情是可能的,并且受到 Gradle 团队的欢迎。

总结

现在,您对现代编程语言中的快速编译所带来的挑战有了基本的了解。请注意,一些语言故意选择让他们的编译器不那么智能,以避免不得不做这一切。不管好坏,Kotlin 走的是另一条路,让 Kotlin 编译器如此智能似乎是用户最喜欢的特性,因为它们同时提供了强大的抽象、可读性和简洁的代码。

虽然我们正在开发新一代编译器前端,它将通过重新考虑核心类型检查和名称解析算法的实现来加快编译速度,但我们知道这篇博文中描述的所有内容都不会过时。

原因之一是使用 Java 编程语言的体验,它享受 IntelliJ IDEA 的增量编译功能,甚至拥有比今天的 kotlinc 快得多的编译器。

另一个原因是我们的目标是尽可能接近解释语言的开发体验,这些语言无需任何编译即可立即获取更改。

所以,Kotlin 的快速编译策略是:优化的编译器 + 优化的工具链 + 复杂的增量。

译者总结

本文主要介绍了Kotlin编译器在加快编译速度方面做的一些工作,介绍了编译避免与增量编译的区别以及什么是ABI

了解Kotlin增量编译的原理可以帮助我们提高增量编译成功的概率,比如inline函数体也是ABI的一部分,因此当我们声明内联函数时,内联函数体应该写得尽量简单,内部通常只需要调用另一个非内联函数即可。

这样当inline函数内部逻辑发生更改时,不需要重新编译依赖于它的那些文件,从而实现增量编译。

同时从实际开发过程中体验,Kotlin增量编译还是经常会失效,尤其是发生跨模块更改时。Kotlin新一代编译器已经发布了Alpha版本,期待会有更好的表现~

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿