编写规则的挑战

本页面简要介绍了编写高效 Bazel 规则的具体问题和挑战 。

要求摘要

  • 假设:以正确性、吞吐量、易用性和延迟为目标
  • 假设:大规模代码库
  • 假设:类似 BUILD 的说明语言
  • 历史:加载、分析和执行之间的硬性分离已 过时,但仍会影响 API
  • 固有:远程执行和缓存很难
  • 固有:使用更改信息进行正确且快速的增量构建 需要不寻常的编码模式
  • 固有:避免二次时间和内存消耗很难

假设

以下是关于构建系统的一些假设,例如需要 正确性、易用性、吞吐量和大规模代码库。以下部分将介绍这些假设,并提供指南以确保 规则以有效的方式编写。

以正确性、吞吐量、易用性和延迟为目标

我们假设构建系统首先需要就增量构建而言是正确的。对于给定的源代码树,无论输出树是什么样子,同一构建的 输出都应始终相同。在第一次近似中,这意味着 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 是一个图库和评估框架,它接受一个 目标节点(例如“使用这些选项构建 //foo”),并将其分解为 组成部分,然后对这些部分进行评估并组合以生成此 结果。在此过程中,Skyframe 会读取软件包、分析规则和 执行操作。

在每个节点上,Skyframe 都会跟踪任何给定节点用于计算 其自身输出的确切节点,从目标节点一直到输入文件(也是 Skyframe 节点)。在内存中显式表示此图 可让构建系统准确识别哪些节点受到对输入文件的给定 更改(包括创建或删除输入文件)的影响,从而以最少的工作量将输出树恢复到预期状态。

在此过程中,每个节点都会执行依赖项发现过程。每个 节点都可以声明依赖项,然后使用这些依赖项的内容 来声明更多依赖项。原则上,这与每个节点的 线程模型非常契合。但是,中等规模的构建包含数十万个 Skyframe 节点,这在当前的 Java 技术中很难实现(由于历史原因,我们目前只能使用 Java,因此没有轻量级线程,也没有延续)。

相反,Bazel 使用固定大小的线程池。但是,这意味着,如果某个节点 声明了尚未可用的依赖项,我们可能必须中止该 评估并在依赖项可用时重新启动该评估(可能在另一个线程中)。反过来,这意味着节点不应过度执行此操作;a 声明 N 个依赖项的节点可能会被重启 N 次, 从而花费 O(N^2) 时间。相反,我们的目标是提前批量声明 依赖项,这有时需要重新组织代码,甚至将节点拆分为多个节点,以限制重启次数。

请注意,此技术目前在规则 API 中不可用;相反, 规则 API 仍使用加载、分析、 和执行阶段的旧概念进行定义。但是,一个基本限制是,对 其他节点的所有访问都必须通过框架,以便框架可以跟踪 相应的依赖项。无论构建系统以哪种语言实现,或者规则以哪种语言编写(它们不必相同),规则作者都不得使用绕过 Skyframe 的标准库或模式。对于 Java,这意味着避免使用 java.io.File 以及任何形式的 反射,以及执行这两项操作的任何库。支持对这些低级接口进行依赖项 注入的库仍需要为 Skyframe 正确设置。

这强烈建议首先避免向规则作者公开完整的语言运行时 。意外使用此类 API 的危险性太大了 - 过去有几个 Bazel bug 是由规则使用不安全的 API 引起的,即使 这些规则是由 Bazel 团队或其他领域专家编写的也是如此。

避免二次时间和内存消耗很难

更糟糕的是,除了 Skyframe 施加的要求、使用 Java 的 历史限制以及规则 API 的过时之外, 意外引入二次时间或内存消耗是任何基于库和二进制规则的构建系统中的基本 问题。有两种 非常常见的模式会引入二次内存消耗(因此 也会引入二次时间消耗)。

  1. 库规则链 - 考虑库规则链 A 依赖于 B,B 依赖于 C,依此类推的情况。然后,我们希望计算 这些规则的传递闭包的某些属性,例如 Java 运行时类路径或 每个库的 C++ 链接器命令。简单来说,我们可能会采用标准列表实现;但是, 这已经引入了二次内存消耗:第一个库 包含类路径上的一个条目,第二个库包含两个条目,第三个库包含三个条目,依此类推,总共有 1+2+3+...+N = O(N^2) 个条目。

  2. 依赖于相同库规则的二进制规则 - 考虑一组依赖于相同库 规则的二进制文件的情况,例如,如果您有多个测试规则来测试相同的 库代码。假设在 N 个规则中,一半是二进制规则, 另一半是库规则。现在考虑每个二进制文件都会复制 在库规则的传递闭包上计算的某些属性,例如 Java 运行时类路径或 C++ 链接器命令行。例如,它可以展开 C++ 链接操作的命令行字符串表示形式。N/2 个 N/2 元素的副本是 O(N^2) 内存。

自定义集合类,以避免二次复杂性

Bazel 受到这两种情况的严重影响,因此我们引入了一组 自定义集合类,这些类通过避免在每个步骤中复制来有效地压缩内存中的信息。几乎所有这些数据结构都具有集合 语义,因此我们将其称为 depset (在内部实现中也称为 NestedSet)。过去几年中,为减少 Bazel 的内存消耗而进行的大部分 更改都是 使用 depset 而不是以前使用的任何内容。

遗憾的是,使用 depset 并不能自动解决所有问题; 特别是,即使只是在每个规则中迭代 depset,也会重新引入 二次时间消耗。在内部,NestedSets 还有一些辅助方法 来促进与普通集合类的互操作性;遗憾的是, 意外将 NestedSet 传递给其中一种方法会导致复制 行为,并重新引入二次内存消耗。