永久性工作器

本页面介绍了如何使用持久性工作器、优势和要求,以及工作器对沙盒的影响。

持久性工作器是由 Bazel 服务器启动的长时间运行的进程,它充当实际工具(通常是编译器)的封装容器,或者是工具本身。为了从持久性工作器中受益,该工具必须支持执行一系列编译,并且封装容器需要在该工具的 API 与下述请求/响应格式之间进行转换。在同一个 build 中,无论是否使用 --persistent_worker 标志都可以调用同一 worker,它们负责适当地启动工具、与工具进行通信,以及在退出时关闭 worker。每个工作器实例都会分配到 <outputBase>/bazel-workers 下的单独工作目录(但不会获得 chroot 权限)。

使用永久性工作器是一种执行策略,可以减少启动开销,允许更多的 JIT 编译,并在操作执行过程中启用抽象语法树等缓存。此策略通过向长时间运行的进程发送多个请求来实现这些改进。

持久性 worker 是针对多种语言实现的,包括 Java、ScalaKotlin 等。

使用 NodeJS 运行时的程序可以使用 @bazel/worker 辅助库来实现工作器协议。

使用永久性工作器

Bazel 0.27 及更高版本在执行构建时默认使用永久性工作器,但远程执行优先。对于不支持持久性工作器的操作,Bazel 会回退到为每个操作启动工具实例。您可以通过为适用的工具助记符设置 worker 策略,将构建明确设置为使用永久性工作器。作为最佳实践,此示例包括指定 local 作为 worker 策略的回退:

bazel build //my:target --strategy=Javac=worker,local

使用 worker 策略而不是本地策略可以显著提高编译速度,具体取决于实现情况。对于 Java,构建速度可以提高 2-4 倍,对于增量编译,有时速度会更快。使用 worker 编译 Bazel 的速度大约是 2.5 倍如需了解详情,请参阅“选择工作器数量”部分。

如果您还有与本地构建环境匹配的远程构建环境,则可以使用实验性动态策略,该策略会对远程执行和工作器执行进行竞争。如需启用动态策略,请传递 --experimental_spawn_scheduler 标志。此策略会自动启用工作器,因此无需指定 worker 策略,但您仍然可以使用 localsandboxed 作为回退。

选择工作器数量

每个助记符的默认工作器实例数为 4,但可以使用 worker_max_instances 标志进行调整。您需要在充分利用可用的 CPU 与获得的 JIT 编译量和缓存命中数之间进行权衡。工作器越多,目标就越多,将需要支付运行非 JIT 编译的代码和访问冷缓存的启动成本。如果您要构建的目标数量较少,单个工作器可能会在编译速度和资源使用情况之间取得最佳权衡(例如,请参阅问题 8586)。worker_max_instances 标志用于设置每个助记符和标志集的最大工作器实例数(见下文),因此在混合系统中,如果您保留默认值,最终可能会占用大量内存。对于增量构建,多个工作器实例的优势更小。

