[TOC]
過慢的編譯速度有非常明顯的副作用。一方面,程式設計師在等待打包的過程中可能會分心,比如刷刷朋友圈,看條新聞等等。這種認知上下文的切換會帶來很多隱形的時間浪費。另一方面,大部分 app 都有自己的持續整合工具,如果打包速度太慢, 會影響整個團隊的開發進度。
因此,本文會分別討論日常開發和持續整合這兩種場景,分析打包速度慢的瓶頸所在,以及對應的解決方案。利用這些方案,筆者成功的把公司 app 的持續整合時間從 45 min 成功的減少到 9 min,效率提升高達 80%,理論上打包速度可以提升 10 倍以上。如果用一句話總結就是:
在絕對的實力(硬體)面前,一切技巧(軟體)都是浮雲
日常開發
其實日常開發的優化空間並不大,因為預設情況下 Xcode 會使用上次編譯時留下的快取,也就是所謂的增量編譯。因此,日常開發的主要耗時由三部分構成:
總耗時 = 增量編譯 + 連結 + 生成除錯資訊(dSYM)
這裡的增量編譯耗時比較短,即使是在我 14 年高配的 MacBook Pro(4核心,8 執行緒,2.5GHz i7 4870HQ,下文簡稱 MBP) 上,也僅僅耗時十秒上下。我們的應用程式碼量大約一百多萬行,業內超過這個量級的應用應該不多。連結和生成除錯資訊各花費不到 20s,因此一次增量的編譯的時間開銷在半分鐘到一分鐘左右,我們逐個分析:
- 增量編譯: 因為耗時較短(大概十幾秒或者更少),幾乎不存在優化的空間,但是非常容易惡化。因為只有標頭檔案不變的編譯單元才能被快取,如果某個檔案被 N 個檔案引用,且這個檔案的標頭檔案發生了變化,那麼這 N 個檔案都會重編譯。APP 的分層架構一般都會做,但一個典型的誤區是在基礎庫的標頭檔案中使用巨集定義,比如定義一些全域性都可以讀取的常量,比如是否開啟除錯,伺服器的地址等等。這些常量一旦改變(比如為了除錯或者切換到某些分支)就會導致應用重編譯。
- 連結:連結沒有快取,而且只能用單核進行,因此它的耗時主要取決於單核效能和磁碟讀寫速度。考慮到我們的目標檔案一般都比較小,因此 4K 隨機讀寫的效能應該會更重要一些。
- 除錯資訊:日常開發時,並不需要生成 dSYM 檔案,這個檔案主要用於崩潰時查詢呼叫棧,方便線上應用進行除錯,而開發過程中的崩潰可以直接在 Xcode 中看到,關閉這個功能 不會對開發產生任何負面影響。
日常開發的優化空間不大,即使是龐大的專案,落後的機器效能,關閉 dSYM 以後也就耗時 30s 左右。相比之下,打包速度可以優化和討論的地方就比較多了。
持續整合
在利用 Jenkins 等工具進行持續整合時,快取不推薦被使用。這是因為蘋果的快取不夠穩定,在某些情況下還存在 bug。比如明明本地已經修復了 bug,可以編譯通過,但上次的編譯快取沒有被正確清理,導致在打包機器上依然無法編譯通過。或者本地明明寫出了 bug,但同樣由於快取問題,打包機器依然可以編譯通過。
因此,無論是手動刪除 Derived Data
資料夾,還是呼叫 xcodebuild clean
命令,都會把快取清空。或者直接使用 xcodebuild archive
,會自動忽略快取。每次都要全部重編譯是導致打包速度慢的根本原因。以我們的專案為例,總計 45min 的打包時間中,有 40min 都在執行 xcodebuild
這一行命令。
使用 CCache 快取
最自然的想法就是使用快取了,既然蘋果的快取不靠譜,那麼就找一個靠譜的快取,比如 CCache。它是基於編譯器層面的快取,根據目前反饋的情況看,並不存在快取不一致的問題。根據筆者的實驗,使用 CCache 確實能夠較大幅度的提升打包速度,刪除快取並使用 CCache 重編譯後,耗時只有十幾分鍾。
然而,CCache 最致命的問題是不支援 PCH 檔案和 Clang modules。PCH 的本意是優化編譯時間,我們假設有一個標頭檔案 A 依賴了 M 個標頭檔案,其中每個被依賴的標頭檔案又依賴了 N 個 標頭檔案,如下圖所示:
由於 #import
的本質就是把被依賴標頭檔案的內容拷貝到自己的標頭檔案中來,因此標頭檔案 A 中實際上包含了 M N 個標頭檔案的內容,也就需要 M N 次檔案 IO 和相關處理。當專案中每增加一個依賴標頭檔案 A 的檔案,就會重複一次上述的 M * N 複雜度的過程。
PCH 檔案的好處是,這個檔案中的標頭檔案只會被編譯一次並快取下來,然後新增到專案中 所有 的標頭檔案中去。上述問題倒是解決了,但很智障的一點是,所有檔案都會隱式的依賴所有 PCH 中的檔案,而真正需要被全域性依賴的檔案其實非常少。因此實際開發中,更多的人會把 PCH 當成一種快速 import
的手段,而非編譯效能的優化。前文解釋過,PCH 檔案一旦發生修改,會導致徹徹底底,完完整整的專案重編譯,從而降低編譯速度。正是因為 PCH 的副作用甚至抵消了它帶來的優化,蘋果已經預設不使用 PCH 檔案了。
用來取代 PCH 的就是 Clang modules 技術,對於開啟了這一選項的專案,我們可以用 @import
來替代過去的 #import
,比如:
@import UIKit;複製程式碼
等價於
#import <UIKit/UIKit.h>複製程式碼
拋開自動連結 framework 這些小特性不談,Clang modules 可以理解為模組化的 PCH,它具備了 PCH 可以快取標頭檔案的優點,同時提供了更細粒度的引用。
說回到 CCache,由於它不支援 PCH 和 Clang modules,導致無法在我們的專案中應用。即使可以用,也會拖累專案的技術升級,以這種代價來換取快取,只怕是得不償失。
distcc
distcc 是一種分散式編譯工具,可以把需要被編譯的檔案傳送到其他機器上編譯,然後接收編譯產物。然而,經過貼吧、貝聊、手Q 等應用的多方實驗,發現並不適合 iOS 應用。它的原理是多個客戶端共同編譯,但是絕大多數檔案其實編譯時間非常短,並不值得通過網路來回傳送,這種方案應該只適合單個檔案體量非常大的專案。在我們的專案中,使用 distcc
大幅度 增加了打包時間,大約耗時 1 小時左右。
定位瓶頸
在尋求外部工具無果後,筆者開始嘗試著對編譯時間直接做優化。為了搞清楚這 40min 究竟是如何花費的,我首先對 xcodebuild
的輸出結果進行詳細分析。
使用過 xcodebuild
命令的人都會知道,它的輸出結果對開發者並不友好,幾乎沒有可讀性,好在還有 xcpretty
這個工具可以格式化它:
gem install xcpretty複製程式碼
通過 gem
安裝後,只要把 xcodebuild
的輸出結果通過管道傳給 xcpretty
即可:
xcodebuild -scheme Release ... | xcpretty複製程式碼
下面是官方文件中的 Demo:
我只對其中的編譯部分感興趣,所以簡單的做下過濾,我們就可以得到格式高度統一的輸出:
Compiling A.m
Compiling B.m
Compiling ...
Compiling N.m複製程式碼
到了這一步,終於可以做最關鍵的計算了,我們可以通過設定定時器,計算相鄰兩行輸出之間的間隔,這個間隔就是檔案的編譯時間。當然,也有類似的輔助工具做好了這個邏輯:
npm install gnomon複製程式碼
簡單的做一下排序,就可以看到最耗時的前 200 個檔案了,還可以針對檔案字尾作區分,計算總耗時等等。經過排查,我們發現一半的編譯時間都花在了編譯 protobuf 檔案上。
工程設定
除了針對超長耗時的檔案進行 case-by-case 的分析外,另一種方案是調整工程設定。一般來說,我們的持續整合工具主要是用來給產品經理或者測試人員使用,用來體驗功能或者驗證 Bug,除非是需要上架 App Store,否則並不需要關心執行時效能。然而在手機上使用的 Release 模式,預設會開啟各種優化,這些優化都是犧牲編譯效能,換取執行時速度,對於上架的包而言無可厚非,但對於那些 Daily Build 包來說,就顯得得不償失了。
因此,加速打包的思路和優化的思路是完全互逆的,我們要做的就是關閉一切可能的優化。這裡推薦一篇文章:關於Xcode編譯效能優化的研究工作總結,可以說相當全面了。
經過對其中各個引數的查詢資料和嘗試關閉,按照提升速度的降序排列,簡單整理幾個:
- 僅支援 armv7 指令集。手機上的指令集都屬於 ARM 系列,從老到新依次是 armv7、armv7s 和 arm64。新的指令集可以相容舊的機型,但舊的機型不能相容新的指令集。預設情況下我們打出來的包會有 armv7 和 arm64 兩種指令集, 前者負責兜底,而對於支援 arm64 指令集的機型來說,使用最新的指令集可以獲得更好的效能。當然代價就是生成兩種指令集花費了更多時間。所以在急速打包模式下,我們只生成 armv7 這種最老的指令集,犧牲了執行時效能換取編譯速度。
- 關閉編譯優化。優化的基本原理是犧牲編譯時效能,追求執行時效能。常見的優化有編譯時刪除無用程式碼,保留除錯資訊,函式內聯等等。因此提升打包速度的祕訣就是反其道而行之,犧牲執行時效能來換取編譯時效能。筆者做的兩個最主要的優化是把
Optimize level
改成 O0,表示不做任何優化。 - 使用虛擬磁碟。編譯過程中需要大量的磁碟 IO,這主要發生在
Derived Data
目錄下,因此如果記憶體足夠,可以考慮劃出 4G 左右的記憶體,建一個虛擬磁碟,這樣將會把磁碟 IO 優化為 記憶體 IO,從而提高速度。由於打包機器每次都會重編譯,因此並不需要擔心重啟機器後快取丟失的問題。 - 不生成 dYSM 檔案,前文已經介紹過。
- 一些其他的選項,參考前面推薦的文章。
在以上幾個操作中,精簡指令集的作用最大,大約可以把編譯時間從 45 min 減少到 30min 以內,配合關閉編譯優化,可以進一步把打包時間減少到 20min。虛擬磁碟大約可以減少兩三分鐘的編譯時間,dSYM 耗時大約二十秒,其它選項的優化程度更低,大約在幾秒左右,沒有精確測算。
因此,一般來說 只要精簡指令集並關閉優化即可,有條件的機器可以使用虛擬磁碟,不建議再做其它修改。
二進位制化
二進位制化主要指的是利靜態庫代替原始碼,避免編譯。前文已經介紹過如何分析檔案的耗時,因此二進位制化的收益非常容易計算出來。由於團隊分工問題,筆者沒有什麼二進位制化的經驗,一般來說這個優化比較適合基礎架構組去實施。
硬體加速
以上主要是通過修改軟體的方式來加速打包,自從公司申請了 2013 年款 Mac Pro(Xeon-E5 1630 6 核 12 執行緒,16G 記憶體,256G SSD 標配,下文簡稱 Mac Pro)後,不需要修改任何配置,僅僅是簡單的遷移打包機器,就可以把打包時間降低到 15 min,配和上一節中的前三條優化,最終的打包時間大概在 10min 以內。
在我的黑蘋果(i7 7820x 8 核 16 執行緒,16G 記憶體,三星 PM 961 512G SSD,下文簡稱黑蘋果)上,即使不開啟任何優化,從零開始編譯也僅需 5min。如果將 protobuf 檔案二進位制化,再配合一些工程設定的優化,我不敢想象需要花多長時間,預計在 4min 左右吧,速度提升了大概 11 倍。
編譯是一個考驗多核效能的操作,在我的黑蘋果上,編譯時可以看到 8 個 CPU 的負載都達到了 100%,因此在一定範圍內(比如 10 核以內),提升 CPU 核數遠比提升單核主頻對編譯速度的影響大。至於某些 20 核以上、單核效能較低的 CPU 編譯效能如何,希望有經驗的讀者給予反饋。
優化點總結
下表總結了文章中提到的各種優化手段帶來的速度提升,參考原始時間均為 45 min(打包機器:13 寸 MacBook Pro):
方案序號 | 優化方案 | 優化後耗時 (min) | 時間減少百分比 |
---|---|---|---|
1 | 不常修改的檔案二進位制化 | 25 | 44.4% |
2 | 精簡指令集 | 27 | 40% |
3 | 關閉編譯優化 | 38 | 15.6% |
4 | 使用 Mac Pro | 15 | 66.7% |
5 | 虛擬磁碟 | 42 | 6.7% |
6 | 公司現行方案(2+3+4+5) | 9 | 80% |
7 | 黑蘋果 | 5 | 88.9% |
8 | 終極方案(1+2+3+5+7) | 4(預計) | 91.1%(預計) |
嚴格意義上講,文章有點標題黨了,因為一句話來說就是:
能用硬體解決的問題,就不要用軟體解決。