正如《iOS Monorepo 全源码解决方案》 提到,Monorepo方案,把二方库全源码化,导致需要编译的源文件比之前大很多,如下图所示:
以头条工程为例,一次构建涉及的ObjC
源文件,数量从几千提升到接近三万。
如果不采取措施,构建时间也会增长为原先的数倍,纵使Monorepo有再多优势,也显得苍白无力。
虽然面临极大的挑战,但当Monorepo正式上线的时候,大家却惊喜的发现编译速度比之前更快了。我们是如何做到这一点的呢,本文就和大家详细聊一聊这背后的技术细节。
首先必须要提到"Remote Execution 协议",它由Bazel团队提出,在提升构建效率上起到了巨大的作用。让我们看看协议的设计理念,以及在落地头条项目过程中,遇到的困难和解决方案。
Remote Execution 协议:https://github.com/bazelbuild/remote-apis/blob/main/build/bazel/remote/execution/v2/remote_execution.proto
协议的主体思想可以抽象成两句话:
这两句话对应的专业术语分别是“构建缓存”和“分布式编译”。这两项技术早在20年前就有了,例如C语言系列构建优化方案中,大名鼎鼎的distcc + ccache解决方案,如下图所示:
distcc:https://www.distcc.org/ ccache:https://ccache.dev/
这套方案虽然简单易集成,但只能作用于C系语言的编译(即从源码生成Obj文件),使用范围比较窄。此外,该方案设计上的缺陷也很多,例如对本地资源的过度使用和大量的网络IO冗余,具体内容本文不做展开。我们主要看看Bazel是如何精妙设计规则,以实现上面提到的“主体思想”的。
首先,Bazel将构建过程的原子操作抽象为Action
,一切需要执行的任务,都可以对应一个Action
, 例如生成一个protobuf
文件,将一个.cpp
文件编译成.o
,把若干.o
链接成二进制文件, 都可以叫做Action
每个Action
,根据其组成部分的描述(Command
和Inputs
),都可以唯一映射一个 Action Digest
(摘要),通过摘要可以查询Action的执行结果,即Action Result
,它包含了“程序退出码”,“标准输出/错误流”,和“产物下载地址”,如下图所示:
这样的设计,使得Action
本身是一个可复用的结构。Bazel工作时,计算原子任务Action
的Digest
,并尝试获取Action Result
,如果获取成功则直接下载结果,这样就实现了构建缓存。
有了高度抽象的Action
结构,远程执行也是水到渠成的一件事情。只需要将Action的所有inputs
发送给编译集群,后者按照inputs
的描述,在一个沙盒环境中,建立Action
所需的工作空间,并执行相应的命令,再将结果上传即可。下面的目录树,就是一个编译类型的Action
常见的inputs
结构。
.
├── src
│ └── main
│ └── main.cpp
└── lib
└── time
└── time.h
inputs被多个Action共享的场景很常见,为了避免inputs的重复上传,协议在客户端和服务端之间,又抽象了一层中央仓库,它基于内容哈希索引的,称为Content Addressable Storage,简称CAS。有了这一层存储层,客户端和服务端交互文件时,会先基于文件hash查询缺失的文件列表,这样就实现了增量上传的逻辑。
完整的分布式编译流程如下图所示:
在数万源文件的仓库规模下,每次代码提交涉及的改动占总体的比例是很小的。如果把构建缓存做好,理论上就能解决编译速度的问题。
在技术上,引入分布式编译的代价也是比较高的,不仅整体链路变长,而且集群的维护也需要投入一部分精力。那么,分布式编译的收益在哪呢?
我认为主要有以下两个方面:
构建缓存在提升效率的同时,也带来了一定的风险,比如:“命中的缓存到底是不是准确的呢”?
💡 举一个在前公司工作时的真实案例: 内部构建系统,某一次做了maven构建的优化,通过复用缓存的方式跳过某些步骤。上线之后,节约了大量工程师的时间,但这个系统得到了差评!😰 |
缓存缓存相关的错误有两类:
上面的例子说的就是第二种情况,而它往往是致命的,下图展示了造成这种错误的原因和后果:
分布式编译的引入可以解决此类问题,按照协议,Action的依赖需要发送到构建集群,在沙盒环境中执行。一旦缺少依赖,集群侧会立即报错,帮助研发提前解决问题,避免更大的线上事故。
下图展示了这种情况,缺少inputs
导致生成了错误的ActionResult,这样的结果将被丢弃掉,或直接报警。
大部分情况下,每次构建涉及的代码改动很少,缓存命中率较高。但某些情况下,也存在缓存命中率较低的情况,这种现象一般发生在全局参数的变更,或者某个较底层的依赖发生变更时。由于这种现象发生概率较低(通常低于10%),从宏观的视角来看,分布式编译解决的是编译效率提升的“P90”问题。
全局参数通常指工具链,编译参数等等。这些参数的变更比较少见,往往发生在某些偏实验性质的场景,底层依赖指的是被大多数源文件依赖的头文件,或者像hmap
,pch
这样的特殊文件,这些文件的变更也会造成大面积的缓存失效。分布式编译可以很好提升以上情况的编译效率。
事实上,针对移动端的分布式编译尝试,早在Xcode体系下就开始了。
由于Xcode本身不具备相关能力,我们采用了hook编译器的方式,提供一个wrapper脚本,使xcode在调用编译器的时候,其实调用的是该脚本,在脚本中生成协议要求的Action
,并与分布式编译集群对接。
Google内部,Chromium工程的编译就采用这套方案作为官方方案。相关的解决方案叫goma,我们在xcode体系下的尝试也是基于goma,针对移动端场景进行的二次开发,内部代号叫sailfish(旗鱼),象征极致的构建效率。
goma:https://chromium.googlesource.com/infra/goma/client/
hook编译器虽然能解决大部分问题,但也存在一定的局限性。hook编译器使得我们只能拦截到编译命令,而无法感知用户的编译描述文件,信息量是缺失的。
Remote Execution协议非常依赖构建系统的封闭性,也就是说构建过程的所有依赖都应该是安全可控的,目录结构中也不应该包含工作空间以外的部分。但一旦用户使用了非Bazel系统,就可能打破这种封闭性,比如下面的目录结构:
.
├── project
│ └── src
│ └── hello.cc
└── 20230401
└── thirdparty
└── zlib
由于不严谨的目录组织方式,导致某些“本地特征”(比如示例中以日期命名的目录),成为了计算缓存特征的一部分,影响了构建缓存的复用。
在Bazel体系下使用Remote Execution更加方便,Bazel负责了Action的计算,理论上只需提供实现标准协议的构建集群即可。生成Action的过程,使用的依赖列表来自于Bazel的构建描述文件BUILD
。
但实际的使用体验上,这种方法存在很大的问题。主要原因是头条工程的BUILD
文件是从Xcode的Podfile
体系迁移过来,使用脚本自动生成的。因为一些历史原因,转换而来的依赖并不准确:
**/*.h
的表达方式,造成依赖的大量冗余hmap
间接的找到了头文件,这种方法的隐患就是构建缓存不准确,可能导致正式出包时用到“旧”的缓存文件。为了解决描述依赖(declared inputs)和实际依赖(real inputs)之间的差异,我们引入sailfish的依赖解析能力,在Bazel生成Action的时候,增加了一道依赖矫正的工作。
为了确保依赖矫正的结果准确,我们把结果和编译器生成的.d
文件进行对比,并达到了100%的准确率。
经过依赖矫正的Action更加精确,也因此最终的缓存命中率维持在一个比较高的范畴(P50数据大于99%)上。
在Bazel体系下,Remote Execution相关的架构图如下所示:
依赖解析功能以本地服务的方式进行提供,之所以采用与本地服务通信的方式,主要是为了复用数据,提升解析效率,下文会详细介绍。
缓存服务这里也对Bazel的原生行为做了一定改造,Bazel自身提供了本地缓存 + 远程缓存的功能,而我们禁用了原生本地缓存的能力,使用了一体化的缓存解决方案,方便从全局视角优化缓存下载效率,提升本地缓存命中率等等。
在功能建设的同时,我们也进行了大量数据指标的建设,指标包括“依赖解析”,“缓存读写”,和“集群执行”这三个主要动作的时长, 以及和业务逻辑高度相关的“缓存命中率”跟踪。
数据指标由Bazel profile和BitSky命令行工具采集,汇聚到hummer平台(内部的构建数据分析平台),通过报表展示指标的变化趋势,而飞书机器人则用来对异常数据及时报警,方便我们更快的定位问题。
下面分别介绍具体的组件是如何工作的。
依赖解析服务由Sailfish改造而成,基本保留了原先的设计。
它的原理是直接阅读源码的预处理指令,例如#include
, #import
, #ifdef
等。通过深度优先遍历的顺序,找出所有依赖的头文件。约等于实现了一个轻量级的预处理器。
针对复杂的编译任务,几千条预处理指令,50+ 头文件搜索路径,依赖解析服务可以在毫秒级的时间得到精确的结果,这取决于依赖解析器内部的缓存和索引的设计。
由于这部分的内容比较复杂,展开讲的话,篇幅比本文还要长的多,本文暂且略过。感兴趣的同学可以看看这篇文章:
缓存服务主要提供
本文仅简单介绍其中一些关键的设计思想:
远程执行服务相对比较标准,原则上,实现了Remote Execution协议的开源组件均可以使用。因此,在Bazel体系下我们没有做过多定制化的设计,而是复用了之前支持Xcode业务时的标准解决方案。
在具体的引擎选择上,我们采用了“先用开源支持,再同步自研”的路径。
自研的产品代号叫Tide (潮汐) ,它由rust编写,在语言层面,和开源届普遍采用的go相比,有明显的性能优势。
同时,在任务调度方面也做了比较精心的设计,当集群负载较高产生排队时,调度器会把任务同时发给多台worker,并根据先到先得的原则,最终确定执行的worker。这样的设计使得任务的分配更加均衡。
和开源项目对比,在集群资源充足时,集群Action执行的平均时间从422ms下降到389ms, 当Action数量达到集群CPU核心数5倍时,含排队的平均时间从2172ms下降到1983ms。
最后展示一下Remote Execution实际的效果。BitSky整体带来的收益,已经在 iOS Monorepo 全源码解决方案 一文中介绍过了,那个收益是端到端视角的收益,Remote Execution只占了其中一部分。
Remote Execution相关的收益整理如下图所示:
从图中可以看出,当缓存命中率较低时,开启分布式编译的提升非常明显。即使缓存命中率达到了80%甚至90%,分布式编译依然可以带来效率上的显著提升。
本文主要介绍了Remote Execution在iOS Monorepo方案中的作用,原理和实现。作为和Monorepo配套的基础设施,Remote Execution很好的解决了仓库体积膨胀背景下,构建性能方面遇到的新挑战。
Remote Execution方案结合了构建缓存和分布式编译两种技术手段,大幅度减少了构建耗时,在云构建场景也取得了很好的效果。但是本地研发的构建场景更加的复杂,本地计算资源和网络带宽都比较受限,在这样的限制下,我们如何取舍,如何优化,也是一个很有意思的话题,在本系列的另一篇文章中将详细给大家介绍。