|
一、Monorepo 背景下的新挑战正如《iOS Monorepo 全源码解决方案》 提到,Monorepo方案,把二方库全源码化,导致需要编译的源文件比之前大很多,如下图所示:以头条工程为例,一次构建涉及的ObjC源文件,数量从几千提升到接近三万。如果不采取措施,构建时间也会增长为原先的数倍,纵使Monorepo有再多优势,也显得苍白无力。虽然面临极大的挑战,但当Monorepo正式上线的时候,大家却惊喜的发现编译速度比之前更快了。我们是如何做到这一点的呢,本文就和大家详细聊一聊这背后的技术细节。二、Remote Execution 协议首先必须要提到"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.hinputs被多个Action共享的场景很常见,为了避免inputs的重复上传,协议在客户端和服务端之间,又抽象了一层中央仓库,它基于内容哈希索引的,称为Content Addressable Storage,简称CAS。有了这一层存储层,客户端和服务端交互文件时,会先基于文件hash查询缺失的文件列表,这样就实现了增量上传的逻辑。完整的分布式编译流程如下图所示:三、分布式编译的必要性在数万源文件的仓库规模下,每次代码提交涉及的改动占总体的比例是很小的。如果把构建缓存做好,理论上就能解决编译速度的问题。在技术上,引入分布式编译的代价也是比较高的,不仅整体链路变长,而且集群的维护也需要投入一部分精力。那么,分布式编译的收益在哪呢?我认为主要有以下两个方面:1、构建缓存准确性构建缓存在提升效率的同时,也带来了一定的风险,比如:“命中的缓存到底是不是准确的呢”? 举一个在前公司工作时的真实案例:内部构建系统,某一次做了maven构建的优化,通过复用缓存的方式跳过某些步骤。上线之后,节约了大量工程师的时间,但这个系统得到了差评!原因就是缓存的计算有很小的比例会出现错误,导致把旧版本的包发布上线(幸运的是该平台主要支持内部系统发布)。由于员工非常信任构建系统,他们第一时间怀疑自己的代码出了问题,耗费了大量时间排查。而当最后定位到构建的原因时,每个当事人都非常愤怒。即便受影响的人群很少,造成的影响也是极其恶劣的。缓存缓存相关的错误有两类:应该命中缓存却没有命中, 在统计学中被称为“一类错误”不应命中缓存却命中,在统计学中被称为“二类错误”上面的例子说的就是第二种情况,而它往往是致命的,下图展示了造成这种错误的原因和后果:分布式编译的引入可以解决此类问题,按照协议,Action的依赖需要发送到构建集群,在沙盒环境中执行。一旦缺少依赖,集群侧会立即报错,帮助研发提前解决问题,避免更大的线上事故。下图展示了这种情况,缺少inputs导致生成了错误的ActionResult,这样的结果将被丢弃掉,或直接报警。2、 P90问题大部分情况下,每次构建涉及的代码改动很少,缓存命中率较高。但某些情况下,也存在缓存命中率较低的情况,这种现象一般发生在全局参数的变更,或者某个较底层的依赖发生变更时。由于这种现象发生概率较低(通常低于10%),从宏观的视角来看,分布式编译解决的是编译效率提升的“P90”问题。全局参数通常指工具链,编译参数等等。这些参数的变更比较少见,往往发生在某些偏实验性质的场景,底层依赖指的是被大多数源文件依赖的头文件,或者像hmap,pch这样的特殊文件,这些文件的变更也会造成大面积的缓存失效。分布式编译可以很好提升以上情况的编译效率。四、在Xcode体系下的尝试事实上,针对移动端的分布式编译尝试,早在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中的运用在Bazel体系下使用Remote Execution更加方便,Bazel负责了Action的计算,理论上只需提供实现标准协议的构建集群即可。生成Action的过程,使用的依赖列表来自于Bazel的构建描述文件BUILD。但实际的使用体验上,这种方法存在很大的问题。主要原因是头条工程的BUILD文件是从Xcode的Podfile体系迁移过来,使用脚本自动生成的。因为一些历史原因,转换而来的依赖并不准确:依赖冗余,由于Podfile的依赖描述是模块粒度的,比较宽泛,而Bazel的思想需要精确到具体头文件的依赖。这就导致了自动生成的头文件依赖,大量采用了**/*.h的表达方式,造成依赖的大量冗余依赖缺失,在之前维护Podfile的时候,就存在“漏写”依赖的情况,之所以能编译通过,是因为通过全局的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协议,并在性能方面做了大量的优化。构建缓存的优化方案,主要围绕提升本地命中率和优化上传/下载时间等方面展开,本专题将通过另一篇文章详细介绍它的设计思想和实现机理,因此本文不过多展开。本文仅简单介绍其中一些关键的设计思想:缓存预下载在编译开始前,提前下载距离上一次编译结束后,服务端产生的增量文件,使编译时尽可能命中本地缓存。网络探针针对弱网环境,动态监测网络状况,一旦发现下载速率低于阈值,自动降级。等网络状况恢复后再自动将服务恢复至原状态。边缘集群(建设中)针对部分网络环境较差的工区,直接建设工区机房,并根据实际网络状况,动态选择合适的缓存服务节点。远程任务执行远程执行服务相对比较标准,原则上,实现了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方案结合了构建缓存和分布式编译两种技术手段,大幅度减少了构建耗时,在云构建场景也取得了很好的效果。但是本地研发的构建场景更加的复杂,本地计算资源和网络带宽都比较受限,在这样的限制下,我们如何取舍,如何优化,也是一个很有意思的话题,在本系列的另一篇文章中将详细给大家介绍。
|
|