如何挖掘Bazel的极致性能

Bazel是Google公司于2015年开源的一款构建框架,至今收获了21k的star数,远超gradle、maven、cmake等同类产品。近几年来,字节阿里腾讯等互联网大厂也逐步拥抱Bazel,搭建自己的构建体系。

Bazel为什么如此受欢迎,原因正如它的宣传: "Correct & Fast, Choose Two",这并不是一句口号,从实际的用户体验也能看出。

(1) 得益于强大的增量构建机制,几万个文件的大型项目,可以做到秒级构建。

(2) Bazel的封闭性设计,使得增量构建和缓存可信赖,用户不需要通过clean操作在构建前清理环境。

本文将分两部分阐述文章的主题。第一部分将分析Bazel高性能,高可靠的原理;第二部分则结合实际场景,聊一聊如何挖掘Bazel的极致性能。

Bazel的优势

基于制品(Artifact)的构建系统

传统构建系统有很多是基于任务的,例如Ant,Maven,Gradle。用户可以自定义"任务"(Task),例如执行一段shell脚本。用户配置它们的依赖关系,构建系统则按照顺序调度。

图1 基于Task的调度模型

这种模式对使用者很友好,他可以专注任务的定义,而不用关心复杂的调度逻辑。构建系统通常给予任务制定者极大的"权利",比如Gradle允许用户用Java代码编写任务,原则上可以做任何事。

如果一个任务,在输入条件不变的情况下,永远输出相同的结果,我们就认为这个任务是"封闭"(Hermeticity)的。构建系统可以利用封闭性提升构建效率,例如第二次构建时,跳过某些输入没变的Task,这种方式也称为增量构建。

不满足封闭性的任务,则会导致增量构建失效,例如Task访问某个互联网资源,或者Task在执行时依赖随机数或时间戳这样的动态特征,这些都会导致多次执行Task得到不同的结果。

Bazel采用了不同的调度模型,它是基于制品(Artifact)的。Bazel官方定义了一些规则(rule),用于构建某些特定产物,例如c++的library或者go语言的package,用户配置和调用这些规则。他仅仅需要告诉Bazel要构建什么Artifact,而由Bazel来决定如何构建它。

规则由官方和可信赖第三方维护,规则产生的任务,满足封闭性需求,这使得用户可以信赖系统的增量构建能力。

用户需要构建的Artifact,在Bazel概念里被称为Target,基于Target的调度模型如下图所示:

图2 基于Target的调度模型

图2中,File表示原始文件,Target表示构建时生成的文件。当用户告诉Bazel要构建某个Target的时候,Bazel会分析这个文件如何构建(构建动作定义为Action,和其他构建系统的Task大同小异),如果Target依赖了其他Target,Bazel会进一步分析依赖的Target又是如何构建生成的,这样一层层分析下去,最终绘制出完整的执行计划。

并行编译

Bazel精准的知道每个Action依赖哪些文件,这使得没有相互依赖关系的Action可以并行执行,而不用担心竞争问题。基于任务的构建系统则存在这样的问题:

图3 基于任务的构建系统存在竞争问题

如图3所示,两个Task都会向同一个文件写一行字符串,这就造成两个Task的执行顺序会影响最终的结果。要想得到稳定的结果,就需要定义这两个Task之间的依赖关系。

Bazel的Action由构建系统本身设计,更加安全,也不会出现类似的竞争问题。因此我们可以充分利用多核CPU的特性,让Action并行执行。

通常我们采用CPU逻辑核心数作为Action执行的并发度,如果开启了远端执行(后面会提到),则可以开启更高的并发度。

增量编译

对Bazel来说,每个Target的构建过程,都对应若干Action的执行。Action的执行本质上就是"输入文件 + 编译命令 + 环境信息 = 输出文件"的过程。

图4 Action的描述

如果本地文件系统保留着上一次构建的outputs,此时Bazel只需要分析inputs, commands和envs和上次相比有没有改变,没有改变就直接跳过该Action的执行。

这对于本地开发非常有用,如果你只修改了少量代码,Bazel会自动分析哪些Action的inputs发生了变化,并只构建这些Action,整体的构建时间会非常快。

不过增量构建并不是Bazel独有的能力,大部分的构建系统都具备。但对于几万个文件的大型工程,如果不修改一行代码,只有Bazel能在一秒以内构建完毕,其他系统都至少需要几十秒的时间,这简直就是降维打击了。

Bazel是如何做到的呢?

