Kotlin 快速編譯背後的黑科技,瞭解一下~

語言: CN / TW / HK

theme: smartblue highlight: a11y-dark


原文連結:The Dark Secrets of Fast Compilation for Kotlin

前言

快速編譯大量程式碼一向是一個難題,尤其是當編譯器必須執行很多複雜操作時,例如過載方法解析和泛型型別推斷。 本文主要介紹在日常開發中做一些小改動時,Kotlin編譯器是如何加快編譯速度的

為什麼編譯那麼耗時?

編譯時間長通常有三大原因:

  1. 程式碼庫大小:通常程式碼碼越大,編譯耗時越長
  2. 你的工具鏈優化了多少,這包括編譯器本身和你正在使用的任何構建工具。
  3. 你的編譯器有多智慧:無論是在不打擾使用者的情況下計算出許多事情,還是需要不斷提示和樣板程式碼

前兩個因素很明顯,讓我們談談第三個因素:編譯器的智慧。 這通常是一個複雜的權衡,在 Kotlin 中,我們決定支援乾淨可讀的型別安全程式碼。這意味著編譯器必須非常智慧,因為我們在編譯期需要做很多工作。

Kotlin 旨在用於專案壽命長、規模大且涉及大量人員的工業開發環境。

因此,我們希望靜態型別安全,能夠及早發現錯誤,並獲得精確的提示(支援自動補全、重構和在 IDE 中查詢使用、精確的程式碼導航等)。

然後,我們還想要乾淨可讀的程式碼,沒有不必要的噪音。這意味著我們不希望程式碼中到處都是型別。 這就是為什麼我們有支援 lambda 和擴充套件函式型別的智慧型別推斷和過載解析演算法等等。 Kotlin 編譯器會自己計算出很多東西,以同時保持程式碼乾淨和型別安全。

編譯器可以同時智慧與高效嗎?

為了讓智慧編譯器快速執行,您當然需要優化工具鏈的每一部分,這是我們一直在努力的事情。 除此之外,我們正在開發新一代 Kotlin 編譯器,它的執行速度將比當前編譯器快得多,但這篇文章不是關於這個的。

不管編譯器有多快,在大型專案上都不會太快。 而且,在除錯時所做的每一個小改動都重新編譯整個程式碼庫是一種巨大的浪費。 因此,我們試圖儘可能多地複用之前的編譯,並且只編譯我們絕對需要的檔案。

有兩種通用方法可以減少重新編譯的程式碼量:

  • 編譯避免:即只重新編譯受影響的模組,
  • 增量編譯:即只重新編譯受影響的檔案。

人們可能會想到一種更細粒度的方法,它可以跟蹤單個函式或類的變化,因此重新編譯的次數甚至少於一個檔案,但我不知道這種方法在工業語言中的實際實現,總的來說它似乎沒有必要。

現在讓我們更詳細地瞭解一下編譯避免和增量編譯。

編譯避免

編譯避免的核心思想是:

  • 查詢dirty(即發生更改)的檔案
  • 重新編譯這些檔案所屬的module
  • 確定哪些其他模組可能會受到更改的影響,重新編譯這些模組,並檢查它們的ABI
  • 然後重複這個過程直到重新編譯所有受影響的模組

從以上步驟可以看出,沒有人依賴的模組中的更改將比每個人都依賴的模組(比如util模組)中的更改編譯得更快(如果它影響其 ABI),因為如果你修改了util模組,依賴了它的模組全都需要編譯

ABI是什麼

上面介紹了在編譯過程中會檢查ABI,那麼ABI是什麼呢?

ABI 代表應用程式二進位制介面,它與 API 相同,但用於二進位制檔案。本質上,ABI 是依賴模組關心的二進位制檔案中唯一的部分。

粗略地說,Kotlin 二進位制檔案(無論是 JVM 類檔案還是 KLib)包含declarationbody兩部分。其他模組可以引用declaration,但不是所有declaration。因此,例如,私有類和成員不是 ABI 的一部分。

