偷偷摘套内射激情视频,久久精品99国产国产精,中文字幕无线乱码人妻,中文在线中文a,性爽19p

Kotlin 云端差分緩存技術(shù)

移動開發(fā)
本篇文章我們來具體闡述下 BuildInfra 團隊自研的解決方案 - Kotlin 云端差分方案的原理和技術(shù)實現(xiàn)。

本文由字節(jié)跳動 Buildinfra 團隊出品。

在我們的工程上線 Monorepo 全源碼后,Kotlin 編譯成了整個編譯中最耗時的步驟,全源碼過程中大量的 BuildCache Miss 導致我們的編譯數(shù)據(jù)落后原來多倉二進制時代很多,且業(yè)界沒有相關(guān)的解決方案。本篇文章我們來具體闡述下 BuildInfra 團隊自研的解決方案 - Kotlin 云端差分方案的原理和技術(shù)實現(xiàn)。

一、Monorepo 中的噩夢

在 2022-2023 年,我們的頭部業(yè)務開始慢慢地從原來的多倉二進制模式,遷移到全新 Monorepo 方案。

在多倉二進制時代,由于 Maven 的加持,大部分時候我們的都不需要直接編譯代碼,而是復用 Maven 的『緩存』。

在工程進入 Monorepo 時代之后,我們花了大量的精力建設 BuildCache,來試圖抹平 Monorepo 與二進制的構(gòu)建速度差異,但遺憾的是,盡管我們已經(jīng)做了很多的努力,但由于 Gradle 脆弱的 BuildCache 設計,導致很多時候改動一行代碼就會全局 miss,不得不重新編譯一遍所有的源碼。這很不環(huán)保(

  • 本地編譯:首次編譯/更新代碼/切分支等場景,非常容易觸發(fā)整個工程的重新編譯,動輒 20min+。
  • CI 編譯:從原來多倉二進制時代的 25min 劣化到了 40min+

并且,90 分位的構(gòu)建劣化更是飆升到不可控的地步,它直接決定了本地開發(fā)、CI 合碼的體驗。

在可預見的將來,隨著代碼的快速增長、更多子倉的合入,僅靠 BuildCache 已經(jīng)完全無法支撐大型 Monorepo 工程的開發(fā)。

二、分析

如果想優(yōu)化 Kotlin 的全量編譯,如果從計算機通用優(yōu)化角度來看,不外乎以下幾種方案:

  • 更高效的實現(xiàn)
  • 并發(fā)
  • 緩存

更高效的實現(xiàn):語言編譯是一個龐大且復雜的系統(tǒng),涉及前后端編譯,目前針對前后端編譯流程進行優(yōu)化實施起來難度較大,是一個長期且艱巨的任務。但它并不和其他優(yōu)化方案有沖突。

并發(fā):如果是相互之間沒有復雜依賴關(guān)系的多 Module,那么 Gradle 已經(jīng)保證了模塊之間的并行度;只要你的項目依賴足夠 flat,那么就可以充分利用計算機并發(fā)資源,在 CI 場景會獲得非常大的提升,所以從工程角度可以進行依賴的優(yōu)化來提升并行度。但這也不是一個通用的方案,非常依賴于業(yè)務方的改造。

緩存:Kotlin 目前使用的是 Gradle 的緩存,眾所周知,Gradle 為了保證正確性,有著一套極其嚴格的 Cache key 生成策略,很多「可能」不影響 kotlin 編譯正確性的變化(比如編譯插件的 classpath)卻會讓整個 BuildCache 崩盤,全部 Task 全部 Cache Miss。

在 kotlin 中,不僅是 classpath 這些變動會導致 cache miss,kotlin 的 sourceset(即源碼)也會作為 cache key 的計算輸入。這意味著,只要代碼里有一個字符不一樣,就會導致 cache miss,整個模塊就需要重新編譯,那么這塊是否有優(yōu)化空間呢?

綜合來看,從緩存角度入手,可以以比較低成本來實現(xiàn)較大的收益。

為此,我們提出了一套創(chuàng)新性的方案:

通過云端模糊匹配緩存來將全量編譯轉(zhuǎn)化為增量編譯,來減少全量編譯的耗時。

三、核心原理

我們通過改造 Kotlin Gradle Plugin 入手,當 Kotlin Task 由于各種原因不能命中 Build Cache 時,就會 fallback 到我們的『Kotlin 差分編譯系統(tǒng)』。通過云端模糊匹配緩存來將全量編譯轉(zhuǎn)化為增量編譯,來將本來動輒 10min+ 的全量編譯轉(zhuǎn)化成 10s 內(nèi)的增量編譯。

云端緩存模糊匹配

常規(guī)編譯構(gòu)建緩存方案如 Gradle Build Cache 采用的是 kv 一對一匹配。

對于 Kotlin 來說,即通過對源碼文件、依賴 Jar 包,編譯參數(shù)等信息算出一個緩存 key 之后,唯一匹配出一個緩存。當匹配到緩存后,緩存包的內(nèi)容就是 Kotlin 的最終產(chǎn)物,然后就可以跳過 Kotlin Task,直接進入下一步。

由于精確匹配需要達成的條件較多,比如只要修改了一個 .kt文件,就無法匹配到緩存。

因此可以考慮實現(xiàn)一套模糊匹配的機制:

  • 先根據(jù)一些必要參數(shù)匹配一組可能符合要求的緩存
  • 然后從這一組緩存中尋找與當前場景最接近的緩存包來進行使用

全量轉(zhuǎn)增量

最接近的緩存包,也只是最接近的,是不能直接拿來用的。

那我們是不是可以將這個緩存包對應的源碼與當前源碼做 diff,然后將全量轉(zhuǎn)增量呢?

比如,某個模塊有 A.kt,B.kt,我們基于某個 Commit 打出來了一個云端緩存包。

有一天,我們新增了一個 C.kt,改動了 B.kt,這時候,我們找到之前打出來的緩存包,對他們對應的源碼做一次 diff,那么我們可以得到這組變動:

[
   changed: ["B.kt"],
   added: ["C.kt"]
]

我們只需要將這個 Diff 輸入給 kotlin 編譯器,kotlin 就會自動幫我們完成耗時較低的增量編譯。

基于 #1#2 的兩個核心原理,我們設計了一套『Kotlin 差分編譯系統(tǒng)』,整體架構(gòu)圖如下,有四個重要的角色:

  • 客戶端:Hook Kotlin Compiler,負責將全量編譯轉(zhuǎn)為增量編譯。
  • 服務端:基于客戶端的請求匹配最佳緩存。
  • 種子節(jié)點:基于 develop 分支,定時打包,上傳緩存。
  • 分布式緩存:我們內(nèi)部使用的緩存系統(tǒng),主要通過 K-V 的形式來存儲我們的緩存包。

圖片圖片

四、方案詳解

客戶端方案

客戶端的核心邏輯:對 kotlin 編譯流程進行 hook,構(gòu)造/加載緩存包,將全量編譯轉(zhuǎn)換為增量編譯,轉(zhuǎn)換的關(guān)鍵在于增量編譯與全量編譯的差異部分:

  1. 增量編譯環(huán)境中有上次編譯好的編譯產(chǎn)物、增量追蹤文件(符號引用關(guān)系,abi 信息等)。
  2. 增量編譯相比于上次編譯有哪些輸入文件產(chǎn)生了修改的文件 diff 信息。

圖片圖片

緩存包內(nèi)容

為了能夠在全量編譯時順利還原出一個正確的增量編譯現(xiàn)場,要求加載的緩存包中應該包含的內(nèi)容有:

  1. 編譯產(chǎn)物 (減少不必要的源文件重新編譯耗時)
  2. metadata 包括 kt 、java、layout 文件等 源文件信息,包括這些文件在項目中的相對路徑以及 hash 值,在還原編譯現(xiàn)場時,根據(jù)這些內(nèi)容還原為源文件的 diff 信息 ( add, remove ,modify)
  3. 編譯器信息:編譯器參數(shù),編譯器 JAR 包的 hash 信息等。
  4. 增量中間產(chǎn)物:涉及到一系列追蹤文件,這些文件包含了kotlin 編譯器在增量編譯時 用來計算 dirty file (需要重新編譯的源文件)的關(guān)鍵信息,包括符號引用關(guān)系、abi 信息、源文件與編譯產(chǎn)物的對應關(guān)系等。

圖片圖片

緩存匹配

如前文中的『核心原理』章節(jié)所描述,我們采取以下方案來進行緩存的匹配。

  • 先根據(jù)一些精準參數(shù)匹配一組可能符合要求的緩存
  • 然后從這一組緩存中尋找與當前場景最接近的緩存包來進行使用

其中以下參數(shù)會參與精確參數(shù)(unique_hash)的計算

  • Kotlin 版本
  • Kotlin Compiler Plugin (KCP)
  • Kotlin 編譯器參數(shù)

我們通過將這些參數(shù)組合到一起組成一個 hash 值:unique_hash。

在之后的流程中,會使用 unique_hash 來篩選出一組緩存。我們再通過服務端的策略來篩選出最佳的緩存。

圖片圖片

編譯流程 hook

拿到了緩存包后,我們就可以進行緩存包的復用了。但是我們在上一個流程中匹配到的僅僅是最優(yōu)緩存,但是這個緩存和我們當前的包還存在 diff,比如代碼、模塊依賴信息 等的差異。

我們需要計算出拉到的緩存包和當前編譯的 diff,然后將 diff 傳給 kotlin 編譯器,并將全量編譯轉(zhuǎn)成增量編譯。

為了能夠?qū)崿F(xiàn)增量編譯的轉(zhuǎn)換,需要在編譯流程的三個階段進行 hook

  1. 編譯前執(zhí)行前解析模塊依賴信息。
  2. 在提交編譯參數(shù)到編譯器執(zhí)行前,匹配、下載、校驗、加載緩存包,將全量編譯參數(shù)轉(zhuǎn)換為增量編譯參數(shù)。
  3. 編譯完成后構(gòu)造并上傳緩存包。

