← 文章 / 开源项目
Hacker News 11小时前 · 2026-09-13 11:31:09 · 6 阅读

我做了一个构建可视化工具来搞懂 Bun 的编译耗时

我开发了 buildprofGitHub 仓库),这是一个开源的 tracing 工具,能直观展示在 Linux 上编译软件时时间都花在了哪里。下面是一段实时视频,演示了它如何分析 ripgrep 的一次全新构建:

观看 buildprof 演示

构建缓慢有时仅仅是因为代码量太大,但更常见的是存在可优化的问题:并行度不足、重复工作、依赖项下载或编译器/链接器调用开销过大。buildprof 让这些问题清晰可见,帮助快速定位值得调查和优化的环节。

使用方式非常简单,在现有构建命令前加上 buildprof -- 即可:

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

buildprof 会记录构建命令启动的所有进程(包括子进程及其子进程……),并将它们绘制在一条时间线上。时间从左向右推进,条形宽度代表持续时长,子进程会显示在启动它的父进程下方。

ripgrep 构建示例:Cargo 贯穿整个构建过程,rustc 并行调用编译各个 crate,最后的 rustc 调用启动链接器链。

我开发 buildprof 的初衷,源于 Bun JavaScript 运行时首席架构师 Jarred Sumner 发布的这条推文,它一直在我脑海中挥之不去:

Jarred Sumner 的对比数据:Bun 1.3.14 在 Linux 上的中位构建时间为 30 分 6 秒,而 Bun 1.4.0 降至 5 分 37 秒。

尤其是关于 Bun 新版 Rust 构建在 Linux 上比旧版 Zig 构建快 5 倍以上这一说法,让我非常在意。根据我的经验,Zig 项目的编译速度通常比同等复杂度的 Rust 项目要快得多。这种直觉让我觉得背后有个谜题需要解开。

推文里还有另一个重要但容易被忽视的细节,进一步加剧了这种怀疑:Zig 构建使用了 Full LTO,而 Rust 构建使用的是 ThinLTO。

编译器通常独立优化各个编译单元。1 链接时优化(LTO)让编译器能够跨越这些边界进行优化。Full LTO 会将这些单元合并为一个大型优化任务,而 ThinLTO 则保留更多独立性,让大部分工作可以并行执行。

根据过往经验,这种差异对构建时间的影响巨大。推文顺带提到了这一点,但我想知道它在多大程度上解释了那个显著的性能提升。

我先尝试复现那些数据。

数据复现成功。但接下来呢?#

我检出了 Bun 1.3.14Bun 1.4.0,并编写了 一些脚本,在一台 6 核 12 线程的 Linux 虚拟机上重放它们的 Linux x64 CI 构建。脚本保留了构建步骤及其依赖关系,所有操作都在同一台机器上运行。2

我的耗时数据与 Jarred 的大致相当:

Linux x64 构建Zig 时期Rust 时期
Bun 报告的 CI 中位数30m06s5m37s
我单机 CI 配置复现24m24s5m40s

好的,差距在我的机器上也出现了。但两次测量之间除了语言外还有其他变化;真正的原因是什么?是 Zig 编译器消耗了那额外的时间?还是 Full LTO 链接?抑或是 Bun 构建中有些我没想到的东西?

此时我擅长剖析和开发者工具的经验发挥了作用。通常,当我试图理解为什么某件事很慢时,我想要一个追踪记录:发生了什么、何时发生、耗时多久。如果能为这些构建提供那样的东西,将它们放在时间线上,看看时间究竟花在了哪里,那就太棒了。

但构建涉及许多不同的工具,每个工具对正在发生什么都有自己的看法。我能记录什么,从而看到所有工具的整体情况?

构建就是进程树#

当你输入 cargo buildzig build 时,感觉就像在运行一个程序。构建系统会算出哪些部分需要重新构建、它们之间的执行顺序,以及哪些可以并行执行。但这些活通常不是它自己干的——它会启动编译器、代码生成器、归档工具、链接器和各种脚本,而这些程序又可能启动更多的程序,层层嵌套……

