標籤:table png 分布式 刪除 使用 note 重複 提高 介紹
如何將 iOS 工程打包速度提升十倍以上
過慢的編譯速度有非常明顯的副作用。一方面,程式員在等待打包的過程中可能會分心,比如刷刷朋友圈,看條新聞等等。這種認知內容相關的切換會帶來很多隱形的時間浪費。另一方面,大部分 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.mCompiling B.mCompiling ...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%(預計) |
嚴格意義上講,文章有點標題黨了,因為一句話來說就是:
能用硬體解決的問題,就不要用軟體解決。
如何將 iOS 工程打包速度提升十倍以上