编写规则的挑战

报告问题 查看来源 每晚 · 7.2。 · 7.1敬上 · 7.0 · 6.5 条 · 6.4

本页将简要介绍面临的具体问题和挑战 编写有效的 Bazel 规则

摘要要求

  • 假设:力求正确性、吞吐量、易用性和延迟时间
  • 假设:大规模存储库
  • 假设:类似于 build 的说明语言
  • 历史:加载、分析和执行之间的硬性分离是 已过时,但仍会影响 API
  • 固有可解释性:远程执行和缓存很难
  • 固有特性:使用更改信息实现正确且快速的增量构建 需要异常编码模式
  • 固有可解释性:避免二次时间和内存消耗并非易事

假设

以下是关于构建系统的一些假设 正确性、易用性、吞吐量和大型存储库。通过 以下部分针对这些假设进行了解释,并提供了相关指南, 以有效的方式编写这些规则。

力求实现正确性、吞吐量、易用性和延迟时间

我们假设构建系统首先必须确保正确运行 与增量构建相比较对于指定的源代码树, 无论输出树是什么样子,同一 build 应始终相同 类似。对于第一种情形,这意味着 Bazel 需要了解 该输入会进入指定构建步骤,以便它可以重新运行该步骤(如果有) 变化。由于 Bazel 泄漏,正确获取的方式有限 一些信息,例如 build 的日期 / 时间,而会忽略某些类型的 更改,例如文件属性更改。沙盒 通过防止读取未声明的输入文件来帮助确保正确性。除了 系统的固有限制,但也存在一些已知的正确性问题, 其中大多数规则都与 Fileset 或 C++ 规则相关,这两者都很难 问题。我们长期致力于解决这些问题。

构建系统的第二个目标是具有高吞吐量;我们是 永久性地打破了 远程执行服务的机器分配如果远程执行 服务过载,没有人可以完成工作。

