我做了一个构建分析器,来搞清Bun的编译时间为什么差5倍

43

我开发了 buildprof(GitHub,这是一款开源的跟踪工具,能够展示在 Linux 上编译软件时时间都花在了哪里。以下是它对 ripgrep 的一次干净构建进行剖析的实时视频:

有时,构建速度慢是因为需要编译的代码量确实很大。但更多时候,问题是可以修复的:并行度不足、重复工作、依赖项下载,或者编译器/链接器的调用过于庞大。

buildprof 能够将这些情况清晰地呈现出来,让你一眼看出哪些地方值得深入调查和优化。

你只需在任何现有的构建命令前加上 buildprof -- 即可运行它:

buildprof -- make -j16
buildprof -- cargo build
buildprof -- ninja -C out/target
buildprof -- just build
buildprof -- ./dev/custom-build-script.sh

buildprof 会记录您的构建命令启动的每一个进程,包括它们的子进程(以及这些子进程的子进程……),并将它们按时间轴排列。时间从左向右推进,条形宽度表示持续时间,子进程则显示在其父进程下方。

我之所以开发 buildprof,是因为 Jarred Sumner——Bun JavaScript 运行时的首席架构师——在推特上发的一条推文[1]一直在我脑海里挥之不去:

具体来说,他声称 Bun 的全新 Rust 构建在 Linux 上比旧的 Zig 构建快了 5 倍以上,这让我颇为在意。根据我的经验,Zig 项目的编译速度通常远高于同等复杂度的 Rust 项目。

仅凭这一直觉,我就觉得这里面一定有值得探究的谜团。

而推文中的另一个重要细节——很容易被忽略——更是加深了我的困惑:Zig 构建使用了全量 LTO,而 Rust 构建则采用了瘦 LTO。

编译器通常会在很大程度上独立地优化各个编译单元。链接时优化(LTO)则允许它们跨这些边界进行优化。全量 LTO 会将所有单元合并为一个大型优化任务,而瘦 LTO 则保留更多的隔离性,以便大部分工作可以并行执行。

根据以往的经验,这种差异会对构建时间产生巨大的影响。推文中只是顺带提到了这一点,但我很好奇它究竟解释了多少那项显著的性能提升。

我首先尝试复现这些数据。

数字确实能复现,但接下来呢?

我检出了 Bun 1.3.14 和 Bun 1.4.0,并编写了一些脚本[2],在一台 6 核 12 线程的 Linux 虚拟机上重现其 Linux x64 CI 构建过程。

这些脚本保留了构建步骤及其依赖关系,并在同一台机器上完整执行。

我的计时结果与 Jarred 报告的数值基本一致:

Linux x64 构建 Zig 时代 Rust 时代
Bun 官方 CI 中位数 30 分 06 秒 5 分 37 秒
我在单机上重现的 CI 性能剖析 24 分 24 秒 5 分 40 秒

看来,在我的机器上也出现了同样的差距。不过,两次测量之间除了编程语言之外还发生了许多变化;那么,真正的原因到底是什么?是 Zig 编译器耗费了那么多额外时间吗?

还是说问题出在全量 LTO 链接环节?抑或 Bun 的构建中还有其他我尚未关注的因素?

这时,我作为开发者和性能分析专家的思维开始发挥作用。通常,当我试图弄清某件事为何变慢时,我需要一份完整的调用链:发生了什么、何时发生以及持续了多久。

如果能为这些构建生成这样的记录,把它们按时间轴排列,就能清楚地看到时间都花在了哪里,那就太好了。

然而,构建过程涉及大量不同的工具,每种工具对“正在发生什么”都有自己的理解。我该记录哪些信息,才能跨越所有这些工具,获得全局视图呢?

构建就是一棵进程树

当您输入 cargo build 或 zig build 时,感觉就像在运行一个程序。构建系统会计算出哪些部分需要重新构建、各部分之间的依赖顺序以及哪些任务可以并行执行。

但一般来说,它并不会亲自完成所有这些工作;而是会启动编译器、代码生成器、归档工具、链接器以及各种脚本。而这些工具又可能再启动更多程序……

不同的构建系统以不同的方式描述这项工作。Cargo 看的是 crate,Ninja 看的是构建边,CMake 则会生成给其他构建系统的指令。但从操作系统的角度来看,它们(大多)不过是进程在启动其他进程而已。

例如,一个 Rust 构建可能包含这样一条链条:

cargo
└── rustc
 └── cc
 └── collect2
 └── ld.lld

如果我们记录每个子进程的开始和结束时间,就可以把它们在时间线上排列出来。以下是 buildprof 中该链条的呈现:

在这一层可视化构建过程还有几个不错的特性:

  1. 与构建系统无关:Cargo、Ninja、Zig、Make 以及大多数其他构建系统都会通过启动进程来完成大量工作,因此我们无需为每一种构建系统单独编写集成代码。
  2. 自然包含自定义脚本:这既包括位于构建系统之上的脚本(如仓库初始化、依赖项获取),也包括位于构建系统之下的脚本(如代码生成器、资源处理器)。
  3. 我们可以追踪各构建步骤之间的文件流转:通过记录每个进程读取和写入的文件,我们就能看到哪些步骤为其他步骤提供了输入。这种追踪甚至可以跨构建系统进行!

这为 buildprof 提供了一个起点:记录进程树,然后将其转化为可供探索的时间线。还有很多细节有待深入探讨,我稍后再做介绍。不过一旦这项功能运行起来,我就终于可以回到最初的问题:Bun 那二十四分钟都在做什么?

将它用于 Bun

Zig CI 构建为何慢得多?

我首先用 buildprof 记录了 Zig 时代的 CI 构建,使用的仍然是之前的脚本:

在 buildprof 中探索

我们立刻发现一个大问题:ld.lld 链接器调用主导了整个构建时间。它在最后单独运行了超过十六分钟,约占整个构建时长的三分之二。

它究竟在这段时间里做了什么?

点击链接器会显示其命令行,buildprof 会自动捕获这些信息:

正如 Jarred 所说,确实启用了全量 LTO。鉴于链接耗时如此之久,它现在成了我的首要怀疑对象。

但仅凭进程树还无法判断 LTO 是否真的导致了那十六分钟的耗时。幸运的是,LLD 会记录自身的内部计时事件,而当你使用 --compiler-traces 时,buildprof 可以将这些事件纳入其中。

我再次记录了最终的链接过程,这次启用了 --compiler-traces:

在 buildprof 中探索

现在我们可以看到,几乎所有时间都花在了 LTO 上。链接器不仅是在合并已编译好的文件,还在对程序执行多轮编译优化。仅 OptModule 这一栏就耗时十余分钟,包含了生成机器码的那些优化阶段。

Rust CI 构建有何不同?

既然 Zig 构建的大部分时间都花在了 LTO 上,我想看看 Rust 构建在链接上花了多少时间。我也记录了那次构建:

在 buildprof 中探索

仅为 2 分 24 秒。而且这一次,正如预期,链接器命令中包含了 -plugin-opt=thinlto:

两次构建都启用了 LTO,但配置不同,链接时间也相差甚远。如果我保留 Bun 的 Zig 代码,但把全量 LTO 改为 ThinLTO,又能缩小多少差距呢?

尝试 ThinLTO

我将 Zig 版 Bun 的构建标志改为 ThinLTO,并再次记录了一次干净构建,同时为了对比还记录了一次新的全量 LTO 构建:

在 buildprof 中探索: 全量 LTO · 部分 ThinLTO

在这两组记录中,链接时间缩短了 3 分 40 秒,但仍需近十三分钟。为什么链接仍然如此耗时?

回顾编译器的追踪日志,大量工作集中在函数名中带有 JSC 的函数上。那是 JavaScriptCore,即 Bun 用来执行 JavaScript 的引擎。链接器也在花时间编译这个 JavaScript 引擎。

点击链接器调用,可以看到其输入中包含了 WebKit 库,其中包括 libJavaScriptCore.a:

顺着这些输入回溯整个构建流程,我发现 Bun 并未自行编译这些库,而是从另一个 WebKit 构建中下载它们。当我查看那个构建的标志时,又看到了 -flto=full。

Rust 构建使用的是更新的 WebKit 版本,其构建配方选择了 ThinLTO。

尽管我已经改变了 Bun 编译自身代码的方式,但那些下载的库中仍含有全量 LTO 的输入,因此链接器仍需对这些代码进行优化并生成机器码。

要改变这一点,我还必须重新构建 WebKit。

重新构建 WebKit

我查看了历史版WebKit,并用兼容的ThinLTO设置重建了它及其ICU依赖。然后我用自己构建的库替换了下载的库,保留了Bun的ThinLTO更改。

以下是录制的组装:

之字形时代的建造 整个构建 最后链接
原始全LTO 24分24秒 16分35秒
Bun ThinLTO;原始WebKit档案 20分20秒 12分55秒
Bun ThinLTO;重建ThinLTO WebKit和ICU 15米11 7分22秒

链接现在花了7分22秒。虽然还是比Rust版本慢,但进步足够让我想超越链接器。

那其他部分怎么办?

构建过程仍然花了十五分钟,而链接器开始前就过了将近八分钟。它还在等什么?我回到最初的CI追踪,跟随Bun自己代码的输入。

buildprof 还记录每个进程读取和写入的文件。如果一个进程读取了另一个进程写的文件,它会在内部将两者连接起来。打开“显示时间线”会将这些链接绘制为箭头。

这里,链接器读取libbun-profile.a来自C++编译和bun-zig.o来自Zig。两者都是通过复制步骤获得的;追溯这些步骤,我们会看到产生它们的过程:

编译的C++端最先完成。链接器正在等待bun-zig.o所以在齐格分支完成之前,工作无法开始。

这时我回头看了Rust版本,和它的实际工作方式对比,Rust版本更快的主要原因变得明显:Bun被拆分成>90个箱子,而Zig里则试图编译成一个单一的Zig模块!

这意味着 Zig 构建无法像 Rust 那样并行化。我也怀疑(虽然我没能证明),这解释了链接速度慢的原因:链接器必须优化一个庞大的 ThinLTO 比特码模块,而不是分散在各个箱子里的同样工作。

就在这时我不得不停下来:如果要继续,我得自己拆分Zig模块,而且这些代码本来就过时了,我觉得没必要这么做。

总结:

  • 最初Zig构建和Rust构建最大的异类是那个在构建末端单独运行的巨大链接步骤。
  • 仅仅更改 Bun 的 LTO 设置不够,因为 WebKit 作为构建的重要部分,仍然使用全 LTO。
  • 完成后,Zig的构建时间从24分钟缩短到了15分钟。
  • 即便如此,链接依然花了7分钟,整个构建也花了15分钟。
  • 最大的区别在于结构:Rust将编译分散到>90个箱子上,而Zig构建则将所有内容集中到一个模块中。

顺便说一句,这些痕迹还发现了一些我忍不住想戳的东西......

建筑里还藏着其他东西

一个构建几乎可以包含任何东西

在 Bun 的 CI 构建过程中,我发现有命令向公共互联网请求机器的 IP 地址,检查运行中的 Docker 容器,并读取最新的 Git 提交消息。

这些设置总共不到一秒钟。没什么优化的,但我没想到会在构建追踪中找到它们。

冷依赖取物

上面的构建重用了下载的依赖,所以我也记录了一个新的WebKit取材.下载和解压档案大约花了二十秒。前十二秒,我们看到的只有节点运行。

然后它启动了tar以及gzip,我们可以单独看到提取过程。

查看一个C++编译

之前,我们跟着链接器的输入回到了Bun的C++编译。我们也可以查看那些编译器调用。我选了最后几个文件之一完成,ZigGeneratedClasses.cpp, 和重播了忍者指挥其中--compiler-traces.对于Clang,buildprof 使得-ftime-trace并将其内部时序添加到流程时间线中。

回放大约持续了十二秒,几乎平均分配在《Clang》的前后端。进一步放大,我们看到ModuleInlinerWrapperPassClang的一个阶段,占后端工作时间超过四秒。

buildprof 的底层工作原理

Buildprof 的录制部分使用ptrace,调试器使用的同一个Linux接口。我也考虑过eBPF和ftrace,但ptrace对于这类问题来说,是非常完美的;eBPF追踪意味着CAP_BPF以及CAP_PERFMON权限和对可能不稳定的追踪点/内核函数的挂钩。

而使用 trace,我得同时处理追踪实例以避免干扰其他用户,而让过滤器只针对构建过程及其所有后代非常繁琐。

其中ptrace我可以启动构建并直接跟随其子节点。其内置事件会告诉 buildprof 进程何时分叉、执行新程序或退出。对于文件系统活动,buildprof 使用seccomp过滤器只拦截其需要调用。

buildprof 的成本几乎完全取决于构建打开了多少文件。对于 ripgrep,录制几乎不会影响构建时间。Redis 打开文件的频率更高,录制大约增加了五秒钟:

构建 未追踪 仅进程 进程+文件
ripgrep / Cargo 12.27秒 12.30秒 12.43秒
Redis / Make 26.78秒 27.04秒 31.89秒

如果这些开销成了阻碍,你可以通过设置--no-file-events来关闭文件系统追踪,同时保留进程时间线。

我参与开发Perfetto,因此将其作为 UI 的自然起点;buildprof 的 UI 实际上是 Perfetto UI 的一个软分叉。我本可以直接在ui.perfetto.dev中打开记录,但我希望对进程树的布局、点击某个命令时显示哪些细节,以及文件生产者与消费者之间那些按需出现的箭头等细节拥有更多控制权。

幸运的是,过去几年我们一直在通过插件提升 Perfetto UI 的可扩展性。buildprof 的大部分 UI 都复用了这一基础设施。Perfetto 负责处理那些困难的部分(解析跟踪数据、查询事件、渲染时间线并管理工作区),而我则专注于如何让这些功能更好地服务于构建场景。

我计划在另一篇技术文章中详细介绍记录器和 UI。如果你希望在文章发布时收到通知,请订阅!:)

