I made a build visualizer to understand Bun’s compile times
I made a build visualizer to understand Bun’s compile times
我制作了一个构建可视化工具,以探究 Bun 的编译耗时
I built buildprof (Github), an open-source tracing tool that shows where the time goes when you compile software on Linux. Here’s a realtime video of it profiling a clean build of ripgrep: Watch the buildprof demo. 我构建了 buildprof (Github),这是一个开源追踪工具,可以展示在 Linux 上编译软件时时间都花在了哪里。以下是它对 ripgrep 进行全新构建分析的实时视频:观看 buildprof 演示。
Sometimes, builds are slow because there is simply a lot of code to compile. But more often than not, there are fixable problems: poor parallelism, repeated work, dependency downloads or a huge compiler/linker invocation. buildprof makes all of this clearly visible, so you can see what’s worth investigating and optimizing. 有时构建缓慢仅仅是因为代码量太大。但更多时候,存在一些可修复的问题:并行度不足、重复工作、依赖下载或庞大的编译器/链接器调用。buildprof 将这一切清晰地呈现出来,让你能够看出哪些部分值得调查和优化。
You run it by putting buildprof -- in front of any build command you already use:
你只需在你现有的任何构建命令前加上 buildprof -- 即可运行它:
buildprof -- make -j16
buildprof -- cargo build
buildprof -- ninja -C out/target
buildprof -- just build
buildprof -- ./dev/custom-build-script.sh
buildprof records every process your build command launches, including their subprocesses (and their subprocesses…), and lays them out on one timeline. Time moves from left to right, bar width shows duration, and child processes appear beneath whatever launched them. buildprof 会记录构建命令启动的每一个进程,包括它们的子进程(以及子进程的子进程……),并将它们排列在同一时间轴上。时间从左向右推移,条形宽度表示持续时间,子进程显示在启动它们的父进程下方。
I made buildprof because this tweet from Jarred Sumner, chief architect of the Bun JavaScript runtime, was living rent free in my head: 我制作 buildprof 是因为 Bun JavaScript 运行时首席架构师 Jarred Sumner 的这条推文一直在我脑海中挥之不去:
Specifically, the claim that Bun’s new Rust build was >5× faster on Linux than its old Zig build really bothered me. In my experience, Zig projects had usually compiled much faster than Rust projects of similar complexity. That intuition was enough to make me feel there was a mystery to solve. 具体来说,关于 Bun 的新 Rust 构建在 Linux 上比旧的 Zig 构建快 5 倍以上的说法让我非常困扰。根据我的经验,同等复杂度的 Zig 项目通常比 Rust 项目编译快得多。这种直觉让我觉得其中必有蹊跷,值得一探究竟。
This was further compounded by another important, yet easily missed, detail in the tweet: the Zig build used Full LTO, while the Rust build used ThinLTO. 推文中另一个重要但容易被忽略的细节加剧了我的疑惑:Zig 构建使用了 Full LTO,而 Rust 构建使用了 ThinLTO。
Compilers normally optimize separate compilation units largely in isolation. Link-time optimization (LTO) lets them optimize across those boundaries. Full LTO brings those units together into one large optimization job, while ThinLTO preserves more separation so much of the work can run in parallel. 编译器通常在很大程度上孤立地优化各个编译单元。链接时优化 (LTO) 允许它们跨越这些边界进行优化。Full LTO 将这些单元合并为一个大型优化任务,而 ThinLTO 则保留了更多的独立性,以便大部分工作可以并行运行。
From past experience, this difference can have an enormous effect on build time. The tweet mentioned it in passing, but I wondered how much of the headline improvement it explained. 根据以往的经验,这种差异会对构建时间产生巨大影响。推文中只是顺带提了一下,但我很想知道这在多大程度上解释了标题中提到的性能提升。
I started by trying to reproduce the numbers. The numbers reproduced. But now what? 我首先尝试复现这些数据。数据复现成功了。但接下来该怎么办?
I checked out Bun 1.3.14 and Bun 1.4.0 and wrote some scripts to replay their Linux x64 CI builds on a 6-core, 12-thread Linux VM. The scripts preserved the build steps and their dependencies, running everything on one machine. 我检出了 Bun 1.3.14 和 Bun 1.4.0,并编写了一些脚本,在 6 核 12 线程的 Linux 虚拟机上重现它们的 Linux x64 CI 构建。这些脚本保留了构建步骤及其依赖关系,并在同一台机器上运行所有内容。
My timings were in the same ballpark as Jarred’s: 我的计时结果与 Jarred 的数据大致相当:
| Linux x64 build | Zig era | Rust era |
|---|---|---|
| Bun’s reported CI median | 30m06s | 5m37s |
| My single-machine CI-profile replay | 24m24s | 5m40s |
OK, so the gap showed up on my machine too. But a lot had changed between the two measurements besides the language; so what was actually responsible? Was it the Zig compiler that was taking all that extra time? Or maybe it was the Full LTO link? Or perhaps there was something else in Bun’s build I hadn’t even thought to look at? 好吧,差距在我的机器上也出现了。但在两次测量之间,除了编程语言之外,还有很多东西发生了变化;那么到底是什么原因导致的呢?是 Zig 编译器占用了所有额外时间吗?还是 Full LTO 链接的问题?又或者 Bun 的构建过程中还有我根本没想到的其他因素?
This is where my profiling and developer-tools brain kicked in. Usually, when I’m trying to understand why something is slow, I want a trace: what happened, when it happened and how long it took. It would be really cool to have that for these builds, to put them on a timeline and see where their time actually went. 这时,我的性能分析和开发者工具思维开始发挥作用。通常,当我试图理解某件事为什么慢时,我需要一个追踪记录:发生了什么、何时发生以及耗时多久。如果能为这些构建过程提供这样的记录,将它们放在时间轴上并查看时间到底花在了哪里,那将非常酷。
But a build involves a lot of different tools, each with its own idea of what’s happening. What could I record that would let me see across all of them? 但构建过程涉及许多不同的工具,每个工具对正在发生的事情都有自己的理解。我能记录什么才能让我洞察所有这些工具呢?
Builds are process trees
构建即进程树
When you type cargo build or zig build, it feels like you are running one program. The build system works out what needs to be rebuilt, the ordering between those pieces and what can run in parallel. But generally, it does not perform all that work itself; it launches compilers, code generators, archivers, linkers and arbitrary scripts. Which can launch more programs which launch some more…
当你输入 cargo build 或 zig build 时,感觉就像是在运行一个程序。构建系统会计算出需要重新构建的内容、各部分之间的顺序以及可以并行运行的内容。但通常情况下,它并不会亲自完成所有工作;它会启动编译器、代码生成器、归档器、链接器和各种脚本。而这些程序又会启动更多的程序,进而启动更多……
Different build systems describe that work in different ways. Cargo sees crates, Ninja sees build edges and CMake generates instructions for another build system. From the operating system’s point of view, however, they (mostly) look like processes launching other processes. 不同的构建系统以不同的方式描述这些工作。Cargo 看到的是 crate,Ninja 看到的是构建边(build edges),而 CMake 则为另一个构建系统生成指令。然而,从操作系统的角度来看,它们(大部分)看起来就像是进程在启动其他进程。
A Rust build, for example, might contain a chain like this:
例如,一个 Rust 构建可能包含这样的链条:
cargo └── rustc └── cc └── collect2 └── ld.lld
If we record when each subprocess starts and ends, we can lay them out on a timeline. Here’s what that chain looks like in buildprof: 如果我们记录每个子进程的开始和结束时间,就可以将它们排列在时间轴上。这就是该链条在 buildprof 中的样子:
There are also several nice properties to visualizing a build at this layer: 在这一层级可视化构建还有几个优点:
- It’s build-system agnostic: Cargo, Ninja, Zig, Make and most other build systems do much of their work by spawning processes, so we do not need to write a special integration for each one.
- 与构建系统无关: Cargo、Ninja、Zig、Make 和大多数其他构建系统都是通过生成进程来完成大部分工作的,因此我们不需要为每一个系统编写专门的集成。
- It naturally includes custom scripts: This includes both scripts above the build system (repository setup, dependency fetching) and scripts underneath it (code generators, asset processors).
- 自然包含自定义脚本: 这既包括构建系统之上的脚本(仓库设置、依赖获取),也包括其之下的脚本(代码生成器、资源处理器)。
- We can follow the files between build steps: recording which files each process reads and writes lets us see which steps produce the inputs for others. This even works across build systems!
- 我们可以追踪构建步骤之间的文件: 记录每个进程读取和写入的文件,让我们能够看到哪些步骤为其他步骤生成了输入。这甚至可以跨构建系统工作!
This gave me a starting point for buildprof: record the process tree, then turn it into a timeline I could explore. There are plenty more details to get into, which I will do later. But once I had that working, I could finally go back to my initial question: what was Bun doing for those twenty-four minutes? 这为 buildprof 提供了一个起点:记录进程树,然后将其转换为我可以探索的时间轴。还有很多细节需要深入探讨,我稍后会进行说明。但一旦我完成了这些工作,我终于可以回到最初的问题:Bun 在那 24 分钟里到底在做什么?
Pointing it at Bun
将其指向 Bun
Why was the Zig CI build so much slower? 为什么 Zig CI 构建慢那么多?
I started by recording the Zig-era CI build with buildprof, using the same scripts as before: 我首先使用 buildprof 记录了 Zig 时代的 CI 构建,使用了与之前相同的脚本:
[Explore in buildprof] [在 buildprof 中探索]
Right away we can see a huge problem: the ld.lld linker invocation dominates the build time. It ran alone at the very end for over sixteen minutes, about two-thirds of the entire build. What the heck was it doing for all that time?
我们立刻就能发现一个巨大的问题:ld.lld 链接器的调用占据了构建时间的主导地位。它在最后独自运行了超过 16 分钟,大约占整个构建时间的三分之二。它在那段时间里到底在干什么?
Clicking on the linker shows its command line, which buildprof captures automatically: 点击链接器可以看到它的命令行,buildprof 会自动捕获这些信息:
There’s Full LTO, just as Jarred said. Given how long the link was taking, it was now my main suspect. But the process tree alone couldn’t tell me whether LTO was actually responsible for those sixteen minutes. Thankfully, LLD records its own internal timing… 正如 Jarred 所说,确实是 Full LTO。考虑到链接耗时之长,它现在成了我的头号嫌疑对象。但仅凭进程树无法告诉我 LTO 是否真的导致了那 16 分钟的耗时。幸运的是,LLD 会记录其自身的内部计时……