優化 Xcode 編譯時間

RickeyBoy發表於2018-04-15

最近在使用 Swift 開發專案時,發現編譯時間實在是慢的出奇。每次 git 切換分支之後,都得編譯好久,而且動輒卡死。有時候改了一點小地方想 debug 看下效果,也得編譯那麼好一會兒,實在是苦不堪言。所以下決心要好好研究一下,看看有沒有什麼優化 Xcode 編譯時間的好辦法。

本文中有不少實驗資料,都是對基於現有專案進行的簡單測試,優化效果僅供參考?。

第一步就是搞定編譯時間的測算,方法如下。完成了之後就可進入正題了。

檢視編譯消耗的時間
  1. 在命令列輸入如下語句,則 Xcode 編譯成功之後,會在頂部 "Succeed" 欄位旁邊顯示編譯時間。
defaults write com.apple.dt.Xcode ShowBuildOperationDuration YES
複製程式碼
  1. 使用 Github 上這一個外掛 BuildTimeAnalyer-for-Xcode,還可以具體地顯示每個檔案的編譯時間。

一、提高 Xcode 編譯效率

1. 全模組優化(Whole Module Optimization)

module 是 Swift 檔案的集合,每個 module 編譯成一個 framework 或可執行程式。在編譯時,Swift 編譯器分別編譯 module 中的每一個檔案,編譯完成後再連結到一起,最終再輸出 framework 或可執行程式。

由於這種編譯方式侷限於單個檔案,所以像有需要跨函式的優化等就可能會受到影響,比如函式內聯、基本塊合併等。因此,編譯時間會變長。

而如果使用全模組優化,編譯器會先將所有檔案合稱為同一個檔案,然後再進行編譯,這樣能夠極大的加快編譯速度。比如編譯器瞭解模組中所有函式的實現,所以它能夠確保執行跨函式的優化(包括函式內聯和函式特殊化等)。

另外,全模組優化時編譯器能夠推出所有非公有(non-public)函式的使用。非公有函式僅能在模組內部呼叫,所以編譯器能夠確定這些函式的所有引用。於是編譯器能夠知道一個非公有函式或方法是否根本沒有被使用,從而直接刪除冗餘函式。

####函式特殊化舉例

函式特殊化是指編譯器建立一個新版本的函式,這個函式通過一個特定的呼叫上下文來優化效能。在 Swift 中常見的是夠針對各種具體型別對泛型函式進行特殊化處理。

main.swift

func add (c1: Container<Int>, c2: Container<Int>) -> Int {
  return c1.getElement() + c2.getElement()
}
複製程式碼

utils.swift

struct Container<T> {
  var element: T

  func getElement() -> T {
    return element
  }
}
複製程式碼

單檔案編譯時,當編譯器優化 main.swift 時,它並不知道 getElement 如何被實現。所以編譯器生成了一個 getElement 的呼叫。另一個方面,當編譯器優化 utils.swift 時,它並不知道函式被呼叫了哪個具體的型別。所以它只能生成一個通用版本的函式,這比具體型別特殊化過的程式碼慢很多。

即使簡單的在 getElement 中宣告返回值,編譯器也需要在型別的後設資料中查詢來解決如何拷貝元素。它有可能是簡單的 Int 型別,但它也可以是一個複雜的型別,甚至涉及一些引用計數操作。而在單檔案編譯的情況下,編譯器都無從得知,更無法優化。

而在全模組編譯時,編譯器能夠對範型函式進行函式特殊化:

utils.swift

struct Container {
  var element: Int

  func getElement() -> Int {
    return element
  }
}
複製程式碼

將所有 getElement 函式被呼叫的地方都進行特殊化之後,函式的範型版本就可以被刪除。這樣,使用特殊化之後的 getElement 函式,編譯器就可以進行進一步的優化。

SWIFT_WHOLE_MODULE_OPTIMIZATION 啟用全模組優化

