我们有一个梦想,让每一名研发工程师拥有一台“超级”计算机。
近年来,基于Devops流水线的研发流程,逐渐成为软件研发的行业标准。流水线的运行效率,决定了团队的研发效能。对大型项目来说,编译构建往往是流水线中耗时占比的大头。有些工程的编译时长超过30分钟,甚至达到几个小时。这样的性能,是非常糟糕的。
字节iOS大型项目的构建时长,大多控制在5分钟以内。这主要得益于内部的编译加速解决方案,它集分布式编译和分布式缓存为一体,本文将详细介绍它的工作原理。不过在这之前,我们先来分析一下大型项目的编译瓶颈和解决思路。
先说结论,机器性能不足和重复作业,是影响工程编译效率的两个最大因素,对此,可以采取分布式编译+编译缓存的方式,提升整体的性能。
C
, C++
, ObjC
)为例,项目中往往存在上千甚至上万的源代码文件(以 .c
, .cc
或 .m
作为扩展名的文件),每个编译子任务将源代码文件编译为目标文件(以 .o
作为扩展名的文件),再整体链接成最终的可执行文件。CPU的数量,决定了编译的并行度上限。个人电脑(PC)的CPU核心数通常在4~12之间,专用服务器可以达到24~96,但对于动辄上万文件的大型工程,CPU的数量还是显得不足。这时候,利用分布式编译的技术,可以得到一台“超级计算机”。
大型工程全量编译,需要处理几千甚至几万个编译子任务。但大多数子任务,之前已经编译过,如果我们能通过某种方式,直接获取编译产物,就可以大大节省时间。
建立一个中央仓库,存储编译子任务的产物,这些产物可以通过“任务摘要”来索引。这样每次遇到一个新任务,我们首先向中央仓库查询摘要,如果查询成功,直接下载编译产物,就省去了重复编译的动作。
上面提到的分布式编译和编译缓存,是提升大型项目编译效率的两大法宝,本文主要介绍字节跳动的分布式编译解决方案。
借助云计算,我们可以以组装的方式,得到一台“超级”计算机,如下图所示:
这台“超级”计算机,由一台中心节点和若干台工作节点组成。中心节点负责生成和调度编译子任务,依照它们的执行顺序,将任务发送给空闲的工作节点来执行。这样整个系统的并行处理能力,取决于所有工作节点的CPU之和,性能比单机高出数倍,甚至数十倍。
像这样把任务分发给工作节点的方案,又称为分布式编译。分布式编译并不是新鲜的概念,2008年开源的distcc工具就提供了分布式编译的解决方案。Google在2017年提出的Remote Execution API,又从协议的角度规范了分布式编译和编译缓存的实现方式。
我们先看一下分布式编译的核心思路。
核心思路很简单,本地计算出编译命令需要读的文件,把文件列表和编译命令,发给远端机器,执行编译命令。编译结束后,再请求拉取编译产物。
其中,如何找到所需文件是关键。
#include xx.h
和 #import xx.h
的方式,声明对某头文件的依赖。main.m
中有一行为#import Car.h
,编译器会遍历所有搜索路径,找到Car.h
文件,并读取该文件内容,替换掉main.m
中的#import Car.h
行。其中搜索路径由编译命令中的 -I
, -isystem
等参数给出Car.h
文件中有 #import
语句,编译器会重复上述动作,找到依赖的文件,读取内容,进行替换,直到把所有的 #import
语句全部展开。clang
为例。-M
可以获取完整的编译依赖,而 -MM
则可以得到用户定义的依赖,相关参数解析如下:
-M
,
--dependencies
Like -MD, but also implies -E and writes to stdout by default
-MD
,
--write-dependencies
Write a depfile containing user and system headers
-MM
,
--user-dependencies
Like -MMD, but also implies -E and writes to stdout by default
-MMD
,
--write-user-dependencies
Write a depfile containing user headers
recc
直接使用了编译器能力。-M
参数隐含了参数 -E
,后者代表“预处理”,预处理阶段除了依赖分析,还做了不少其它工作,这部分工作我们可以优化掉。goma
采用了自研的依赖分析模块,并且在Chromium和Android这两个大型项目上取得了非常好的结果。它在实现依赖分析的时候,借助常驻进程的架构优势,运用了大量缓存,索引等技巧,提高了中间数据的复用率。goma
加速内部iOS的项目的过程中,我们发现当编译任务依赖的Framework过多,或者依赖的hmap文件过大的情况下,性能会受到较大影响,于是,我们针对大型iOS项目的特点,在goma
基础上进行了优化,最终可以以平均50ms的速度,完成编译任务依赖解析。goma
在设计时运用了哪些技巧,以及我们针对iOS项目做了哪些优化。由于篇幅有限,本文只介绍比较有代表性的部分。goma采用了依赖缓存和依赖分析结合的方案,如果之前在工作目录下进行过编译,下次使用时,可以直接使用依赖缓存,只有在缓存不命中的情况下,才进行依赖分析。
依赖缓存的核心原理是:检查相同编译参数对应的,上一次的依赖,如果依赖的文件都没变,即复用依赖关系。
其流程如下图所示:
有人可能会有疑问,为什么可以检查上一次的依赖?
如果这次引入了列表外的新文件,岂不是无法判断文件是否改变吗。
其实不然,引入新文件的前提是加入了新的#import
指令,它必然导致旧依赖列表中的某个文件发生改变,因此这种做法是相对安全的。
命中依赖缓存的话,可以在5毫秒以内得到编译命令的依赖文件列表,这是一个很理想的性能。
不过在实践中经常发现,即使文件修改了,依赖关系也大多是不变的,例如修改变量的值或增加一个类成员。如果我们能抓住这个特性,就可以大大增加缓存命中率。
有些代码修改影响依赖,有些则不会,如果我们只考虑影响依赖的改动,就可以排除掉大量干扰因素。下面是两个例子,展示了有效改动和无效改动的区别。
有效改动(导致依赖分析缓存失效)
无效改动 (不影响依赖分析缓存)
#include
和 #import
,还有如下语句可能造成缓存失效:#if
, #else
, #define
, #ifdef
, #ifndef
, #include_next
。#
开头,在预处理阶段会被编译器解析。这些指令统称为Directive
,因此,我们只需缓存文件的Directive
列表,当文件内容发生改变时,重新获取Direcitive
列表,并和之前缓存的内容对比,如果列表不变,就可以认为该文件的改动不影响依赖关系。如果没命中依赖缓存或者关闭了该功能,就会进入依赖分析的阶段。
#include
和 #import
对应的文件。需要注意的是,#if
和#else
这样的条件宏,也需要在预处理阶段解析。图中紫色部分是一个文件栈,栈中每一个元素都存放了文件相关的信息。每一个文件都对应一个Directive
(预处理指令)列表,并维护一个指针,指向当前的Directive
。
流程开始阶段,入口文件进栈,随后遍历入口文件的所有Directive
,当读到 #include
或 #import
相关的 Directive
时,搜索依赖文件,并入栈。
此时,虽然入口文件还没有解析完,但按照规则应该优先解析新入栈的文件,所以需要通过指针维护入口文件当前读到的行号,以保证下次回到入口文件时,可以继续向下解析。
依赖分析的过程中,存在大量重复的操作,可以通过很多小技巧来优化这个过程。本文将介绍两个比较典型的小技巧。
依赖分析中最常见的操作在一堆备选目录中,找到对应名称的文件。
#import <A/A.h>
语句中提到的A.h
文件。命令行中有10个-I
参数,分别指向10个不同的目录-Ifoo, -Ibar, ...
,最朴素的方法是依次遍历这10个目录,拼接路径,尝试找到A.h
文件。这种方法当然可行,但是效率较低。对于大型项目,仅一条编译命令就可能涉及超过5000条#import
语句,和超过50个头文件搜索路径。这意味着至少5000*50=25万次文件系统查找,时间开销非常大。
建立倒排索引,可以大大加快这个过程。其思路是预先遍历待搜索目录(directory
),找到目录下的文件和子目录(统称entry
),然后建立entry
指向directory
的倒排索引, 如下图所示:
回到上面的问题,当我们搜索#import <A/A.h>
时,首先需要找到foo
, bar
, taz
三个目录里,哪个含有A
子目录,根据倒排索引,可以快速定位到bar
目录,而不需要从头开始遍历。
值得注意的是,objc工程普遍采用HeaderMap技术(即Xcode自动生成的.hmap
文件),提升编译时查找头文件的效率。HeaderMap本质上也是一种索引表,它建立了 Directive -> Path 的直接映射关系。我们在建倒排索引的时候,需要解析.hmap
中的内容,并合并到倒排索引中。
foo.m
和bar.m
可能都依赖了common.h
文件,编译foo.m
的时候已经找到了common.h
, 编译bar.m
的时候,是否不需要再找一次了呢?-I
,-isystem
影响头文件搜索路径,-F
影响Framework搜索路径。iOS项目通常采用Xcode + CocoaPods的研发模式,针对同一个Pod内源文件的编译命令,头文件搜索路径基本是一致的。利用这个特性,我们提供了跨任务的缓存加速方案。
我们对搜索路径列表整体做了一层hash,当两个命令的搜索路径相同时,对同名Directive的搜索结果一定相同。方案如下所示:
1. 在对单条命令进行依赖解析之前,先提取搜索路径的特征值。
2. 寻找头文件时,先查询缓存,如果查不到,在找到头文件后,将结果缓存。
举一个具体的例子:
编译任务1: clang -c foo.m -IFoo -IBar -FCar
编译任务2: clang -c bar.m -IFoo -IBar -FCar
foo.m
和bar.m
均包含行:#import common.h
假设编译任务1先执行,我们的做法应该是:
1. 提取搜索目录列表为:-IFoo -IBar -FCar
3. 进行依赖分析,读到foo.m
依赖common.h
的部分,遍历搜索目录,找到common.h
的位置,假设在目录Bar
下面。
4. 写缓存,缓存用哈希表实现,key为<598cf1e..., common.h>
,value为Bar
5. 执行编译任务2,再次遇到寻找common.h
的请求。
6. 直接从缓存中查到common.h
在Bar
目录下
# 关于字节终端技术团队
字节跳动终端技术团队(Client Infrastructure)是大前端基础技术的全球化研发团队(分别在北京、上海、杭州、深圳、广州、新加坡和美国山景城设有研发团队),负责整个字节跳动的大前端基础设施建设,提升公司全产品线的性能、稳定性和工程效率;支持的产品包括但不限于抖音、今日头条、西瓜视频、飞书、番茄小说等,在移动端、Web、Desktop等各终端都有深入研究。
就是现在!客户端/前端/服务端/端智能算法/测试开发 面向全球范围招聘!一起来用技术改变世界,感兴趣请联系bits-dev-better@bytedance.com。邮件主题:简历-姓名-求职意向-期望城市-电话。
MARS- TALK 04 期来啦!
2月24日晚 MARS TALK 直播间,我们邀请了火山引擎 APMPlus 和美篇的研发工程师,在线为大家分享「APMPlus 基于 Hprof 文件的 Java OOM 归因方案」及「美篇基于MARS-APMPlus 性能监控工具的优化实践」等技术干货。现在报名加入活动群 还有机会获得最新版VR一体机——Pico Neo3哦!
作为开年首期MARS TALK,本次我们为大家准备了丰厚的奖品。除了Pico Neo3之外,还有罗技M720蓝牙鼠标、筋膜枪及字节周边礼品等你来拿。千万不要错过哟!
报名赢大奖
惊喜好礼带回家
👇 点击阅读原文,了解APMPlus