我真的需要从头构建一个新工具吗?

如今,仅仅因为“可以做”就去开发一款工具变得非常容易。但这次并非如此;在开发 buildprof 之前,我曾长时间寻找能够提供这种视图的现有工具。

我最初尝试了ninjatracing,这款工具我已多次使用。它能将 Ninja 的构建日志转化为时间线,展示哪些任务被执行,以及有多少任务是并行运行的。

这是 Zig 时代构建的 Ninja 日志。

然而,Ninja 只能看到 Bun 构建的一部分。调用它的脚本并未记录在日志中,而且即使它启动了整个子进程树,这些命令也只以单个块的形式出现。

此外还有其他几种工具,各自覆盖了问题的不同方面:

  • Cargo 计时 对于由 Cargo 管理的构建效果很好,但无法细分build.rs内部的任意任务,也无法看到位于 Cargo 之上的封装脚本。在 Bun 中,Cargo 只是构建过程的一部分:我捕获的报告涵盖了 5 分 40 秒 CI 构建中的 1 分 51 秒。
  • Clang 的-ftime-trace提供了编译器调用内部的详细信息,但无法展示构建其余部分在做什么;而Zig 的 Tracy 集成则更深入,其设计初衷更多是为了理解编译器本身。
  • strace和tracexec能够跟踪跨fork和exec的任意进程,但它们展示的是通用的进程事件,而非面向构建的时间线。

