柯特林快速编译的秘密

2020-09-26 01:01:22

快速编译大量代码是一个难题,尤其是当编译器必须使用泛型执行复杂的分析(如重载解析和类型推断)时。在这篇文章中,我将告诉你Kotlin的一个巨大且基本上不可见的部分,这使得它在日常运行-测试-调试循环中经常发生的相对较小的更改上编译得更快。

此外,我们正在寻找高级开发人员加入JetBrains的团队,为Kotlin进行快速编译,所以如果你感兴趣,请看这篇文章的底部。

这篇文章是关于每个开发人员生活中的一个非常重要的方面:在更改代码之后,运行测试(或者只是进入程序的第一行)需要多长时间。这通常被称为测试时间。

如果考试时间太短,你永远不会被强迫去喝咖啡(或剑术),

如果测试时间太长,你就会开始浏览社交媒体,或者在其他一些方面分心,并且忘记了你所做的改变是什么。

虽然这两种情况都各有利弊,但我认为最好是有意识地休息,而不是在编译器告诉你的时候休息。编译器是聪明的软件,但健康的人类工作时间表并不是编译器聪明的地方。

当开发人员感到高效时,他们往往会更快乐。编译暂停会打断流程,让我们感觉停滞不前,停滞不前,效率低下。几乎没有人喜欢这样。

您的工具链优化了多少,这包括编译器本身和您正在使用的任何构建工具。

您的编译器有多聪明:无论它是在不用仪式打扰用户的情况下计算出许多事情,还是不断地需要提示和样板代码。

前两个因素是显而易见的,让我们谈谈第三个因素:编译器的智能性。这通常是一个复杂的权衡,在Kotlin中,我们决定使用干净、可读的类型安全代码。这意味着编译器必须相当智能,原因如下。

Kotlin被设计用于项目寿命长、规模大、涉及大量人员的工业环境中。因此,我们希望静态类型安全能够及早捕获错误并获得精确的工具(完成、重构和在IDE中查找用法、精确的代码导航等)。然后,我们还需要干净、可读的代码,没有不必要的噪音或仪式。其中,这意味着我们不希望代码中到处都是类型。这就是为什么我们有支持lambdas和扩展函数类型的智能类型推理和重载解析算法,我们有智能强制转换(基于流的类型),等等。Kotlin编译器自己计算出很多东西,以同时保持代码的整洁和类型安全。

要让智能编译器快速运行,您当然需要优化工具链的每一点,这是我们一直在做的事情。在其他方面,我们正在开发新一代的Kotlin编译器,它的运行速度将比当前的快得多。但这篇帖子不是关于那个的。

无论编译器有多快,在大型项目上都不会太快。而且,在调试过程中所做的每一个小更改都要重新编译整个代码库,这是一种巨大的浪费。因此,我们正在尝试尽可能多地重用以前的编译,并且只编译我们绝对必须编译的内容。

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

重新编译这些文件所属的模块(使用以前编译其他模块的结果作为二进制依赖项)。

如果您知道如何比较ABI,则算法或多或少很简单。否则,我们将重新编译那些受更改影响的模块。当然,没有人依赖的模块中的更改将比每个人都依赖的“util”模块中的更改编译得更快(如果它影响其ABI的话)。

ABI是Application Binary Interface的缩写,除了用于二进制文件之外,它与API有些相同。从本质上讲,ABI是依赖模块唯一关心的二进制文件部分(这是因为Kotlin有单独的编译,但我们在这里不会深入讨论)。

粗略地说,Kotlin二进制文件(JVM类文件或klib)包含声明和主体。其他模块可以引用声明,但不能引用所有声明。因此,例如,私有类和成员不是ABI的一部分。身体能成为ABI的一部分吗?是的,如果该主体在调用位置是内联的。Kotlin具有内联函数和编译时常量(Const Val)。如果内联函数体或const val的值发生更改,则可能需要重新编译依赖模块。

因此,粗略地说,Kotlin模块的ABI由声明、内联函数体和从其他模块可见的常量的值组成。

以某种形式存储来自上一次编译的ABI(您可能希望存储散列以提高效率),

当模块很小时,这种方法非常有用,因为重新编译的单元是整个模块。如果您的模块很大,重新编译的时间会很长。因此,基本上,避免编译意味着我们有很多小模块,作为开发人员,我们可能想要也可能不想要。小模块听起来不一定是糟糕的设计,但我更愿意为人而不是机器来构建我的代码。

另一个观察结果是,许多项目都有类似于“util”模块的东西,其中驻留着许多小的有用函数。实际上,其他所有模块都依赖于“util”,至少是过渡性的。现在,假设我想添加另一个在我的代码库中使用了三次的小而有用的函数。它增加了模块ABI,所以所有依赖的模块都会受到影响,我陷入了一场漫长的走廊剑拔弩张,因为我的整个项目都在重新编译。

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

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