狀態列 -> Editor -> Build Setting -> Add User-Defined Settings,然後增加 key 為 SWIFT_WHOLE_MODULE_OPTIMIZATION,value 為 YES 就可以了。

為什麼 Swift 的編譯器預設不是全模組優化?

Swift 預設設定是 Debug 時只編譯 active 架構,Build active architecture only,Xcode 預設就是這個設定。可以在 Build Settings --> Build active architecture only 中檢查到這一設定。

也就是說,在對每一個檔案單獨進行編譯時,編譯器會快取每個檔案編譯後的產物。這樣的好處在於,如果之前編譯過了一次,之後只改動了少部分檔案的內容,影響範圍不大,那麼其他檔案就不用重新編譯,速度就會很快。

而我們來看一看全模組優化的整體過程,包括:分析程式,型別檢查,SIL 優化,LLVM 後端。而大多數情況下,前兩項都是非常快速的。SIL 優化主要進行的是上文所說的函式內聯、函式特殊化等優化,LLVM 後端採用多執行緒的方式對 SIL 優化的結果進行編譯,生成底層程式碼。

優化 Xcode 編譯時間

而設定 SWIFT_WHOLE_MODULE_OPTIMIZATION = YES,全模組優化會讓增量編譯的顆粒度從 File 級別增大到 Module 級別。一個只要修改我們專案裡的一個檔案,想要編譯 debug 一下,就又得重新合併檔案從頭開始編譯一次。理論上講,如果單個 LLVM 執行緒沒有被修改,那麼也能利用之前的快取進行加速。但現實情況是,分析程式、型別檢查、SIL 優化肯定會被重新執行一次,而絕大部分情況下 LLVM 也基本得重新執行一次,和第一次編譯時間差不多。

不過注意,pod 裡的庫,storyboard 和 xib 檔案是不會受影響的。

2. 生成 dSYM 檔案(dSYM generation)

dSYM 檔案儲存了 debug 的一些資訊,裡面包含著 crash 的資訊,像 Fabric 可以自動的將 project 中的 dSYM 檔案進行解析。

新專案的預設設定是 Debug 配置編譯時不生成 dSYM 檔案。有時候為了在開發時進行 Crash 日誌解析,會去修改這個引數。生成 dSYM 會消耗大量時間,如果不需要的話,可以去 Debug Information Format 修改一下。DWARF 是預設的不生成 dSYM 檔案,DWARF with dSYM file 是會生成 dSYM 檔案。

3. 使用新的 Xcode 9 編譯系統

在 Xcode 9 中,蘋果官方悄悄引入了一個新的編譯系統,你可以在 Github 中找到這一個專案。這還只是一個預覽版,所以並沒有在 Xcode 中預設開啟。官方新系統會改變 Swift 中處理物件間依賴的方式,旨在提高編譯速度。不過現在還不完善,有可能導致寫程式碼時的詭異行為以及較長的編譯時間。果然,我試了一下確實比原來還要慢。

如果想要開啟試試的話,可以在 **File選單 -> Working space ** Building System -> New Building System(Preview)

Build Time 記錄

Generate dSYM Who Module Optimization 增加空行後第二次編譯 首次編譯 使用 New Build System 編譯總時間
8m 42s
8m 18s
2m 2s
1m 36s
0m 38s
0m 16s
1m 26s
0m 55s
9m 24s
1m 46s

二、優化 Swift 程式碼

1. 減少型別推斷

let array = ["a", "b", "c", "d", "e", "f", "g"]
複製程式碼

這種寫法會更簡潔,但是編譯器需要進行型別推斷才能知道 array 的準確型別,所以最好的方法是直接寫出型別,避免推斷。

let array: [String] = ["a", "b", "c", "d", "e", "f", "g"]
複製程式碼

2. 減少使用 ternary operator

let letter = someBoolean ? "a" : "b"
複製程式碼

