原文链接: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版本,期待会有更好的体现~

我正在参与技术社区创作者签约方案招募活动,点击链接报名投稿。