圖片圖片

其中,最核心的 Hook 點在于:將全量編譯轉(zhuǎn)化成增量編譯。

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 轉(zhuǎn)換成增量 Env。增量的 InputChanges 來源于我們的緩存包和編譯的 diff (源碼、依賴的變更等)。

而這個關(guān)鍵的全量轉(zhuǎn)增量 Hook 步驟為:

  1. 匹配下載緩存:為了能夠匹配到最接近的緩存包,實現(xiàn)最佳增量編譯效率,需要對這些參數(shù)進行比較:
  1. 根據(jù) metadata 中 java/kotlin/layout 文件的 hash 比較源碼文件的 diff (其中包括 layout xml 是因為 KAE 會根據(jù) layout 進行 kotlin 代碼插樁)
  2. 利用 kotlin 1.7+ 的 classpath-snapshot.bin 計算依賴模塊依賴信息的 abi diff
  1. 校驗加載緩存: 基于緩存包中的 metadata ,結(jié)合編譯產(chǎn)物、編譯中間產(chǎn)物等,將對應信息加載到對應位置。
  2. 構(gòu)造增量編譯參數(shù): 將加載的緩存包中的相對路徑轉(zhuǎn)換為絕對路徑,用絕對路徑的 diff 信息等構(gòu)造出可用的增量 Env , 并提交給 Kotlin Compiler