三目運算子寫法更加簡潔,但會增加編譯時間,如果想要減少編譯時間,可以改寫為下面的寫法。

var letter = ""
if someBoolean { 
  letter = "a"
} else {
  letter = "b"
}
複製程式碼

3. 減少使用 nil coalescing operator

let string = optionalString ?? ""
複製程式碼

這是 Swift 中的特殊語法,在使用 optional 型別時可以通過這樣的方式設定 default value。但是這種寫法本質上也是三目運算子。

let string = optionalString != nil ? optionalString! : nil
複製程式碼

所以,如果以節約編譯時間為目的,也可以改寫為

if let string = optionalString{ 
    print("\(string)")
} else {
    print("")
}
複製程式碼

4. 改進拼接字串方式

let totalString = "A" + stringB + "C"
複製程式碼

這樣拼接字串可行,但是 Swift 編譯器並不青睞這樣的寫法,儘量改寫成下面的方式。

let totalString = "A\(stringB)C"
複製程式碼

5. 改進轉化字串的方式

let StringA = String(IntA)
複製程式碼

這樣拼接字串可行,但是 Swift 編譯器並不青睞這樣的寫法,儘量改寫成下面的方式。

let StringA = "\(IntA)"
複製程式碼

6. 提前計算

if time > 14 * 24 * 60 * 60 {}
複製程式碼

這樣寫可讀性會更好,但是會對編譯器造成極大的負擔。可以將具體內容寫在註釋中,這樣改寫:

if time > 1209600 {} // 14 * 24 * 60 * 60
複製程式碼

Build Time 記錄

減少型別推斷

在一個檔案中,共減少了 2 處型別推斷,一共優化 0.3ms,改進效果如下:

-- 總時間
更改前 135.3 ms
更改後 135.0 ms

所見 Xcode 對型別推斷的處理優化還是效果很不錯的,而且在宣告階段的型別推斷實際上並不是很困難,因此提前宣告型別其實對編譯時間的優化效果影響不大。

減少使用 ternary operator

在一個檔案中,共減少了 2 處使用三目運算子的地方,一共優化 51.2ms,改進效果如下:

-- 總時間
更改前 229.2 ms
更改後 178.0 ms

可見使用三目運算子的地方會對編譯速度產生一定的影響,因此在不是特別需要的時候,出於編譯時間的考慮可以改寫為 if-else 語句。

減少使用 nil coalescing operator

在一個檔案中,共減少了 5 處使用 nil coalescing operator 的地方,一共優化 2.8ms,具體改進效果如下:

-- 總時間
更改前 386.4 ms
更改後 178.0 ms

根據結果而言,優化效果並不顯著。可是根據前文所述,nil coalescing operator 實際上是基於三目運算子的,那麼為何優化效果反而不如三目運算子?據我推測,原因可能在於三目運算子只需要改寫為 if-else 語句即可,而 nil coalescing operator 大部分時候需要先用 var 實現賦值語句,在使用 if-else 對賦值進行更改,所以總的來說優化效果不大。

字串連線方式

在一個檔案中,共改進了 7 處字串的拼接方式,一共優化 73ms,具體改進效果如下:

-- 總時間
更改前 696.1 ms
更改後 623.1 ms

可見改進字串的拼接方式效果還是十分明顯的,而且也更符合 Swift 的語法規範,所以何樂而不為呢?

字串轉換方式

在一個檔案中,進行了 5 處修改,一共優化 4952.5ms,效果十分顯著。具體改進效果如下:

-- 總時間
更改前 5106.2 ms
更改後 153.7 ms
提前計算

在一個檔案中,進行了之前例子中的修改,一共優化 843.2ms,效果十分顯著。具體改進效果如下:

-- 總時間
更改前 1034.7 ms
更改後 191.5 ms

更多內容

?Github

參考文獻

  1. Whole-Module Optimization in Swift 3
  2. How to enable build timing in Xcode? - Stack Overflow
  3. Speed up Swift compile time

相關文章