编写规则的挑战

报告问题 查看源代码

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

摘要要求

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

假设

以下是对构建系统的一些假设,例如对正确性、易用性、吞吐量和大型代码库的需求。以下部分将探讨这些假设,并提供了相关准则,以确保以有效的方式编写规则。

旨在确保正确性、吞吐量、易用性和延迟时间

我们假设,在增量 build 方面,构建系统首先必须确保正确。对于给定的源代码树,同一 build 的输出应始终相同,无论输出树是什么样子。在第一个近似情况下,这意味着 Bazel 需要知道进入给定构建步骤的每个输入,以便 Bazel 能够在任何输入发生更改时重新运行该步骤。Bazel 正确获取的方式有限,因为它会泄露一些信息(例如构建的日期 / 时间),并会忽略特定类型的更改(例如对文件属性的更改)。沙盒可防止读取未声明的输入文件,有助于确保正确性。除了系统的固有限制之外,还存在一些已知的正确性问题,其中大多数与 Fileset 或 C++ 规则有关,都是硬性问题。我们长期致力于解决这些问题。

构建系统的第二个目标是实现高吞吐量;我们会永久突破当前机器分配中针对远程执行服务可以执行的操作极限。如果远程执行服务过载,任何人都无法完成工作。

易用性是下一个问题。从远程执行服务占用空间相同(或类似)的多个正确方法中,我们会选择更易于使用的方法。

延迟时间表示从开始构建到获得预期结果所需的时间,无论是通过测试还是失败测试的测试日志,还是 BUILD 文件有拼写错误的错误消息。

请注意,这些目标经常是重叠的;延迟时间与远程执行服务的吞吐量相关,其准确性与易用性同样重要。

大型代码库

构建系统需要在大型代码库的规模下运行,大规模代码库意味着它不适合单个硬盘,因此不可能在几乎所有开发者机器上进行全面检出。中型 build 需要读取和解析数万个 BUILD 文件,以及评估数十万个 glob。从理论上说,可以在一台机器上读取所有 BUILD 文件,但目前无法在合理的时间和内存内完成读取操作。因此,能够独立加载和解析 BUILD 文件至关重要。

类似于 build 的说明语言

在此上下文中,我们假定配置语言与库和二进制文件规则及其相互依赖关系声明中的 BUILD 文件大致类似。BUILD 文件可以单独读取和解析,并且我们甚至避免尽可能查看源文件(除非源文件不存在)。

历史古迹

造成质疑的 Bazel 版本之间存在一些差异,以下各部分概述了其中一些差异。

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

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

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

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

固有可解释性

有一些固有属性使得编写规则颇具挑战性,下面几部分将介绍一些最常见的属性。

远程执行和缓存很难

与在单台机器上运行构建相比,远程执行和缓存可将大型代码库中的构建时间缩短大约两个数量级。不过,它需要的执行规模令人难以置信:Google 的远程执行服务每秒可处理大量请求,并且该协议会谨慎地避免不必要的往返以及服务端不必要的工作。

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

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

在上文中,我们认为,为了保证正确性,Bazel 需要知道构建步骤中的所有输入文件,以便检测该构建步骤是否仍然处于最新状态。软件包加载和规则分析也是如此,我们设计了 Skyframe 来总体上处理这种情况。Skyframe 是一个图表库和评估框架,它接受一个目标节点(例如“build //foo with these options”),然后将其分解为各个组成部分,然后对这些组成部分进行评估和组合,以得出相应的结果。在此过程中,Skyframe 会读取软件包、分析规则并执行操作。

在每个节点上,Skyframe 会准确跟踪用于计算其自身输出的任何指定节点,一直到从目标节点到输入文件(它们也是 Skyframe 节点)的整个过程。通过在内存中明确表示此图,构建系统可以准确地确定对输入文件的指定更改(包括创建或删除输入文件)影响了哪些节点,从而只需完成最少量的工作即可将输出树恢复到预期状态。

其中,每个节点都会执行依赖项发现过程。每个节点都可以声明依赖项,然后使用这些依赖项的内容来声明更多依赖项。原则上,这可以很好地映射到每个节点一个线程的模型。不过,中型 build 包含数十万 Skyframe 节点,这在使用当前 Java 技术时很难实现(而且由于历史原因,我们目前只能使用 Java,因此无法提供轻量级的线程和接续服务)。

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

请注意,Rules API 目前不支持此技术;不过,Rules 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 个元素的 N/2 个副本为 O(N^2) 内存。

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

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

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