What the Fork(via)最接近我的需求:它能够跨构建系统跟踪进程,并呈现专用于构建的视图。但据我所知,它目前仍处于私密测试阶段,似乎也没有开源的计划。

buildprof 的下一步

buildprof 已经实现了我想要的功能,我也计划在自己的构建中继续使用并完善它。不过,还有一些地方我希望改进。

首先是记录开销;Redis 的测量结果表明,在文件系统追踪方面仍有优化空间,尤其是对于需要打开大量文件的构建。我还希望能支持我在工作中也会用到的macOS, 如果有需求的话,也许还能加上Windows。

此外,我还想测试更多构建系统和工具链,包括 npm、Gradle 和 Bazel。计算关键路径也将是一项重大改进:在这篇文章中我们是手动追踪依赖关系的,但 buildprof 可以帮助识别拖慢构建的那条工作链,并自动标注出来。

我可能会在实际需要时逐步解决这些问题。不过,如果你试用了 buildprof 并发现它缺少某些功能,我很乐意听取你的反馈。大家觉得哪些功能有用,将有助于我决定把精力放在哪里。

结语

我终于满足了自己的好奇心,尽管最终花在这上面的时间比预期要多得多。在这个过程中,我打造了一款工具,现在每当构建耗时过长时,我都希望能用上它。

