La tecnología negra detrás de la compilación rápida de Kotlin, infórmate ~

Enlace original: Los oscuros secretos de la compilación rápida para Kotlin

prefacio

La compilación rápida de grandes cantidades de código siempre ha sido un desafío, especialmente cuando el compilador tiene que realizar muchas operaciones complejas, como la resolución de métodos sobrecargados y la inferencia de tipos genéricos. KotlinEste artículo presenta principalmente cómo el compilador acelera la compilación al realizar algunos pequeños cambios en el desarrollo diario.

¿Por qué la compilación consume tanto tiempo?

En general, hay tres razones para los largos tiempos de compilación:

  1. Tamaño base del código: por lo general, cuanto mayor sea el tamaño del código, mayor será el tiempo de compilación
  2. Cuánto está optimizada su cadena de herramientas, esto incluye el propio compilador y cualquier herramienta de compilación que esté utilizando.
  3. Qué tan inteligente es su compilador: ya sea que averigüe muchas cosas sin molestar al usuario o que necesite sugerencias constantes y código repetitivo

Los dos primeros factores son obvios, hablemos del tercer factor: la inteligencia del compilador. Esto suele ser una solución de compromiso compleja, y Kotlinen , decidimos favorecer un código limpio y legible con seguridad de tipos. Esto significa que el compilador tiene que ser muy inteligente porque tenemos que hacer mucho trabajo en tiempo de compilación.

KotlinDiseñado para su uso en entornos de desarrollo industrial donde los proyectos son de larga duración, a gran escala e involucran a un gran número de personas.

Por lo tanto, queremos seguridad de tipo estático, detección temprana de errores y sugerencias precisas (soporte para autocompletar, refactorización y uso IDEde búsquedas en , navegación de código precisa, etc.).

Entonces, también queremos código limpio y legible sin ruido innecesario. Esto significa que no queremos que el código esté plagado de tipos. Es por eso que tenemos algoritmos de resolución de sobrecarga e inferencia de tipos inteligentes que admiten lambday amplían los tipos de funciones. KotlinEl compilador resuelve muchas cosas por sí solo para mantener el código limpio y seguro al mismo tiempo.

¿Puede un compilador ser inteligente y eficiente al mismo tiempo?

Para hacer que un compilador inteligente funcione rápido, por supuesto que necesita optimizar cada parte de la cadena de herramientas, y eso es algo en lo que hemos estado trabajando. Además de eso, estamos trabajando en una nueva generación de Kotlincompiladores que se ejecutarán mucho más rápido que los compiladores actuales, pero este artículo no se trata de eso.

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

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

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

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

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

编译避免

编译避免的核心思想是:

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

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

Una de las razones es la experiencia de usar Javael lenguaje de programación, que disfruta IntelliJ IDEAde las capacidades de compilación incremental de , e incluso tiene un compilador kotlincmucho más rápido .

Otra razón es que nuestro objetivo es acercarnos lo más posible a la experiencia de desarrollo de lenguajes interpretados que detectan los cambios inmediatamente sin ninguna compilación.

Entonces, Kotlinla estrategia de compilación rápida es: compilador optimizador + cadena de herramientas optimizada + incremento complejo.

resumen del traductor

Este artículo presenta principalmente Kotlinparte del trabajo que realizan los compiladores para acelerar la compilación, presenta la diferencia entre evitar la compilación y la compilación incremental, y qué es ABI.

Comprender Kotlinel principio de la compilación incremental puede ayudarnos a mejorar la probabilidad de una compilación incremental exitosa. Por ejemplo, el inlinecuerpo de la función también es ABIuna parte, por lo que cuando declaramos una función en línea, el cuerpo de la función en línea debe escribirse de la manera más simple posible y, por lo general, solo necesita llamar a otra función no en línea está bien.

De esta forma, cuando inlinecambia la lógica interna de la función, no es necesario volver a compilar aquellos archivos que dependen de ella, lo que permite la compilación incremental.

Al mismo tiempo, a partir del proceso de desarrollo real, Kotlinla compilación incremental todavía suele fallar, especialmente cuando se producen cambios entre módulos. KotlinSe ha lanzado una nueva generación de compiladores Alpha, esperando un mejor rendimiento ~

Estoy participando en el reclutamiento del programa de firma de creadores de la Comunidad Tecnológica de Nuggets, haga clic en el enlace para registrarse y enviar .

Supongo que te gusta

Origin juejin.im/post/7118908219841314823
Recomendado
Clasificación