微信團隊分享:極致優化,iOS版微信編譯速度3倍提高的實踐總結

一、引言

歲月真是個養豬場,這幾年,人胖了,微信代碼也翻了。html

記得 14 年轉崗來微信時,用本身筆記本編譯微信工程才十來分鐘。現在用公司配的 17 年款 27-inch iMac 編譯要接近半小時;偶然間更新完代碼,又莫名其妙須要全新編譯。在這麼低的編譯效率下,開發心情受到嚴重影響。前端

因而年初我向上頭請示,優化微信編譯效率,上頭也贊成了。git

二、現有方案

在動手以前,先搜索目前已有方案,大概狀況以下。github

2.1 優化工程配置

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 也相似處理。

2.2 使用 CocoaPods 管理第三方庫

這是業界經常使用的作法,利用 cocoapods 插件 cocoapods-packager 將任意的 pod 打包成 Static Library,省去重複編譯的時間;但缺點是不方便調試源碼,若是庫代碼反覆修改,須要從新生成二進制並上傳到內部服務器,等等。

2.3 CCache

CCache 是一個可以把編譯的中間產物緩存起來的工具,不須要過多修改項目配置,也不須要修改開發工具鏈。Xcode 9 有個很偶然的 bug,在源碼沒有任何修改的狀況下常常觸發全新編譯,用 CCache 很好的解決這一問題。但隨着 Xcode 10 修復全量編譯問題,這一方案逐步棄用了。

2.4 distcc

distcc 是一個分佈式編譯工具,它原理是把本地多個編譯任務分發到網絡中多個機器,其餘機器編譯完成後,再把產物返回給本機上執行連接,最終獲得編譯結果。

2.5 硬件解決

如把 Derived Data 目錄放到由內存建立的虛擬磁盤,或者購買最新款的 iMac Pro...

三、實踐過程

3.1 優化編譯選項

1)優化頭文件搜索路徑:

把一些遞歸引用路徑去了後,總體編譯速度快了 20s。

2)關閉 Enable Index-While-Building Functionality:

這選項無心中找到的(Xcode 9 的新特性?),默認打開,做用是 Xcode 編譯時會順帶創建代碼索引,但影響編譯速度。關閉後總體編譯速度快 80s(Xcode 會換回之前的方式,在空閒時間創建代碼索引)。

3.2 優化 kinda

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%,效果特別明顯。

3.3 使用 PCH 預編譯頭文件

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 分鐘,使人不滿意。

若是繼續優化,得從編譯器下手。正如咱們日常作的客戶端性能優化,在優化以前,先分析原理,輸出每一個地方的耗時,針對耗時作相對應的優化。

4.1 編譯原理

編譯器,是把一種語言(一般是高級語言)轉換爲另外一種語言(一般是低級語言)的程序。

大多數編譯器由三部分組成: 

各部分的做用以下:

前端(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)把若干個目標文件連接在一塊兒,生成可執行文件。

4.2 分析耗時

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 頭文件裏的頭文件引用,儘可能不包含其餘頭文件。

4.3 解決耗時

一般咱們寫代碼時,若是用到某個類,就直接 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

七、參考文獻

[1] 如何將 iOS 項目的編譯速度提升5倍

[2] 深刻剖析 iOS 編譯 Clang / LLVM

[3] Clang之語法抽象語法樹AST

[4] time-trace: timeline / flame chart profiler for Clang

[5] Introduction to the Clang AST

相關文章
相關標籤/搜索