Monorepo 解决方案之 Remote Execution

1,536 阅读14分钟

一、Monorepo 背景下的新挑战

正如《iOS Monorepo 全源码解决方案》 提到,Monorepo方案,把二方库全源码化,导致需要编译的源文件比之前大很多,如下图所示:

以头条工程为例,一次构建涉及的ObjC源文件,数量从几千提升到接近三万

如果不采取措施,构建时间也会增长为原先的数倍,纵使Monorepo有再多优势,也显得苍白无力。

虽然面临极大的挑战,但当Monorepo正式上线的时候,大家却惊喜的发现编译速度比之前更快了。我们是如何做到这一点的呢,本文就和大家详细聊一聊这背后的技术细节。

二、Remote Execution 协议

首先必须要提到"Remote Execution 协议",它由Bazel团队提出,在提升构建效率上起到了巨大的作用。让我们看看协议的设计理念,以及在落地头条项目过程中,遇到的困难和解决方案。

Remote Execution 协议:github.com/bazelbuild/…

协议的主体思想可以抽象成两句话:

  1. 复用已有操作产生的结果
  2. 投入更多计算资源加速执行

这两句话对应的专业术语分别是“构建缓存”和“分布式编译”。这两项技术早在20年前就有了,例如C语言系列构建优化方案中,大名鼎鼎的distcc + ccache解决方案,如下图所示:

这套方案虽然简单易集成,但只能作用于C系语言的编译(即从源码生成Obj文件),使用范围比较窄。此外,该方案设计上的缺陷也很多,例如对本地资源的过度使用和大量的网络IO冗余,具体内容本文不做展开。我们主要看看Bazel是如何精妙设计规则,以实现上面提到的“主体思想”的。

首先,Bazel将构建过程的原子操作抽象为Action,一切需要执行的任务,都可以对应一个Action, 例如生成一个protobuf文件,将一个.cpp文件编译成.o,把若干.o链接成二进制文件, 都可以叫做Action

每个Action,根据其组成部分的描述(CommandInputs),都可以唯一映射一个 Action Digest (摘要),通过摘要可以查询Action的执行结果,即Action Result,它包含了“程序退出码”,“标准输出/错误流”,和“产物下载地址”,如下图所示:

暂时无法在飞书文档外展示此内容

这样的设计,使得Action本身是一个可复用的结构。Bazel工作时,计算原子任务ActionDigest,并尝试获取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查询缺失的文件列表,这样就实现了增量上传的逻辑。

完整的分布式编译流程如下图所示:

三、分布式编译的必要性

在数万源文件的仓库规模下,每次代码提交涉及的改动占总体的比例是很小的。如果把构建缓存做好,理论上就能解决编译速度的问题。

在技术上,引入分布式编译的代价也是比较高的,不仅整体链路变长,而且集群的维护也需要投入一部分精力。那么,分布式编译的收益在哪呢?

我认为主要有以下两个方面:

  1. 构建缓存准确性

构建缓存在提升效率的同时,也带来了一定的风险,比如:“命中的缓存到底是不是准确的呢”?

💡举一个在前公司工作时的真实案例。

内部构建系统,某一次做了maven构建的优化,通过复用缓存的方式跳过某些步骤。上线之后,节约了大量工程师的时间,但这个系统得到了差评!😰

原因就是缓存的计算有很小的比例会出现错误,导致把旧版本的包发布上线(幸运的是该平台主要支持内部系统发布)。 由于员工非常信任构建系统,他们第一时间怀疑自己的代码出了问题,耗费了大量时间排查。而当最后定位到构建的原因时,每个当事人都非常愤怒。即便受影响的人群很少,造成的影响也是极其恶劣的。

缓存缓存相关的错误有两类

  1. 应该命中缓存却没有命中, 在统计学中被称为“一类错误”
  2. 不应命中缓存却命中,在统计学中被称为“二类错误”

上面的例子说的就是第二种情况,而它往往是致命的,下图展示了造成这种错误的原因和后果:

分布式编译的引入可以解决此类问题,按照协议,Action的依赖需要发送到构建集群,在沙盒环境中执行。一旦缺少依赖,集群侧会立即报错,帮助研发提前解决问题,避免更大的线上事故。