預期之外的問題

理論上來講,如果 kotlin 編譯器是能保證增量編譯是完全不出錯的,那么我們的方案肯定不會引入穩(wěn)定性的問題。

但是這樣的期待或許過高。我們的方案是基于 Kotlin 1.7.21 進行開發(fā)的,kotlin 在 1.7+ 引入了新的 kotlin 增量編譯方案,還在一個相對不穩(wěn)定的階段。實際上,我們也在上線前后遇到了各種奇怪的問題。經(jīng)過長時間的 debug 和翻閱源碼后得到了解決,目前在抖音項目上已經(jīng)趨于穩(wěn)定。

跨端緩存復用問題

問題表現(xiàn)為:OSX 復用 ci 種子節(jié)點打包的緩存時,對于部分刪除的源代碼文件,并沒有將其對應的編譯產(chǎn)物刪除

通過一系列的排查發(fā)現(xiàn),該問題是 Kotlin Compiler 自身的 bug ,且在高版本進行了修復,bug 來源于 kotlin 的增量編譯中間產(chǎn)物中,用于追蹤源碼文件與編譯產(chǎn)物關(guān)系的信息異常,在涉及到大小寫敏感不一致的系統(tǒng)時,無法正常刪除編譯產(chǎn)物。

解決方案: 對高版本的修復 commit 進行了 cherry-pick