body可以成為 ABI 的一部分嗎?也是可以的,比如當我們使用inline時。 同時Kotlin 具有行內函數和編譯時常量(const val)。因此如果行內函數的bodyconst val 的值發生更改,則可能需要重新編譯相關模組。

因此,粗略地說,Kotlin 模組的 ABIdeclaration、內聯body和其他模組可見的const val值組成。

因此檢測 ABI 變化的直接方法是

  • 以某種形式儲存先前編譯的 ABI(您可能希望儲存雜湊以提高效率)
  • 編譯模組後,將結果與儲存的 ABI 進行比較:
  • 如果相同,我們就完成了;
  • 如果改變了,重新編譯依賴模組。

編譯避免的優缺點

避免編譯的最大優點是相對簡單。

當模組很小時,這種方法確實很有幫助,因為重新編譯的單元是整個模組。 但如果你的模組很大,重新編譯的耗時會很長。 因此為了儘可能地利用編譯避免提升速度,決定了我們的工程應該由很多小模組組成。作為開發人員,我們可能想要也可能不想要這個。 小模組不一定聽起來像一個糟糕的設計,但我寧願為人而不是機器構建我的程式碼。為了利用編譯避免,實際上限制了我們專案的架構。

另一個觀察結果是,許多專案都有類似於util的基礎模組,其中包含許多有用的小功能。 幾乎所有其他模組都依賴於util模組,至少是可傳遞的。 現在,假設我想新增另一個在我的程式碼庫中使用了 3 次的小實用函式。 它新增到util模組中會導致ABI發生變化,因此所有依賴模組都受到影響,進而導致整個專案都需要重新編譯。

最重要的是,擁有許多小模組(每個都依賴於多個其他模組)意味著我的專案的configuration時間可能會變得巨大,因為對於每個模組,它都包含其獨特的依賴項集(原始碼和二進位制檔案)。 在 Gradle 中配置每個模組通常需要 50-100 毫秒。 大型專案擁有超過 1000 個模組的情況並不少見,因此總配置時間可能會超過一分鐘。 它必須在每次構建以及每次將專案匯入 IDE 時都執行(例如,新增新依賴項時)。

Gradle 中有許多特性可以減輕編譯避免的一些缺點:例如,可以使用快取configuration cache。 儘管如此,這裡仍有很大的改進空間,這就是為什麼在 Kotlin 中我們使用增量編譯。

增量編譯

增量編譯比編譯避免更加細粒度:它適用於單個檔案而不是模組。 因此,當通用模組的 ABI 發生微小變化時,它不關心模組大小,也不重新編譯整個專案。這種方式不會限制使用者專案的架構,並且可以加快編譯速度

JPS(IntelliJ的內建構建系統)一直支援增量編譯。 而Gradle僅支援開箱即用的編譯避免。 從 1.4 開始,Kotlin Gradle 外掛為 Gradle 帶來了一些有限的增量編譯實現,但仍有很大的改進空間。

理想情況下,我們只需檢視更改的檔案,準確確定哪些檔案依賴於它們,然後重新編譯所有這些檔案。

聽起來很簡單,但實際上準確地確定這組依賴檔案非常複雜。

一方面,原始檔之間可能存在迴圈依賴關係,這是大多數現代構建系統中的模組所不允許的。並且單個檔案的依賴關係沒有明確宣告。請注意,如果引用了相同的包和鏈呼叫,imports不足以確定依賴關係:對於 A.b.c(),我們最多需要匯入A,但 B 型別的更改也會影響我們。

由於所有這些複雜性,增量編譯試圖通過多輪來獲取受影響的檔案集,以下是它的完成方式的概要:

  • 查詢dirty(更改)的檔案
  • 重新編譯它們(使用之前編譯的結果作為二進位制依賴,而不是編譯其他原始檔)
  • 檢查這些檔案對應的ABI是否發生了變化
  • 如果沒有,我們就完成了!
  • 如果發生了變化,則查詢受更改影響的檔案,將它們新增到髒檔案集中,重新編譯
  • 重複直到 ABI 穩定(這稱為“固定點”)