不同的构建系统对这套工作的描述方式各不相同:Cargo 看到的是 crate,Ninja 看到的是构建边(build edge),CMake 则是给另一个构建系统生成指令。但从操作系统的视角看,它们(大体上)都是一个个进程在启动别的进程。3

比如一次 Rust 构建可能包含这样一条链:

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

如果把每个子进程的启动和结束时间都记录下来,就能把它们排到一条时间线上。下面是这条链在 buildprof 中的样子:

ripgrep 构建的最后链接阶段:cargo 启动 rustc,其下依次是 cc、collect2 和 ld.lld。

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

  1. 与构建系统无关:Cargo、Ninja、Zig、Make 以及大多数构建系统都是靠启动进程来干活的,所以不需要为每个系统单独写集成。
  2. 天然覆盖自定义脚本:既包括构建系统之上的脚本(仓库初始化、拉取依赖),也包括构建系统之下的脚本(代码生成器、资源处理器)。
  3. 可以追踪构建步骤之间的文件流转:记录每个进程读写了哪些文件,就能看出哪些步骤的产出是另一些步骤的输入。甚至跨构建系统也能做到!

这就是 buildprof 的起点:先记录进程树,再把它变成可以探索的时间线。细节还有很多,后面再展开。不过等功能跑通后,我终于可以回到最初的问题了:那二十四分钟里,Bun 到底在干什么?

对准 Bun#

为什么 Zig 的 CI 构建慢了这么多?#

我先用 buildprof 录下 Zig 时代 CI 构建过程,沿用的是之前的同一套脚本

完整的 Zig 时代 CI 构建

在 buildprof 中查看

一眼就能看出大问题:ld.lld 链接器调用占用了绝大部分构建时间。它单独在最后运行了超过 16 分钟,占整个构建时长约三分之二。它到底在干什么?

点进链接器可以看到它的命令行,buildprof 会自动捕获这些信息:

选中的 Zig 时代链接器及其 Full LTO 标志

正如 Jarred 所说,确实用了 Full LTO。考虑到链接耗时如此之长,Full LTO 成了首要嫌疑人。

但仅靠进程树无法判断 LTO 是否真的导致了那 16 分钟的耗时。幸运的是,LLD 会记录自己的内部计时事件,使用 --compiler-traces 参数时 buildprof 可以包含这些数据。

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

LLD 的内部阶段

在 buildprof 中查看

现在可以清楚看到,LTO确实消耗了几乎全部时间。链接器正在对程序执行编译器 pass,而不仅仅是组合已编译的文件。仅 OptModule 这一项就耗时略超 10 分钟,其中包含生成机器代码的 pass。4

Rust CI 构建有何不同?#

Zig 构建中大部分时间都花在 LTO 上,我想看看 Rust 构建在链接上花了多久,于是也录制了这个构建过程:

完整的 Rust 时代 CI 构建

在 buildprof 中探索

仅耗时 2 分 24 秒。而且这一次,不出所料,链接器命令中包含了 -plugin-opt=thinlto

启用 ThinLTO 的 Rust 链接器调用

两次构建都启用了 LTO,只是配置不同,链接耗时也差异巨大。如果保留 Bun 的 Zig 代码,但将 Full LTO 改为 ThinLTO,能缩小多少差距呢?

尝试 ThinLTO#

我将 Zig 版 Bun 的构建标志切换为 ThinLTO ,并记录了一次干净构建,同时保留了一次新的 Full-LTO 构建以供对比:

匹配的 Full-LTO 构建与部分 ThinLTO 实验

在 buildprof 中探索:Full LTO · 部分 ThinLTO

在这组对比记录中,链接环节快了 3 分 40 秒,但耗时仍接近 13 分钟。为什么链接依然如此昂贵?

回顾编译器追踪日志,发现大量工作集中在名称包含 JSC 的函数上。JSC 即 JavaScriptCore,是 Bun 用于执行 JavaScript 的引擎。链接器还在花费时间编译 JavaScript 引擎本身。5

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

链接器命令已启用 ThinLTO,但仍包含 WebKit 的库文件,如 libJavaScriptCore.a。

顺着这些输入文件往构建流程上游追溯,我发现 Bun 并不是自己在编译这些库,而是直接下载了一份单独构建的 WebKit。我查看了一下那份构建的编译参数,果然又看到了熟悉的 -flto=full。而 Rust 构建用的是更新版本的 WebKit,它的构建脚本选择的是 ThinLTO。 也就是说,尽管我已经改了 Bun 自身代码的编译方式,但那些下载来的库里仍然包含 Full-LTO 的输入,链接器还是得对这部分代码做优化并生成机器码。想改变这一点,就得连 WebKit 一起重新构建。

重新构建 WebKit#

我检出那个历史版本的 WebKit,用兼容的 ThinLTO 配置把它连同 ICU 依赖一起重新构建,然后用自己构建的库替换下载来的版本,同时保留 Bun 侧的 ThinLTO 改动。 以下是记录的构建数据:6
Zig 时代的构建完整构建最终链接
原始 Full LTO24分24秒16分35秒
Bun ThinLTO;原始 WebKit 库20分20秒12分55秒
Bun ThinLTO;重新构建的 ThinLTO WebKit 与 ICU15分11秒7分22秒
链接时间降到了 7分22秒。虽然还是比 Rust 构建慢,但这个提升已经让我想把目光转向链接器之外的部分了。

构建的其他部分呢?#

整个构建仍需十五分钟,其中将近八分钟花在链接器启动之前。它在等什么?我回到最初的 CI trace,从 Bun 自身的代码开始追踪输入来源。 buildprof 还会记录每个进程读写哪些文件。如果某进程读取的文件是另一个进程写入的,它会在内部把两者关联起来。打开"在时间线上显示"后,这些关联会以箭头形式呈现。下图中,链接器读取了来自 C++ 编译的 libbun-profile.a 和来自 Zig 的 bun-zig.o。这两个文件都经过拷贝步骤传入,继续向上追溯,就能找到最初生成它们的进程。

沿着链接器的依赖箭头穿过复制步骤,抵达 C++ 和 Zig 生产端。生产端面板使用相同的时间刻度;C++ 率先完成。

编译流程中 C++ 部分率先完成。链接器在等待 bun-zig.o,因此直到 Zig 分支也完成,链接器才能开始工作。

当时我回到 Rust 构建过程,对比了它们的运行方式,Rust 构建更快的原因便一目了然:Bun 已被拆分为 >90 个 crate,而在 Zig 中,所有代码都试图作为单个 Zig 模块进行编译!

Cargo 扇形展开为多个具名 rustc 进程以处理 Bun 的各个 crate,旁边是未生成任何子进程的单个 zig build-obj 进程。

这意味着 Zig 构建无法像 Rust 那样并行化。我还怀疑,尽管未能证实,这也解释了链接缓慢的原因:链接器需要优化一个巨大的 ThinLTO 位码模块,而不是将同样的工作量分散在各个 crate 中。

此时我不得不暂停:若要深入下去,得亲自拆分 Zig 模块,但鉴于这些代码已然过时,我认为不值得花那个功夫。

总结如下:

  • 初始 Zig 构建与 Rust 构建相比,最大的异常项是构建末尾单独运行的巨大链接步骤。
  • 仅调整 Bun 的 LTO 设置并不足够,因为 WebKit 仍在使用 Full LTO,而它是构建的重要部分。
  • 完成上述调整后,Zig 构建时间从 24 分钟降至 15 分钟。
  • 即便调整后,链接仍需 7 分钟,整体构建耗时 15 分钟。
  • 剩余的巨大差异在于结构层面:Rust 将编译分散在 >90 个 crate 中,而 Zig 构建将所有工作汇入单个模块。

顺便一提,这些 trace 还揭示了一些让我忍不住想深入探究的细节……

构建中隐藏的其他问题#

构建过程可以包含几乎任何操作#

在 Bun 的 CI 构建过程中,我发现了请求公网上获取机器 IP 地址、检查正在运行的 Docker 容器,以及读取最新 Git 提交信息的命令。

Small CI setup commands visible in the process tree

这些步骤总共耗时不足一秒。没什么可优化的,只是没想到它们会出现在构建轨迹里。

