默認
打賞 發表評論 2
想開發IM:買成品怕坑?租第3方怕貴?找開源自已擼?盡量別走彎路了... 找站長給點建議
微信團隊分享:極致優化,iOS版微信編譯速度3倍提升的實踐總結
閱讀(1827) | 評論(2 收藏2 淘帖1 2

本文來自微信開發團隊WeMobileDev公眾號的原創技術分享,原題“iOS 微信編譯速度優化分享”,即時通訊網收錄時排版及部分文字有修訂和優化。


1、引言


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

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

于是年初我向上頭請示,優化微信編譯效率,上頭也同意了。

微信團隊分享:極致優化,iOS版微信編譯速度3倍提升的實踐總結_a-small.jpg

2、相關文章



3、現有方案


在動手之前,先搜索目前已有方案,大概情況如下。

3.1優化工程配置


1)將 Debug Information Format 改為 DWARF:
Debug 時是不需要生成符號表,可以檢查一下子工程(尤其開源庫)有沒有設置正確。

2)將 Build Active Architecture Only 改為 Yes:
Debug 時是不需要生成全架構,可以檢查一下子工程(尤其開源庫)有沒有設置正確。

3)優化頭文件搜索路徑:
避免工程 Header Search Paths 設置了路徑遞歸引用:
微信團隊分享:極致優化,iOS版微信編譯速度3倍提升的實踐總結_1.png

Xcode 編譯源文件時,會根據 Header Search Paths 自動添加 -I 參數,如果遞歸引用的路徑下子目錄越多,-I 參數也越多,編譯器預處理頭文件效率就越低,所以不能簡單的設置路徑遞歸引用。同樣 Framework Search Paths 也類似處理。

3.2使用 CocoaPods 管理第三方庫


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

3.3CCache


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

3.4distcc


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

3.5硬件解決


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

4、實踐過程


4.1優化編譯選項


1)優化頭文件搜索路徑:
把一些遞歸引用路徑去了后,整體編譯速度快了 20s。

2)關閉 Enable Index-While-Building Functionality:
這選項無意中找到的(Xcode 9 的新特性?),默認打開,作用是 Xcode 編譯時會順帶建立代碼索引,但影響編譯速度。關閉后整體編譯速度快 80s(Xcode 會換回以前的方式,在空閑時間建立代碼索引)。

4.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 <typename Request, typename Response>
class BaseCgi {
public:
    BaseCgi(Request request, std::function<void(Response &)> &callback) {
        _request = request;
        _callback = callback;
    }

    void onRequest(std::vector<uint8_t> &outData) {
        _request.toData(outData);
    }

    void onResponse(std::vector<uint8_t> &inData) {
        Response response;
        response.fromData(inData);
        callback(response);
    }

public:
    Request _request;
    std::function<void(Response &)> _callback;
};

class CgiA : public BaseCgi<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;
};

class BaseResponse {
public:
    virtual void fromData(std::vector<uint8_t> &outData) = 0;
};

class BaseCgi {
public:
    template <typename Request, typename Response>
    BaseCgi(Request &request, hyper_function<void(Response &)> callback) {
        _request = new Request(request);
        _response = new Response;
        _callback = callback;
    }
    
    void onRequest(std::vector<uint8_t> &outData) {
        _request->toData(outData);
    }
    
    void onResponse(std::vector<uint8_t> &inData) {
        _response->fromData(inData);
        _callback(*_response);
    }
    
public:
    BaseRequest *_request;
    BaseResponse *_response;
    hyper_function<void(BaseResponse &)> _callback;
};

class RequestA : public BaseRequest { ... };

class ResponseA : public BaseResponse { ... };

class CgiA : public BaseCgi {
public:
    CgiA(RequestA &request, hyper_function<void(ResponseA &)> &callback) :
        BaseCgi(request, callback) {}
};

BaseCgi 由模版基類變成只有構造函數是模板的基類,onRequest 和 onResponse 邏輯代碼并不因為基類模版實例化而被“復制黏貼”。

經過上述優化:整體編譯速度快了 70s,而 kinda 二進制也減少了 60%,效果特別明顯。

4.3使用 PCH 預編譯頭文件


PCH(Precompile Prefix Header File)文件,也就是預編譯頭文件,其文件里的內容能被項目中的其他所有源文件訪問。通常放一些通用的宏和頭文件,方便編寫代碼,提高效率。

另外 PCH 文件預編譯完成后,后面用到 PCH 文件的源文件編譯速度也會加快。缺點是 PCH 文件和 PCH 引用到的頭文件內容一旦發生變化,引用到 PCH 的所有源文件都要重新編譯。所以使用時要謹慎。

在 Xcode 里設置 Prefix Header 和 Precompile Prefix Header 即可使用 PCH 文件并對它進行預編譯:
微信團隊分享:極致優化,iOS版微信編譯速度3倍提升的實踐總結_2.png