首先,Bazel采用了Client/Server架构,当用户键入bazel build命令时,调用的是bazel的client工具,而client会拉起server,并通过grpc协议将请求(buildRequest)发送给它。由server负责配置的加载,ActionGraph的生成和执行。

图5 Bazel的C/S架构

构建结束后,Server并不会立即销毁,而ActionGraph也会一直保存在内存中。当用户第二次发起构建时,Bazel会检测工作空间的哪些文件发生了改变,并更新ActionGraph。如果没有文件改变,就会直接复用上一次的ActionGraph进行分析。

这个分析过程完全在内存中完成,所以如果整个工程无需重新构建,即便是几万个Action,也能在一秒以内分析完毕。而其他系统,至少需要花费几十秒的时间来重新构建ActionGraph。

远程缓存与远程执行

远程缓存

增量构建极大的提升了本地研发的构建效率,但有些场合它的效果不是很好,例如CI环境通常采用"干净"的容器,此时没有上一次的构建数据,只能全量构建。

即使是本地研发,如果从远端同步代码时修改了全局参数,也会导致增量构建失效。缓存(Remote Cache)与远程执行(Remote Execution)可以很好的解决这个问题。

前面聊到,Action满足封闭性,即相同的Action信息一定产生相同的结果。因此可以建立Action到ActionResult的映射。为了便于索引,Bazel把Action信息通过sha256哈希算法压缩成摘要(Digest),把Digest到ActionResult的映射存储在云端,就可以实现Action的跨构建共享。

图6 Action共享示意图

这里的Storage是完全基于内容寻址的,即"一个Digest唯一对应一个ActionResult",内容寻址的好处是不容易污染存储空间,因为修改任何一行代码会计算出不同的Digest,不用担心污染别人的ActionResult。内容寻址的存储引擎,被称为Content Addressable Storage(CAS),如果没有特别强调,本文后续使用简称CAS来表述。

CAS里存放的任何文件,无论是Action的Meta信息还是编译产物二进制,都被称为Blob。

为保证CAS的存储空间被有效利用,通常会使用LRU算法管理CAS里存储的Blob,当存储空间写满时,最久没被访问的Blob就会被自动淘汰,这样就保证了空间里的Blob是最活跃的。

远程执行

既然ActionResult可以被不同的Bazel任务共享,说明ActionResult和Action在哪里执行并没有关系。因此,Bazel在构建时,可以把Action发送给另一台服务器执行,对方执行完,向CAS上传ActionResult,然后本地再下载。

这种做法减少了本地执行Action的开销,使得我们设置更高的构建并发度。

Bazel为Remote Cache和Remote Execution设计了专门的协议Remote Execution API,用于规范协议的客户端和服务端的行为。

完整的流程如下图所示:

图7 远程执行流程

可以看到,Client和Server的直接交互是很少的,大部分情况还是和CAS交互,这部分采用了增量的设计,Client先调用findMissingBlobs接口,该接口的请求参数是一堆Blob Digest列表,返回值是CAS缺失的Digest列表。这样Client只上传这些Blob,可以减少网络传输的浪费。

Remote Execution API是一套通用的远程执行协议,客户端部分由Bazel实现,服务端部分可自行定制。Bazel团队开发两款开源实现,分别是Bazel Remote(CAS) 和 Buildfarm (Remote Executoin & CAS),除此之外也有Buildbarn,Buildgrid等开源实现以及Engflow,Buildbuddy这样的企业版。

企业版除了提供更稳定,弹性的远程执行服务外,通常还提供数据分析能力,用户可以根据自己的条件选择合适的开源软件或企业版服务。

外部依赖缓存(repository_cache)

前面我们主要分析了基于Action的增量构建,缓存和远程执行机制。现在让我们看看Bazel是如何管理外部依赖的。

大部分项目都没法避免引入第三方的依赖项。构建系统通常提供了下载第三方依赖的能力。为了避免重复下载,Bazel要求在声明外部依赖的时候,需要记录外部依赖的hash,例如下面的这种形式:

图8 外部依赖描述

Bazel会将下载的依赖,以CAS的方式存储在内置的repository_cache目录下。你可以通过bazel info repository_cache命令查看目录的位置。

Bazel认为通过checksum机制,外部依赖应该是全局共享的,因此无论你的本地有多少个工程,哪怕使用的是不同的Bazel版本,都可以共享一份外部依赖。