由於我們已經知道如何比較 ABI,所以這裡基本上只有兩個棘手的地方:

  • 使用先前編譯的結果來編譯源的任意子集
  • 查詢受一組給定的 ABI 更改影響的檔案。

這兩者都是 Kotlin 增量編譯器的功能。 讓我們一個一個看一下。

編譯髒檔案

編譯器知道如何使用先前編譯結果的子集來跳過編譯非髒檔案,而只需載入其中定義的符號來為髒檔案生成二進位制檔案。 如果不是為了增量,編譯器不一定能夠做到這一點:從模組生成一個大二進位制檔案而不是每個原始檔生成一個小二進位制檔案,這在 JVM 世界之外並不常見。 而且它不是 Kotlin 語言的一個特性,它是增量編譯器的一個實現細節。

當我們將髒檔案的 ABI 與之前的結果進行比較時,我們可能會發現我們很幸運,不需要再進行幾輪重新編譯。 以下是一些只需要重新編譯髒檔案的更改示例(因為它們不會更改 ABI):

  • 註釋、字串文字(const val 除外)等,例如:更改除錯輸出中的某些內容
  • 更改僅限於非內聯且不影響返回型別推斷的函式體,例如:新增/刪除除錯輸出,或更改函式的內部邏輯
  • 僅限於私有宣告的更改(它們可以是類或檔案私有的),例如:引入或重新命名私有函式
  • 重新排序函式宣告

如您所見,這些情況在除錯和迭代改進程式碼時非常常見。

擴大髒檔案集

如果我們不那麼幸運並且某些宣告已更改,則意味著某些依賴於髒檔案的檔案在重新編譯時可能會產生不同的結果,即使它們的程式碼中沒有任何一行被更改。

一個簡單的策略是此時放棄並重新編譯整個模組。
這將把所有編譯避免的問題都擺在桌面上:一旦你修改了一個宣告,大模組就會成為一個問題,而且大量的小模組也有效能成本,如上所述。
所以,我們需要更細化:找到受影響的檔案並重新編譯它們。

因此,我們希望找到依賴於實際更改的 ABI 部分的檔案。
例如,如果使用者將 foo 重新命名為 bar,我們只想重新編譯關心名稱 foobar 的檔案,而不管其他檔案,即使它們引用了此 ABI的其他部分。
增量編譯器會記住哪些檔案依賴於先前編譯中的哪個宣告,我們可以使用這種資料,有點像模組依賴圖。同樣,這不是非增量編譯器通常會做的事情。

理想情況下,對於每個檔案,我們應該儲存哪些檔案依賴於它,以及它們關心 ABI 的哪些部分。實際上,如此精確地儲存所有依賴項的成本太高了。而且在許多情況下,儲存完整簽名毫無意義。

我們看一下下面這個例子:

```kotlin // dirty.kt // rename this to be 'fun foo(i: Int)' fun changeMe(i: Int) = if (i == 1) 0 else bar().length

// clean.kt fun foo(a: Any) = "" fun bar() = foo(1) ```

我們定義兩個kt檔案 ,dirty.ktclean.kt

假設使用者將函式 changeMe 重新命名為 foo。 請注意,雖然 clean.kt 沒有改變,但 bar() 的主體將在重新編譯時改變:它現在將從dirty.kt 呼叫 foo(Int),而不是從 clean.kt 呼叫 foo(Any) ,並且它的返回型別 也會改變。

這意味著我們必須重新編譯dirty.ktclean.kt。 增量編譯器如何發現這一點?

我們首先重新編譯更改的檔案:dirty.kt。 然後我們看到 ABI 中的某些內容發生了變化:

  • 沒有功能 changeMe
  • 有一個函式 foo 接受一個 Int 並返回一個 Int

現在我們看到 clean.kt 依賴於名稱 foo。 這意味著我們必須再次重新編譯 clean.ktdirty.kt。 為什麼? 因為型別不能被信任。

