Kotlin 云端差分缓存技术

本文由字节跳动 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 的形式来存储我们的缓存包。

e67917671e26352924f8409cb27ae3cb.png

四、方案详解

客户端方案

客户端的核心逻辑:对 kotlin 编译流程进行 hook,构造/加载缓存包,将全量编译转换为增量编译,转换的关键在于增量编译与全量编译的差异部分:

  1. 增量编译环境中有上次编译好的编译产物、增量追踪文件(符号引用关系,abi 信息等)。

  2. 增量编译相比于上次编译有哪些输入文件产生了修改的文件 diff 信息。

226f61615e8f2dd4045bcf25b6136e04.jpeg

缓存包内容

为了能够在全量编译时顺利还原出一个正确的增量编译现场,要求加载的缓存包中应该包含的内容有:

  1. 编译产物 (减少不必要的源文件重新编译耗时)

  2. metadata 包括 kt 、java、layout 文件等 源文件信息,包括这些文件在项目中的相对路径以及 hash 值,在还原编译现场时,根据这些内容还原为源文件的 diff 信息 ( add, remove ,modify)

  3. 编译器信息:编译器参数,编译器 JAR 包的 hash 信息等。

  4. 增量中间产物:涉及到一系列追踪文件,这些文件包含了kotlin 编译器在增量编译时 用来计算 dirty file (需要重新编译的源文件)的关键信息,包括符号引用关系、abi 信息、源文件与编译产物的对应关系等。

4ff465b416641c0ea157435a75ed6dd4.png
缓存匹配

如前文中的『核心原理』章节所描述,我们采取以下方案来进行缓存的匹配。

  • 先根据一些精准参数匹配一组可能符合要求的缓存

  • 然后从这一组缓存中寻找与当前场景最接近的缓存包来进行使用

其中以下参数会参与精确参数(unique_hash)的计算

  • Kotlin 版本

  • Kotlin Compiler Plugin (KCP)

  • Kotlin 编译器参数

我们通过将这些参数组合到一起组成一个 hash 值:unique_hash。

在之后的流程中,会使用 unique_hash 来筛选出一组缓存。我们再通过服务端的策略来筛选出最佳的缓存。

596f6942b42c425bc88a24a7b45e5589.png
编译流程 hook

拿到了缓存包后,我们就可以进行缓存包的复用了。但是我们在上一个流程中匹配到的仅仅是最优缓存,但是这个缓存和我们当前的包还存在 diff,比如代码、模块依赖信息 等的差异。

我们需要计算出拉到的缓存包和当前编译的 diff,然后将 diff 传给 kotlin 编译器,并将全量编译转成增量编译。

为了能够实现增量编译的转换,需要在编译流程的三个阶段进行 hook

  1. 编译前执行前解析模块依赖信息。

  2. 在提交编译参数到编译器执行前,匹配、下载、校验、加载缓存包,将全量编译参数转换为增量编译参数。

  3. 编译完成后构造并上传缓存包。

5d23093f28c134ec8bc59ce226e7bad8.png

其中,最核心的 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 步骤为:

  1. 匹配下载缓存:为了能够匹配到最接近的缓存包,实现最佳增量编译效率,需要对这些参数进行比较:

    1. 根据 metadata 中 java/kotlin/layout 文件的 hash 比较源码文件的 diff (其中包括 layout xml 是因为 KAE 会根据 layout 进行 kotlin 代码插桩)

    2. 利用 kotlin 1.7+ 的 classpath-snapshot.bin 计算依赖模块依赖信息的 abi diff

  2. 校验加载缓存: 基于缓存包中的 metadata ,结合编译产物、编译中间产物等,将对应信息加载到对应位置。

  3. 构造增量编译参数: 将加载的缓存包中的相对路径转换为绝对路径,用绝对路径的 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 不是一个大小写敏感的操作系统,这意味着,在一个文件夹中,不能同时存在去掉大小写后字符一样的两个文件。比如:

074d10652492df8780339b20d6bf2eff.png

但是这个行为在 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+ , 为了减少不必要的缓存上传,采取了两个方案:

  1. 与 build-cache 进行关联,如果种子节点打包时命中 build-cache ,将 kotlin 缓存与 build-cache 进行关联,而不是重复上 kotlin 缓存

  2. 计算缓存包内容 hash ,如果存在相同的缓存包,将他们关联到一起,而不是重复上传缓存。

服务端策略

区别于传统的 Build Cache 服务端的简单 K-V 存储,我们的服务端需要帮助客户端去存储产物、匹配产物、选择最佳产物。客户端和服务端的通信时序图如下:

6208cdee321509d8399e60e7669a7108.png

其中,服务端最核心的逻辑在于匹配最佳产物

每一个 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
}

每个产物和当前编译都会产生一个 diffdiff 里有 addedremovedmodified

我们定义一个函数 diffScoreremove 记三分,modified 记两分,added 记一分。

之所以按照这样的系数,是因为 removed 大概率会引起很多底层依赖的重新编译,所以我们需要尽可能地减少 removed 的代码,added 的代码一般不会引起其它代码重新编译,所以我们可以记为一分。modified 介于两者之间,记两分。

那么,我们的核心的逻辑就变成了:从一组缓存里,找到 diffScore 最少的产物

但是因为我们的 unique hash 对应的 hash 生成并不复杂,一般来说,除非是升级 kotlin 版本,其它情况这个 unique hash 基本不会变,所以这个缓存的量可能会很大,我们不可能实现找到所有的缓存,并且进行 diffScore 的比较。我们需要筛选出一定范围内的缓存。

首先,我们的所有的产物生成都是在主干分支上。

那么意味着,对于 feature 分支,与其代码最接近的 commit 应该是图中的 base commit

95a9b2df1c67070483ad5c03cee08fa1.png

因为成本的原因,种子任务可能并不会在主干分支的每个节点都打缓存包,可能是定时打包。那么意味着不是所有的 merge commit 都有缓存包。

所以,我们找到这个 commit 的前后一天时间内的 commit。并和表中的所有产物进行取交集

a5ea0a36d03e651033ac3b65541e29d5.png

最后得到所有黄色的点。(黄色的点是打了缓存包的 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 研发体验方向进行技术突破。简历投递至:

lanjunjian@bytedance.com

最 Nice 的工作氛围和成长机会,福利与机遇多多,北京、上海、杭州均有职位,欢迎加入字节 Android 中台基建团队 !

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值