iOS 微信编译速度优化分享

前言

岁月真是个养猪场,这几年,人胖了,微信代码也翻了。记得 14 年转岗来微信时,用自己笔记本编译微信工程才十来分钟。如今用公司配的 17 年款 27-inch iMac 编译要接近半小时;偶然间更新完代码,又莫名其妙需要全新编译。在这么低的编译效率下,开发心情受到严重影响。于是年初我向上头请示,优化微信编译效率,上头也同意了。

现有方案

在动手之前,先搜索目前已有方案,大概有这几个优化点:

一、优化工程配置

1、将 Debug Information Format 改为 DWARF

Debug 时是不需要生成符号表,可以检查一下子工程(尤其开源库)有没有设置正确。

2、将 Build Active Architecture Only 改为 Yes

Debug 时是不需要生成全架构,可以检查一下子工程(尤其开源库)有没有设置正确。

3、优化头文件搜索路径

避免工程 Header Search Paths 设置了路径递归引用:

Xcode 编译源文件时,会根据 Header Search Paths 自动添加 -I 参数,如果递归引用的路径下子目录越多,-I 参数也越多,编译器预处理头文件效率就越低,所以不能简单的设置路径递归引用。同样 Framework Search Paths 也类似处理。

二、使用 CocoaPods 管理第三方库

这是业界常用的做法,利用 cocoapods 插件 cocoapods-packager 将任意的 pod 打包成 Static Library,省去重复编译的时间;但缺点是不方便调试源码,如果库代码反复修改,需要重新生成二进制并上传到内部服务器,等等。

三、CCache

CCache 是一个能够把编译的中间产物缓存起来的工具,不需要过多修改项目配置,也不需要修改开发工具链。Xcode 9 有个很偶然的 bug,在源码没有任何修改的情况下经常触发全新编译,用 CCache 很好的解决这一问题。但随着 Xcode 10 修复全量编译问题,这一方案逐步弃用了。

四、distcc

distcc 是一个分布式编译工具,它原理是把本地多个编译任务分发到网络中多个机器,其他机器编译完成后,再把产物返回给本机上执行链接,最终得到编译结果。

五、硬件解决

如把 Derived Data 目录放到由内存创建的虚拟磁盘,或者购买最新款的 iMac Pro…

实践过程

一、优化编译选项

1、优化头文件搜索路径

把一些递归引用路径去了后,整体编译速度快了 20s。

2、关闭 Enable Index-While-Building Functionality

这选项无意中找到的(Xcode 9 的新特性?),默认打开,作用是 Xcode 编译时会顺带建立代码索引,但影响编译速度。关闭后整体编译速度快 80s(Xcode 会换回以前的方式,在空闲时间建立代码索引)。

二、优化 kinda

kinda 是今年引入支付跨平台框架(C++),但编译速度奇慢,一个源文件编译都要 30s。另外生成的二进制大小在 App 占比较高,感觉有不少冗余代码,理论上减少冗余代码也能加快编译速度。经过分析 LinkMap 文件和使用 Xcode Preprocess 某些源文件,发现有以下问题:

  • proto 文件生成的代码较多
  • 某个基类/宏使用了大量模版

对于问题一,可以设置 proto 文件选项为 optimize_for=CODE_SIZE 来让 protobuf 编译器生成精简版代码。但我是用自己的工具生成(具体原理可看这里),代码更少。

对于问题二,由于模版是编译期间的多态(增加代码膨胀和编译时间),所以可以把模版基类改成虚基类这种运行时的多态;另外推荐使用 hyper_function 取代 std::function,使得基类用通用函数指针,就能存储任意 lambda 回调函数,从而避免基类模板化。例如:

template 
class BaseCgi {
public:
    BaseCgi(Request request, std::function &callback) {
        _request = request;
        _callback = callback;
    }

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

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

public:
    Request _request;
    std::function _callback;
};

class CgiA : public BaseCgi {
public:
    CgiA(RequestA &request, std::function &callback) :
        BaseCgi(request, callback) {}
};

可改成:

class BaseRequest {
public:
    virtual void toData(std::vector &outData) = 0;
};

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

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

class RequestA : public BaseRequest { ... };

class ResponseA : public BaseResponse { ... };

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

BaseCgi 由模版基类变成只有构造函数是模板的基类,onRequest 和 onResponse 逻辑代码并不因为基类模版实例化而被“复制黏贴”。经过上述优化,整体编译速度快了 70s,而 kinda 二进制也减少了 60%,效果特别明显。

三、使用 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 分钟,令人不满意。如果继续优化,得从编译器下手。正如我们平常做的客户端性能优化,在优化之前,先分析原理,输出每个地方的耗时,针对耗时做相对应的优化。

一、编译原理

编译器,是把一种语言(通常是高级语言)转换为另一种语言(通常是低级语言)的程序。大多数编译器由三部分组成:

  • 前端(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、编译

这阶段做的事情比较多,主要有:

  • 词法分析(Lexical Analysis):将代码转换成一系列 token,如大中小括号 paren'()' square'[]' brace'{}'、标识符 identifier、字符串 string_literal、数字常量 numeric_constant 等等
  • 语法分析(Semantic Analysis):将 token 流组成抽象语法树 AST
  • 静态分析(Static Analysis):检查代码错误,例如参数类型是否错误,调用对象方法是否有实现
  • 中间代码生成(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 格式的耗时报告,列出所有阶段的耗时。效果如下:

  • 整体编译(ExecuteCompiler)耗时 8,423.8ms
  • 其中前端(Frontend)耗时 5,307.9ms,后端(Backend)耗时 3,009.6ms
  • 而前端编译里头文件 SourceA 耗时 xx ms,B 耗时 xx ms,…
  • 头文件处理里 Parse ClassA 耗时 xx ms,B 耗时 xx ms,…
  • 等等

这就是我想要的耗时报告!接下来修改工程 CC={YOUR PATH}/clang,让 Xcode 编译时使用自己的编译器;同时编译选项 OTHER_CFLAGS 后面增加 -ftime-trace,每个源文件编译后输出耗时报告。最终把所有报告汇聚起来,形成整体的编译耗时:

由整体耗时可以看出,编译器前端处理(Frontend)耗时 7,659.2s,占整体 87%;而前端处理下头文件处理(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 {
    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 <> 
|-RecordDecl 0x7faa62831d78 <./HeaderA.h:12:1, line:14:1> line:12:8 struct StructA definition
| `-FieldDecl 0x7faa6383da38 <13:2 col:6><13:2 col:6><35:1 line:37:1><19 col:35><38 col:54><57 line:37:1><36:2 col:12><9 col:12><9 col:12><9><9>

<:astconsumer>&CI&CI

&&context

文章来源于互联网:iOS 微信编译速度优化分享

下载说明:
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.shuli.cc/?p=16057,转载请注明出处。
0

评论0

显示验证码
没有账号?注册  忘记密码?