增量編譯必須產生與所有程式碼的完全重新編譯相同的結果。
考慮dirty.kt 中新出現的foo 的返回型別。它是推斷出來的,實際上它取決於 clean.ktbar 的型別,它是檔案之間的迴圈依賴。
因此,當我們將 clean.kt 新增到組合中時,返回型別可能會發生變化。在這個例子中,我們會得到一個編譯錯誤,但是在我們重新編譯 clean.ktdirty.kt 之前,我們不知道它。

Kotlin 增量編譯的第一原則:您可以信任的只是名稱。

這就是為什麼對於每個檔案,我們儲存它產生的 ABI,以及在編譯期間查詢的名稱(不是完整的宣告)。

我們儲存所有這些的方式可以進行一些優化。

例如,某些名稱永遠不會在檔案之外查詢,例如區域性變數的名稱,在某些情況下還有區域性函式的名稱。
我們可以從索引中省略它們。為了使演算法更精確,我們記錄了在查詢每個名稱時查閱了哪些檔案。為了壓縮我們使用雜湊的索引。這裡有更多改進的空間。

您可能已經注意到,我們必須多次重新編譯初始的髒檔案集。 唉,沒有辦法解決這個問題:可能存在迴圈依賴,只有一次編譯所有受影響的檔案才能產生正確的結果。

在最壞的情況下,增量編譯可能會比編譯避免做更多的工作,因此應該有適當的啟發式方法來防止它。

跨模組的增量編譯

迄今為止最大的挑戰是可以跨越模組邊界的增量編譯。

比如說,我們在一個模組中有髒檔案,我們做了幾輪並在那裡到達一個固定點。現在我們有了這個模組的新 ABI,需要對依賴的模組做一些事情。

當然,我們知道初始模組的 ABI 中哪些名稱受到影響,並且我們知道依賴模組中的哪些檔案查找了這些名稱。

現在,我們可以應用基本相同的增量演算法,但從 ABI 更改開始,而不是從一組髒檔案開始。

如果模組之間沒有迴圈依賴,單獨重新編譯依賴檔案就足夠了。但是,如果他們的 ABI 發生了變化,我們需要將更多來自同一模組的檔案新增到集合中,並再次重新編譯相同的檔案。

Gradle 中完全實現這一點是一個公開的挑戰。這可能需要對 Gradle 架構進行一些更改,但我們從過去的經驗中知道,這樣的事情是可能的,並且受到 Gradle 團隊的歡迎。

總結

現在,您對現代程式語言中的快速編譯所帶來的挑戰有了基本的瞭解。請注意,一些語言故意選擇讓他們的編譯器不那麼智慧,以避免不得不做這一切。不管好壞,Kotlin 走的是另一條路,讓 Kotlin 編譯器如此智慧似乎是使用者最喜歡的特性,因為它們同時提供了強大的抽象、可讀性和簡潔的程式碼。

雖然我們正在開發新一代編譯器前端,它將通過重新考慮核心型別檢查和名稱解析演算法的實現來加快編譯速度,但我們知道這篇博文中描述的所有內容都不會過時。

原因之一是使用 Java 程式語言的體驗,它享受 IntelliJ IDEA 的增量編譯功能,甚至擁有比今天的 kotlinc 快得多的編譯器。

另一個原因是我們的目標是儘可能接近解釋語言的開發體驗,這些語言無需任何編譯即可立即獲取更改。

所以,Kotlin 的快速編譯策略是:優化的編譯器 + 優化的工具鏈 + 複雜的增量。

譯者總結

本文主要介紹了Kotlin編譯器在加快編譯速度方面做的一些工作,介紹了編譯避免與增量編譯的區別以及什麼是ABI

瞭解Kotlin增量編譯的原理可以幫助我們提高增量編譯成功的概率,比如inline函式體也是ABI的一部分,因此當我們宣告行內函數時,行內函數體應該寫得儘量簡單,內部通常只需要呼叫另一個非行內函數即可。

這樣當inline函式內部邏輯發生更改時,不需要重新編譯依賴於它的那些檔案,從而實現增量編譯。

同時從實際開發過程中體驗,Kotlin增量編譯還是經常會失效,尤其是發生跨模組更改時。Kotlin新一代編譯器已經發布了Alpha版本,期待會有更好的表現~

我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