Erfahren Sie mehr über die schwarze Technologie hinter Kotlins schneller Zusammenstellung~

Ursprünglicher Link: The Dark Secrets of Fast Compilation für Kotlin

Vorwort

Das schnelle Kompilieren großer Codemengen war schon immer eine Herausforderung, insbesondere wenn der Compiler viele komplexe Operationen ausführen muss, z. B. die Auflösung von überladenen Methoden und generische Typrückschlüsse. KotlinDieser Artikel stellt hauptsächlich vor, wie der Compiler die Kompilierung beschleunigt, wenn er einige kleine Änderungen in der täglichen Entwicklung vornimmt.

Warum ist das Kompilieren so zeitaufwändig?

Generell gibt es drei Gründe für lange Übersetzungszeiten:

  1. Codebasisgröße: Je größer die Codegröße, desto länger die Kompilierungszeit
  2. Inwieweit ist Ihre Toolchain optimiert, dazu gehören der Compiler selbst und alle von Ihnen verwendeten Build-Tools.
  3. Wie schlau ist Ihr Compiler: Ob er viele Dinge herausfindet, ohne den Benutzer zu stören, oder ob er ständig Hinweise und Boilerplate-Code benötigt

Die ersten beiden Faktoren liegen auf der Hand, lassen Sie uns über den dritten Faktor sprechen: die Intelligenz des Compilers. Dies ist oft ein komplexer Kompromiss, und Kotlinin haben wir uns entschieden, sauberen und lesbaren typsicheren Code zu bevorzugen. Das bedeutet, dass der Compiler sehr schlau sein muss, da wir zur Kompilierzeit viel Arbeit leisten müssen.

KotlinVorgesehen für den Einsatz in industriellen Entwicklungsumgebungen, in denen Projekte langlebig und umfangreich sind und eine große Anzahl von Personen einbeziehen.

Daher wollen wir statische Typsicherheit, frühzeitige Erkennung von Fehlern und präzise Hinweise (Unterstützung für Autovervollständigung, Refactoring und Verwendung IDEvon Lookups in , präzise Codenavigation usw.).

Dann wollen wir auch sauberen und lesbaren Code ohne unnötiges Rauschen. Das bedeutet, dass wir nicht wollen, dass der Code mit Typen übersät ist. Aus diesem Grund verfügen wir über intelligente Typinferenz- und Überladungsauflösungsalgorithmen, die Funktionstypen unterstützen lambdaund erweitern. KotlinDer Compiler findet viele Dinge selbst heraus, um den Code sauber und gleichzeitig typsicher zu halten.

Kann ein Compiler gleichzeitig intelligent und effizient sein?

Damit ein intelligenter Compiler schnell läuft, müssen Sie natürlich jeden Teil der Toolchain optimieren, und daran haben wir gearbeitet. Darüber hinaus arbeiten wir an einer neuen Generation von KotlinCompilern , die viel schneller laufen werden als aktuelle Compiler, aber darum geht es in diesem Artikel nicht.

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

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

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

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

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

编译避免

编译避免的核心思想是:

  • 查找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 编译器如此智能似乎是用户最喜欢的特性,因为它们同时提供了强大的抽象、可读性和简洁的代码。

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

Einer der Gründe ist die Erfahrung mit der Verwendung der JavaProgrammiersprache , die sich IntelliJ IDEAder inkrementellen Kompilierungsfähigkeiten von erfreut und sogar einen kotlincviel schnelleren .

Ein weiterer Grund ist, dass wir uns bemühen, der Entwicklungserfahrung von interpretierten Sprachen, die Änderungen sofort ohne Kompilierung sofort aufnehmen, so nahe wie möglich zu kommen.

KotlinDie schnelle Kompilierungsstrategie lautet also: Compiler optimieren + Toolchain optimieren + komplexes Inkrement .

Zusammenfassung des Übersetzers

Dieser Artikel stellt hauptsächlich Kotlineinige der Arbeiten vor, die Compiler leisten, um die Kompilierung zu beschleunigen, stellt den Unterschied zwischen Kompilierungsvermeidung und inkrementeller Kompilierung vor und was es ist ABI.

Das Verständnis Kotlindes Prinzips der inkrementellen Kompilierung kann uns dabei helfen, die Wahrscheinlichkeit einer erfolgreichen inkrementellen Kompilierung zu verbessern.Zum Beispiel ist der inlineFunktionskörper auch ABIein Teil, also sollte, wenn wir eine Inline-Funktion deklarieren, der Inline-Funktionskörper so einfach wie möglich und normalerweise geschrieben werden Sie müssen nur eine andere aufrufen. Eine Nicht-Inline-Funktion ist in Ordnung.

Wenn sich die inlineinterne Logik der Funktion ändert, müssen auf diese Weise die davon abhängigen Dateien nicht neu kompiliert werden, wodurch eine inkrementelle Kompilierung ermöglicht wird.

Gleichzeitig Kotlinscheitert die inkrementelle Kompilierung aus dem eigentlichen Entwicklungsprozess immer noch oft, insbesondere wenn modulübergreifende Änderungen auftreten. KotlinEine neue Generation von Compilern wurde veröffentlicht Alpha, die sich auf eine bessere Leistung freut~

Ich nehme an der Rekrutierung des Signierprogramms für Ersteller der Nuggets Technology Community teil. Klicken Sie auf den Link, um sich zu registrieren und einzureichen .

Ich denke du magst

Origin juejin.im/post/7118908219841314823
Empfohlen
Rangfolge