微信使用 PCH 預編譯后:編譯速度提升非?捎^,快了接近 280s。

5、終極優化


通過上述優化,微信工程的編譯時間由原來的 1,626.4s 下降到 1,182.8s,快了將近 450s,但仍然需要 20 分鐘,令人不滿意。

如果繼續優化,得從編譯器下手。正如我們平常做的客戶端性能優化,在優化之前,先分析原理,輸出每個地方的耗時,針對耗時做相對應的優化。

5.1編譯原理


編譯器,是把一種語言(通常是高級語言)轉換為另一種語言(通常是低級語言)的程序。

大多數編譯器由三部分組成:
微信團隊分享:極致優化,iOS版微信編譯速度3倍提升的實踐總結_3.png

各部分的作用如下:

  • 前端(Frontend):負責解析源碼,檢查錯誤,生成抽象語法樹(AST),并把 AST 轉化成類匯編中間代碼;
  • 優化器(Optimizer):對中間代碼進行架構無關的優化,提高運行效率,減少代碼體積,例如刪除 if (0) 無效分支;
  • 后端(Backend):把中間代碼轉換成目標平臺的機器碼。

LLVM 實現了更通用的編譯框架,它提供了一系列模塊化的編譯器組件和工具鏈。首先它定義了一種 LLVM IR(Intermediate Representation,中間表達碼)。Frontend 把原始語言轉換成 LLVM IR;LLVM Optimizer 優化 LLVM IR;Backend 把 LLVM IR 轉換為目標平臺的機器語言。這樣一來,不管是新的語言,還是新的平臺,只要實現對應的 Frontend 和 Backend,新的編譯器就出來了。

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

在 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)把若干個目標文件鏈接在一起,生成可執行文件。

5.2分析耗時


Clang/LLVM 編譯器是開源的,我們可以從官網下載其源碼,根據上述編譯過程,在每個編譯階段埋點輸出耗時,生成定制化的編譯器。在自己準備動手的前一周,國外大神 Aras Pranckevičius 已經在 LLVM 項目提交了 rL357340 修改:clang 增加 -ftime-trace 選項,編譯時生成 Chrome(chrome://tracing) JSON 格式的耗時報告,列出所有階段的耗時。

效果如下:
微信團隊分享:極致優化,iOS版微信編譯速度3倍提升的實踐總結_5.png

說明如下:

  • 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,每個源文件編譯后輸出耗時報告。

最終把所有報告匯聚起來,形成整體的編譯耗時:
微信團隊分享:極致優化,iOS版微信編譯速度3倍提升的實踐總結_6.png

由整體耗時可以看出:

  • 1)編譯器前端處理(Frontend)耗時 7,659.2s,占整體 87%;
  • 2)而前端處理下頭文件處理(Source)耗時 7,146.2s,占整體 71.9%!

猜測:頭文件嵌套嚴重,每個源文件都要引入幾十個甚至幾百個頭文件,每個頭文件源碼要做預處理、詞法分析、語法分析等等。實際上源文件不需要使用某些頭文件里的定義(如 class、function),所以編譯時間才那么長。

于是又寫了個工具,統計所有頭文件被引用次數、總處理時間、頭文件分組(指一個耗時頂部的頭文件所引用到的所有子頭文件的集合)。

列出一份表格(截取 Top10):
微信團隊分享:極致優化,iOS版微信編譯速度3倍提升的實踐總結_7.png

如上表所示:

  • Header1 處理時間 1187.7s,被引用 2,304 次;
  • Header2 處理時間 1,124.9s,被引用 3,831 次;
  • 后面 Header3~10 都是被 Header1 引用。

所以可以嘗試優化 TopN 頭文件里的頭文件引用,盡量不包含其他頭文件。

5.3解決耗時


通常我們寫代碼時,如果用到某個類,就直接 include 該類聲明所在頭文件,但在頭文件,我們可以用前置聲明解決。

因此優化頭文件思路很簡單:就是能用前置聲明,就用前置聲明替代 include。

實際上改動量非常大:我跟組內另外的同事 vakeee 分工優化 Header1 和 Header2,花了整整 5 個工作日,才改完。效果還是有,整體編譯時間減少 80s。

但需要優化的頭文件還有幾十個,我們不可能繼續做這種體力活。因此我們可以做這樣的工具,通過 AST 找到代碼里出現的標識符(包括類型、函數、宏),以及標識符定義所在文件,然后分析是否需要 include 它定義所在文件。

先看看代碼如何轉換 AST,如以下代碼:
// HeaderA.h
struct StructA {
    int val;
};

// HeaderB.h
struct StructB {
    int val;
};

// main.c
#include "HeaderA.h"
#include "HeaderB.h"

