我创建了一个构建分析器来理解Bun的编译时间

Lobsters Hottest 工具

摘要

作者创建了一个名为buildprof的开源构建分析器,以可视化软件在Linux上编译过程中时间花费的位置,其灵感来源于Bun的编译时间改进。

<p><a href="https://lobste.rs/s/qsrjs2/i_made_build_profiler_understand_bun_s">讨论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/09/12 16:39

# 我制作了一个构建性能分析器来理解 Bun 的编译时间 来源:https://lalitm.com/post/buildprof/ 我创建了 buildprof (https://buildprof.lalitm.com/)\(GitHub (https://github.com/lalitMaganti/buildprof)\),一个开源的跟踪工具,可以显示你在 Linux 上编译软件时时间花费在哪里。这是一个它分析 ripgrep 干净构建的实时视频: 观看 buildprof 演示 (https://lalitm.com/assets/buildprof/buildprof-initial-demo-v2.mp4) 有时候,构建缓慢仅仅是因为有大量代码需要编译。但更多时候,存在一些可修复的问题:并行性差、重复工作、依赖下载或巨大的编译器/链接器调用。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 的这条推文 (https://x.com/jarredsumner/status/2090619419059974620)一直萦绕在我脑海: 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 (https://lalitm.com/post/buildprof/#fn:1)链接时优化(LTO)允许它们**跨**这些边界进行优化。Full LTO 将这些单元合并为一个大的优化任务,而 ThinLTO 保留更多的独立性,因此大部分工作可以并行运行。 根据过去的经验,这种差异可能对构建时间产生**巨大**的影响。推文只是顺便提到了这一点,但我想知道这在多大程度上解释了标题中的改进。 我首先尝试重现这些数字。 ## 数字重现了。但接下来呢?# (https://lalitm.com/post/buildprof/#the-numbers-reproduced-but-now-what) 我检出了Bun 1.3.14 (https://github.com/oven-sh/bun/releases/tag/bun-v1.3.14)和Bun 1.4.0 (https://github.com/oven-sh/bun/releases/tag/bun-v1.4.0),并编写了一些脚本 (https://github.com/LalitMaganti/blog-code/tree/main/buildprof-bun),在一个 6 核 12 线程的 Linux 虚拟机上重放它们的 Linux x64 CI 构建。这些脚本保留了构建步骤及其依赖项,在一台机器上运行所有内容。2 (https://lalitm.com/post/buildprof/#fn:2) 我的计时与 Jarred 的大致相同: | Linux x64 构建 | Zig 时代 | Rust 时代 | | :--- | :--- | :--- | | Bun 报告的 CI 中位时间 | 30m06s | 5m37s | | 我的单机 CI 配置重放 | 24m24s | 5m40s | 好的,这种差距在我的机器上也出现了。但在两次测量之间,除了语言之外,还有很多变化;那么实际上是什么导致了这种差异?是 Zig 编译器花费了所有额外的时间?还是 Full LTO 链接?或者可能是 Bun 构建中还有其他我甚至没想到去查看的东西? 这就是我作为性能分析和开发者工具开发者的思维发挥作用的时候。通常,当我试图理解为什么某样东西慢时,我想要一个跟踪记录:发生了什么、何时发生以及花了多长时间。如果能为这些构建提供这样的记录,将它们放在时间线上,看看时间到底花在哪里,那将非常酷。 但一个构建涉及许多不同的工具,每个工具对正在发生的事情都有自己的理解。我可以记录什么才能让我看到所有工具的情况? ## 构建是进程树# (https://lalitm.com/post/buildprof/#builds-are-process-trees) 当你输入`cargo build`或`zig build`时,感觉就像你在运行一个程序。构建系统需要弄清楚需要重新构建什么、这些部分之间的顺序以及什么可以并行运行。但通常,它本身并不执行所有这些工作;它启动编译器、代码生成器、归档工具、链接器和任意脚本。而这些又可以启动更多程序,进而启动更多... 不同的构建系统以不同的方式描述这些工作。Cargo 看到 crate,Ninja 看到构建边,CMake 为另一个构建系统生成指令。然而,从操作系统的角度来看,它们(大多数情况下)看起来就像进程启动其他进程。3 (https://lalitm.com/post/buildprof/#fn:3) 例如,一个 Rust 构建可能包含这样的链: `` cargo └── rustc └── cc └── collect2 └── ld.lld ``` 如果我们记录每个子进程的启动和结束时间,就可以将它们排列在时间线上。这就是该链在 buildprof 中的样子: 一个 ripgrep 构建的最终链接环节,显示 cargo 启动 rustc,然后是 cc、collect2 和 ld.lld。 在这种层面上可视化构建还有几个很好的特性: 1. **它与构建系统无关**:Cargo、Ninja、Zig、Make 和大多数其他构建系统都通过生成进程来完成大部分工作,因此我们不需要为每个构建系统编写特殊的集成。 2. **它自然地包含自定义脚本**:这包括构建系统之上(仓库设置、依赖获取)和之下(代码生成器、资产处理器)的脚本。 3. **我们可以跟踪构建步骤之间的文件**:记录每个进程读取和写入的文件,可以让我们看到哪些步骤为其他步骤产生了输入。这甚至可以跨构建系统工作! 这给了我一个构建性能分析器的起点:记录进程树,然后将其转换成我可以探索的时间线。还有很多细节需要深入,我稍后会谈到。但一旦这个功能可用,我终于可以回到最初的问题:Bun 在那二十四分钟里到底在做什么? ## 应用于 Bun# (https://lalitm.com/post/buildprof/#pointing-it-at-bun) ### 为什么 Zig CI 构建慢得多?# (https://lalitm.com/post/buildprof/#why-was-the-zig-ci-build-so-much-slower) 我首先使用 buildprof 记录了 Zig 时代的 CI 构建,使用的是与之前相同的脚本 (https://github.com/LalitMaganti/blog-code/blob/main/buildprof-bun/scripts/record-original-zig-ci.sh): 完整的 Zig 时代 CI 构建 *在 buildprof 中探索 (https://buildprof.lalitm.com/v0.2.2/#!/?url=https%3A%2F%2Fblogexamples.lalitm.com%2Fbuildprof-bun%2F2026-09-04%2Fbun-zig-original-ci-b5a45845003ecae0.buildprof)* 我们立刻就能看到一个巨大的问题:`ld.lld`链接器调用*主导*了构建时间。它在最后单独运行了超过十六分钟,大约占整个构建时间的三分之二。它到底在做什么,花了这么长时间? 点击链接器会显示它的命令行,buildprof 会自动捕获: 选定的 Zig 时代链接器及其 Full LTO 标志 就是 Full LTO,正如 Jarred 所说。考虑到链接花费的时间之长,它现在是我的主要怀疑对象。 但仅凭进程树无法告诉我 LTO 是否真的对那十六分钟负责。幸运的是,LLD 记录自己的内部计时事件,当你使用`\-\-compiler\-traces`时,buildprof 可以包含它们。 我再次记录了最终的链接 (https://github.com/LalitMaganti/blog-code/blob/main/buildprof-bun/scripts/record-zig-full-lto-link-detail.sh),这次启用了`\-\-compiler\-traces`: LLD 的内部阶段 *在 buildprof 中探索 (https://buildprof.lalitm.com/v0.2.2/#!/?url=https%3A%2F%2Fblogexamples.lalitm.com%2Fbuildprof-bun%2F2026-09-04%2Fbun-zig-full-lto-link-detail-d1e7a0e1dd7750d4.buildprof)* 现在我们可以看到,LTO *确实是*几乎所有时间花费的地方。链接器正在对程序运行编译器 pass,而不仅仅是合并已编译的文件。仅`OptModule`条就花费了刚刚超过十分钟,并且包括了生成机器代码的 pass。4 (https://lalitm.com/post/buildprof/#fn:4) #### Rust CI 构建有何不同?# (https://lalitm.com/post/buildprof/#how-did-the-rust-ci-build-differ) 既然如此多的 Zig 构建时间都花在 LTO 上,我想看看 Rust 构建在链接上花了多少时间。我也记录了那次构建: 完整的 Rust 时代 CI 构建 *在 buildprof 中探索 (https://buildprof.lalitm.com/v0.2.2/#!/?url=https%3A%2F%2Fblogexamples.lalitm.com%2Fbuildprof-bun%2F2026-09-04%2Fbun-rust-original-ci-4c9dbcbe6d041d98.buildprof)* 只有 2 分 24 秒。而且正如预期的那样,链接器命令包含`\-plugin\-opt=thinlto`: 启用了 ThinLTO 的 Rust 链接器调用 两个构建都使用了 LTO,但设置不同,链接时间也大不相同。如果我保留 Bun 的 Zig 代码,但将 Full LTO 改为 ThinLTO 会怎样?这将弥合多大差距? #### 尝试 ThinLTO# (https://lalitm.com/post/buildprof/#trying-thinlto) 我切换了 Zig Bun 的构建标志到 ThinLTO (https://github.com/LalitMaganti/blog-code/blob/main/buildprof-bun/patches/zig-thinlto.patch)并记录了另一个干净构建,同时进行了一次新的 Full-LTO 构建以供比较: 匹配的 Full-LTO 构建和部分 ThinLTO 实验 *在 buildprof 中探索:Full LTO (https://buildprof.lalitm.com/v0.2.2/#!/?url=https%3A%2F%2Fblogexamples.lalitm.com%2Fbuildprof-bun%2F2026-09-04%2Fbun-zig-ci-dag-full-lto-6111eb9fd9486a35.buildprof)·部分 ThinLTO (https://buildprof.lalitm.com/v0.2.2/#!/?url=https%3A%2F%2Fblogexamples.lalitm.com%2Fbuildprof-bun%2F2026-09-04%2Fbun-zig-ci-dag-thin-lto-fdfb05e3d506d810.buildprof)* 在这对记录中,链接快了 3 分 40 秒,但仍然花了将近十三分钟。为什么链接仍然如此昂贵? 回顾编译器跟踪,大量工作是在名字中带有`JSC`的函数上。那是 JavaScriptCore,Bun 用来执行 JavaScript 的引擎。链接器也在花时间编译 JavaScript 引擎。5 (https://lalitm.com/post/buildprof/#fn:5) 点击链接器调用显示其输入中包含WebKit (https://github.com/oven-sh/WebKit/tree/5488984d20e0dbfe4be2c3ba8fb18eb81a5e0e8b)库,包括`libJavaScriptCore.a`: 链接器命令启用了 ThinLTO 但仍包含 WebKit 的库,包括 libJavaScriptCore.a。 沿着这些输入追溯到构建过程中,我发现 Bun 自己并没有编译这些库。它是从一个单独的 WebKit 构建中下载的。当我检查那个构建的标志时 (https://github.com/oven-sh/WebKit/blob/5488984d20e0dbfe4be2c3ba8fb18eb81a5e0e8b/Dockerfile#L4),又发现了:`\-flto=full`。Rust 构建使用了更新的 WebKit 版本,其构建脚本选择了 ThinLTO (https://github.com/oven-sh/WebKit/blob/0f966e81b78c84bb/Dockerfile#L4)。 尽管我更改了 Bun 编译自身代码的方式,但这些下载的库仍然包含 Full-LTO 输入,因此链接器仍然必须优化这些代码并将其转换为机器代码。要改变这一点,我也需要重新构建 WebKit。 #### 重新构建 WebKit# (https://lalitm.com/post/buildprof/#rebuilding-webkit) 我检出了历史 WebKit 版本,并使用兼容的 ThinLTO 设置重新构建了它及其 ICU 依赖项。然后我用自己构建的库替换了下载的库,同时保留了对 Bun 的 ThinLTO 更改。 以下是记录的构建:6 (https://lalitm.com/post/buildprof/#fn:6) | | Zig 时代构建 | | | | :--- | :--- | :--- | :--- | | | **整个构建** | **最终链接** | | | **原始 Full LTO** | 24m24s | 16m35s | | | **Bun ThinLTO; 原始 WebKit 归档** | 20m20s | 12m55s | | | **Bun ThinLTO; 重新构建的 ThinLTO WebKit 和 ICU** | 15m11s | 7m22s | | 链接现在花了 7 分 22 秒。仍然比 Rust 构建慢,但改进足够大,让我想超越链接器去看看。 #### 构建的其余部分呢?# (https://lalitm.com/post/buildprof/#what-about-the-rest-of-the-build) 构建仍然花了十五分钟,其中近八分钟在链接器开始之前就过去了。它在等什么?我回到原始 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 展开为 Bun 的各个 crate 命名的 rustc 进程,旁边是单个 zig build-obj 进程,它根本不生成任何子进程。 这意味着 Zig 构建无法像 Rust 那样进行并行化。我还怀疑,尽管没有证明,这解释了链接缓慢的原因:链接器必须优化一个巨大的 ThinLTO 位代码模块,而不是相同的工作分散在各个 crate 中。 正是在这个时候,我不得不停下来:要更进一步,我必须自己拆分 Zig 模块,但考虑到这些代码已经过时了,我认为不值得这么做。 总结一下: - 初始 Zig 构建与 Rust 构建之间的巨大差异在于构建末尾单独运行的巨大链接步骤。 - 仅更改 Bun 的 LTO 设置是不够的,因为构建的重要组成部分 WebKit 仍然使用 Full LTO。 - 完成此操作后,Zig 构建时间从二十四分钟降至十五分钟。 - 即使如此之后,链接仍需 7 分钟,整个构建需要 15 分钟。 - 仍然存在的压倒性差异是结构性的:Rust 将编译分散到 >90 个 crate,而 Zig 构建将所有内容都通过单个模块来处理。 顺便说一下,跟踪记录也发现了一些我忍不住想探究的事情... ### 构建中隐藏的其他东西# (https://lalitm.com/post/buildprof/#other-things-hiding-in-the-build) #### 构建中几乎可以包含任何东西# (https://lalitm.com/post/buildprof/#a-build-can-contain-almost-anything) 在 Bun 的 CI 构建过程中,我发现了一些命令,它们向公共互联网询问机器的 IP 地址、检查运行中的 Docker 容器并读取最新的 Git 提交消息。 在进程树中可见的小型 CI 设置命令 这些总共花费不到一秒钟。没什么需要优化的,只是我没想到会在构建跟踪中发现它们。 #### 冷依赖获取# (https://lalitm.com/post/buildprof/#a-cold-dependency-fetch) 上面的构建重用了下载的依赖项,因此我还记录了一次新的 WebKit 获取 (https://github.com/LalitMaganti/blog-code/blob/main/buildprof-bun/scripts/capture-bun-cold-prebuilt.sh)。下载和解压缩归档文件大约花了二十秒。前十二秒,我们只看到 Node 在运行。然后它启动了`tar`和`gzip`,我们可以看到解压过程。 一次冷 WebKit 下载和解压

相似文章

主机调优GCC以加快编译速度

Lobsters Hottest

这篇博客文章介绍了如何使用配置文件引导优化、LTO和-O3构建主机调优的GCC编译器,以实现更快的编译速度,并附有详细的说明和基准测试。

Claude Code现在使用Rust重写的Bun

Simon Willison's Blog

Claude Code v2.1.181及以上版本使用了Bun的Rust移植版,在Linux上启动速度提升了10%。有证据显示它附带的是Bun v1.4.0的预览版。

如何对eBPF代码进行性能分析?

Hacker News Top

本文演示了如何通过创建一个简单的C测试框架来衡量文件打开延迟,从而对eBPF代码性能进行分析,使开发者能够比较附加eBPF钩子前后的开销。

深入理解Go运行时:性能分析

Hacker News Top

深入探讨Go的性能分析机制,解释运行时如何收集CPU、堆、阻塞、互斥锁和协程的性能分析数据,以及这些数据在pprof格式中的表示方式。