下图展示了这种情况,缺少inputs导致生成了错误的ActionResult,这样的结果将被丢弃掉,或直接报警。

  1. P90问题

大部分情况下,每次构建涉及的代码改动很少,缓存命中率较高。但某些情况下,也存在缓存命中率较低的情况,这种现象一般发生在全局参数的变更,或者某个较底层的依赖发生变更时。由于这种现象发生概率较低(通常低于10%),从宏观的视角来看,分布式编译解决的是编译效率提升的“P90”问题。

全局参数通常指工具链,编译参数等等。这些参数的变更比较少见,往往发生在某些偏实验性质的场景,底层依赖指的是被大多数源文件依赖的头文件,或者像hmappch这样的特殊文件,这些文件的变更也会造成大面积的缓存失效。分布式编译可以很好提升以上情况的编译效率。

四、在Xcode体系下的尝试

事实上,针对移动端的分布式编译尝试,早在Xcode体系下就开始了。

由于Xcode本身不具备相关能力,我们采用了hook编译器的方式,提供一个wrapper脚本,使xcode在调用编译器的时候,其实调用的是该脚本,在脚本中生成协议要求的Action,并与分布式编译集群对接。

Google内部,Chromium工程的编译就采用这套方案作为官方方案。相关的解决方案叫goma,我们在xcode体系下的尝试也是基于goma,针对移动端场景进行的二次开发,内部代号叫sailfish(旗鱼),象征极致的构建效率。

goma:chromium.googlesource.com/infra/goma/…

hook编译器虽然能解决大部分问题,但也存在一定的局限性。hook编译器使得我们只能拦截到编译命令,而无法感知用户的编译描述文件,信息量是缺失的。

Remote Execution协议非常依赖构建系统的封闭性,也就是说构建过程的所有依赖都应该是安全可控的,目录结构中也不应该包含工作空间以外的部分。但一旦用户使用了非Bazel系统,就可能打破这种封闭性,比如下面的目录结构:

.
├── project
│   └── src
│       └── hello.cc
└── 20230401
    └── thirdparty
        └── zlib

由于不严谨的目录组织方式,导致某些“本地特征”(比如示例中以日期命名的目录),成为了计算缓存特征的一部分,影响了构建缓存的复用。

五、在Bazel中的运用

在Bazel体系下使用Remote Execution更加方便,Bazel负责了Action的计算,理论上只需提供实现标准协议的构建集群即可。生成Action的过程,使用的依赖列表来自于Bazel的构建描述文件BUILD

但实际的使用体验上,这种方法存在很大的问题。主要原因是头条工程的BUILD文件是从Xcode的Podfile体系迁移过来,使用脚本自动生成的。因为一些历史原因,转换而来的依赖并不准确:

  1. 依赖冗余,由于Podfile的依赖描述是模块粒度的,比较宽泛,而Bazel的思想需要精确到具体头文件的依赖。这就导致了自动生成的头文件依赖,大量采用了**/*.h的表达方式,造成依赖的大量冗余
  2. 依赖缺失,在之前维护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协议,并在性能方面做了大量的优化。构建缓存的优化方案,主要围绕提升本地命中率和优化上传/下载时间等方面展开,本专题将通过另一篇文章详细介绍它的设计思想和实现机理,因此本文不过多展开。

本文仅简单介绍其中一些关键的设计思想:

  1. 缓存预下载 在编译开始前,提前下载距离上一次编译结束后,服务端产生的增量文件,使编译时尽可能命中本地缓存。
  2. 网络探针 针对弱网环境,动态监测网络状况,一旦发现下载速率低于阈值,自动降级。等网络状况恢复后再自动将服务恢复至原状态。
  3. 边缘集群(建设中) 针对部分网络环境较差的工区,直接建设工区机房,并根据实际网络状况,动态选择合适的缓存服务节点。

远程任务执行

远程执行服务相对比较标准,原则上,实现了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方案结合了构建缓存和分布式编译两种技术手段,大幅度减少了构建耗时,在云构建场景也取得了很好的效果。但是本地研发的构建场景更加的复杂,本地计算资源和网络带宽都比较受限,在这样的限制下,我们如何取舍,如何优化,也是一个很有意思的话题,在本系列的另一篇文章中将详细给大家介绍。