kotlin 修復 https://github.com/JetBrains/kotlin/commit/be71d8841ebc22c79bb6b4bc6f3ad93c147ba9c0

大小寫敏感問題

由于 OSX 不是一個大小寫敏感的操作系統(tǒng),這意味著,在一個文件夾中,不能同時存在去掉大小寫后字符一樣的兩個文件。比如:

圖片圖片

但是這個行為在 Linux 系統(tǒng)上是允許的。

我們的遇到的問題是,項目中不同文件夾中有兩個包名叫 com.xxx.legoImp ,一個叫com.xxx.legoimp 。這兩個包名會在 Linux 種子節(jié)點上進行 Jar 包壓縮,在 mac 上進行解壓 ,由于 Linux 不是 Case Sensitive 的系統(tǒng),所以 jar 包中,他倆會同時存在,但是在 mac 上解壓時,mac 會自動合并了這兩個包,并且轉(zhuǎn)換成小寫。這樣就會在運行時崩潰,找不到這個包名。

解決方案:我們提供了一個類似的包名大小寫檢測,幫助業(yè)務提前暴露問題,并進行代碼的整改。

方案性能極致優(yōu)化

加速緩存解壓

在最初的緩存包壓縮中,采用的是 Java ZipFile 中默認的壓縮算法,上線后發(fā)現(xiàn)有較多模塊的緩存解壓時間較長,后續(xù)參考了Gradle build-cache 方案,將壓縮算法修改為 lz4,大大降低了解壓縮的時間。

降低緩存淘汰率

在本方案中,緩存遠端存儲在我們的分布式緩存服務中,由于分布式緩存空間有限(2TB),超過緩存空間時會根據(jù) LRU 淘汰最近未使用的緩存,這樣就會導致緩存命中率下降,而由于在抖音項目中,生產(chǎn)緩存的種子節(jié)點會在每個 MR 合入后進行緩存打包,每次全量打包緩存時,緩存包約1GB+ , 為了減少不必要的緩存上傳,采取了兩個方案:

  1. 與 build-cache 進行關(guān)聯(lián),如果種子節(jié)點打包時命中 build-cache ,將 kotlin 緩存與 build-cache 進行關(guān)聯(lián),而不是重復上 kotlin 緩存
  2. 計算緩存包內(nèi)容 hash ,如果存在相同的緩存包,將他們關(guān)聯(lián)到一起,而不是重復上傳緩存。

服務端策略

區(qū)別于傳統(tǒng)的 Build Cache 服務端的簡單 K-V 存儲,我們的服務端需要幫助客戶端去存儲產(chǎn)物、匹配產(chǎn)物、選擇最佳產(chǎn)物。客戶端和服務端的通信時序圖如下:

圖片圖片

其中,服務端最核心的邏輯在于匹配最佳產(chǎn)物

每一個 unique hash 都會對應一組產(chǎn)物,我們要從這一組產(chǎn)物里找到一個最優(yōu)解。

最優(yōu)解的定義是:這個產(chǎn)物離我們當前的編譯較為接近。我們?yōu)檫@個『接近』,定義了一個記分制度。

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
}

每個產(chǎn)物和當前編譯都會產(chǎn)生一個 diff,diff 里有 added、removedmodified。

我們定義一個函數(shù) diffScore,remove 記三分,modified 記兩分,added 記一分。

之所以按照這樣的系數(shù),是因為 removed 大概率會引起很多底層依賴的重新編譯,所以我們需要盡可能地減少 removed 的代碼,added 的代碼一般不會引起其它代碼重新編譯,所以我們可以記為一分。modified 介于兩者之間,記兩分。

那么,我們的核心的邏輯就變成了:從一組緩存里,找到 diffScore 最少的產(chǎn)物

但是因為我們的 unique hash 對應的 hash 生成并不復雜,一般來說,除非是升級 kotlin 版本,其它情況這個 unique hash 基本不會變,所以這個緩存的量可能會很大,我們不可能實現(xiàn)找到所有的緩存,并且進行 diffScore 的比較。我們需要篩選出一定范圍內(nèi)的緩存。

首先,我們的所有的產(chǎn)物生成都是在主干分支上。

那么意味著,對于 feature 分支,與其代碼最接近的 commit 應該是圖中的 base commit。

圖片圖片

