Reed's News
← 返回精选

用 buildprof 追踪 Bun 编译时间:从 24 分钟到 15 分钟的归因实验

Tech 79 lalitmaganti 2026/9/12 2921 字 原文 ↗

我开发了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会记录构建命令启动的所有进程,包括其子进程(以及子进程的子进程……),并将它们按时间轴排列。时间从左向右推进,条形宽度代表进程持续时长,子进程显示在父进程下方。

ripgrep构建过程:Cargo贯穿全程,rustc并行编译多个包,最终rustc调用链接器链

我开发buildprof的契机,是Bun JavaScript runtime首席架构师Jarred Sumner的一条推文始终在我脑海里挥之不去:

Jarred Sumner的对比数据:Bun 1.3.14在Linux上的构建中位数为30分06秒,Bun 1.4.0则为5分37秒

尤其让我在意的是,推文称Bun改用Rust后的构建速度比之前的Zig版本快5倍以上。根据我的经验,复杂度相近的项目中,Zig编译通常比Rust快得多。这种直觉让我觉得其中必有蹊跷。

推文里还有一个关键但容易被忽略的细节:Zig版本使用的是全链接时优化(Full LTO),而Rust版本用的是瘦链接时优化(Thin LTO)。

编译器通常会独立优化各个编译单元¹。链接时优化(LTO)则允许跨单元优化。Full LTO会将所有单元整合为一个大型优化任务,而Thin LTO保留更多独立性,让大部分工作可以并行执行。

根据过往经验,两者对构建时间的影响极其显著。推文只是顺带提到了这一点,但我想知道它在多大程度上解释了构建速度的大幅提升。

于是我开始尝试复现这些数据。

数据复现完成,接下来该怎么做?#