增量编译比编译避免更精细:它在单个文件上工作,而不是在模块上工作。因此,当“流行”模块的ABI发生微小变化时,它既不关心模块大小,也不重新编译整个项目。通常,此方法不会对用户进行太多限制,并会缩短测试时间。此外,开发人员的剑会被忽视和生锈,并乞求至少偶尔使用一次。

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

理想情况下,我们只需查看更改的文件,准确确定哪些文件依赖于它们,然后重新编译所有这些文件。听起来又好又简单,但实际上精确地确定这组依赖文件非常重要。首先,源文件之间可能存在循环依赖关系,这是大多数现代构建系统中的模块所不允许的。并且不显式声明各个文件的依赖关系。注意,导入不足以确定依赖关系,因为引用了相同的包和链调用:对于a.b.c(),我们最多需要导入A,但是B类型的更改也会影响我们。

由于所有这些复杂性,增量编译试图通过多次循环来近似受影响的文件集,以下是它是如何完成的概要:

重新编译它们(使用上一次编译的结果作为二进制依赖项,而不是编译其他源文件)。

检查与这些文件对应的ABI是否已更改。如果已更改,请查找受更改影响的文件,将其添加到脏文件集中,然后重新编译。

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

这两个都至少是Kotlin的增量编译器的部分特性。让我们逐一来看一看。

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

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

仅限于未内联且不影响返回类型推断的函数体的更改。

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

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

一个简单的策略是在这一点上放弃,重新编译整个模块。如上所述,这将使所有与编译避免有关的问题摆在桌面上:一旦修改声明,大模块就会成为问题,而大量的小模块也会造成性能损失。因此,我们需要更细粒度:找到受影响的文件并重新编译它们。

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

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

//将其重命名为';Fun Foo(i:int)'; Fun changeMe(i:int)=if(i==1)0其他bar().length。

假设用户将函数changeMe重命名为foo。注意,虽然lean.kt没有改变,但是bar()的主体在重新编译时会改变:它现在将从dirty.kt调用foo(Int),而不是从lean.kt调用foo(Any),并且它的返回类型也会改变。这意味着我们必须重新编译dirty.kt和lean.kt。增量编译器如何才能发现这一点呢?

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

现在我们可以看到,lean.kt依赖于名称foo。这意味着我们必须再次重新编译lean.kt和dirty.kt。为什么?因为类型不可信任。

增量编译必须产生与完全重新编译所有源代码相同的结果。考虑dirty.kt中新出现的foo的返回类型。它是推断出来的,实际上它依赖于文件之间循环依赖的cle.kt中的bar的类型。因此,当我们向Mix中添加Clean.kt时,返回类型可能会改变。在本例中,我们将得到一个编译错误,但是在我们重新编译lean.kt和dirty.kt之前,我们不知道它的存在。

对于Kotlin而言,最先进的增量编译的一条重要规则是:您所能信任的就是名称。这就是为什么对于每个文件,我们都存储。

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

正如您可能已经注意到的,我们必须多次重新编译初始的脏文件集。唉,这是无可奈何的:可能存在循环依赖,只需一次编译所有受影响的文件就能产生正确的结果。在最坏的情况下,这会得到二次,增量编译可能会比编译避免做更多的工作,所以应该有适当的启发式方法来保护它。

比方说,我们在一个模块中有脏文件,我们进行了一些循环,并在那里找到了一个固定点。现在我们有了此模块的新ABI,并且需要对相关模块执行一些操作。

当然,我们知道初始模块的ABI中受影响的名称,也知道相关模块中的哪些文件查找了这些名称。现在,我们可以应用基本上相同的增量算法,但是从ABI更改开始,而不是从一组脏文件开始。顺便说一句,如果模块之间没有循环依赖关系,那么只需重新编译依赖文件就足够了。但是,如果它们的ABI发生了更改,我们将需要将更多的文件从相同的模块添加到集合中,然后再次重新编译相同的文件。

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

我在这里的目标是让您体验一下Kotlin的快速编译的迷人机制。那里还有很多我故意遗漏的东西,包括但不限于。

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

虽然我们正在开发新一代编译器前端,通过重新考虑核心类型检查和名称解析算法的实现,它将使编译速度大大加快,但我们知道这篇博客文章中描述的一切都不会消失。其中一个原因是使用Java编程语言的经验,即使拥有比今天的kotlinc快得多的编译器,也可以享受IntelliJ Idea的增量编译功能。另一个原因是,我们的目标是尽可能接近解释型语言的开发往返过程,这些语言无需任何编译即可即时获取更改。因此,Kotlin的快速编译策略是:优化编译器+优化工具链+复杂增量。

如果您有兴趣解决这类问题,请考虑JetBrains的Kotlin快速编译团队目前的职位空缺。这是英文的职位列表,还有一份俄文的。以前使用编译器或构建工具的经验不是必需的。我们在JetBrains的所有办事处(圣彼得堡、慕尼黑、阿姆斯特丹、波士顿、布拉格、莫斯科、新西伯利亚)招聘员工,您也可以在世界任何地方远程工作。很高兴听到你的消息!