我做了一个构建可视化工具来搞懂 Bun 的编译耗时
我开发了 buildprof (GitHub 仓库),这是一个开源的 tracing 工具,能直观展示在 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 的初衷,源于 Bun JavaScript 运行时首席架构师 Jarred Sumner 发布的这条推文,它一直在我脑海中挥之不去:

尤其是关于 Bun 新版 Rust 构建在 Linux 上比旧版 Zig 构建快 5 倍以上这一说法,让我非常在意。根据我的经验,Zig 项目的编译速度通常比同等复杂度的 Rust 项目要快得多。这种直觉让我觉得背后有个谜题需要解开。
推文里还有另一个重要但容易被忽视的细节,进一步加剧了这种怀疑:Zig 构建使用了 Full LTO,而 Rust 构建使用的是 ThinLTO。
编译器通常独立优化各个编译单元。1 链接时优化(LTO)让编译器能够跨越这些边界进行优化。Full LTO 会将这些单元合并为一个大型优化任务,而 ThinLTO 则保留更多独立性,让大部分工作可以并行执行。
根据过往经验,这种差异对构建时间的影响巨大。推文顺带提到了这一点,但我想知道它在多大程度上解释了那个显著的性能提升。
我先尝试复现那些数据。
数据复现成功。但接下来呢?#
我检出了 Bun 1.3.14 和 Bun 1.4.0,并编写了 一些脚本,在一台 6 核 12 线程的 Linux 虚拟机上重放它们的 Linux x64 CI 构建。脚本保留了构建步骤及其依赖关系,所有操作都在同一台机器上运行。2
我的耗时数据与 Jarred 的大致相当:
| Linux x64 构建 | Zig 时期 | Rust 时期 |
|---|---|---|
| Bun 报告的 CI 中位数 | 30m06s | 5m37s |
| 我单机 CI 配置复现 | 24m24s | 5m40s |
好的,差距在我的机器上也出现了。但两次测量之间除了语言外还有其他变化;真正的原因是什么?是 Zig 编译器消耗了那额外的时间?还是 Full LTO 链接?抑或是 Bun 构建中有些我没想到的东西?
此时我擅长剖析和开发者工具的经验发挥了作用。通常,当我试图理解为什么某件事很慢时,我想要一个追踪记录:发生了什么、何时发生、耗时多久。如果能为这些构建提供那样的东西,将它们放在时间线上,看看时间究竟花在了哪里,那就太棒了。
但构建涉及许多不同的工具,每个工具对正在发生什么都有自己的看法。我能记录什么,从而看到所有工具的整体情况?
构建就是进程树#
当你输入 cargo build 或 zig build 时,感觉就像在运行一个程序。构建系统会算出哪些部分需要重新构建、它们之间的执行顺序,以及哪些可以并行执行。但这些活通常不是它自己干的——它会启动编译器、代码生成器、归档工具、链接器和各种脚本,而这些程序又可能启动更多的程序,层层嵌套……
不同的构建系统对这套工作的描述方式各不相同:Cargo 看到的是 crate,Ninja 看到的是构建边(build edge),CMake 则是给另一个构建系统生成指令。但从操作系统的视角看,它们(大体上)都是一个个进程在启动别的进程。3
比如一次 Rust 构建可能包含这样一条链:
cargo
└── rustc
└── cc
└── collect2
└── ld.lld
如果把每个子进程的启动和结束时间都记录下来,就能把它们排到一条时间线上。下面是这条链在 buildprof 中的样子:

在这一层做构建可视化,还有几个不错的特性:
- 与构建系统无关:Cargo、Ninja、Zig、Make 以及大多数构建系统都是靠启动进程来干活的,所以不需要为每个系统单独写集成。
- 天然覆盖自定义脚本:既包括构建系统之上的脚本(仓库初始化、拉取依赖),也包括构建系统之下的脚本(代码生成器、资源处理器)。
- 可以追踪构建步骤之间的文件流转:记录每个进程读写了哪些文件,就能看出哪些步骤的产出是另一些步骤的输入。甚至跨构建系统也能做到!
这就是 buildprof 的起点:先记录进程树,再把它变成可以探索的时间线。细节还有很多,后面再展开。不过等功能跑通后,我终于可以回到最初的问题了:那二十四分钟里,Bun 到底在干什么?
对准 Bun#
为什么 Zig 的 CI 构建慢了这么多?#
我先用 buildprof 录下 Zig 时代 CI 构建过程,沿用的是之前的同一套脚本:

