抖音 Swift 編譯優化 - 基于自定義 Toolchain 編譯提速 60%
優化方案基于 Swift Toolchain 源碼,本文不再探討 Toolchain 相關基本概念及配置流程等,僅聚焦方案本身。?
背景
隨著混編落地的業務場景越來越多,越來越大,開發中出現的性能痛點開始顯現,問題很明顯集中在被 Swift 環境所依賴的 OC 倉的頭文件改動上。因此基建架構把重點放在接口層依賴的性能分析上,力求解決性能瓶頸。
抖音基礎技術團隊借助自定義 Toolchain 能力,通過自定義編譯參數,裁剪 Clang Header 指定內容,最終實現編譯提速 60% 。
本方案已于 2022 年 11 月底上線,在抖音穩定運行近 5 個月。下面就讓我們一起回顧下整個方案從提出到落地的全過程。
初步分析
在混編場景下,若要確保 OC 與 Swift 間盡可能充分地互操作,則模塊化的啟用無法僅用在 Swift 編譯上下文中——Swift編譯導出的 Clang Header,在工程中以??$(project_name)-Swift.h?
?形式出現,將其需要被 re-export 引用的 OC 依賴項,以模塊的形式導出,這就意味著若 OC 編譯不啟用模塊化,則無法正確使用 Swift 提供的頭文件。
如圖,二者不可兼得,Objc Pod D 為了能夠解析語句??@import A;?
?而引入??A.modulemap?
?,則其與 A 的互操作不可能再基于文本導入的邏輯,而全面轉向模塊化。
對于抖音而言,巨型 OC 項目的大量頭文件傳遞依賴的歷史包袱,使得在 OC 編譯中引入模塊化是一場災難。模塊化環境下,緩存系統決議是否要命中 .o 緩存的耗時,比文本環境下重新編譯耗時還要長;增量編譯時,也會導致廣泛的模塊重編,改動一個頭文件,就要等待數分鐘。
傳遞依賴治理是一項長期工程,但編譯優化等不了那么久,我們需要一個可以快速解決的方案。
優化效果
在介紹方案之前,先上結論。
在抖音工程中選取代碼量最大的 OC&Swift 混編倉庫進行測試:
- OC 增量編譯:選取被 Swift 依賴的 OC 接口層頭文件進行改動,編譯耗時降低 60%
- Swift 增量編譯:選取被 OC 依賴的 Swift public class 進行改動,編譯耗時相近,無變化
- 全量編譯:清除本地編譯緩存進行 clean build,編譯耗時降低 17%
可見該方案對編譯速度的巨大提升。接下來就讓我們回顧一下整個方案從預研到上線的過程。
方案原理
解決問題的關鍵在于降低將 OC 頭文件預編譯耗時,這里有兩個思路:
- 長期:模塊解析的耗時根源在于傳遞依賴,模塊的特性導致不同模塊內包含的頭文件的傳遞依賴會將模塊增量重編的影響范圍擴展到很大。業務庫在現有工程架構體系下已經嚴格控制了接口層傳遞依賴,因此長期方案會逐步推動治理基礎庫的傳遞依賴問題。
- 短期:將 OC 頭文件預編譯轉回文本導入,即裁剪
-fmodule-map-files
注入,但依然保留對 OC 調用 Swift 代碼的支持
Swift會將自身接口層(即 public/open )聲明使用到的 C/OC 模塊,在??xxx-Swift.h?
?中以??@import aaa?
?形式給出,這就要求 OC 側使用該頭文件時也需要將這些模塊對 OC 側可見,我們想要達成目的,就需要對這些聲明進行裁剪。這需要自定義工具鏈的支持。
本次優化方案效果測試針對的是短期方案。
通過修改編譯器,對 Swift 編譯生成的 Clang Header Interface 進行裁剪,刪除掉系統庫以外的 @import,而 OC 側引用該頭文件的地方手動補全依賴。即以暫時犧牲接口self-contained為代價,使OC側不必再關心模塊相關的因素。為支持更細粒度的控制,通過向編譯器注入編譯參數,以針對不同組件控制此功能的啟用,以及實現更具體的裁剪內容。
而對于??-fmodule-map-files?
?的裁剪相對容易,只需修改??OTHER_CFLAGS?
?即可關閉??-fmodule-map-files?
?的注入。
預研
方案拆解
我們先來對整個方案做一個任務拆解,可以分析出各部分的依賴關系,節省預研階段的耗時。
一個工具鏈相關的落地方案,必須保證其穩定性,因此一定是可以通過一種簡單的方式進行外部控制開關的。
從發版角度講,工具鏈發版并不像業務代碼,和存放在開發倉庫的配置一樣可以靈活發版,因此應盡可能保證工具鏈代碼的穩定,非必要不修改。
基于這兩個原則,我們可以拆解為:
1.分析 ??swiftc?
? 的參數解析機制,在編譯時的參數列表中拼接新的自定義參數以控制裁剪能力。??swiftc?
? 是實際的前端 ??swift-frontend?
? 的一個入口,下面會詳細提到,向 swiftc 注入的參數列表,在各 ??swift-frontend?
? 子任務中并不總是以相同的全集出現,作用機制需要進一步分析。
2.基于細粒度控制的考量,參數選擇傳入一個配置文件,包含一個白名單,來確定哪些??@import Module?
?是可以留下的。我們也有考慮過黑名單,但實際工程的依賴情況是復雜的,不論是 Cocoapods 還是 seer ,都僅能描述工程層面的依賴情況,而不能保證實際編譯時的依賴情況,難以構建一個全面的業務黑名單。而系統庫白名單是相對固定的,并不需要經常維護。
3.尋找生成 -Swift.h 的具體函數,以及寫入??@import Module;?
?的邏輯以進行裁剪。
4.在寫入邏輯處加載白名單文件并進行過濾。
5.通過本地驗證,完成無感知下發 Toolchain 的驗證,打出測試 Toolchain 。
6.灰度驗證。
7.合碼發版上線。
快速驗證
想要驗證方向是否正確,同時給予飽受編譯耗時困擾的業務同學以信心,需要先找到最關鍵的點快速驗證。
因此我們決定先直接整體關掉所有 -Swift.h 的??@import Module;?
?生成邏輯。此時我們對整體 Swift 源碼的認知還較為模糊,但我們只需要去尋找類似??<< "@import"?
?或其他寫文件的邏輯再去篩選即可,所幸這一過程沒有花費太久。
我們很快找到了這塊邏輯,并直接將??out << "@import " << Name.str() << ";\n";?
?注釋掉,打包驗證成功,出具了本文開頭的數據報告,給業務同學吃下一顆定心丸。
接下來,我們就可以穩健地按部就班地去執行其他任務了。
開發、調試
swift-frontend 參數解析流程
于是我們將目光轉向了其他在前端層級應用的原生參數,并參考它們的寫法。很快我們將目光鎖定在??module-cache-path?
?,這是一個 Swift 前端編譯必需的參數,指定模塊緩存位置,且后面傳入一個路徑,完全符合我們的要求。
根據對該參數的分析,可得 -frontend 階段的參數解析流程,具體調研過程不再展開,直接簡單過下流程。
簡單流程如上圖,下面具體過下修改參數解析流程的代碼位置。
定義
此處使用了一種十分類似 python 的,LLVM 推出的 TableGen (https://llvm.org/docs/TableGen/)語言,后面這些 flag ,我們需要的是
- FrontendOption 前端參數,擁有這個flag才會進入前端參數解析流程,而 Clang Header 生成的過程就發生在前端流程中
- ArgumentIsPath 參數為路徑,告知編譯器該參數后攜帶路徑字符串作為參數
仿照這種形式的自定義參數:
第二個 EQ 定義其實是一種 Alias,定義了可以使用" flag=arg "這種形式來進行傳參,沒有其他額外作用。
通過??tablegen?
?工具,把 Options.td 的內容生成為 Options.inc ,如下圖
結合 Swift 源碼中 Options.h 的 OPTION 定義,引入并提供給 cpp 代碼使用
解析
解析過程發生在 CompilerInvOCation 的參數解析流程中
在 ArgsToFrontendOptionsConverter 方法中,從參數列表讀取需要的信息,賦值到 Opts 當中
Opts 是一個 FrontOptions 類型的實例,我們需要在這里定義一個字符串以存儲我們需要的參數
Opts 會在整個前端流程中流轉,為各環節提供必要參數。
Clang Header 生成流程
調用過程的流程圖如下,PrintAsClang 是一個相對獨立的模塊,我們改動只需要關注這兩個標紅環節即可。
增加入參定義
在原方法定義上加入兩個傳參,分別是我們傳入的白名單文件路徑,以及診斷信息,診斷信息后面會提到,用于提示一些自定義錯誤。
這里也是相同,增加兩個參數定義。
白名單解析
printAsClangHeader 這里是我們的主要修改之一,在這個 function_ref 當中,我們對 allow list path 指向的文件進行了內容解析,得到白名單指定的模塊名稱,以參數形式傳遞給下一個環節。
writeImports 方法在原有基礎上增加一個 function_ref,可以理解為 ??lambda?
? 表達式,就是我們剛剛做的白名單解析的過程。
在具體寫入??@import Module;?
?處進行白名單篩選,在白名單內部的允許寫入,否則跳過。
自定義診斷信息
DiagnosticsClangImporter.def 中加入兩個自定義條目,error 用于提示解析錯誤,note 僅提示白名單為空,為空是允許的操作,此時退化為默認邏輯。
前面我們在方法定義中傳入了 Diags 實例,想要提示信息,只需簡單調用即可,note 只會輸出到日志,error 則會打斷編譯流程。
驗證、上線
可使用云構建機器打出測試 Toolchain,下載至本地,集成到 Xcode 中在抖音驗證即可。
將自定義參數加入到指定混編組件的編譯參數當中,即可成功構建。
后記
Swift 工具鏈定制是一個擁有無限可能的方向,包括編譯優化這類效率提升的工作等等,都可以在底層進行傳統意義上的架構層所難以進行的深度優化,后續針對這塊可做的事還有很多,相信有更多的經驗可以分享給到大家。