易用性是下一个问题。有多种具有相同(或 与远程执行服务占用的空间相似,因此我们选择 更易于使用。

延迟时间表示从开始构建到实现预期目标所需的时间 比如来自通过或失败测试的测试日志, 显示 BUILD 文件存在拼写错误的消息。

请注意,这些目标通常会重叠:延迟时间与吞吐量的关系 正确性与易用性相关。

大型代码库

构建系统需要在大型代码库的规模下运行,而大型代码库 意味着它不适合安装在单个硬盘上, 在几乎所有开发者机器上进行全面结账。中型 build 将需要读取和解析数万个 BUILD 文件,并评估 成千上万个 glob。从理论上讲,可以读取所有 BUILD 个文件,但我们还无法在 合理分配时间和内存因此,BUILD 文件 可以独立加载和解析

类似于 build 的说明语言

在此上下文中,我们假设配置语言 大致类似于库和二进制文件规则声明中的 BUILD 文件 及其相互依赖性。BUILD 文件可单独读取和解析, 我们甚至会尽量避免查看源文件(除了 存在)。

历史古迹

导致各种问题的 Bazel 版本之间存在一些差异, 下面几部分将对其进行概述。

加载、分析和执行之间硬性分离已过时,但仍会影响 API

从技术上讲,让规则知道数据的输入和输出文件 在将操作发送到远程执行之前执行的操作。不过, 原始 Bazel 代码库对加载软件包进行了严格分离, 使用配置(实际上就是命令行标志)分析规则,以及 然后才能运行任何操作这种区别仍然是 Rules API 的一部分 ,尽管 Bazel 的核心已不再需要它(详见下文)。

这意味着,Rules API 需要规则的声明式说明 接口(它具有什么属性、属性类型)。有一些 当 API 允许自定义代码在加载阶段运行以 计算输出文件的隐式名称和属性的隐式值。对于 例如,名为“foo”的 java_library 规则隐式生成名为 “libfoo.jar”,可以从构建图中的其他规则引用。

此外,分析规则时无法读取任何源文件或检查 操作的输出;而是需要生成部分定向的两部分 显示仅根据规则确定的构建步骤和输出文件名的图表 及其依赖项。

固有可解释性

由于存在一些固有属性,这使得编写规则具有挑战性, 下面几部分将介绍一些最常见的代码。

远程执行和缓存很难

远程执行和缓存可缩短大型代码库中的构建时间,具体方法是 与在单个集群上运行构建相比 虚拟机。然而,它需要的规模令人难以置信:Google 的 远程执行服务旨在为每个网站处理大量请求, 其次,该协议会谨慎地避免不必要的往返 在服务端进行不必要的工作

目前,协议要求构建系统知道 提前执行特定操作;然后,构建系统会计算 指纹,并向调度程序请求缓存命中。如果发现缓存命中 调度器会使用输出文件的摘要进行回复;这些文件本身 通过摘要解决但是,这会对 Bazel 规则,因此需要提前声明所有输入文件。

使用更改信息来正确快速地进行增量构建需要采用不同寻常的编码模式

在上文中,我们认为,要判断真伪,Bazel 需要知道 文件,以检测该构建步骤是否 保持最新状态软件包加载和规则分析也是如此, Skyframe 专门设计了用于处理这种现象的 。Skyframe 是一个图表库和评估框架,它接受 目标节点(例如“build //foo with these options”),并将其分解为 并对其进行评估和组合,得出 结果。在这个过程中,Skyframe 会读取数据包、分析规则, 执行操作

在每个节点上,Skyframe 会准确跟踪任何给定节点用于计算 这一过程从目标节点一直到输入文件( 也是 Skyframe 节点)。在内存中明确表示此图 可以让构建系统准确识别 更改输入文件(包括创建或删除输入文件)、 将输出树恢复到预期状态所需的最少工作量。

其中,每个节点都会执行依赖项发现过程。每个 可以声明依赖项,然后使用这些依赖项的内容 以声明更多依赖项。原则上,这与 每个节点的线程模型不过,中型 build 包含数百个 数千个 Skyframe 节点并不容易, (由于历史原因,我们目前与使用 Java 密切相关, 没有轻量级线程和连续)。

但 Bazel 使用的是固定大小的线程池。不过,这意味着 声明了尚不可用的依赖项,我们可能必须取消该依赖项 评估并重启(可能在另一个线程中) 可用。这也意味着节点不应过度执行此操作;一 连续声明 N 个依赖项的节点有可能会重启 N 次, 花费 O(N^2) 时间。我们旨在 有时需要重新整理代码 复制到多个节点中以限制重启次数。

请注意,Rules API 目前不支持此技术;改为 但规则 API 仍然使用传统的加载、分析、 和执行阶段。不过,一个基本的限制是,对 其他节点必须通过框架才能跟踪 相应的依赖项无论构建系统使用哪种语言, 实施或编写规则时所处的位置(不一定是 ),规则作者不得使用绕过 Skyframe。对于 Java,这意味着避免使用 java.io.File 以及任何形式的 以及任何执行上述操作的库。支持依赖项的库 您仍然需要正确设置这些低级别接口的注入, Skyframe。

这强烈建议避免将规则作者公开给完整的语言运行时 。意外使用此类 API 的风险实在是太大了 - 过去有几个 Bazel 错误是由使用不安全 API 的规则引起的, 但这些规则是由 Bazel 团队或其他领域专家撰写的。

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

更糟糕的是,除了 Skyframe 施加的要求之外, 使用 Java 的历史限制以及规则 API 的过时 意外引入二次时间或内存消耗是 问题。有两个 引入二次内存消耗(因此 二次时间消耗)。

  1. 库链规则 - 设想一个库规则 A 依赖于 B 并且依赖于 C 的链式规则。 依此类推。然后,我们需要通过 这些规则,如 Java 运行时类路径或 C++ 链接器命令 每个库简单地说,我们可以采用标准列表实现:不过, 这已经引入了二次内存消耗:第一个库 包含类路径中的一个条目,其中包含第二个条目,第三个条目,第三个条目,依此类推, 总计 1+2+3+...+N = O(N^2) 个条目。

  2. 依赖于相同库规则的二进制规则 - 假设一组二进制文件依赖于同一个库 例如,如果您有多个测试规则测试同一个 库代码中。假设有 N 条规则,其中一半规则是二元规则 另一半库规则现在假设每个二进制文件都会 通过库规则传递闭包计算得出的某种属性,例如 Java 运行时类路径或 C++ 链接器命令行。例如, 可以扩展 C++ 链接操作的命令行字符串表示法。不适用 N/2 个元素的副本为 O(N^2) 内存。

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

这两种情况都会严重影响 Bazel,因此我们针对 自定义集合类,可有效地压缩内存中的信息, 避免在每一步复制代码。这些数据结构中的几乎所有 所以我们称之为 depset (在内部实现中也称为 NestedSet)。大部分 过去几年中旨在减少 Bazel 内存消耗量的变化 更改为使用 depsets,而不是之前所用的任何内容。

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