buildprof 揭示了 Bun 的 Zig 构建为何比 Rust 构建慢了 5 倍
快速要点
buildprof 证明,Bun 的 Zig 构建之所以慢,是因为一个巨大的 Full‑LTO 链接阶段和单模块 Zig 编译阻止了并行化,而 Rust 构建使用了 Thin‑LTO 和多个 crate,将总时间从约 24 分钟减少到约 5 分钟。
什么是 buildprof?
buildprof 是一个开源的 Linux 跟踪工具(https://github.com/lalitMaganti/buildprof),它记录构建过程中生成的每个进程,以及文件的读写操作,还可选择性地记录编译器内部的追踪信息。它以分层的时间线形式呈现数据,时间从左向右流动,条形宽度表示持续时间,子进程显示在其父进程下方。使用方法是在任何构建命令前加上前缀:
buildprof -- make -j16
buildprof -- cargo build
buildprof -- ninja -C out/target
buildprof -- just build
buildprof -- ./dev/custom-build-script.sh
记录的数据可以在基于 Perfetto 平台构建的 Web UI(https://buildprof.lalitm.com/)中探索。
为何要调查 Bun 的编译时间?
Jarred Sumner(Bun 的首席架构师)在一条推文中声称,Bun 1.4.0(基于 Rust)在 Linux 上的编译速度比 Bun 1.3.14(基于 Zig)快了 5 倍。该推文指出,Zig 构建使用了 Full LTO,而 Rust 构建使用了 ThinLTO。由于 LTO 策略可能对构建时间产生巨大影响,作者开发了 buildprof 来验证这一说法并理解其根本原因。
重现数据
作者检出了 Bun 1.3.14 和 1.4.0,脚本化了其在 6 核/12 线程虚拟机上的 CI 构建,并获得了与推文接近的时间:
| 构建 | 报告的 CI 中位数 | 在单台虚拟机上重现 |
|---|---|---|
| Zig 时代(Bun 1.3.14) | 30 m 06 s | 24 m 24 s |
| Rust 时代(Bun 1.4.0) | 5 m 37 s | 5 m 40 s |
差距依然存在,促使作者进行更深入的探究。
将构建可视化为进程树
构建本质上是一个 进程树:顶层命令(如 cargo)会启动编译器、链接器、脚本,这些进程可能进一步启动其他进程。通过使用 ptrace 记录每个 fork/exec/exit 事件,并通过 seccomp 过滤器拦截文件 I/O,buildprof 构建了一个完整的树结构,并映射节点之间的文件依赖关系。这种方法是 构建系统无关的,并能自动包含自定义脚本。
Zig 构建:链接器瓶颈
Zig 时代 CI 构建的时间线显示,单个 ld.lld 调用消耗了约 16 分钟(约占总时间的 2/3)。启用 --compiler-traces 后,揭示了内部 LLD 阶段;仅 OptModule 阶段就耗时超过 10 分钟,证实了 Full LTO 会迫使链接器对整个程序运行重量级优化步骤。
Rust 构建:轻量链接
Rust 时代 CI 构建的时间线显示,总链接时间仅为 2 m 24 s。其链接器命令包含 -plugin-opt=thinlto,这意味着链接器仅执行轻量级符号解析,而大部分优化在并行的 rustc 调用中提前完成。这种鲜明对比表明,LTO 策略是造成时间差距的主要原因。
实验:将 Zig 切换到 ThinLTO
作者修改了 Bun 的 Zig 构建以使用 ThinLTO,并记录了一次新运行。链接时间缩短了 3 m 40 s,但整体构建仍耗时约 13 分钟,因为许多 WebKit 库(如 libJavaScriptCore.a)仍使用 Full LTO 构建。这些预先构建的归档文件迫使链接器对大型 bitcode 模块执行完整优化。
使用 ThinLTO 重新构建 WebKit
通过使用 ThinLTO 重新构建所需的 WebKit 版本及其 ICU 依赖项,并替换下载的归档文件,作者取得了以下结果:
| 配置 | 整体构建时间 | 最终链接时间 |
|---|---|---|
| 原始 Full LTO(Zig) | 24 m 24 s | 16 m 35 s |
| Zig ThinLTO + 原始 WebKit | 20 m 20 s | 12 m 55 s |
| Zig ThinLTO + 重新构建的 ThinLTO WebKit | 15 m 11 s | 7 m 22 s |
重新构建的 WebKit 使链接时间减半,但构建速度仍不及 Rust 版本。
并行性:crates 与单个模块
检查依赖箭头显示,链接器在等待 bun-zig.o 完成的同时,C++ 部分已提前完成。关键观察是:Bun 的 Rust 构建被拆分为 >90 个 crate,允许 Cargo 并行启动多个 rustc 进程。相比之下,Zig 构建将整个代码库编译为 一个庞大的 Zig 模块,限制了并行性,并增加了链接器的工作量(一个巨大的 ThinLTO bitcode 模块 vs 多个小型模块)。
其他发现
- 杂项 CI 步骤 – 跟踪捕获了网络探测、Docker 检查和 Git 查询,每个耗时均在 1 秒以内。
- 冷依赖获取 – 一次全新的 WebKit 下载/解压耗时约 20 秒,表现为
node → tar → gzip事件。 - C++ 编译细节 – 为 Clang 启用
-ftime-trace显示,12 秒的编译时间在前端和后端之间平均分配,其中ModuleInlinerWrapperPass消耗了 >4 秒。
buildprof 的工作原理
- 记录 – 使用
ptrace跟踪 fork/exec,并使用 seccomp 过滤器处理文件事件。这避免了 eBPF 或 ftrace 所带来的权限和稳定性问题。 - 开销 – 主要与文件打开次数成正比。示例开销:ripgrep(≈ 0.2 秒),Redis(≈ 5 秒)。
--no-file-events标志可消除文件追踪开销。 - UI – 基于 Perfetto UI 的插件系统构建,提供时间线渲染、进程树布局以及生产者与消费者之间的按需箭头。
相关工具及其局限性
| 工具 | 优势 | 局限性 |
|---|---|---|
| ninjatracing | 显示 Ninja 边缘和并行性 | 忽略包装脚本和子进程树 |
| Cargo timings | 提供详细的 Cargo 阶段时间 | 忽略自定义脚本和非 Cargo 工作 |
clang -ftime-trace |
显示编译器内部阶段 | 无法查看整个构建过程 |
| strace / tracexec | 通用系统调用跟踪 | 非构建导向,时间线难以阅读 |
| What the Fork | 进程树视图 | 私有测试版,非开源 |
buildprof 通过在单一交互式时间线上结合完整的进程树捕获、文件依赖箭头和可选的编译器追踪,填补了这一空白。
未来方向
- 降低文件系统追踪开销(尤其是对打开大量文件的构建)。
- 添加对 macOS 和 Windows 的支持。
- 支持更多构建系统(npm、Gradle、Bazel)。
- 自动计算并显示 关键路径。
- 通过 GitHub 问题收集用户反馈。
社区反应(精选 HN 评论)
"写得真好!我原本希望作者能让 Bun 的 Zig 构建比 Rust 构建快得多,但深入分析仍然很有价值" – @anaqin
"这就像 Electric Insight……你可以对比构建来查看为何一个更慢" – @t43562
"不错!macOS 的等价工具是什么?" – @jiehong
"WebKit 链接中的 Full LTO 是串行部分。你的可视化工具能否将链接时间与代码生成时间分开?" – @xcc3641
这些评论突显了该工具在本案例研究之外的应用潜力,以及对跨平台支持的好奇心。
结论
- Bun 的 Zig 构建变慢的主要原因是 一个持续约 16 分钟的 Full‑LTO 链接步骤。
- 切换到 ThinLTO 虽然缩短了链接时间,但仍然存在较大差距,因为预构建的 WebKit 归档文件仍使用 Full LTO。
- 使用 ThinLTO 重新构建 WebKit 后,总构建时间降至 15 分钟,但 Rust 构建仍更快,原因是其在 >90 个 crate 上实现了 大规模并行性。
- buildprof 在揭示隐藏瓶颈、验证假设和指导具体优化方面发挥了不可替代的作用。
如果你遇到构建缓慢的问题,不妨试试 buildprof:buildprof -- <your‑build‑command>,并在 https://buildprof.lalitm.com/ 探索时间线。
Sources
相关
- 项目
- Dispatch
- Dispatch
- Dispatch
- 项目