编写规则的挑战

报告问题 查看源代码 每夜 build · 8.0 · 7.4 · 7.3 · 7.2 · 7.1 · 7.0 · 6.5

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

摘要要求

  • 假设:力求实现正确性、吞吐量、易用性和延迟时间
  • 假设:大型代码库
  • 假设:类似于 BUILD 的描述语言
  • 历史记录:加载、分析和执行之间的硬分离已过时,但仍会影响 API
  • 内建:远程执行和缓存很难
  • 内在:使用更改信息以实现正确且快速的增量 build 需要采用不寻常的编码模式
  • 内建:避免时间和内存消耗呈二次方增长很难

假设

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

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

我们假定,构建系统首先需要正确处理增量 build。对于给定的源代码树,无论输出树的显示方式如何,相同 build 的输出都应始终相同。粗略地说,这意味着 Bazel 需要知道进入给定构建步骤的每个输入,以便在任何输入发生变化时重新运行该步骤。Bazel 可以获得的信息准确性是有限制的,因为它会泄露一些信息(例如构建日期 / 时间),并会忽略某些类型的更改(例如文件属性的更改)。沙盒化有助于防止读取未声明的输入文件,从而确保正确性。除了系统固有限制之外,还有一些已知的正确性问题,其中大多数与文件集或 C++ 规则相关,这两个问题都很难解决。我们会长期努力解决这些问题。

构建系统的第二个目标是实现高吞吐量;我们会不断突破当前机器分配范围内可为远程执行服务执行的操作的边界。如果远程执行服务过载,则任何人都无法完成工作。

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

延迟时间是指从启动 build 到获得预期结果所需的时间,无论是通过或失败测试的测试日志,还是 BUILD 文件拼写错误的错误消息。

请注意,这些目标通常会重叠;延迟时间与远程执行服务的吞吐量密切相关,而易用性与正确性密切相关。

大型代码库

构建系统需要在大型代码库的规模下运行,其中“大型”意味着它无法存储在单个硬盘上,因此几乎无法在所有开发者机器上执行完整的检出。一个中等规模的 build 需要读取和解析数万个 BUILD 文件,并评估数十万个正则表达式。虽然理论上可以在一台机器上读取所有 BUILD 文件,但我们尚未能够在合理的时间和内存用量内做到这一点。因此,必须能够独立加载和解析 BUILD 文件。

类似于 BUILD 的说明语言

在本上下文中,我们假设一种配置语言,该语言在库和二进制规则以及它们的相互依赖项的声明中与 BUILD 文件大致相似。BUILD 文件可以独立读取和解析,我们尽可能避免查看源文件(除了检查是否存在之外)。

历史古迹

Bazel 版本之间存在差异,这些差异会造成一些问题,以下部分概述了其中的一些问题。

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

从技术层面来说,只要在将操作发送到远程执行之前,规则知道操作的输入和输出文件即可。不过,原始 Bazel 代码库严格区分了加载软件包、使用配置(本质上是命令行标志)分析规则,然后才运行任何操作。尽管 Bazel 的核心不再需要此区分,但此区分目前仍是 Rules API 的一部分(详见下文)。

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

此外,对规则的分析无法读取任何源文件或检查操作的输出;而是需要生成一个包含构建步骤和输出文件名的部分有向二分图,该图仅由规则本身及其依赖项决定。

固有可解释性

有些固有属性会使编写规则变得非常困难,以下部分介绍了一些最常见的属性。

远程执行和缓存很难

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

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

若要使用更改信息来实现正确且快速的增量构建,需要采用不寻常的编码模式

我们在前面提到,为了确保正确性,Bazel 需要知道进入构建步骤的所有输入文件,以便检测该构建步骤是否仍处于最新状态。软件包加载和规则分析也是如此,我们设计了 Skyframe 来处理这类问题。Skyframe 是一个图书库和评估框架,它会接受一个目标节点(例如“使用这些选项构建 //foo”),并将其拆解为其组成部分,然后对这些部分进行评估并组合以生成此结果。在此过程中,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,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 传递给其中一种方法,就会导致复制行为,并重新引入二次方内存用量。