int testAndReturn(struct StructA *a, struct StructB *b) {
    return a->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 函數體括號內實現)。

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

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:
    virtual std::unique_ptr<clang::ASTConsumer> CreateASTConsumer(clang::CompilerInstance &CI, llvm::StringRef file) override {
        TheRewriter.setSourceMgr(CI.getASTContext().getSourceManager(), CI.getASTContext().getLangOpts());
        return llvm::make_unique<MyASTConsumer>(&CI);
    }
};

int main(int argc, const char **argv) {
    clang::tooling::CommonOptionsParser op(argc, argv, OptsCategory);
    clang::tooling::ClangTool Tool(op.getCompilations(), op.getSourcePathList());
    int result = Tool.run(clang::tooling::newFrontendActionFactory<MyFrontendAction>().get());

    return result;
}

ASTConsumer 有若干個可以 override 的方法,用來接收 AST 解析過程中的回調,其中之一是工具用到的 HandleTranslationUnit 方法。當編譯單元 TranslationUnit 的 AST 完整解析后,HandleTranslationUnit 會被回調。我們在 HandleTranslationUnit 使用 RecursiveASTVisitor 對象以深度優先的方式遍歷 AST 所有結點。

代碼如下:
class MyASTVisitor
: public clang::RecursiveASTVisitor<MyASTVisitor> {
public:
    explicit MyASTVisitor(clang::ASTContext *Ctx) {}
    
    bool VisitFunctionDecl(clang::FunctionDecl* decl) {
        // FunctionDecl 下的所有參數聲明允許前置聲明取代 include
        // 如上面 Demo 代碼里 StructA、StructB
        return true;
    }
    
    bool VisitMemberExpr(clang::MemberExpr* expr) {
        // 被引用的成員所在的類,需要 include 它定義所在文件
        // 如 StructA
        return true;
    }

    bool VisitXXX(XXX) {
        return true;
    }
    
    // 同一個類型,可能出現若干次判定結果
    // 如果其中一個判斷的結果需要 include,則 include
    // 否則使用前置聲明代替 include
    // 例如 StructA 只能 include,StructB 可以前置聲明
};

class MyASTConsumer : public clang::ASTConsumer {
private:
    MyASTVisitor Visitor;
public:
    explicit MyASTConsumer(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 for main.c:
#include "HeaderA.h"  // for StructA
struct StructB;

我們在 IWYU 基礎上,增加了 ObjC 語言的支持,并增強它的邏輯,讓結果更好看(通常 IWYU 處理完后,會引入很多頭文件和前置聲明,我們做剪枝處理,進一步去掉多余的頭文件和前置聲明,篇幅限制就不多做解釋了)。

微信源碼通過工具優化頭文件引入后,整體編譯時間降到了 710s。另外頭文件依賴的減少,也能降低因修改頭文件引起大規模源碼重編的可能性。

我們再用編譯耗時分析工具分析當前瓶頸:
微信團隊分享:極致優化,iOS版微信編譯速度3倍提升的實踐總結_9.png

WCDB 頭文件處理時間太長了,業務代碼(如 Model 類)沒有很好的隔離 WCDB 代碼,把 WINQ 暴露出去,外面被動 include WCDB 頭文件。解決方法有很多,例如 WCDB 相關放 category 頭文件(XXModel+WCDB.h)里引入,或者跟其他庫一樣,把 <WCDB/WCDB.h> 放 PCH。

最終編譯時間優化到 540s 以下,是原來的三分之一,編譯效率得到巨大的提升。

6、優化總結


總結微信的編譯優化方案:
微信團隊分享:極致優化,iOS版微信編譯速度3倍提升的實踐總結_10.png

即:
  • A)優化頭文件搜索路徑;
  • B)關閉 Enable Index-While-Building Functionality;
  • C)優化 PB/模版,減少冗余代碼;
  • D)使用 PCH 預編譯;
  • E)使用工具優化頭文件引入;盡量避免頭文件里包含 C++ 標準庫。

7、未來展望


期待公司的藍盾分布式編譯 for ObjC;另外可以把業務代碼模塊化,項目文件按模塊加載,目前 kinda/小程序/mars 在很好的實踐中。

8、參考文獻



附錄:QQ、微信團隊原創技術文章匯總


>> 更多同類文章 ……

即時通訊網 - 即時通訊開發者社區! 來源: - 即時通訊開發者社區!

上一篇:自認為編程能力很強?看完知乎“輪子哥”的編程之路,估計得跪!

本帖已收錄至以下技術專輯

推薦方案
評論 2
一臉懵逼的進來,一臉懵逼的出去
簽名: 新的一天從學習開始
又get到了
打賞樓主 ×
使用微信打賞! 使用支付寶打賞!

返回頂部
辽宁十一选五开奖结