编写规则的挑战

报告问题 查看源代码

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

摘要要求

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

假设

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

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

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

构建系统的第二个目标是获得高吞吐量;我们正不断突破当前机器分配中可以在远程执行服务中执行的操作的极限。如果远程执行服务过载,则没有人可以完成工作。

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

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

请注意,这些目标往往是重叠的;延迟时间不仅受远程执行服务吞吐量的影响,还与易用性相关的准确性。

大型代码库

构建系统需要以大规模存储库的规模运行,大型代码库不适合单个硬盘驱动器,因此在几乎所有开发者计算机上都无法执行完整的检出。中型 build 需要读取和解析数万个 BUILD 文件,并评估数十万个 glob。虽然理论上可以在一台机器上读取所有 BUILD 文件,但我们无法在合理的时间和内存内执行此操作。因此,可以独立加载和解析 BUILD 文件至关重要。

类似 build 的说明语言

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

历史古迹

Bazel 版本之间存在一些差异,这些差异会带来一些挑战,以下各部分介绍了其中一些差异。

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

从技术上讲,只要规则在将某项操作的临近执行到远程执行之前就知道其输入和输出文件,就足够了。不过,原始 Bazel 代码库严格分离了加载软件包,然后使用配置(实质上是命令行标志)分析规则,然后才运行任何操作。尽管 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) 次时间。相反,我们的目标是预先批量声明依赖项,这有时需要重新整理代码,甚至将节点拆分为多个节点以限制重启次数。

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

这强烈建议不要从一开始就将规则作者公开给完整的语言运行时。意外使用此类 API 的风险太大了,过去有几个 Bazel bug 是由于使用不安全的 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 内存消耗所做的大部分变更都是更改为使用 depset,而不是之前使用过的任何方式。

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