本页面简要介绍了编写高效 Bazel 规则的具体问题和挑战。
摘要要求
- 假设:目标是正确、吞吐量、易用性和延迟时间
- 假设:大型代码库
- 假设:类似 build 的说明语言
- 历史上:加载、分析和执行之间已过时,但仍然会影响 API
- 固有:远程执行和缓存很难
- 固有:使用更改信息进行正确的快速增量构建需要异常的编码模式
- 固有:避免二次时间和内存消耗很困难
假设
以下是一些关于构建系统的假设,例如正确性、易用性、吞吐量和大型代码库的需求。以下部分介绍了这些假设,并提供了有关如何以有效方式编写规则的指南。
正确性、吞吐量、易用性和延迟时间
对于增量构建,我们假定构建系统需要最先正确。对于给定的源代码树,无论输出树是什么样的,同一 build 的输出都应始终相同。在第一个近似值中,这意味着 Bazel 需要知道进入指定构建步骤的每个输入,以便在任何输入更改时能够重新运行该步骤。Bazel 的正确获取方式存在限制,因为它会泄露一些信息(例如构建的日期 / 时间),而会忽略某些类型的更改(例如文件属性的更改)。沙盒可防止读取未声明的输入文件,帮助确保正确性。除了系统的固有限制之外,还存在一些已知的正确性问题,其中大多数问题都与 Fileset 或 C++ 规则有关,二者都是硬性问题。我们一直致力于解决这些问题。
构建系统的第二个目标是提高吞吐量;我们将不断突破当前机器分配中针对远程执行服务可以执行的操作。如果远程执行服务过载,则任何人都无法完成工作。
接下来是易用性。在具有相同远程执行服务的多个相同正确(或相似)的方法中,我们会选择更易于使用的方法。
延迟时间表示从启动构建到获得预期结果所需的时间,可能是通过或失败测试的测试日志,也可能是 BUILD
文件有拼写错误的错误消息。
请注意,这些目标通常重叠;延迟时间与远程执行服务的吞吐量一样重要,同时也与易用性相关。
大型代码库
构建系统需要以大型代码库的规模运行,其中大型代码库意味着它不适合单个硬盘,因此几乎无法在所有开发者机器上进行全面结算。中型构建需要读取和解析数万个 BUILD
文件,并评估数十万个 glob。从理论上讲,可以在一台机器上读取所有 BUILD
文件,但我们在合理的时间内和内存中尚无法执行此操作。因此,可以独立加载和解析 BUILD
文件,这一点至关重要。
类似 build 的说明语言
在此情况下,我们假定配置语言与库和二进制文件规则及其相互依赖性声明中的 BUILD
文件大致类似。您可以独立读取和解析 BUILD
文件,我们甚至尽可能避免查看源文件(存在文件除外)。
历史古迹
导致挑战的 Bazel 版本之间存在一些差异,以下部分概述了其中一些差异。
加载、分析和执行之间已过时,但仍然会影响 API
从技术上讲,规则在操作发送到远程执行之前就知道操作的输入和输出文件。但是,原始 Bazel 代码库具有严格的分离机制,即先加载软件包,然后使用配置(基本上是命令行标志)分析规则,最后再运行任何操作。尽管 Bazel 的核心不再需要该区别,但该差异目前仍是规则 API 的一部分(详见下文)。
也就是说,规则 API 需要对规则接口(它具有哪些属性、哪些类型的属性)进行声明式描述。有一些例外情况,比如 API 允许自定义代码在加载阶段运行,以计算输出文件的隐式名称和属性的隐式值,例如,名为“foo”的 java_library 规则会隐式生成名为“libfoo.jar”的输出,该输出可以从构建图中的其他规则引用。
此外,规则的分析无法读取任何源文件或检查操作的输出;相反,它需要生成构建步骤和输出文件名的部分定向两侧图,该图仅由规则本身及其依赖项确定。
固有
有一些固有属性会让编写规则变得具有挑战性,下面几部分将介绍一些最常见的属性。
远程执行和缓存很难执行
与在单台机器上运行构建相比,远程执行和缓存在大型代码库中的构建时间缩短了大约两个数量级。但是,它需要的执行速度令人难以置信:Google 的远程执行服务旨在每秒处理大量请求,并且该协议会谨慎地避免不必要的往返以及服务端的不必要工作。
目前,协议要求构建系统提前了解指定操作的所有输入;然后,构建系统会计算唯一的操作指纹,并请求调度程序进行缓存命中。如果找到缓存命中,调度程序会使用输出文件的摘要回复;文件本身稍后会由摘要处理。但是,这会对 Bazel 规则施加限制,这些规则需要提前声明所有输入文件。
使用更改信息实现快速高效的增量构建需要异常的编码模式
上文认为,为了正确性,Bazel 需要知道进入构建步骤的所有输入文件,以便检测该构建步骤是否仍是最新的。软件包加载和规则分析也是如此,并且我们设计了 Skyframe 以一般方式处理这种情况。Skyframe 是一种图库和评估框架,采用目标节点(例如“build //foo with these options”)并将其分解成其组成部分,然后对其进行求值和组合以得出此结果。在此过程中,Skyframe 会读取软件包、分析规则和执行操作。
在每个节点,Skyframe 会精确地跟踪任意给定节点用于计算自己的输出的节点,从目标节点一直到输入文件(也是 Skyframe 节点)。通过在内存中明确表示该图,构建系统可以准确地识别哪些输入节点受到输入文件的指定更改(包括创建或删除输入文件)的影响,只需执行极少的工作量即可将输出树恢复为预期状态。
在此过程中,每个节点都会执行依赖项发现过程。每个节点都可以声明依赖项,然后使用这些依赖项的内容声明更多依赖项。原则上,这很好地映射到每个节点的线程模型。不过,中型 build 包含数十万个 Skyframe 节点,这在使用当前 Java 技术的情况下很难做到(由于历史原因,我们目前还在使用 Java,因此没有轻量级线程和延续)。
但 Bazel 会改用固定大小的线程池。不过,这意味着,如果某个节点声明的依赖项尚不可用,当依赖项可用时,我们可能必须取消该评估并重启该评估(可能在另一个线程中)。这反过来意味着,节点不应过度执行此操作;连续声明 N 依赖项的节点可能会重启 N 次,从而耗费 O(N^2) 时间。相反,我们的目标是预先批量声明依赖项,这有时需要重新组织代码,甚至需要将一个节点拆分为多个节点以限制重启次数。
请注意,该规则目前不适用于规则 API;不过,规则 API 仍然使用加载、分析和执行阶段的旧版概念进行定义。不过,有一项基本限制是,对其他节点的所有访问都必须通过框架,以便跟踪相应的依赖项。无论构建系统使用哪种语言编写,或者规则编写哪种语言(不一定相同),规则作者不得使用绕过 Skyframe 的标准库或模式。对于 Java,这意味着避免使用 java.io.File 以及任何形式的反射,以及任何一种反射模式的库。支持这些低级别接口的依赖项注入的库仍需针对 Skyframe 进行正确设置。
因此强烈建议您先避免将规则作者公开给一个完整语言的运行时。意外使用此类 API 的危险性太大,过去的几个 Bazel 错误是由使用不安全的 API 的规则导致的,即使规则是由 Bazel 团队或其他网域专家编写的。
避免二次时间和内存消耗很困难
更糟糕的是,除了 Skyframe 施加的要求、使用 Java 的历史限制以及规则 API 的过时之外,任何基于库和二进制规则的构建系统都意外地引入了二次时间或内存消耗。有两种常见模式会导致二次内存消耗(从而引起二次时间消耗)。
库规则链 - 假设有这样一条库规则:A 规则依赖于 B,而 C 依赖于 C 等等。然后,我们希望计算这些规则的传递性关闭情况,如 Java 运行时类路径或每个库的 C++ 链接器命令。我们可能只是采用标准列表实现,不过,这已经引入了二次内存消耗:第一个库在类路径中包含一个条目,后两个库包含第三个 3,以此类推,总共有 1+2+3+...+N = O(N^2) 个条目。
依赖于同一库规则的二进制规则 - 假设有一组依赖于相同库规则的二进制文件,例如,您有多项测试同一库代码的测试规则。假设有 N 条规则,一半的规则是二进制规则,还有一半的库规则。现在,假设每个二进制文件都会复制一些通过库规则传递闭包计算得出的属性,例如 Java 运行时类路径或 C++ 链接器命令行。例如,它可以展开 C++ 链接操作的命令行字符串表示形式。N/2 个元素的 N/2 副本是 O(N^2) 内存。
自定义集合类以避免二次复杂性
Bazel 会受到这两种方案的严重影响,因此我们引入了一组自定义集合类,通过在每个步骤避免复制来有效地压缩内存中的信息。几乎所有这些数据结构都有已设置的语义,因此我们将其称为“depset”(在内部实现中也称为“NestedSet
”)。过去几年,为减少 Bazel 的内存消耗而进行的大部分更改都是使用 Depset,而不是之前使用的更改。
遗憾的是,使用 depset 并不能自动解决所有问题;具体而言,只是遍历每个规则中的 depset 就会重新引入二次耗时。在内部,NestedSet 也有一些辅助方法,用于促进与普通集合类的互操作;遗憾的是,意外将 NestedSet 传递给其中一种方法会导致复制行为,并重新引入二次内存消耗。