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