我检出了Bun 1.3.14和Bun 1.4.0版本,编写了若干脚本,在一台6核12线程的Linux虚拟机上复现它们的Linux x64 CI构建流程。脚本完整保留了构建步骤和依赖关系,所有操作都在同一台机器上完成。²(https://lalitm.com#fn: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则为其他构建系统生成指令。但从操作系统的角度看,它们(大多数情况下)本质上都是进程启动其他进程。³(https://lalitm.com#fn:3)

以Rust构建为例,可能存在这样的进程链:

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

如果记录每个子进程的启动和结束时间,就能将它们按时间轴排列。以下是buildprof呈现的这一进程链:

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

在这一层级可视化构建过程有几个显著优势:

  • 构建系统无关性:Cargo、Ninja、Zig、Make等多数构建系统都通过生成进程完成主要工作,因此无需为每个系统编写专门的集成代码。
  • 天然支持自定义脚本:既包括构建系统上层的脚本(如仓库初始化、依赖下载),也包括下层的脚本(如代码生成器、资源处理器)。
  • 追踪文件流转:记录每个进程读写的文件,就能看出各步骤间的输入输出依赖关系,甚至跨构建系统也能生效。

这为buildprof提供了核心思路:记录进程树,再将其转化为可交互的时间轴。其中还有很多细节后续再展开。工具可用后,我终于可以回到最初的问题:Bun那24分钟的构建时间到底花在了哪里?

用buildprof分析Bun#

Zig版本的CI构建为何如此缓慢?#

我先用buildprof记录了Zig版本的CI构建过程,使用的是之前的同一套脚本:

完整的Zig版本CI构建时间轴

立刻就能发现一个严重问题:ld.lld链接器调用占据了绝大部分构建时间。它在构建末尾单独运行了16分钟以上,占总时长的约三分之二。它到底在做什么?

点击链接器条目,可以看到buildprof自动捕获的命令行参数:

选中的Zig版本链接器及其Full LTO参数

正如Jarred所说,这里确实启用了Full LTO。考虑到链接耗时之久,它自然成了我的首要怀疑对象。

但仅靠进程树无法确认LTO是否真的导致了这16分钟的耗时。好在LLD会记录内部时间事件,只要使用--compiler-traces参数,buildprof就能捕获这些信息。

我重新记录了最终链接阶段,这次启用了--compiler-traces:

LLD的内部执行阶段

现在可以清楚看到,几乎所有时间都花在了LTO上。链接器不仅是合并已编译文件,还在对整个程序执行编译器优化步骤。其中OptModule阶段耗时就超过10分钟,包含生成机器码的优化过程。⁴(https://lalitm.com#fn:4)

Rust版本的CI构建有何不同?#

既然Zig版本的大部分时间都花在了LTO上,我想看看Rust版本的链接耗时。于是我也记录了它的构建过程:

完整的Rust版本CI构建时间轴

仅耗时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的函数上——这是Bun用于执行JavaScript的JavaScriptCore引擎。链接器还在花时间编译这个JavaScript引擎。⁵(https://lalitm.com#fn:5)

点击链接器调用条目,可以看到其输入文件中包含WebKit库,比如libJavaScriptCore.a:

链接器命令已启用ThinLTO,但仍包含WebKit库,包括libJavaScriptCore.a

追溯这些输入文件的来源,我发现Bun并未自行编译这些库,而是从单独的WebKit构建产物中下载。查看该构建的参数后,果然发现了-flto=full。而Rust版本使用了更新的WebKit修订版,其构建脚本选择了ThinLTO。

尽管我修改了Bun自身代码的编译参数,但下载的库仍包含Full LTO输入文件,因此链接器仍需优化这部分代码并生成机器码。要改变这一点,我必须重新编译WebKit。

重新编译WebKit#

我检出了历史版本的WebKit,用兼容的ThinLTO参数重新编译了WebKit及其ICU依赖,然后用自己编译的库替换了下载的库,同时保留Bun的ThinLTO修改。

以下是记录的构建数据:⁶(https://lalitm.com#fn:6)

Zig版本构建 总时长 最终链接耗时
原版Full LTO 24m24s 16m35s
Bun启用ThinLTO;使用原版WebKit归档 20m20s 12m55s
Bun启用ThinLTO;使用重新编译的ThinLTO WebKit和ICU 15m11s 7m22s

现在链接耗时为7分22秒,仍比Rust版本慢,但提升幅度已足够让我将目光投向链接器之外的环节。

构建的其他部分耗时如何?#

整个构建仍需15分钟,其中近8分钟都在等待链接器启动。它在等什么?我回到最初的CI追踪记录,追踪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的各个包;而Zig版本只有一个zig build-obj进程,未生成任何子进程

这意味着Zig版本无法像Rust版本那样并行编译。我还猜测(虽未验证),这也是链接速度慢的原因:链接器需要优化一个巨大的ThinLTO字节码模块,而不是像Rust那样将工作分散到多个包中。

到这里我不得不停止深入:要进一步优化,我得自己拆分Zig模块,但考虑到这段代码已经过时,我认为不值得这么做。

总结如下:

  • 最初Zig版本与Rust版本的巨大差距,主要源于构建末尾耗时极长的链接步骤。
  • 仅修改Bun的LTO设置不足以解决问题,因为占比很大的WebKit仍使用Full LTO。
  • 同时修改Bun和WebKit的LTO设置后,Zig版本的构建时长从24分钟降至15分钟。
  • 即便如此,链接仍需7分钟,总构建时长15分钟。
  • 剩余的显著差异源于结构设计:Rust将编译任务分散到90多个包中,而Zig版本则全部集中到单个模块。

顺带一提,追踪记录还发现了一些我忍不住探究的细节……

构建中隐藏的其他细节#

构建过程可能包含任何操作#

在Bun的CI构建过程中,我发现了一些访问公网获取机器IP、检查运行中Docker容器、读取最新Git提交信息的命令。

进程树中可见的小型CI初始化命令

这些操作总共耗时不到1秒,虽然无需优化,但我没想到会在构建追踪中看到它们。

冷启动时的依赖下载#

上述构建都复用了已下载的依赖,因此我还记录了一次全新的WebKit下载过程。下载并解压归档耗时约20秒:前12秒只有Node在运行,之后启动tar和gzip,可以看到解压过程单独执行。

WebKit冷启动下载与解压

深入单个C++编译过程#

之前我们追踪到链接器的输入来自Bun的C++编译。我们也可以深入查看这些编译器调用的细节。我选了最后完成的文件之一ZigGeneratedClasses.cpp,用--compiler-traces参数复现了它的Ninja命令。对于Clang,buildprof会自动启用-ftime-trace,并将其内部计时信息添加到进程时间轴中。⁷(https://lalitm.com#fn:7)

Clang编译ZigGeneratedClasses.cpp的前端与后端阶段

复现过程耗时约12秒,Clang前端和后端的耗时几乎各占一半。进一步放大可以看到,Clang的ModuleInlinerWrapperPass阶段占了后端工作的4秒多。

buildprof的底层实现#

buildprof的记录端使用ptrace——这是Linux调试器所用的同一接口。我也曾考虑过eBPF和ftrace,但ptrace恰好完美适配这类场景:eBPF追踪需要CAP_BPF和CAP_PERFMON权限,且要挂钩可能不稳定的追踪点或内核函数;而ftrace则需要管理追踪实例以避免干扰其他用户,同时要精准过滤仅与构建进程及其子进程相关的事件,操作起来十分繁琐。⁸(https://lalitm.com#fn:8)

使用ptrace,我可以直接启动构建进程并跟踪其子进程。它的内置事件能通知buildprof进程何时fork、exec新程序或退出。对于文件系统操作,buildprof使用seccomp过滤器仅拦截所需的系统调用。

buildprof的性能开销几乎完全取决于构建过程中打开的文件数量。对于ripgrep,记录过程几乎不影响构建时长;Redis则会频繁打开文件,记录操作增加了约5秒耗时:⁹(https://lalitm.com#fn: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负责处理复杂的工作(解析追踪记录、查询事件、渲染时间轴、管理工作区),我则可以专注于实现对构建场景有用的功能。

我计划在另一篇技术文章中详细介绍记录器和UI的实现细节。如果想收到发布通知,欢迎订阅!:)

我是否需要开发新工具?#

如今人们很容易为了做而做开发一款工具,但这次并非如此。在开发buildprof之前,我花了大量时间寻找能提供类似视图的现有工具。

我首先尝试了ninjatracing——我曾多次使用它。它能将Ninja的构建日志转化为时间轴,展示任务的执行情况和并行度。

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

还有其他一些工具,各自覆盖了问题的不同方面:

  • 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排查。如果你也有这样的构建需求,不妨试试看。我很想听听你的发现!