一眼就能看出大问题:ld.lld 链接器调用占用了绝大部分构建时间。它单独在最后运行了超过 16 分钟,占整个构建时长约三分之二。它到底在干什么?
点进链接器可以看到它的命令行,buildprof 会自动捕获这些信息:

正如 Jarred 所说,确实用了 Full LTO。考虑到链接耗时如此之长,Full LTO 成了首要嫌疑人。
但仅靠进程树无法判断 LTO 是否真的导致了那 16 分钟的耗时。幸运的是,LLD 会记录自己的内部计时事件,使用 --compiler-traces 参数时 buildprof 可以包含这些数据。
我
再次录制了最终的链接过程,这次启用了 --compiler-traces:

现在可以清楚看到,LTO确实消耗了几乎全部时间。链接器正在对程序执行编译器 pass,而不仅仅是组合已编译的文件。仅 OptModule 这一项就耗时略超 10 分钟,其中包含生成机器代码的 pass。4
Rust CI 构建有何不同?#
Zig 构建中大部分时间都花在 LTO 上,我想看看 Rust 构建在链接上花了多久,于是也录制了这个构建过程:

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

两次构建都启用了 LTO,只是配置不同,链接耗时也差异巨大。如果保留 Bun 的 Zig 代码,但将 Full LTO 改为 ThinLTO,能缩小多少差距呢?
尝试 ThinLTO#
我将 Zig 版 Bun 的构建标志切换为 ThinLTO ,并记录了一次干净构建,同时保留了一次新的 Full-LTO 构建以供对比:

在 buildprof 中探索:Full LTO · 部分 ThinLTO
在这组对比记录中,链接环节快了 3 分 40 秒,但耗时仍接近 13 分钟。为什么链接依然如此昂贵?
回顾编译器追踪日志,发现大量工作集中在名称包含
JSC 的函数上。JSC 即 JavaScriptCore,是 Bun 用于执行 JavaScript 的引擎。链接器还在花费时间编译 JavaScript 引擎本身。5
点击链接器调用可以看到,其输入中包含
WebKit
的库文件,其中包括 libJavaScriptCore.a:

-flto=full。而 Rust 构建用的是更新版本的 WebKit,它的构建脚本选择的是 ThinLTO。
也就是说,尽管我已经改了 Bun 自身代码的编译方式,但那些下载来的库里仍然包含 Full-LTO 的输入,链接器还是得对这部分代码做优化并生成机器码。想改变这一点,就得连 WebKit 一起重新构建。
重新构建 WebKit#
我检出那个历史版本的 WebKit,用兼容的 ThinLTO 配置把它连同 ICU 依赖一起重新构建,然后用自己构建的库替换下载来的版本,同时保留 Bun 侧的 ThinLTO 改动。 以下是记录的构建数据:6| Zig 时代的构建 | 完整构建 | 最终链接 |
|---|---|---|
| 原始 Full LTO | 24分24秒 | 16分35秒 |
| Bun ThinLTO;原始 WebKit 库 | 20分20秒 | 12分55秒 |
| Bun ThinLTO;重新构建的 ThinLTO WebKit 与 ICU | 15分11秒 | 7分22秒 |
构建的其他部分呢?#
整个构建仍需十五分钟,其中将近八分钟花在链接器启动之前。它在等什么?我回到最初的 CI trace,从 Bun 自身的代码开始追踪输入来源。 buildprof 还会记录每个进程读写哪些文件。如果某进程读取的文件是另一个进程写入的,它会在内部把两者关联起来。打开"在时间线上显示"后,这些关联会以箭头形式呈现。下图中,链接器读取了来自 C++ 编译的libbun-profile.a 和来自 Zig 的 bun-zig.o。这两个文件都经过拷贝步骤传入,继续向上追溯,就能找到最初生成它们的进程。

编译流程中 C++ 部分率先完成。链接器在等待
bun-zig.o,因此直到 Zig 分支也完成,链接器才能开始工作。
当时我回到 Rust 构建过程,对比了它们的运行方式,Rust 构建更快的原因便一目了然:Bun 已被拆分为 >90 个 crate,而在 Zig 中,所有代码都试图作为单个 Zig 模块进行编译!

这意味着 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 提交信息的命令。

