本文由字节跳动 Buildinfra 团队出品。
在我们的工程上线 Monorepo 全源码后,Kotlin 编译成了整个编译中最耗时的步骤,全源码过程中大量的 BuildCache Miss 导致我们的编译数据落后原来多仓二进制时代很多,且业界没有相关的解决方案。本篇文章我们来具体阐述下 BuildInfra 团队自研的解决方案 - Kotlin 云端差分方案的原理和技术实现。
在 2022-2023 年,我们的头部业务开始慢慢地从原来的多仓二进制模式,迁移到全新 Monorepo 方案。
在多仓二进制时代,由于 Maven 的加持,大部分时候我们的都不需要直接编译代码,而是复用 Maven 的『缓存』。
在工程进入 Monorepo 时代之后,我们花了大量的精力建设 BuildCache,来试图抹平 Monorepo 与二进制的构建速度差异,但遗憾的是,尽管我们已经做了很多的努力,但由于 Gradle 脆弱的 BuildCache 设计,导致很多时候改动一行代码就会全局 miss,不得不重新编译一遍所有的源码。这很不环保(
并且,90 分位的构建劣化更是飙升到不可控的地步,它直接决定了本地开发、CI 合码的体验。
在可预见的将来,随着代码的快速增长、更多子仓的合入,仅靠 BuildCache 已经完全无法支撑大型 Monorepo 工程的开发。
如果想优化 Kotlin 的全量编译,如果从计算机通用优化角度来看,不外乎以下几种方案:
更高效的实现:语言编译是一个庞大且复杂的系统,涉及前后端编译,目前针对前后端编译流程进行优化实施起来难度较大,是一个长期且艰巨的任务。但它并不和其他优化方案有冲突。
并发:如果是相互之间没有复杂依赖关系的多 Module,那么 Gradle 已经保证了模块之间的并行度;只要你的项目依赖足够 flat,那么就可以充分利用计算机并发资源,在 CI 场景会获得非常大的提升,所以从工程角度可以进行依赖的优化来提升并行度。但这也不是一个通用的方案,非常依赖于业务方的改造。
缓存:Kotlin 目前使用的是 Gradle 的缓存,众所周知,Gradle 为了保证正确性,有着一套极其严格的 Cache key 生成策略,很多「可能」不影响 kotlin 编译正确性的变化(比如编译插件的 classpath)却会让整个 BuildCache 崩盘,全部 Task 全部 Cache Miss。
在 kotlin 中,不仅是 classpath 这些变动会导致 cache miss,kotlin 的 sourceset(即源码)也会作为 cache key 的计算输入。这意味着,只要代码里有一个字符不一样,就会导致 cache miss,整个模块就需要重新编译,那么这块是否有优化空间呢?
综合来看,从缓存角度入手,可以以比较低成本来实现较大的收益。
为此,我们提出了一套创新性的方案:
通过云端模糊匹配缓存来将全量编译转化为增量编译,来减少全量编译的耗时。
我们通过改造 Kotlin Gradle Plugin 入手,当 Kotlin Task 由于各种原因不能命中 Build Cache 时,就会 fallback 到我们的『Kotlin 差分编译系统』。通过云端模糊匹配缓存来将全量编译转化为增量编译,来将本来动辄 10min+ 的全量编译转化成 10s 内的增量编译。
常规编译构建缓存方案如 Gradle Build Cache 采用的是 kv 一对一匹配。
对于 Kotlin 来说,即通过对源码文件、依赖 Jar 包,编译参数等信息算出一个缓存 key 之后,唯一匹配出一个缓存。当匹配到缓存后,缓存包的内容就是 Kotlin 的最终产物,然后就可以跳过 Kotlin Task,直接进入下一步。
由于精确匹配需要达成的条件较多,比如只要修改了一个 .kt文件,就无法匹配到缓存。
因此可以考虑实现一套模糊匹配的机制:
最接近的缓存包,也只是最接近的,是不能直接拿来用的。
那我们是不是可以将这个缓存包对应的源码与当前源码做 diff,然后将全量转增量呢?
比如,某个模块有 A.kt,B.kt,我们基于某个 Commit 打出来了一个云端缓存包。
有一天,我们新增了一个 C.kt,改动了 B.kt,这时候,我们找到之前打出来的缓存包,对他们对应的源码做一次 diff,那么我们可以得到这组变动:
[
changed: ["B.kt"],
added: ["C.kt"]
]
我们只需要将这个 Diff 输入给 kotlin 编译器,kotlin 就会自动帮我们完成耗时较低的增量编译。
基于 #1 和 #2 的两个核心原理,我们设计了一套『Kotlin 差分编译系统』,整体架构图如下,有四个重要的角色:
客户端的核心逻辑:对 kotlin 编译流程进行 hook,构造/加载缓存包,将全量编译转换为增量编译,转换的关键在于增量编译与全量编译的差异部分:
为了能够在全量编译时顺利还原出一个正确的增量编译现场,要求加载的缓存包中应该包含的内容有:
add
, remove
,modify
)dirty file
(需要重新编译的源文件)的关键信息,包括符号引用关系、abi 信息、源文件与编译产物的对应关系等。如前文中的『核心原理』章节所描述,我们采取以下方案来进行缓存的匹配。
其中以下参数会参与精确参数(unique_hash)的计算
我们通过将这些参数组合到一起组成一个 hash 值:unique_hash。
在之后的流程中,会使用 unique_hash 来筛选出一组缓存。我们再通过服务端的策略来筛选出最佳的缓存。
拿到了缓存包后,我们就可以进行缓存包的复用了。但是我们在上一个流程中匹配到的仅仅是最优缓存,但是这个缓存和我们当前的包还存在 diff,比如代码、模块依赖信息 等的差异。
我们需要计算出拉到的缓存包和当前编译的 diff,然后将 diff 传给 kotlin 编译器,并将全量编译转成增量编译。
为了能够实现增量编译的转换,需要在编译流程的三个阶段进行 hook
其中,最核心的 Hook 点在于:将全量编译转化成增量编译。
internal fun CallCompilerAsyncOpt.callCompilerAsyncOpt(
....
) {
val icEnv = IncrementalCompilationEnvironment(
changedFiles,
classpathChanges,
workingDir ,
...
)
val hookedIcEnv = hook(icEnv) val environment = GradleCompilerEnvironment(
incrementalCompilationEnvironment = hookedIcEnv,
outputFiles,...
)
optRunJvmCompilerAsync(..., source, args, environment , ... )
}
我们会 Hook KotlinCompile
中的 callCompilerAsync
方法,将原来的全量 Env 转换成增量 Env。增量的 InputChanges
来源于我们的缓存包和编译的 diff (源码、依赖的变更等)。
而这个关键的全量转增量 Hook 步骤为:
匹配下载缓存:为了能够匹配到最接近的缓存包,实现最佳增量编译效率,需要对这些参数进行比较:
classpath-snapshot.bin
计算依赖模块依赖信息的 abi diff校验加载缓存: 基于缓存包中的 metadata ,结合编译产物、编译中间产物等,将对应信息加载到对应位置。
构造增量编译参数: 将加载的缓存包中的相对路径转换为绝对路径,用绝对路径的 diff 信息等构造出可用的增量 Env , 并提交给 Kotlin Compiler
理论上来讲,如果 kotlin 编译器是能保证增量编译是完全不出错的,那么我们的方案肯定不会引入稳定性的问题。
但是这样的期待或许过高。我们的方案是基于 Kotlin 1.7.21 进行开发的,kotlin 在 1.7+ 引入了新的 kotlin 增量编译方案,还在一个相对不稳定的阶段。实际上,我们也在上线前后遇到了各种奇怪的问题。经过长时间的 debug 和翻阅源码后得到了解决,目前在抖音项目上已经趋于稳定。
问题表现为:OSX 复用 ci 种子节点打包的缓存时,对于部分删除的源代码文件,并没有将其对应的编译产物删除
通过一系列的排查发现,该问题是 Kotlin Compiler 自身的 bug ,且在高版本进行了修复,bug 来源于 kotlin 的增量编译中间产物中,用于追踪源码文件与编译产物关系的信息异常,在涉及到大小写敏感不一致的系统时,无法正常删除编译产物。
解决方案: 对高版本的修复 commit 进行了 cherry-pick
kotlin 修复 https://github.com/JetBrains/kotlin/commit/be71d8841ebc22c79bb6b4bc6f3ad93c147ba9c0
由于 OSX 不是一个大小写敏感的操作系统,这意味着,在一个文件夹中,不能同时存在去掉大小写后字符一样的两个文件。比如:
但是这个行为在 Linux 系统上是允许的。
我们的遇到的问题是,项目中不同文件夹中有两个包名叫 com.xxx.legoImp
,一个叫com.xxx.legoimp
。这两个包名会在 Linux 种子节点上进行 Jar 包压缩,在 mac 上进行解压 ,由于 Linux 不是 Case Sensitive 的系统,所以 jar 包中,他俩会同时存在,但是在 mac 上解压时,mac 会自动合并了这两个包,并且转换成小写。这样就会在运行时崩溃,找不到这个包名。
解决方案:我们提供了一个类似的包名大小写检测,帮助业务提前暴露问题,并进行代码的整改。
在最初的缓存包压缩中,采用的是 Java ZipFile 中默认的压缩算法,上线后发现有较多模块的缓存解压时间较长,后续参考了Gradle build-cache
方案,将压缩算法修改为 lz4
,大大降低了解压缩的时间。
在本方案中,缓存远端存储在我们的分布式缓存服务中,由于分布式缓存空间有限(2TB),超过缓存空间时会根据 LRU 淘汰最近未使用的缓存,这样就会导致缓存命中率下降,而由于在抖音项目中,生产缓存的种子节点会在每个 MR 合入后进行缓存打包,每次全量打包缓存时,缓存包约1GB+ , 为了减少不必要的缓存上传,采取了两个方案:
build-cache
进行关联,而不是重复上 kotlin 缓存区别于传统的 Build Cache 服务端的简单 K-V 存储,我们的服务端需要帮助客户端去存储产物、匹配产物、选择最佳产物。客户端和服务端的通信时序图如下:
其中,服务端最核心的逻辑在于匹配最佳产物。
每一个 unique hash 都会对应一组产物,我们要从这一组产物里找到一个最优解。
最优解的定义是:这个产物离我们当前的编译较为接近。我们为这个『接近』,定义了一个记分制度。
data class Diff(val added: List<String>,
val removed: List<String>,
val modified: List<String>)fun diffScore(): Int {
return this.diff.removed.size * 3 +
this.diff.modified.size * 2 +
this.diff.added.size
}
每个产物和当前编译都会产生一个 diff
,diff
里有 added
、removed
、modified
。
我们定义一个函数 diffScore
,remove
记三分,modified
记两分,added
记一分。
之所以按照这样的系数,是因为 removed
大概率会引起很多底层依赖的重新编译,所以我们需要尽可能地减少 removed
的代码,added
的代码一般不会引起其它代码重新编译,所以我们可以记为一分。modified
介于两者之间,记两分。
那么,我们的核心的逻辑就变成了:从一组缓存里,找到 diffScore 最少的产物。
但是因为我们的 unique hash 对应的 hash 生成并不复杂,一般来说,除非是升级 kotlin 版本,其它情况这个 unique hash 基本不会变,所以这个缓存的量可能会很大,我们不可能实现找到所有的缓存,并且进行 diffScore 的比较。我们需要筛选出一定范围内的缓存。
首先,我们的所有的产物生成都是在主干分支上。
那么意味着,对于 feature
分支,与其代码最接近的 commit
应该是图中的 base commit
。
因为成本的原因,种子任务可能并不会在主干分支的每个节点都打缓存包,可能是定时打包。那么意味着不是所有的 merge commit 都有缓存包。
所以,我们找到这个 commit 的前后一天时间内的 commit。并和表中的所有产物进行取交集。
最后得到所有黄色的点。(黄色的点是打了缓存包的 merge 节点)
我们将所有黄色的点对应的 MetaData
与当前 commit 的 MetaData
进行 diff。
取一个 diffScore
最少的节点,作为最终的产物。
val bestArtifact = artifacts.minOf {
it.diffScore()
}
这样我们就可以认为它是和当前 commit 最接近的 commit。对应的产物对于客户端来说,转增量编译的风险最小、速度最快。
目前项目上线了大部分的字节 Monorepo 项目,稳定运行了半年有余。期间也修复了 kotlin 1.7 上的若干官方增量编译相关的 bug,最后取得了较为理想的收益。
抖音从二进制演进到 Mopnorepo 后,Kotlin 部分的编译时间劣化超过 10m+,我们通过本文的方案,将劣化的部分基本抹平,90 分位下降 60%,大幅提升了开发的体验和 CI 合码效率。
本文主要阐述了 Kotlin 云端差分方案的技术原理。
实现超大型工程 Monorepo 全源码的研发模式,仅靠 BuildCache 是无法实现的,Kotlin 云端差分方案对于全源码起着不可或缺的作用。
通过模糊匹配缓存的方式将全量编译模拟成增量编译的思想,可能在其他端上比较容易实现或者就是标准方案,但目前 Android CI 构建领域均采用 clean 编译,无法复用构建中间产物。该方案的出现也有望实现思想的复用,运用到所有『类似』的任务中,比如 Java Compile、Transform、Dex 等,只要它本身是支持增量、编译幂等特性的,都可以复用这套方案,从而进一步提升 Android 的构建效率。
在研究云端差分方案期间,我们积累了大量的 Kotlin 编译器相关的知识,为我们平时排查 kotlin 疑难问题提供了非常多的技术储备,也发现了很多 kotlin 官方的 bug 和设计上的缺陷,我们也会在将来修复后回馈 kotlin 社区。
欢迎对编译构建、Kotlin相关技术感兴趣的小伙伴找我们进行技术交流与讨论!
Build Infra Android 团队专注于工程架构、全流程研发体验的优化与提升。如果你对工程架构、编译优化、IDE 体验优化、CI/CD、静态代码分析等技术感兴趣,欢迎与我们一起在 Android 研发体验方向进行技术突破。简历投递至:
最 Nice 的工作氛围和成长机会,福利与机遇多多,北京、上海、杭州均有职位,欢迎加入字节 Android 中台基建团队 !