冷启动依赖获取#

上述构建复用了已下载的依赖项,因此我还记录了一次全新的 WebKit 获取过程。下载和解压压缩包耗时约二十秒。前十二秒只看到 Node 在运行,随后启动 targzip,可以清晰看到解压过程是独立发生的。

深入一次 C++ 编译#

之前我们从链接器输入回溯到 Bun 的 C++ 编译阶段。现在也可以深入查看这些编译器调用。我选取了最后完成编译的文件之一 ZigGeneratedClasses.cpp重放了其 Ninja 命令,并加上 --compiler-traces 参数。对于 Clang,buildprof 会启用 -ftime-trace,并将内部计时数据添加到进程时间线上。7

Clang’s frontend and backend phases while compiling ZigGeneratedClasses.cpp

这次重放耗时约十二秒,Clang 前端和后端各占一半。进一步放大查看,发现 ModuleInlinerWrapperPass(Clang 的某个阶段)占据了后端工作时间的四秒以上。

buildprof 底层工作原理#

buildprof 的录制端用的是 ptrace,也就是调试器所依赖的那套 Linux 接口。我考虑过 eBPF 和 ftrace,但 ptrace 对这类问题来说简直是完美选择:eBPF 追踪需要 CAP_BPFCAP_PERFMON 权限,还得挂接可能不稳定的 tracepoint 和内核函数;而用 ftrace 的话,我得小心管理追踪实例以免干扰其他用户,而且要精确设置过滤器只针对构建进程及其所有子进程,相当麻烦。8

ptrace,我只需启动构建并跟踪其子进程。它的内置事件能让 buildprof 知道进程何时 fork、exec 新程序或退出。至于文件系统活动,buildprof 用一个 seccomp 过滤器只拦截所需的系统调用。

buildprof 的开销几乎完全取决于构建过程打开文件的次数。对 ripgrep 来说,录制几乎没影响构建时间;而 Redis 打开文件频繁得多,录制增加了大约五秒:9

构建未追踪仅进程进程 + 文件
ripgrep / Cargo12.27s12.30s12.43s
Redis / Make26.78s27.04s31.89s

如果这些开销碍事,可以用 --no-file-events 关闭文件系统追踪,保留进程时间线。

我在 Perfetto 团队工作,所以用它做 UI 是顺理成章的选择——buildprof 的 UI 是 Perfetto UI 的软分支。其实我完全可以直接在 ui.perfetto.dev 上打开录制结果,但我希望能掌控进程树的布局方式、点击某个命令时展示哪些细节,以及文件生产者和消费者之间那种按需显示的箭头。

幸运的是,过去几年我们一直在让 Perfetto UI 可以通过插件扩展。buildprof 的 UI 大部分就是复用这套基础设施。Perfetto 负责困难的部分(解析 trace、查询事件、渲染时间线、管理工作区),而我只需专注于如何让这些能力对构建分析真正有用。

我打算在另一篇技术文章中详细介绍记录器和 UI 的设计。订阅我的通讯,文章发布时你就会收到通知! :)

我真的需要造个新轮子吗?#

如今,因为技术上可行,人们很容易就动手去做工具。但 buildprof 不是这种情况;在开发它之前,我仔细搜索过,希望能找到一个现成的工具来提供这种视图。

我最初试的是 ninjatracing,以前用过很多次。它能把 Ninja 的构建日志转换成时间线,展示哪些任务运行了,以及并行程度如何。

这里有一个 Zig 时代构建的 Ninja 日志。

但 Ninja 只能看到 Bun 构建的一部分。调用它的脚本不会出现在日志中,而且即使命令启动了整棵子进程树,Ninja 也只把它们显示为单个块。

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

  • Cargo timings 对于由 Cargo 管理的构建效果很好,但无法拆解 build.rs 内部的任意工作,也看不到 Cargo 之上的包装脚本。在 Bun 中,Cargo 只是构建的一部分: 我抓取到的报告 只覆盖了 5 分 40 秒 CI 构建中的 1 分 51 秒。
  • Clang 的 -ftime-trace 能提供编译器单次调用的内部细节,但无法展示构建的其他部分在做什么;而 Zig 对 Tracy 的集成 钻得更深,更适合理解编译器本身。
  • stracetracexec 可以通过 forkexec 跟踪任意进程,但显示的是通用的进程事件,而不是面向构建的时间线。