除此之外,Bazel也支持通过1.0.0这样的SerVer版本号来声明依赖,这是Bazel6.0版本加入的功能,也是官方推荐使用的,具体做法可以查看官网相关部分

如何高效使用Bazel

Bazel为了正确性和高性能,做了很多优秀的设计,那么我们如何正确的使用这些能力,让我们的构建性能"起飞"呢, 我们将从本地研发和CI pipeline两种场景进行分析。

本地研发

本地研发通常采用默认的Bazel配置即可,无需为增量构建和repository_cache做额外配置,Bazel默认就处理的很好。

使用时应该信任bazel的增量构建机制,即便是从远端仓库同步了代码,也可以直接build,无须先通过bazel build清理环境。

至于 Remote Cache和Remote Execution,则需要结合网络状况和Action的执行开销,决定是否开启,参数是 --remote_cache--remote_execution

正确开启bazel的remote能力

正确开启remote_cache和remote_execution对构建效率有显著作用,但网络或Action特性,也可能导致收益不明显甚至劣化。

举个例子说明使用remote_cache的利弊:

我们假设Action的执行时间是a,上传缓存和下载缓存的时间分别是b和c, 缓存命中率是μ

如果不使用remote cache,耗时恒定为a,如果使用remote cache, 命中缓存耗时是c,不命中则是 <math xmlns="http://www.w3.org/1998/Math/MathML"> a + b a + b </math>a+b, 结合命中率,可以求出耗时的数学期望是 <math xmlns="http://www.w3.org/1998/Math/MathML"> μ c + ( 1 − μ ) ( a + b ) μc + (1 - μ)(a + b) </math>μc+(1−μ)(a+b)

也就是说,只有 <math xmlns="http://www.w3.org/1998/Math/MathML"> μ c + ( 1 − μ ) ( a + b ) < a μc + (1 - μ)(a + b) < a </math>μc+(1−μ)(a+b)<a 时,使用缓存才是有效的, 对该表达式进行化简,可以得到: <math xmlns="http://www.w3.org/1998/Math/MathML"> b < μ ( a + b − c ) b < μ(a + b - c) </math>b<μ(a+b−c)

例如Action执行时间是500ms,上传产物时间是200ms,下载产物时间是100ms,缓存命中率是30%, 代入到式子中: <math xmlns="http://www.w3.org/1998/Math/MathML"> 0.3 ∗ ( 500 + 200 − 100 ) m s = 180 m s < 200 m s 0.3 * (500 + 200 - 100)ms = 180ms < 200ms </math>0.3∗(500+200−100)ms=180ms<200ms,在这种情况使用缓存反而会劣化。

实践中,我们不一定能对Action做如此精细的数据分析,但可以根据网络状况大致估算。Bazel提供了精细化的控制方式,可以控制某一种类型的Action是否启用remote_cache,例如:

图9 针对CppLink禁用remote_cache

图9针对CppLink类型的Action禁用了remote_cache能力,其他类型则可以正常使用。甚至还可以通过no-remote-cache-upload,设置为只禁止上传缓存,不禁止下载缓存。

对于缓存的精细化设置属于比较高级的功能,Bazel暂时没有过多开放相关能力,相关的文档也不全。或许我们可以期待一下,未来能使用更方便的配置来管理。

缓存命中率调优

上面的例子可以看出,Action的缓存命中率直接决定了remote cache的收益,如何优化缓存命中率呢。

前文介绍原理时,我们知道Action由inputs和commands组成,inputs指执行Action所需的目录结构和文件内容。而commands包括了参数(args), 执行路径(workdir)和环境变量(envs)。

当缓存命中率不符合预期时,我们需要对Action的详情进行调试。

bazel的--execution_log_binary_file参数可以把Action的详细信息打印到文件里。

对比两次构建的Action详情,就可以知道是什么参数发生了变化。

该参数导出的原始信息是二进制格式,有一些特殊字符,如下图所示:

图10 execution_log_binary_file文本

可以借助bazel的execution_log_parser工具,把它变成更可读的形式:

该工具需要源码编译bazel:

图11 使用parser工具把log变成可读形式

转换后的文件如下图所示:

图12 转换后的execution_log

之后就可以用文本对比工具,对两次构建生成的execution_log进行对比。

CI pipeline

再来看到CI场景,如果你在公司里搭建了持续集成流水线,则需要考虑更多的东西。在公司内网的模式下,CI的网络往往不再是瓶颈,我们应该完整的使用Remote Cache和Remote Execution的能力。

