乐趣区

关于字节跳动:让工程师拥有一台超级计算机字节跳动客户端编译加速方案

咱们有一个幻想,让每一名研发工程师领有一台“超级”计算机。

作者:字节跳动终端技术——孙雄

大型工程的效率瓶颈

近年来,基于 Devops 流水线的研发流程,逐步成为软件研发的行业标准。流水线的运行效率,决定了团队的研发效力。对大型项目来说,编译构建往往是流水线中耗时占比的大头。有些工程的编译时长超过 30 分钟,甚至达到几个小时。这样的性能,是十分蹩脚的。

字节 iOS 大型项目的构建时长,大多管制在 5 分钟以内。这次要得益于外部的编译减速解决方案,它集分布式编译和分布式缓存为一体,本文将具体介绍它的工作原理。不过在这之前,咱们先来剖析一下大型项目的编译瓶颈和解决思路。

先说论断,机器性能有余和反复作业,是影响工程编译效率的两个最大因素,对此,能够采取分布式编译 + 编译缓存的形式,晋升整体的性能。

分布式编译

工程的编译,往往能够拆解为 可并行 的编译子工作。以 C 系列语言 (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 间接应用了编译器能力。

这种办法的益处是开发简略,并且足够平安,但性能存在瓶颈。咱们晚期以头条我的项目测试的时候,通过编译器获取依赖,均匀耗时在 200 毫秒左右。而单个文件的编译时长,大多在 500 毫秒~3000 毫秒的区间内。依赖剖析耗时占比太高,导致工作散发效率不够现实。

依赖剖析工夫过长,一方面因为编译器命令由独立过程执行,不同的编译工作之间无奈复用缓存。另一方面,编译器的 -M 参数隐含了参数 -E,后者代表“预处理”,预处理阶段除了依赖剖析,还做了不少其它工作,这部分工作咱们能够优化掉。

Google 的 goma 采纳了自研的依赖剖析模块,并且在 Chromium 和 Android 这两个大型项目上获得了十分好的后果。它在实现依赖剖析的时候,借助 常驻过程 的架构劣势,使用了大量缓存,索引等技巧,进步了两头数据的复用率。

在应用 goma减速外部 iOS 的我的项目的过程中,咱们发现当编译工作依赖的 Framework 过多,或者依赖的 hmap 文件过大的状况下,性能会受到较大影响,于是,咱们针对大型 iOS 我的项目的特点,在 goma 根底上进行了优化,最终能够以均匀 50ms 的速度,实现编译工作依赖解析。

接下来,让咱们一起看看 goma 在设计时使用了哪些技巧,以及咱们针对 iOS 我的项目做了哪些优化。因为篇幅无限,本文只介绍比拟有代表性的局部。

疾速依赖剖析

goma 采纳了依赖缓存和依赖剖析联合的计划,如果之前在工作目录下进行过编译,下次应用时,能够间接应用依赖缓存,只有在缓存不命中的状况下,才进行依赖剖析。

依赖缓存

依赖缓存的外围原理是:查看雷同编译参数对应的,上一次的依赖,如果依赖的文件都没变,即复用依赖关系

其流程如下图所示:

有人可能会有疑难,为什么能够查看上一次的依赖?如果这次引入了列表外的新文件,岂不是无奈判断文件是否扭转吗。

其实不然,引入新文件的前提是退出了新的#import 指令,它必然导致旧依赖列表中的某个文件产生扭转,因而这种做法是绝对平安的。

命中依赖缓存的话,能够在 5 毫秒以内失去编译命令的依赖文件列表,这是一个很现实的性能。

不过在实践中常常发现,即便文件批改了,依赖关系也大多是不变的,例如批改变量的值或减少一个类成员。如果咱们能抓住这个个性,就能够大大增加缓存命中率。

疏忽无关行

有些代码批改影响依赖,有些则不会,如果咱们只思考影响依赖的改变,就能够排除掉大量烦扰因素。上面是两个例子,展现了无效改变和有效改变的区别。

  • 无效改变(导致依赖剖析缓存生效)
- #include <foo.h>
+ #include <bar.h>

  • 有效改变(不影响依赖剖析缓存)
- int a = 2;
+ int a = 3;

除了前文提及的#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 中的内容,并合并到倒排索引中。

跨工作缓存(针对 iOS 我的项目的优化)

不同的编译工作,可能存在雷同的依赖文件。例如 foo.mbar.m可能都依赖了 common.h 文件,编译 foo.m 的时候曾经找到了 common.h, 编译bar.m 的时候,是否不须要再找一次了呢?

很遗憾,大多数状况须要从新查找,因为不同命令的查找条件往往不一样。影响查找条件的参数有很多,例如-I, -isystem 影响头文件搜寻门路,-F 影响 Framework 搜寻门路。

不过,iOS 我的项目往往能够复用之前的查找后果。

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.mbar.m 均蕴含行:#import common.h

假如编译工作 1 先执行,咱们的做法应该是:

  1. 提取搜寻目录列表为:-IFoo -IBar -FCar
  2. 应用 SHA-256 算法计算摘要,对应的搜寻摘要为:598cf1e…(仅展现前 8 位)
  3. 进行依赖剖析,读到 foo.m 依赖 common.h 的局部,遍历搜寻目录,找到 common.h 的地位,假如在目录 Bar 上面。
  4. 写缓存,缓存用哈希表实现,key 为<598cf1e..., common.h>,value 为Bar
  5. 执行编译工作 2,再次遇到寻找 common.h 的申请。
  6. 间接从缓存中查到 common.hBar目录下

索引缓存(针对 iOS 我的项目的优化)

建索引能够缩小遍历目录寻找头文件的次数,是十分无效的优化计划。然而当头文件搜寻目录过多,或者 hmap 过大时,建索引自身也须要几十毫秒的工夫,对于性能要求非常严苛的依赖解析来说,这个工夫还是略长。

所以咱们想到,对索引自身是否能够做缓存呢?

依照跨工作缓存的思路,索引自身也是能够缓存的,只有两个工作的头文件搜寻门路,以及 hmap 中的索引内容都始终,它们就能够共用一套索引。

具体的计划和跨工作缓存相似,本文就不具体开展了。通过对索引的缓存,咱们将依赖剖析的速度又晋升了 20 毫秒左右。

总结

分布式编译和编译缓存是晋升大型项目编译效率的两大法宝。本文次要介绍了字节跳动的分布式编译解决方案。

该计划外围局部采纳了开源框架 goma 的代码,并在此基础上,针对 iOS 我的项目的个性,做了肯定的优化。

分布式编译的核心思想是 空间换工夫,引入额定的机器,晋升单次编译的 CPU 数量。分布式编译的成果,取决于核心节点散发工作的速度,工作的散发又取决于依赖的解析效率。

传统计划利用编译器的预处理来解析依赖,办法可行,但因为每次解析都要独自 fork 过程,数据难以复用,存在性能瓶颈。咱们采纳了开源框架 goma 的代码,并在此基础上,针对 iOS 我的项目的个性,做了肯定的优化。

本文介绍了依赖解析的四种技巧,别离从打消乐音,索引,缓存三个角度进行了优化。编译优化的路线,任重而道远。感激 goma 团队,提供了许多优良的设计思路和技巧,咱们也会在此方向继续钻研,尽可能的把思路分享给大家。
 

\# 对于字节终端技术团队

字节跳动终端技术团队 (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 哦!

⏰  直播工夫:2 月 24 日(周四)20:00-21:30

💡  流动模式:线上直播

🙋  报名形式:扫码进群报名

作为开年首期 MARS TALK,本次咱们为大家筹备了丰富的奖品。除了 Pico Neo3 之外,还有罗技 M720 蓝牙鼠标、筋膜枪及字节周边礼品等你来拿。千万不要错过哟!

👉  点击这里,理解 APMPlus

退出移动版