我知道下次遇到慢速构建让我烦恼时,一定会再次用到 buildprof。如果你也有类似的构建问题,不妨试试看。我很期待听到你的使用感受!


  1. 在 C 和 C++ 中,一个编译单元通常是一个源文件及其包含的头文件。Rust 编译的是crate,它可以被拆分为多个代码生成单元。Zig 通常会将程序的 Zig 源文件作为一个单一的编译单元一起编译。Bun 的 Zig 编译器分支支持将其拆分为多个 LLVM 模块,但其 CI 构建在启用 LTO 时明确选择了其中的一个模块。
  2. Zig 时代的 CI 构建将其 C++ 和 Zig 编译阶段分别在不同的Buildkite机器上运行,并将它们的输出传递给最后的链接阶段。我的脚本在同一台机器上并发执行这些阶段,等待两个输出都完成,然后将它们本地复制而不是通过网络传输,再进行链接。这应该能保留依赖关系图,但由于硬件差异以及两个阶段都在同一台机器上运行,资源竞争的情况显然会大不相同。另外请注意,我的计时是单次运行的结果(尽管结果相当稳定),而 Bun 报告的数据是中位数。
  3. 进程可以在内部执行大量工作,包括运行多个线程,而无需启动任何其他进程。进程的时间轴不会显示这种并行性。要查看进程内部的情况,我们需要来自程序本身的跟踪信息,就像 Clang 和 LLD 在下面的例子中提供的那样。
  4. LLVM 从其旧版 pass 管理器中发出OptModule,LLD 使用它来进行代码生成。内联和其他 IR 优化 pass 可能会出现在它之前,因此这一条柱状并不代表优化一个模块所花费的总时间。
  5. 在之前的 Full-LTO 链接回放中,带有 JSC 符号的 26,825 个OptFunction事件总计约 209 秒。这是事件时间的总和,而不是对 JavaScriptCore 在整个链接过程中贡献的测量。其中一个示例事件耗时 2.94 秒;其符号经过 demangle 后为JSC::JITThunks::initialize(JSC::VM&)。
  6. 记录脚本。这些计时仅用于在库已就绪的情况下构建 Bun;WebKit 和 ICU 的重新构建是在此之前完成的,未计入其中。当然,我也可以将 buildprof 应用于那次构建,但那又是另一个复杂的问题……我没有重建一个对应的 Full-LTO WebKit 归档作为对照,因此无法将每一秒的节省完全归因于 LTO 设置。
  7. buildprof 目前支持来自 Clang、LLD 和 nightly Rust 的编译器跟踪。
  8. eBPF 跟踪使用诸如CAP_BPF和CAP_PERFMON之类的能力,如内核的能力定义中所述。ftrace 提供了独立的跟踪实例和 PID 过滤器,但仍需进行配置并访问 tracefs。ptrace还依赖于主机的安全设置;容器可能需要额外的权限才能允许跟踪子进程。
  9. 在同一虚拟机上,每种模式下进行五次干净构建的中位数,使用六个构建作业。测量脚本。
参与讨论