在Buck2中启用Kotlin增量编译
Kotlin增量编译器自引入构建工具以来,一直是追求更快编译速度的开发者的宝贵工具。现在,我们很高兴将其优势带给Buck2——Meta的构建系统——为Kotlin开发者解锁更快的速度和更高的效率。
与传统编译器每次重新编译整个模块不同,增量编译器只关注发生变更的部分。这大大减少了编译时间,特别是在模块包含大量源文件时。
背景与动机
Buck2提倡小模块作为实现快速构建的关键策略。我们的代码库长期遵循这一原则,并且运行良好。当每个模块只有少量文件,且Buck2支持快速增量构建和并行执行时,增量编译似乎并不是我们需要的。
但现实是:代码库在增长,团队在变化,现实有时会偏离最初计划。随着时间的推移,一些模块开始变得更大——无论是由于遗留问题还是自然增长。虽然大模块仍然是例外,但它们开始对构建时间产生相当大影响。
因此我们更仔细地研究了Kotlin增量编译器——我们很高兴这样做了。结果如何?一些关键模块现在构建速度提高了3倍。这对开发者的生产力和整体构建体验来说是一个重大胜利。
实施步骤
步骤1:集成Kotlin构建工具API
截至Kotlin 2.2.0,使用编译器的唯一保证公共合约是通过命令行界面(CLI)。但由于CLI不支持增量编译(至少目前如此),它无法满足我们的需求。或者,我们可以通过内部编译器组件直接集成Kotlin增量编译器——这些API在技术上可访问,但不打算供公共使用。然而,依赖它们会使我们的工具链变得脆弱,并且可能随着每个Kotlin更新而破坏,因为没有向后兼容性保证。
然后我们发现了构建工具API(KEEP),在Kotlin 1.9.20中作为编译器的官方集成点引入——包括对增量编译的支持。尽管该API仍被标记为实验性,但我们决定尝试一下。我们知道它最终会稳定下来,并认为这是一个早期参与、提供反馈和帮助塑造其方向的绝佳机会。
依赖kotlin-compiler?注意!
在Java世界中,shaded库是修改后的库版本,其中类和包名称被更改。这个过程——称为shading——是避免类路径冲突、防止库之间版本冲突以及防止内部细节泄漏的便捷方法。
这里有一个简单示例:
|
|
构建工具API依赖于Kotlin编译器的shaded版本(kotlin-compiler-embeddable)。但我们的Android工具链历史上是使用未shaded版本(kotlin-compiler)构建的。这种不匹配在测试集成时导致java.lang.NoClassDefFoundError崩溃,因为shaded类根本不在类路径上。
在整个Android工具链中替换未shaded编译器将是一项巨大的工作。因此为了继续前进,我们采用了一个快速解决方案:我们反而对构建工具API进行了unshaded处理。使用jarjar库,我们从类名中剥离了org.jetbrains.kotlin前缀并重新构建了库。
一旦我们有了可工作的原型并确认一切按预期行为,我们回过头来正确地做了——将我们的工具链完全迁移到使用shaded Kotlin编译器。这使我们回到了API的期望状态,并为未来提供了更稳定的设置。
步骤2:为增量编译器保留先前输出
要进行增量编译,Kotlin编译器需要访问先前构建的输出。这很简单,但Buck2默认在重建模块之前删除该输出。
使用增量操作,您可以配置Buck2跳过自动清理先前输出。这使您的构建操作可以访问上次运行的所有内容。代价是现在需要您自己弄清楚哪些仍然有用,并手动清理其余部分。虽然需要更多工作,但这正是我们实现增量编译所需要的。
步骤3:使增量编译器缓存可重定位
起初,这可能看起来不是什么大问题。您不打算移动代码库,所以为什么要担心使缓存可重定位,对吧?
嗯……直到您意识到您不再是一个小团队,而且您绝对不是唯一构建项目的人。突然之间,这确实很重要。
Buck2支持分布式构建,这意味着您的构建不必仅在本地机器上运行。它们可以在其他地方执行,结果发送回给您。如果您的编译器缓存不可重定位,这种设置很快就会导致麻烦——从冲突的重载到缓存数据中路径不匹配导致的奇怪歧义错误。
因此我们确保在增量编译设置中显式配置根项目目录和构建目录。这使编译器缓存保持稳定可靠,无论谁运行构建或在哪里运行。
步骤4:配置增量编译器
简而言之,为了决定需要重新编译什么,Kotlin增量编译器在两个地方查找变更:
- 正在重建的模块内的文件
- 模块的依赖项
一旦发现变更,编译器会找出模块中哪些文件受到影响——无论是通过直接编辑还是通过更新的依赖项——并且只重新编译那些文件。
为了让这个过程启动,编译器只需要一点推动来了解它真正需要做多少工作。
跟踪模块内的变更
在跟踪变更方面,您有两个选择:您可以让编译器施展魔法自动检测变更,或者您可以通过自己传递修改文件列表来帮助它。如果您不知道哪些文件已更改,或者您只是想快速使某些东西工作(就像我们在原型设计期间所做的那样),第一个选项很好。但是,如果您使用的是早于2.1.20的Kotlin版本,您必须自己提供此信息。通过构建工具API的自动源变更检测在该版本之前不可用。即使使用较新版本,如果构建工具在编译之前已经拥有变更列表,仍然值得使用它来优化过程。
这就是Buck的增量操作再次派上用场的地方!我们不仅可以保留上次运行的输出,还可以获得每个操作输入的哈希摘要。通过将这些哈希与上次构建的哈希进行比较,我们可以生成已更改文件的列表。从那里,我们将该列表传递给编译器以立即启动增量编译——不需要编译器自己进行任何变更检测。
跟踪依赖项中的变更
有时不是模块本身发生变化,而是模块依赖的某些东西发生了变化。在这些情况下,编译器依赖类路径快照。这些快照捕获库的应用程序二进制接口(ABI)。通过将当前快照与先前的快照进行比较,编译器可以检测依赖项中的变更,并找出模块中哪些文件受到影响。这在标准编译避免之上增加了额外的过滤层。
在Buck2中,我们添加了一个专用操作来从库输出生成类路径快照。然后将此工件作为输入传递给消费模块,与库的编译输出一起。最好的部分?由于它是一个单独的操作,它可以在远程运行或从缓存中提取,因此您的机器不必在此步骤承担提取ABI的重负。
如果毕竟只有您的模块更改而您的依赖项没有更改,如果您的构建工具自己处理依赖项分析,API还允许您完全跳过快照比较。由于我们已经从Buck2的增量操作中获得了必要的数据,添加此优化几乎是免费的。
步骤5:使编译器插件与增量编译器配合工作
集成增量编译器时我们面临的最大挑战之一是使其与我们的自定义编译器插件良好配合,其中许多对我们的构建优化策略很重要。此步骤对于解锁增量编译的全部性能优势是必要的,但它带来了两个我们需要解决的主要问题。
问题1:不完整的结果
正如我们已经知道的,增量编译器的输入不必包括所有Kotlin源文件。我们的插件不是为此设计的,最终在仅运行于文件子集时产生不完整的结果。我们必须使它们也变成增量式,以便它们能够正确处理部分输入。
问题2:多轮编译
Kotlin增量编译器不仅重新编译模块中已更改的文件。它可能还需要重新编译同一模块中受这些更改影响的其他文件。找出受影响文件的确切集合很棘手,特别是当涉及循环依赖时。为了处理这个问题,增量编译器通过在单个构建中进行多轮编译来近似受影响的集合。
这个行为带来一个副作用。由于编译器可能以不同的文件集在多轮中运行,编译器插件也可能被多次触发,每次使用不同的输入。这可能是有问题的,因为后来的插件运行可能会覆盖先前运行产生的输出。为了避免这种情况,我们更新了我们的插件以跨轮次累积它们的结果,而不是替换它们。
步骤6:验证注解处理器的功能
我们的大多数注解处理器使用Kotlin符号处理(KSP2),这使得这一步相当顺利。KSP2被设计为一个独立工具,使用Kotlin分析API来分析源代码。与编译器插件不同,它独立于标准编译流程运行。由于这种设置,我们能够继续使用KSP2而无需任何更改。
额外好处:KSP2带有自己的内置增量处理支持。它完全自包含,根本不依赖增量编译器。
在我们采用KSP2之前(或者当我们使用旧版本的Kotlin注解处理工具(KAPT)时,它作为插件运行),我们的注解处理器在专门用于注解处理的单独步骤中运行。该步骤在主编译之前运行,并且始终是非增量的。
步骤7:启用针对ABI的编译
为了最大化缓存命中率,Buck2针对类ABI而不是完整JAR构建Android模块。对于Kotlin目标,我们使用jvm-abi-gen编译器插件在编译期间生成类ABI。
但一旦我们开启增量编译,出现了一些新的挑战:
- jvm-abi-gen插件目前缺乏对增量编译的直接支持,这回到了我们前面提到的编译器插件问题。
- ABI提取现在发生两次——一次在编译期间通过jvm-abi-gen,另一次在增量编译器创建类路径快照时。
理论上,这两个问题都可以通过切换到完整JAR编译并依赖类路径快照来维护缓存命中率来解决。虽然这在原则上可行,但这意味着放弃我们已经拥有的一些构建优化——在做出任何更改之前需要仔细评估的权衡。
目前,我们实现了一个自定义(但次优)的解决方案,将新生成的ABI与先前结果合并。它可以完成工作,但我们仍在积极探索更好的长期替代方案。
理想情况下,我们将能够重用已经为类路径快照收集的信息,或者更好的是,将这种支持直接内置到Kotlin编译器中。有一个开放票证:KT-62881。祝好运!
步骤8:测试
衡量构建更改的影响并非易事。基准测试对于了解功能潜力很好,但它并不总是反映"现实世界"中的表现。前后测试可以帮助解决这个问题,但很难隔离单个更改的影响,特别是当您不是唯一推送代码的人时。
我们设置了A/B测试来克服这些障碍,并高置信度地衡量Kotlin增量编译器对Meta代码库的真正影响。跨变体保持缓存健康需要一些额外的工作,但它给了我们一个清晰、隔离的视图,了解增量编译器在大规模上真正产生了多大差异。
我们从最大的模块开始——那些我们已经知道最拖慢构建的模块。考虑到它们的大小和已知影响,我们期望快速看到好处。果然,我们做到了。
增量编译的影响
下图显示了在4周期间为选定目标启用增量编译如何影响其增量构建期间本地构建时间的早期结果。这不仅包括编译,还包括注解处理,以及我们沿途添加的一些其他优化。
使用增量编译,我们看到平均开发者的效率提高了约30%。对于没有注解处理的模块,速度几乎翻倍。这足以让我们相信增量编译器将长期存在。
下一步计划
Kotlin增量编译现在在Buck2中得到支持,我们正在积极将其推广到我们的代码库中!目前,它仅可供内部使用,但我们正在努力将其引入最近开源的工具链中。
但这还不是全部!我们还在探索在整个Android工具链中扩展增量性的方法,包括Kosabi(Kotlin对应Jasabi的工具),以提供更快的构建时间和更好的开发者体验。