What the Fork链接)最为接近:它能跨构建系统跟踪进程,并呈现构建专用视图。但据我了解,它目前仍处于私有测试阶段,且似乎没有开源计划。

buildprof 的下一步计划#

buildprof 已经实现了我设想的功能,我计划在后续使用它分析自身构建时继续完善它。不过,仍有几个方向值得改进。

首先是记录开销;Redis 的测量表明,文件系统追踪还有优化空间,尤其是在构建过程涉及大量文件打开时。我还希望支持 macOS,因为我在那里进行部分开发工作;如果社区有需求,也可能支持 Windows

此外,我还希望测试更多构建系统和工具链,包括 npm、Gradle 和 Bazel。计算关键路径也将是一个重大改进:本文我们手动追踪了依赖关系,而 buildprof 可以帮助识别阻塞构建的任务链条并自动添加注释。

这些改进我可能会按需推进。但如果你试用 buildprof 后发现某些缺失的功能,欢迎 留言反馈。了解大家觉得什么有用,能帮我决定下一步的时间分配。

结语#

我满足了好奇心,尽管在这件事上花的时间比预想的多。过程中,我构建了一个工具,今后每当构建耗时过长时,我都希望手边有它。

下次再遇到构建缓慢的困扰时,我肯定会重新使用 buildprof。如果你也有类似的构建,不妨试试。我很乐意听听你的发现!

喜欢这篇文章?欢迎在 BlueskyXMastodon 上关注我, 也可以订阅通过邮件或 RSS 获取新文章。 或者把这篇文章分享到 Hacker News

也可以继续阅读相关主题:

Perfetto:Linux 客户端追踪的瑞士军刀 上个月我在 2025 Tracing Summit 上做了一场演讲,题为「Perfetto:Linux 客户端/嵌入式追踪的瑞士军刀」。这场演讲的目标是向 Linux 内核、系统及嵌入式开发者展示如何用 Perfetto 来调试和定位各自领域的性能问题。虽然 Perfetto UI 主要是为查看 Android 或 Chrome 的 trace 而设计的,但它其实是一个非常灵活的工具,可以……

  1. 在 C 和 C++ 中,编译单元通常指源文件及其包含的头文件。Rust 编译的是crate,可以拆分成多个代码生成单元。Zig 通常把程序的所有 Zig 源码作为单一编译单元一起编译。Bun 的 Zig 编译器 fork 支持将其拆分为多个 LLVM module,但其 CI 构建在启用 LTO 时明确选择了单个单元↩︎

  2. 早期的 CI 构建(Zig-era CI build)分别在两台 Buildkite 机器上运行 C++ 和 Zig 编译阶段,然后将输出传递给最终的链接阶段。我的脚本在一台机器上并发运行这些阶段,等待两个输出完成后,将其复制到本地而非通过网络传输,最后执行链接。这应能保持依赖关系图不变,但受硬件差异影响,且两个阶段在同一台机器上运行会导致资源竞争明显不同。另外注意,我的时间戳是单次运行结果(尽管非常稳定),而 Bun 报告的数值是中位数。↩︎

  3. 进程可以在内部执行大量工作(包括运行多个线程),而不启动其他任何进程。进程时间线不会显示这种并行性。要看到进程内部,需要从程序本身获取追踪信息,如 Clang 和 LLD 在下方示例中所提供的那样。↩︎

  4. LLVM 从其遗留的 pass manager 中输出 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_BPFCAP_PERFMON 等权限,具体定义见内核的 capability 定义文档。ftrace 提供独立的追踪实例和 PID 过滤器,但仍需进行配置并访问 tracefs。ptrace 也受主机安全设置限制,容器可能需要额外权限才能追踪子进程。↩︎

  9. 在相同的虚拟机上,对每种模式执行 5 次全量构建(每个构建包含 6 个任务),取其中位数。测量脚本↩︎

原始来源: Hacker News

评论 (0)