搭建Remote Execution服务

使用Remote能力的前提是部署支持Remote Execution协议的服务,一般来说,开源产品buildfarm或buildbarn就足够使用了,如果对性能和数据分析有更加极致的要求,可以考虑企业版产品或者基于Remote Execution API协议自研。

Remote Execution服务的架构设计是一个很大,也很有趣的话题。篇幅关系,本文不过多深入细节,但提供几点设计要求可以参考:

  1. Remote Execution服务通常包括scheduler和worker组件,集群规模较小时,单scheduler可以调度所有Action,而规模较大时,需要多scheduler协同,这是一个很大的挑战。
  2. scheduler的职责是把Action调度给最合适的worker,并且分派的过程越快越好。
  3. 如何衡量任务调度的好与坏,一方面尽量让Action均匀分布,避免排队时间过长,另一方面尽量利用worker的本地文件缓存,减少重复的文件下载。
  4. 不同客户端发来的相同Action,可以考虑在服务端进行合并。
  5. 不同类型的worker,需要根据系统的负载,进行弹性伸缩,以确保资源的高效利用。

客户端调度增强

除了Remote Execution服务,另一块需要注意的地方是客户端调度。不同于本地构建,CI场景为了追求强隔离性,往往以实时运行Docker Container的方式提供构建环境。也就是说,构建环境不包含上一次构建的数据。

这种模式对于Bazel构建很不友好,不仅外部依赖要重新下载,而且增量编译功能也无法使用。但我们也有办法尽可能的加快构建速度。

图13 CI环境可复用的要素

首先是使用Remote Cache和Remote Execution服务,在没有增量构建的场景下,Remote Cache和Remote Execution提供的优化效果是非常夸张的,根据我的观察,提速普遍在70%以上,甚至能达到90%。

其次是缓存本地数据,例如trivas CI这样的流水线编排系统,就支持对特定目录进行缓存。它的原理是把目录打包上传到对象存储,下次构建时再下载下来。我们可以将Bazel的repository_cache和action_local_cache相关的目录进行缓存,下次构建就可以直接复用。

如果条件允许的话,甚至可以要求流水线提供常驻容器,这样Bazel的进程都可以长期保留着,下次构建时,直接Attach到已有的容器上执行命令即可。这种方式有望在CI pipeline场景实现秒级构建,这是多么酷的一件事情啊!

不过,常驻容器对安全性也带来了一定的挑战,企业具体采用哪种方案,也应该因实际情况而异。

总结

本文从原理方面介绍了Bazel高性能的原因,从实践方面针对本地研发和CI pipeline两种场景给出了建议。

Bazel在设计时非常注重"增量","缓存"和"并行",这是高性能的基础。而Bazel官方推出并维护了不同语言的构建规则,也保证了构建过程时封闭,可靠的,这是高性能的前提。除此之外,针对工作空间的完整ActionGraph的内存缓存机制(skyframe),使得Bazel对大型项目拥有秒级的构建速度,这也是其他主流构建系统远远达不到的。

在实际使用中,我们不仅需要深度了解Bazel的缓存和远程执行机制,也需要根据不同的场景配置不同的参数。本地场景需要关注网络和缓存命中率,以决定是否开启远端缓存和远端执行能力。CI场景则需要关心流水线的调度能力,尽可能的提升数据的复用。

相关推荐
ether-lin2 小时前
DevOps实战:用Kubernetes和Argo打造自动化CI/CD流程(2)
kubernetes·自动化·devops
沛沛老爹2 天前
什么是 DevOps 自动化?
大数据·ci/cd·自动化·自动化运维·devops
vvw&2 天前
如何在 Ubuntu 22.04 上安装 Ansible 教程
linux·运维·服务器·ubuntu·开源·ansible·devops
cronaldo912 天前
研发效能DevOps: Vite 使用 Element Plus
vue.js·vue·devops
心灵彼岸-诗和远方2 天前
DevOps工程技术价值流:制品库Nexus与Harbor的实战探索
运维·devops
bigdata-余建新2 天前
SRE 与 DevOps记录
运维·devops
魔幻云4 天前
终章:DevOps实践总结报告
devops
catmes5 天前
使用docker compose安装gitlab
运维·docker·容器·gitlab·敏捷开发·devops
ccnnlxc5 天前
git使用和gitlab部署
devops
编码浪子6 天前
devops和ICCID简介
运维·ci/cd·docker·devops