这些步骤总共耗时不足一秒。没什么可优化的,只是没想到它们会出现在构建轨迹里。
冷启动依赖获取#
上述构建复用了已下载的依赖项,因此我还记录了一次全新的 WebKit 获取过程。下载和解压压缩包耗时约二十秒。前十二秒只看到 Node 在运行,随后启动 tar 和 gzip,可以清晰看到解压过程是独立发生的。
深入一次 C++ 编译#
之前我们从链接器输入回溯到 Bun 的 C++ 编译阶段。现在也可以深入查看这些编译器调用。我选取了最后完成编译的文件之一 ZigGeneratedClasses.cpp,重放了其 Ninja 命令,并加上 --compiler-traces 参数。对于 Clang,buildprof 会启用 -ftime-trace,并将内部计时数据添加到进程时间线上。7

这次重放耗时约十二秒,Clang 前端和后端各占一半。进一步放大查看,发现 ModuleInlinerWrapperPass(Clang 的某个阶段)占据了后端工作时间的四秒以上。
buildprof 底层工作原理#
buildprof 的录制端用的是 ptrace,也就是调试器所依赖的那套 Linux 接口。我考虑过 eBPF 和 ftrace,但 ptrace 对这类问题来说简直是完美选择:eBPF 追踪需要 CAP_BPF 和 CAP_PERFMON 权限,还得挂接可能不稳定的 tracepoint 和内核函数;而用 ftrace 的话,我得小心管理追踪实例以免干扰其他用户,而且要精确设置过滤器只针对构建进程及其所有子进程,相当麻烦。8
用 ptrace,我只需启动构建并跟踪其子进程。它的内置事件能让 buildprof 知道进程何时 fork、exec 新程序或退出。至于文件系统活动,buildprof 用一个 seccomp 过滤器只拦截所需的系统调用。
buildprof 的开销几乎完全取决于构建过程打开文件的次数。对 ripgrep 来说,录制几乎没影响构建时间;而 Redis 打开文件频繁得多,录制增加了大约五秒:9
| 构建 | 未追踪 | 仅进程 | 进程 + 文件 |
|---|---|---|---|
| ripgrep / Cargo | 12.27s | 12.30s | 12.43s |
| Redis / Make | 26.78s | 27.04s | 31.89s |
如果这些开销碍事,可以用 --no-file-events 关闭文件系统追踪,保留进程时间线。
我在 Perfetto 团队工作,所以用它做 UI 是顺理成章的选择——buildprof 的 UI 是 Perfetto UI 的软分支。其实我完全可以直接在 ui.perfetto.dev 上打开录制结果,但我希望能掌控进程树的布局方式、点击某个命令时展示哪些细节,以及文件生产者和消费者之间那种按需显示的箭头。
幸运的是,过去几年我们一直在让 Perfetto UI 可以通过插件扩展。buildprof 的 UI 大部分就是复用这套基础设施。Perfetto 负责困难的部分(解析 trace、查询事件、渲染时间线、管理工作区),而我只需专注于如何让这些能力对构建分析真正有用。
我打算在另一篇技术文章中详细介绍记录器和 UI 的设计。订阅我的通讯,文章发布时你就会收到通知! :)
我真的需要造个新轮子吗?#
如今,因为技术上可行,人们很容易就动手去做工具。但 buildprof 不是这种情况;在开发它之前,我仔细搜索过,希望能找到一个现成的工具来提供这种视图。
我最初试的是 ninjatracing,以前用过很多次。它能把 Ninja 的构建日志转换成时间线,展示哪些任务运行了,以及并行程度如何。
但 Ninja 只能看到 Bun 构建的一部分。调用它的脚本不会出现在日志中,而且即使命令启动了整棵子进程树,Ninja 也只把它们显示为单个块。
还有其他几种工具,各自覆盖问题的不同方面:
- Cargo timings 对于由 Cargo 管理的构建效果很好,但无法拆解
build.rs内部的任意工作,也看不到 Cargo 之上的包装脚本。在 Bun 中,Cargo 只是构建的一部分: 我抓取到的报告 只覆盖了 5 分 40 秒 CI 构建中的 1 分 51 秒。 - Clang 的
-ftime-trace能提供编译器单次调用的内部细节,但无法展示构建的其他部分在做什么;而 Zig 对 Tracy 的集成 钻得更深,更适合理解编译器本身。 strace和tracexec可以通过fork和exec跟踪任意进程,但显示的是通用的进程事件,而不是面向构建的时间线。
What the Fork (链接)最为接近:它能跨构建系统跟踪进程,并呈现构建专用视图。但据我了解,它目前仍处于私有测试阶段,且似乎没有开源计划。
buildprof 的下一步计划#
buildprof 已经实现了我设想的功能,我计划在后续使用它分析自身构建时继续完善它。不过,仍有几个方向值得改进。
首先是记录开销;Redis 的测量表明,文件系统追踪还有优化空间,尤其是在构建过程涉及大量文件打开时。我还希望支持 macOS,因为我在那里进行部分开发工作;如果社区有需求,也可能支持 Windows。
此外,我还希望测试更多构建系统和工具链,包括 npm、Gradle 和 Bazel。计算关键路径也将是一个重大改进:本文我们手动追踪了依赖关系,而 buildprof 可以帮助识别阻塞构建的任务链条并自动添加注释。
这些改进我可能会按需推进。但如果你试用 buildprof 后发现某些缺失的功能,欢迎 留言反馈。了解大家觉得什么有用,能帮我决定下一步的时间分配。
结语#
我满足了好奇心,尽管在这件事上花的时间比预想的多。过程中,我构建了一个工具,今后每当构建耗时过长时,我都希望手边有它。
下次再遇到构建缓慢的困扰时,我肯定会重新使用 buildprof。如果你也有类似的构建,不妨试试。我很乐意听听你的发现!
喜欢这篇文章?欢迎在 Bluesky、 X 或 Mastodon 上关注我, 也可以订阅通过邮件或 RSS 获取新文章。 或者把这篇文章分享到 Hacker News。
也可以继续阅读相关主题:
Perfetto:Linux 客户端追踪的瑞士军刀 上个月我在 2025 Tracing Summit 上做了一场演讲,题为「Perfetto:Linux 客户端/嵌入式追踪的瑞士军刀」。这场演讲的目标是向 Linux 内核、系统及嵌入式开发者展示如何用 Perfetto 来调试和定位各自领域的性能问题。虽然 Perfetto UI 主要是为查看 Android 或 Chrome 的 trace 而设计的,但它其实是一个非常灵活的工具,可以……在 C 和 C++ 中,编译单元通常指源文件及其包含的头文件。Rust 编译的是crate,可以拆分成多个代码生成单元。Zig 通常把程序的所有 Zig 源码作为单一编译单元一起编译。Bun 的 Zig 编译器 fork 支持将其拆分为多个 LLVM module,但其 CI 构建在启用 LTO 时明确选择了单个单元。 ↩︎
早期的 CI 构建(Zig-era CI build)分别在两台 Buildkite 机器上运行 C++ 和 Zig 编译阶段,然后将输出传递给最终的链接阶段。我的脚本在一台机器上并发运行这些阶段,等待两个输出完成后,将其复制到本地而非通过网络传输,最后执行链接。这应能保持依赖关系图不变,但受硬件差异影响,且两个阶段在同一台机器上运行会导致资源竞争明显不同。另外注意,我的时间戳是单次运行结果(尽管非常稳定),而 Bun 报告的数值是中位数。↩︎
进程可以在内部执行大量工作(包括运行多个线程),而不启动其他任何进程。进程时间线不会显示这种并行性。要看到进程内部,需要从程序本身获取追踪信息,如 Clang 和 LLD 在下方示例中所提供的那样。↩︎
LLVM 从其遗留的 pass manager 中输出
OptModule,LLD 用它进行代码生成。内联和其他 IR 优化 pass 可能在此之前执行,因此该条形图不代表优化模块的总耗时。↩︎在前面的 Full-LTO 链接器回放中,包含 JSC 符号的 26,825 个
OptFunction事件总计约 209 秒。这是事件时间的总和,并非 JavaScriptCore 对链接整个贡献的测量。一个示例事件 耗时 2.94 秒;其符号 demangle 后为JSC::JITThunks::initialize(JSC::VM&)。↩︎录制脚本。这些时间仅用于构建已有所需库的 Bun;之前的 WebKit 和 ICU 重建不包含在内。当然,我也可以将 buildprof 指向那个构建,但那是另一个深坑……我没有重建一个匹配的 Full-LTO WebKit 归档作为对照组,因此无法将节省的每一秒都归因于 LTO 设置。↩︎
buildprof 目前支持 Clang、LLD 和 Nightly Rust 的编译器追踪数据。↩︎
eBPF 追踪使用
CAP_BPF和CAP_PERFMON等权限,具体定义见内核的 capability 定义文档。ftrace 提供独立的追踪实例和 PID 过滤器,但仍需进行配置并访问 tracefs。ptrace也受主机安全设置限制,容器可能需要额外权限才能追踪子进程。↩︎