此图显示了在配备 64 GB RAM 的 6 核超线程 Intel Xeon 3.5 GHz Linux 工作站上,Bazel(目标 //src:bazel)的从头开始编译时间。对于每项工作器配置,都会运行五个干净 build,并取最后四个 build 的平均值。

整洁 build 的性能改进图表

图 1. 整洁 build 的性能改进图表。

对于这种配置,两个 worker 的编译速度最快,但与一个 worker 相比只有 14% 的提升。如果您想减少使用的内存,最好使用一个工作器。

增量编译通常更有优势。干净 build 相对罕见,但在编译之间更改单个文件很常见,尤其是在测试驱动型开发中。上面的示例还有一些非 Java 打包操作,这些操作可能会掩盖增量编译时间。

如果在更改 AbstractContainerizingSandboxedSpawn.java 中的内部字符串常量之后再重新编译 Java 源代码 (//src/main/java/com/google/devtools/build/lib/bazel:BazelServer_deploy.jar),可将速度提升 3 倍(平均 20 次增量 build,舍弃 1 个预热 build):

增量构建的性能改进图表

图 2. 增量构建的性能改进图表。

具体速度取决于所进行的更改。在上述情况下,当常用常数发生变化时,系统会测量 6 倍的速度。

修改永久性工作器

您可以传递 --worker_extra_flag 标志,向工作器指定启动标志,由助记符进行键控。例如,传递 --worker_extra_flag=javac=--debug 将仅开启针对 Javac 的调试。每次使用此标志时,只能设置一个工作器标志,且只能用于一种助记符。 不仅可以为每个助记符单独创建 worker,还可以为其启动标志中的变体创建 worker。每个助记标记和启动标志的组合都会组合到一个 WorkerKey 中,并且对于每个 WorkerKey,最多可以创建 worker_max_instances 个工作器。如需了解操作配置如何还可以指定设置标志,请参阅下一部分。

您可以使用 --high_priority_workers 标志指定一个助记符,该助记符应优先于普通优先级助记符运行。这有助于确定始终在关键路径中的操作的优先级。如果有两个或更多高优先级工作器正在执行请求,则系统将阻止所有其他工作器运行。此标志可以多次使用。

传递 --worker_sandboxing 标志会使每个工作器请求使用单独的沙盒目录处理其所有输入。设置sandbox需要一些额外的时间(尤其是在 macOS 上),但可以提供更好的正确性保证。

--worker_quit_after_build 标志主要用于调试和性能分析。此标志会强制所有工作器在构建完成后退出。您还可以传递 --worker_verbose 来获取有关工作器正在执行的操作的更多输出。此标志反映在 WorkRequestverbosity 字段中,使 worker 实现也更详细。

工作器将其日志存储在 <outputBase>/bazel-workers 目录(例如 /tmp/_bazel_larsrc/191013354bebe14fdddae77f2679c3ef/bazel-workers/worker-1-Javac.log)中。文件名包含工作器 ID 和助记符。由于每种助记符可以有多个 WorkerKey,因此对于特定助记符,您可能会看到超过 worker_max_instances 个日志文件。

如需了解 Android build 的详细信息,请参阅“Android build 性能”页面

实现持久性 worker

如需详细了解如何创建工作器,请参阅创建永久性工作器页面。

以下示例展示了使用 JSON 的工作器的 Starlark 配置:

args_file = ctx.actions.declare_file(ctx.label.name + "_args_file")
ctx.actions.write(
    output = args_file,
    content = "\n".join(["-g", "-source", "1.5"] + ctx.files.srcs),
)
ctx.actions.run(
    mnemonic = "SomeCompiler",
    executable = "bin/some_compiler_wrapper",
    inputs = inputs,
    outputs = outputs,
    arguments = [ "-max_mem=4G",  "@%s" % args_file.path],
    execution_requirements = {
        "supports-workers" : "1", "requires-worker-protocol" : "json" }
)

根据此定义,此操作的首次使用将从执行命令行 /bin/some_compiler -max_mem=4G --persistent_worker 开始。然后,编译 Foo.java 的请求将如下所示:

注意:虽然协议缓冲区规范使用“蛇形命名法”(request_id),但 JSON 协议使用“驼峰式大小写”(requestId)。在本文档中,我们将在 JSON 示例中使用驼峰式大小写,但在谈论字段时,无论采用何种协议,我们都将使用蛇形命名法。

{
  "arguments": [ "-g", "-source", "1.5", "Foo.java" ]
  "inputs": [
    { "path": "symlinkfarm/input1", "digest": "d49a..." },
    { "path": "symlinkfarm/input2", "digest": "093d..." },
  ],
}

工作器会在 stdin 上以换行符分隔的 JSON 格式接收此信息(因为 requires-worker-protocol 设置为 JSON)。然后,工作器执行该操作,并向其标准输出将 JSON 格式的 WorkResponse 发送到 Bazel。然后,Bazel 会解析此响应并手动将其转换为 WorkResponse proto。如需使用二进制编码的 protobuf(而不是 JSON)与关联的工作器进行通信,需要将 requires-worker-protocol 设置为 proto,如下所示:

  execution_requirements = {
    "supports-workers" : "1" ,
    "requires-worker-protocol" : "proto"
  }

如果您未在执行要求中添加 requires-worker-protocol,则 Bazel 会默认让工作器通信使用 protobuf。

Bazel 从助记符和共享标志派生 WorkerKey,因此,如果此配置允许更改 max_mem 参数,则会为使用的每个值生成单独的工作器。如果使用太多变体,可能会导致内存消耗过多。

每个工作器目前一次只能处理一个请求。实验性的多线程工作器功能允许使用多个线程,前提是底层工具是多线程的,并且封装容器设置为理解这一点。

此 GitHub 代码库中,您可以看到使用 Java 和 Python 编写的示例工作器封装容器。如果您使用的是 JavaScript 或 TypeScript,则 @bazel/worker 软件包nodejs 工作器示例可能会有帮助。

工作器如何影响沙盒?

local 策略类似,默认使用 worker 策略不会在sandbox中运行操作。您可以设置 --worker_sandboxing 标志以在沙盒内运行所有工作器,并确保该工具的每次执行都只能看到它应该具有的输入文件。该工具可能仍会在内部请求之间泄露信息,例如通过缓存泄露。使用 dynamic 策略需要对 worker 进行沙盒化

为了允许通过工作器正确使用编译器缓存,摘要会与每个输入文件一起传递。因此,编译器或封装容器无需读取文件即可检查输入是否仍有效。

即使使用输入摘要来防范不必要的缓存,沙盒化工作器提供的沙盒机制也不如纯沙盒,因为该工具可能会保留受先前请求影响的其他内部状态。

只有当工作器实现支持多重工作器时,才能对其进行沙盒化处理;并且必须使用 --experimental_worker_multiplex_sandboxing 标志单独启用此沙盒。如需了解详情,请参阅设计文档

深入阅读

如需详细了解持久性 worker,请参阅: