歲月真是個養豬場,這幾年,人胖了,微信代碼也翻了。html
記得 14 年轉崗來微信時,用本身筆記本編譯微信工程才十來分鐘。現在用公司配的 17 年款 27-inch iMac 編譯要接近半小時;偶然間更新完代碼,又莫名其妙須要全新編譯。在這麼低的編譯效率下,開發心情受到嚴重影響。前端
因而年初我向上頭請示,優化微信編譯效率,上頭也贊成了。git
在動手以前,先搜索目前已有方案,大概狀況以下。github
1)將 Debug Information Format 改成 DWARF:objective-c
Debug 時是不須要生成符號表,能夠檢查一會兒工程(尤爲開源庫)有沒有設置正確。chrome
2)將 Build Active Architecture Only 改成 Yes:小程序
Debug 時是不須要生成全架構,能夠檢查一會兒工程(尤爲開源庫)有沒有設置正確。後端
3)優化頭文件搜索路徑:緩存
避免工程 Header Search Paths 設置了路徑遞歸引用:性能優化
Xcode 編譯源文件時,會根據 Header Search Paths 自動添加 -I 參數,若是遞歸引用的路徑下子目錄越多,-I 參數也越多,編譯器預處理頭文件效率就越低,因此不能簡單的設置路徑遞歸引用。一樣 Framework Search Paths 也相似處理。
這是業界經常使用的作法,利用 cocoapods 插件 cocoapods-packager 將任意的 pod 打包成 Static Library,省去重複編譯的時間;但缺點是不方便調試源碼,若是庫代碼反覆修改,須要從新生成二進制並上傳到內部服務器,等等。
CCache 是一個可以把編譯的中間產物緩存起來的工具,不須要過多修改項目配置,也不須要修改開發工具鏈。Xcode 9 有個很偶然的 bug,在源碼沒有任何修改的狀況下常常觸發全新編譯,用 CCache 很好的解決這一問題。但隨着 Xcode 10 修復全量編譯問題,這一方案逐步棄用了。
distcc 是一個分佈式編譯工具,它原理是把本地多個編譯任務分發到網絡中多個機器,其餘機器編譯完成後,再把產物返回給本機上執行連接,最終獲得編譯結果。
如把 Derived Data 目錄放到由內存建立的虛擬磁盤,或者購買最新款的 iMac Pro...
1)優化頭文件搜索路徑:
把一些遞歸引用路徑去了後,總體編譯速度快了 20s。
2)關閉 Enable Index-While-Building Functionality:
這選項無心中找到的(Xcode 9 的新特性?),默認打開,做用是 Xcode 編譯時會順帶創建代碼索引,但影響編譯速度。關閉後總體編譯速度快 80s(Xcode 會換回之前的方式,在空閒時間創建代碼索引)。
kinda 是今年引入支付跨平臺框架(C++),但編譯速度奇慢,一個源文件編譯都要 30s。另外生成的二進制大小在 App 佔比較高,感受有很多冗餘代碼,理論上減小冗餘代碼也能加快編譯速度。
通過分析 LinkMap 文件和使用 Xcode Preprocess 某些源文件,發現有如下問題:
1)proto 文件生成的代碼較多;
2)某個基類/宏使用了大量模版。
對於問題一:能夠設置 proto 文件選項爲 optimize_for=CODE_SIZE 來讓 protobuf 編譯器生成精簡版代碼。但我是用本身的工具生成(具體原理可看《iOS版微信安裝包「減肥」實戰記錄》),代碼更少。
對於問題二:因爲模版是編譯期間的多態(增長代碼膨脹和編譯時間),因此能夠把模版基類改爲虛基類這種運行時的多態;另外推薦使用 hyper_function 取代 std::function,使得基類用通用函數指針,就能存儲任意 lambda 回調函數,從而避免基類模板化。
例如:
template<typenameRequest, typenameResponse>
classBaseCgi {
public:
BaseCgi(Request request, std::function<void(Response &)> &callback) {
_request = request;
_callback = callback;
}
voidonRequest(std::vector<uint8_t> &outData) {
_request.toData(outData);
}
voidonResponse(std::vector<uint8_t> &inData) {
Response response;
response.fromData(inData);
callback(response);
}
public:
Request _request;
std::function<void(Response &)> _callback;
};
classCgiA : publicBaseCgi<RequestA, ResponseA> {
public:
CgiA(RequestA &request, std::function<void(ResponseA &)> &callback) :
BaseCgi(request, callback) {}
};
可改爲:
class BaseRequest {
public:
virtual void toData(std::vector<uint8_t> &outData) = 0;
};
classBaseResponse {
public:
virtualvoidfromData(std::vector<uint8_t> &outData) = 0;
};
classBaseCgi {
public:
template<typenameRequest, typenameResponse>
BaseCgi(Request &request, hyper_function<void(Response &)> callback) {
_request = newRequest(request);
_response = newResponse;
_callback = callback;
}
voidonRequest(std::vector<uint8_t> &outData) {
_request->toData(outData);
}
voidonResponse(std::vector<uint8_t> &inData) {
_response->fromData(inData);
_callback(*_response);
}
public:
BaseRequest *_request;
BaseResponse *_response;
hyper_function<void(BaseResponse &)> _callback;
};
classRequestA : publicBaseRequest { ... };
classResponseA : publicBaseResponse { ... };
classCgiA : publicBaseCgi {
public:
CgiA(RequestA &request, hyper_function<void(ResponseA &)> &callback) :
BaseCgi(request, callback) {}
};
BaseCgi 由模版基類變成只有構造函數是模板的基類,onRequest 和 onResponse 邏輯代碼並不由於基類模版實例化而被「複製黏貼」。
通過上述優化:總體編譯速度快了 70s,而 kinda 二進制也減小了 60%,效果特別明顯。
PCH(Precompile Prefix Header File)文件,也就是預編譯頭文件,其文件裏的內容能被項目中的其餘全部源文件訪問。一般放一些通用的宏和頭文件,方便編寫代碼,提升效率。
另外 PCH 文件預編譯完成後,後面用到 PCH 文件的源文件編譯速度也會加快。缺點是 PCH 文件和 PCH 引用到的頭文件內容一旦發生變化,引用到 PCH 的全部源文件都要從新編譯。因此使用時要謹慎。
在 Xcode 裏設置 Prefix Header 和 Precompile Prefix Header 便可使用 PCH 文件並對它進行預編譯:
微信使用 PCH 預編譯後:編譯速度提高很是可觀,快了接近 280s。
經過上述優化,微信工程的編譯時間由原來的 1,626.4s 降低到 1,182.8s,快了將近 450s,但仍然須要 20 分鐘,使人不滿意。
若是繼續優化,得從編譯器下手。正如咱們日常作的客戶端性能優化,在優化以前,先分析原理,輸出每一個地方的耗時,針對耗時作相對應的優化。
編譯器,是把一種語言(一般是高級語言)轉換爲另外一種語言(一般是低級語言)的程序。
大多數編譯器由三部分組成:
各部分的做用以下:
前端(Frontend):負責解析源碼,檢查錯誤,生成抽象語法樹(AST),並把 AST 轉化成類彙編中間代碼;
優化器(Optimizer):對中間代碼進行架構無關的優化,提升運行效率,減小代碼體積,例如刪除 if (0) 無效分支;
後端(Backend):把中間代碼轉換成目標平臺的機器碼。
LLVM 實現了更通用的編譯框架,它提供了一系列模塊化的編譯器組件和工具鏈。首先它定義了一種 LLVM IR(Intermediate Representation,中間表達碼)。Frontend 把原始語言轉換成 LLVM IR;LLVM Optimizer 優化 LLVM IR;Backend 把 LLVM IR 轉換爲目標平臺的機器語言。這樣一來,無論是新的語言,仍是新的平臺,只要實現對應的 Frontend 和 Backend,新的編譯器就出來了。
在 Xcode,C/C++/ObjC 的編譯器是 Clang(前端)+LLVM(後端),簡稱 Clang。
Clang 的編譯過程有這幾個階段:
➜ clang -ccc-print-phases main.m
0: input, "main.m", objective-c
1: preprocessor, {0}, objective-c-cpp-output
2: compiler, {1}, ir
3: backend, {2}, assembler
4: assembler, {3}, object
5: linker, {4}, image
6: bind-arch, "x86_64", {5}, image
1)預處理:
這階段的工做主要是頭文件導入,宏展開/替換,預編譯指令處理,以及註釋的去除。
2)編譯:
這階段作的事情比較多,主要有:
a. 詞法分析(Lexical Analysis):將代碼轉換成一系列 token,如大中小括號 paren'()' square'[]' brace'{}'、標識符 identifier、字符串 string_literal、數字常量 numeric_constant 等等;
b. 語法分析(Semantic Analysis):將 token 流組成抽象語法樹 AST;
c. 靜態分析(Static Analysis):檢查代碼錯誤,例如參數類型是否錯誤,調用對象方法是否有實現;
d. 中間代碼生成(Code Generation):將語法樹自頂向下遍歷逐步翻譯成 LLVM IR。
3)生成彙編代碼:
LLVM 將 LLVM IR 生成當前平臺的彙編代碼,期間 LLVM 根據編譯設置的優化級別 Optimization Level 作對應的優化(Optimize),例如 Debug 的 -O0 不須要優化,而 Release 的 -Os 是儘量優化代碼效率並減小體積。
4)生成目標文件:
彙編器(Assembler)將彙編代碼轉換爲機器代碼,它會建立一個目標對象文件,以 .o 結尾。
5)連接:
連接器(Linker)把若干個目標文件連接在一塊兒,生成可執行文件。
Clang/LLVM 編譯器是開源的,咱們能夠從官網下載其源碼,根據上述編譯過程,在每一個編譯階段埋點輸出耗時,生成定製化的編譯器。在本身準備動手的前一週,國外大神 Aras Pranckevičius 已經在 LLVM 項目提交了 rL357340 修改:clang 增長 -ftime-trace 選項,編譯時生成 Chrome(chrome://tracing) JSON 格式的耗時報告,列出全部階段的耗時。
效果以下:
說明以下:
1)總體編譯(ExecuteCompiler)耗時 8,423.8ms
2)其中前端(Frontend)耗時 5,307.9ms,後端(Backend)耗時 3,009.6ms
3)而前端編譯裏頭文件 SourceA 耗時 xx ms,B 耗時 xx ms,...
4)頭文件處理裏 Parse ClassA 耗時 xx ms,B 耗時 xx ms,...
5)等等
這就是我想要的耗時報告!
接下來修改工程 CC={YOUR PATH}/clang,讓 Xcode 編譯時使用本身的編譯器;同時編譯選項 OTHER_CFLAGS 後面增長 -ftime-trace,每一個源文件編譯後輸出耗時報告。
最終把全部報告匯聚起來,造成總體的編譯耗時:
由總體耗時能夠看出:
1)編譯器前端處理(Frontend)耗時 7,659.2s,佔總體 87%;
2)而前端處理下頭文件處理(Source)耗時 7,146.2s,佔總體 71.9%!
猜想:頭文件嵌套嚴重,每一個源文件都要引入幾十個甚至幾百個頭文件,每一個頭文件源碼要作預處理、詞法分析、語法分析等等。實際上源文件不須要使用某些頭文件裏的定義(如 class、function),因此編譯時間才那麼長。
因而又寫了個工具,統計全部頭文件被引用次數、總處理時間、頭文件分組(指一個耗時頂部的頭文件所引用到的全部子頭文件的集合)。
列出一份表格(截取 Top10):
如上表所示:
Header1 處理時間 1187.7s,被引用 2,304 次;
Header2 處理時間 1,124.9s,被引用 3,831 次;
後面 Header3~10 都是被 Header1 引用。
因此能夠嘗試優化 TopN 頭文件裏的頭文件引用,儘可能不包含其餘頭文件。
一般咱們寫代碼時,若是用到某個類,就直接 include 該類聲明所在頭文件,但在頭文件,咱們能夠用前置聲明解決。
所以優化頭文件思路很簡單:就是能用前置聲明,就用前置聲明替代 include。
實際上改動量很是大:我跟組內另外的同事 vakeee 分工優化 Header1 和 Header2,花了整整 5 個工做日,才改完。效果仍是有,總體編譯時間減小 80s。
但須要優化的頭文件還有幾十個,咱們不可能繼續作這種體力活。所以咱們能夠作這樣的工具,經過 AST 找到代碼裏出現的標識符(包括類型、函數、宏),以及標識符定義所在文件,而後分析是否須要 include 它定義所在文件。
先看看代碼如何轉換 AST,如如下代碼:
// HeaderA.h
struct StructA {
intval;
};
// HeaderB.h
structStructB {
intval;
};
// main.c
#include "HeaderA.h"
#include "HeaderB.h"
inttestAndReturn(structStructA *a, structStructB *b) {
returna->val;
}
控制檯輸入:
➜ TestContainer clang -Xclang -ast-dump -fsyntax-only main.c
TranslationUnitDecl 0x7f8f36834208 <<invalid sloc>> <invalid sloc>
|-RecordDecl 0x7faa62831d78 <./HeaderA.h:12:1, line:14:1> line:12:8 struct StructA definition
| `-FieldDecl 0x7faa6383da38 <line:13:2, col:6> col:6 referenced val 'int'
|-RecordDecl 0x7faa6383da80 <./HeaderB.h:12:1, line:14:1> line:12:8 struct StructB definition
| `-FieldDecl 0x7faa6383db38 <line:13:2, col:6> col:6 val 'int'
`-FunctionDecl 0x7faa6383de50 <main.c:35:1, line:37:1> line:35:5 testAndReturn 'int (struct StructA *, struct StructB *)'
|-ParmVarDecl 0x7faa6383dc30 <col:19, col:35> col:35 used a 'struct StructA *'
|-ParmVarDecl 0x7faa6383dd40 <col:38, col:54> col:54 b 'struct StructB *'
`-CompoundStmt 0x7faa6383dfc8 <col:57, line:37:1>
`-ReturnStmt 0x7faa6383dfb8 <line:36:2, col:12>
`-ImplicitCastExpr 0x7faa6383dfa0 <col:9, col:12> 'int'<LValueToRValue>
`-MemberExpr 0x7faa6383df70 <col:9, col:12> 'int'lvalue ->val 0x7faa6383da38
`-ImplicitCastExpr 0x7faa6383df58 <col:9> 'struct StructA *'<LValueToRValue>
`-DeclRefExpr 0x7faa6383df38 <col:9> 'struct StructA *'lvalue ParmVar 0x7faa6383dc30 'a''struct StructA *'
從上能夠看出:每一行包括 AST Node 的類型、所在位置(文件名,行號,列號)和結點描述信息。頭文件定義的類也包含進 AST 中。AST Node 常見類型有 Decl(如 RecordDecl 結構體定義,FunctionDecl 函數定義)、Stmt(如 CompoundStmt 函數體括號內實現)。
Clang AST 有三個重要的基類:ASTFrontendAction、ASTConsumer 以及 RecursiveASTVisitor。
ClangTool 類讀入命令行配置項後初始化 CompilerInstance;CompilerInstance 成員函數 ExcutionAction 會調用 ASTFrontendAction 3 個成員函數 BeginSourceFile(準備遍歷 AST)、Execute(解析 AST)、EndSourceFileAction(結束遍歷)。
ASTFrontendAction 有個重要的純虛函數 CreateASTConsumer(會被本身 BeginSourceFile 調用),用於返回讀取 AST 的 ASTConsumer 對象。
代碼以下:
class MyFrontendAction : public clang::ASTFrontendAction {
public:
virtualstd::unique_ptr<clang::ASTConsumer> CreateASTConsumer(clang::CompilerInstance &CI, llvm::StringRef file) override {
TheRewriter.setSourceMgr(CI.getASTContext().getSourceManager(), CI.getASTContext().getLangOpts());
returnllvm::make_unique<MyASTConsumer>(&CI);
}
};
intmain(intargc, constchar**argv) {
clang::tooling::CommonOptionsParser op(argc, argv, OptsCategory);
clang::tooling::ClangTool Tool(op.getCompilations(), op.getSourcePathList());
intresult = Tool.run(clang::tooling::newFrontendActionFactory<MyFrontendAction>().get());
returnresult;
}
ASTConsumer 有若干個能夠 override 的方法,用來接收 AST 解析過程當中的回調,其中之一是工具用到的 HandleTranslationUnit 方法。當編譯單元 TranslationUnit 的 AST 完整解析後,HandleTranslationUnit 會被回調。咱們在 HandleTranslationUnit 使用 RecursiveASTVisitor 對象以深度優先的方式遍歷 AST 全部結點。
代碼以下:
class MyASTVisitor
: public clang::RecursiveASTVisitor<MyASTVisitor> {
public:
explicitMyASTVisitor(clang::ASTContext *Ctx) {}
boolVisitFunctionDecl(clang::FunctionDecl* decl) {
// FunctionDecl 下的全部參數聲明容許前置聲明取代 include
// 如上面 Demo 代碼裏 StructA、StructB
returntrue;
}
boolVisitMemberExpr(clang::MemberExpr* expr) {
// 被引用的成員所在的類,須要 include 它定義所在文件
// 如 StructA
returntrue;
}
boolVisitXXX(XXX) {
returntrue;
}
// 同一個類型,可能出現若干次斷定結果
// 若是其中一個判斷的結果須要 include,則 include
// 不然使用前置聲明代替 include
// 例如 StructA 只能 include,StructB 能夠前置聲明
};
class MyASTConsumer : public clang::ASTConsumer {
private:
MyASTVisitor Visitor;
public:
explicitMyASTConsumer(clang::CompilerInstance *aCI)
: Visitor(&(aCI->getASTContext())) {}
void HandleTranslationUnit(clang::ASTContext &context) override {
clang::TranslationUnitDecl *decl = context.getTranslationUnitDecl();
Visitor.TraverseTranslationUnitDecl(decl);
}
};
工具框架大體如上所示。
不過早在 2011 年 Google 內部作了個基於 Clang libTooling 的工具 include-what-you-use,用來整理 C/C++ 頭文件。
這個工具的使用效果以下:
➜ include-what-you-use main.c
HeaderA.h has correct #includes/fwd-decls)
HeaderB.h has correct #includes/fwd-decls)
main.c should add these lines:
struct StructB;
main.c should remove these lines:
- #include "HeaderB.h" // lines 2-2
The full include-list formain.c:
#include "HeaderA.h" // for StructA
struct StructB;
咱們在 IWYU 基礎上,增長了 ObjC 語言的支持,並加強它的邏輯,讓結果更好看(一般 IWYU 處理完後,會引入不少頭文件和前置聲明,咱們作剪枝處理,進一步去掉多餘的頭文件和前置聲明,篇幅限制就很少作解釋了)。
微信源碼經過工具優化頭文件引入後,總體編譯時間降到了 710s。另外頭文件依賴的減小,也能下降因修改頭文件引發大規模源碼重編的可能性。
咱們再用編譯耗時分析工具分析當前瓶頸:
WCDB 頭文件處理時間太長了,業務代碼(如 Model 類)沒有很好的隔離 WCDB 代碼,把 WINQ 暴露出去,外面被動 include WCDB 頭文件。解決方法有不少,例如 WCDB 相關放 category 頭文件(XXModel+WCDB.h)裏引入,或者跟其餘庫同樣,把 放 PCH。
最終編譯時間優化到 540s 如下,是原來的三分之一,編譯效率獲得巨大的提高。
總結微信的編譯優化方案:
即:
A)優化頭文件搜索路徑;
B)關閉 Enable Index-While-Building Functionality;
C)優化 PB/模版,減小冗餘代碼;
D)使用 PCH 預編譯;
E)使用工具優化頭文件引入;儘可能避免頭文件裏包含 C++ 標準庫。
期待公司的藍盾分佈式編譯 for ObjC;另外能夠把業務代碼模塊化,項目文件按模塊加載,目前 kinda/小程序/mars 在很好的實踐中。(本文同步發佈於:http://www.52im.net/thread-2873-1-1.html)
[3] Clang之語法抽象語法樹AST