因為成本的原因,種子任務可能并不會在主干分支的每個節(jié)點都打緩存包,可能是定時打包。那么意味著不是所有的 merge commit 都有緩存包。

所以,我們找到這個 commit 的前后一天時間內(nèi)的 commit。并和表中的所有產(chǎn)物進行取交集。

圖片圖片

最后得到所有黃色的點。(黃色的點是打了緩存包的 merge 節(jié)點)

我們將所有黃色的點對應的 MetaData 與當前 commit 的 MetaData 進行 diff。

取一個 diffScore 最少的節(jié)點,作為最終的產(chǎn)物。

val bestArtifact = artifacts.minOf {
    it.diffScore()
}

這樣我們就可以認為它是和當前 commit 最接近的 commit。對應的產(chǎn)物對于客戶端來說,轉(zhuǎn)增量編譯的風險最小、速度最快。

五、上線收益

目前項目上線了大部分的字節(jié) Monorepo 項目,穩(wěn)定運行了半年有余。期間也修復了 kotlin 1.7 上的若干官方增量編譯相關(guān)的 bug,最后取得了較為理想的收益。 

抖音從二進制演進到 Mopnorepo 后,Kotlin 部分的編譯時間劣化超過 10m+,我們通過本文的方案,將劣化的部分基本抹平,90 分位下降 60%,大幅提升了開發(fā)的體驗和 CI 合碼效率。

六、總結(jié)

本文主要闡述了 Kotlin 云端差分方案的技術(shù)原理。

實現(xiàn)超大型工程 Monorepo 全源碼的研發(fā)模式,僅靠 BuildCache 是無法實現(xiàn)的,Kotlin 云端差分方案對于全源碼起著不可或缺的作用。

通過模糊匹配緩存的方式將全量編譯模擬成增量編譯的思想,可能在其他端上比較容易實現(xiàn)或者就是標準方案,但目前 Android CI 構(gòu)建領(lǐng)域均采用 clean 編譯,無法復用構(gòu)建中間產(chǎn)物。該方案的出現(xiàn)也有望實現(xiàn)思想的復用,運用到所有『類似』的任務中,比如 Java Compile、Transform、Dex 等,只要它本身是支持增量、編譯冪等特性的,都可以復用這套方案,從而進一步提升 Android 的構(gòu)建效率。

在研究云端差分方案期間,我們積累了大量的 Kotlin 編譯器相關(guān)的知識,為我們平時排查 kotlin 疑難問題提供了非常多的技術(shù)儲備,也發(fā)現(xiàn)了很多 kotlin 官方的 bug 和設計上的缺陷,我們也會在將來修復后回饋 kotlin 社區(qū)。

歡迎對編譯構(gòu)建、Kotlin相關(guān)技術(shù)感興趣的小伙伴找我們進行技術(shù)交流與討論!

責任編輯:龐桂玉 來源: 字節(jié)跳動技術(shù)團隊
相關(guān)推薦

2022-04-06 15:58:25

火山引擎差分隱私LDPDC

2015-04-27 14:42:24

技術(shù)架構(gòu)服務器性能

2023-09-03 19:13:29

AndroidKotlin

2012-04-02 16:35:49

網(wǎng)絡緩存

2012-12-26 09:32:55

智能手機移動傳感平臺云端分載GPS

2012-03-13 08:56:39

2015-05-13 13:13:34

2016-01-04 11:04:47

無線技術(shù)傳感技術(shù)物聯(lián)網(wǎng)

2023-11-11 19:43:12

緩存數(shù)據(jù)庫

2016-02-01 17:17:38

AWS技術(shù)峰會AWS Summit2

2011-12-28 10:44:02

PowerVM虛擬化

2011-07-11 10:00:34

PHP緩存技術(shù)

2018-08-07 10:44:50

緩存技術(shù)瀏覽器

2009-07-29 15:38:01

2015-06-10 11:47:21

云計算互聯(lián)網(wǎng)+O2O

2010-06-03 15:21:49

服務器虛擬化桌面與應用虛擬化數(shù)據(jù)中心

2013-07-11 11:41:31

2013IBM技術(shù)峰會移動

2015-09-09 10:20:00

php緩存技術(shù)

2010-07-21 09:38:15

PHP緩存技術(shù)

2010-06-01 15:35:32

點贊
收藏

51CTO技術(shù)棧公眾號