本文由字节跳动 Buildinfra 团队出品。
在我们的工程上线 Monorepo 全源码后,Kotlin 编译成了整个编译中最耗时的步骤,全源码过程中大量的 BuildCache Miss 导致我们的编译数据落后原来多仓二进制时代很多,且业界没有相关的解决方案。本篇文章我们来具体阐述下 BuildInfra 团队自研的解决方案 - Kotlin 云端差分方案的原理和技术实现。
一、Monorepo 中的噩梦
在 2022-2023 年,我们的头部业务开始慢慢地从原来的多仓二进制模式,迁移到全新 Monorepo 方案。
在多仓二进制时代,由于 Maven 的加持,大部分时候我们的都不需要直接编译代码,而是复用 Maven 的『缓存』。
在工程进入 Monorepo 时代之后,我们花了大量的精力建设 BuildCache,来试图抹平 Monorepo 与二进制的构建速度差异,但遗憾的是,尽管我们已经做了很多的努力,但由于 Gradle 脆弱的 BuildCache 设计,导致很多时候改动一行代码就会全局 miss,不得不重新编译一遍所有的源码。这很不环保(
- 本地编译:首次编译/更新代码/切分支等场景,非常容易触发整个工程的重新编译,动辄 20min+。
- CI 编译:从原来多仓二进制时代的 25min 劣化到了 40min+
并且,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 差分编译系统』,整体架构图如下,有四个重要的角色:
- 客户端:Hook Kotlin Compiler,负责将全量编译转为增量编译。
- 服务端:基于客户端的请求匹配最佳缓存。
- 种子节点:基于 develop 分支,定时打包,上传缓存。
- 分布式缓存:我们内部使用的缓存系统,主要通过 K-V 的形式来存储我们的缓存包。
图片
四、方案详解
客户端方案
客户端的核心逻辑:对 kotlin 编译流程进行 hook,构造/加载缓存包,将全量编译转换为增量编译,转换的关键在于增量编译与全量编译的差异部分:
- 增量编译环境中有上次编译好的编译产物、增量追踪文件(符号引用关系,abi 信息等)。
- 增量编译相比于上次编译有哪些输入文件产生了修改的文件 diff 信息。
图片
缓存包内容
为了能够在全量编译时顺利还原出一个正确的增量编译现场,要求加载的缓存包中应该包含的内容有:
- 编译产物 (减少不必要的源文件重新编译耗时)
- metadata 包括 kt 、java、layout 文件等 源文件信息,包括这些文件在项目中的相对路径以及 hash 值,在还原编译现场时,根据这些内容还原为源文件的 diff 信息 (
add
,remove
,modify
) - 编译器信息:编译器参数,编译器 JAR 包的 hash 信息等。
- 增量中间产物:涉及到一系列追踪文件,这些文件包含了kotlin 编译器在增量编译时 用来计算
dirty file
(需要重新编译的源文件)的关键信息,包括符号引用关系、abi 信息、源文件与编译产物的对应关系等。
图片
缓存匹配
如前文中的『核心原理』章节所描述,我们采取以下方案来进行缓存的匹配。
- 先根据一些精准参数匹配一组可能符合要求的缓存
- 然后从这一组缓存中寻找与当前场景最接近的缓存包来进行使用
其中以下参数会参与精确参数(unique_hash)的计算
- Kotlin 版本
- Kotlin Compiler Plugin (KCP)
- Kotlin 编译器参数
我们通过将这些参数组合到一起组成一个 hash 值:unique_hash。
在之后的流程中,会使用 unique_hash 来筛选出一组缓存。我们再通过服务端的策略来筛选出最佳的缓存。
图片
编译流程 hook
拿到了缓存包后,我们就可以进行缓存包的复用了。但是我们在上一个流程中匹配到的仅仅是最优缓存,但是这个缓存和我们当前的包还存在 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 步骤为:
- 匹配下载缓存:为了能够匹配到最接近的缓存包,实现最佳增量编译效率,需要对这些参数进行比较:
- 根据 metadata 中 java/kotlin/layout 文件的 hash 比较源码文件的 diff (其中包括 layout xml 是因为 KAE 会根据 layout 进行 kotlin 代码插桩)
- 利用 kotlin 1.7+ 的
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 进行关联,如果种子节点打包时命中 build-cache ,将 kotlin 缓存与
build-cache
进行关联,而不是重复上 kotlin 缓存 - 计算缓存包内容 hash ,如果存在相同的缓存包,将他们关联到一起,而不是重复上传缓存。
服务端策略
区别于传统的 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相关技术感兴趣的小伙伴找我们进行技术交流与讨论!