Hacker News 中文摘要

RSS订阅

自研构建可视化工具,透视 Bun 编译耗时 -- I made a build visualizer to understand Bun's compile times

我做了 buildprof(https://buildprof.lalitm.com/),一个开源追踪工具,用来看 Linux 上编译软件时时间都花在哪。它给 ripgrep 的一次干净构建做了实时剖析:慢有时是因为代码量本身就大,但更多时候是并行度差、重复劳动、依赖下载或者某次编译器/链接器调用过于庞大,buildprof 把这些都摆到一条时间线上。用法是在原有构建命令前加 buildprof --,它会记录命令启动的每个进程及其子进程。

正文

我做了 buildprof(在线版),一个开源追踪工具,用来看在 Linux 上编译软件时时间到底花在哪。这里有一段它给 ripgrep 干净构建做剖析的实时视频:Video 3。

有时构建慢只是因为要编译的代码确实多。但更多时候问题是能修的:并行度差、重复工作、依赖下载,或者一次巨大的编译器/链接器调用。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 发的一条推文一直在我脑子里挥之不去:

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。

编译器通常基本在各编译单元内部独立做优化。链接时优化(LTO)让它们能跨这些边界优化。Full LTO 把所有单元合成一个大的优化任务,ThinLTO 保留更多分离,因此大量工作可以并行。

以过去的经验,这个差异对构建时间的影响可能极其大。推文只是顺带提了一句,我很好奇它到底解释了多少提速。

我先从复现数字开始。

数字复现了,然后呢

我检出 Bun 1.3.14 和 Bun 1.4.0,写了一些脚本,在 6 核 12 线程的 Linux 虚拟机上重放它们的 Linux x64 CI 构建。脚本保留了构建步骤及其依赖关系,全部在一台机器上跑。

我的计时和 Jarred 在同一量级:

| Linux x64 构建 | Zig 时代 | Rust 时代 | | --- | --- | --- | | Bun 报告的 CI 中位数 | 30m06s | 5m37s | | 我在单机上按 CI 配置重放 | 24m24s | 5m40s |

差距在我的机器上也出现了。但两次测量之间除了语言还变了很多;那到底是什么造成的?是 Zig 编译器花了那些额外时间?还是 Full LTO 链接?又或者 Bun 的构建里还有我根本没想到要看的东西?

这时我的剖析和开发者工具脑子上线了。通常我想弄明白为什么某个东西慢,就想要一条 trace:发生了什么、什么时候发生、花了多久。要是能给这些构建也来一条,把它们放到时间轴上,看看时间实际去了哪里,那就太好了。

但一次构建涉及很多工具,各自对"发生了什么"都有自己的理解。我该记录什么,才能跨所有这些工具看到全貌?

构建是进程树

当你敲下 cargo build 或 zig build,感觉像在运行一个程序。构建系统算出哪些东西需要重建、它们的先后顺序、哪些能并行。但一般来说,它自己并不干这些活;它启动编译器、代码生成器、归档器、链接器和各种脚本。这些又启动更多程序,再启动更多……

不同构建系统描述这些工作的方式不同。Cargo 看到的是 crate,Ninja 看到的是 build edge,CMake 生成给另一个构建系统的指令。但从操作系统的角度看,它们(大多)就是进程启动进程。

比如一次 Rust 构建可能包含这样一条链:

  • cargo
    • rustc
      • cc
        • collect2
          • ld.lld

如果我们记录每个子进程开始和结束的时间,就能把它们铺到时间轴上。这条链在 buildprof 里长这样:

ripgrep 构建中的最终链接:cargo 启动 rustc,下面是 cc、collect2 和 ld.lld。

在这一层可视化构建还有几个好处:

  1. 与构建系统无关:Cargo、Ninja、Zig、Make 以及其他大多数构建系统都是靠启动进程来干活,所以不必为每个系统写专门的集成。
  2. 自然涵盖自定义脚本:既包括构建系统之上的脚本(仓库初始化、依赖拉取),也包括它下面的脚本(代码生成器、资源处理器)。
  3. 能追踪构建步骤之间的文件:记录每个进程读了哪些文件、写了哪些文件,就能看出哪些步骤为其他步骤生产了输入。这甚至跨构建系统也成立。

这给了 buildprof 一个起点:记录进程树,再把它变成可探索的时间轴。后面还有很多细节要展开,但等这部分跑通,我终于能回到最初的问题:Bun 那 24 分钟在干什么?

指向 Bun

Zig 的 CI 构建为什么慢这么多?

我先用 buildprof 记录了 Zig 时代的 CI 构建,用的是和之前一样的脚本:

完整的 Zig 时代 CI 构建

在 buildprof 中查看

一眼就能看到个大问题:ld.lld 链接器调用主导了整个构建时间。它在最后独自跑了 16 分钟以上,约占整个构建的三分之二。它到底在干什么?

点一下链接器就能看到它的命令行,buildprof 会自动捕获:

选中的 Zig 时代链接器及其 Full LTO 标志

Full LTO 就在那儿,和 Jarred 说的一样。考虑到链接花了那么长时间,它成了我的主要嫌疑。

但光看进程树没法告诉我 LTO 是不是就是那 16 分钟的原因。好在 LLD 会记录自己的内部计时事件,加上 --compiler-traces 参数,buildprof 就能把它们包含进来。

我重新记录了最终链接,这次开着 --compiler-traces:

LLD 的内部阶段

在 buildprof 中查看

现在能看出,几乎全部时间确实花在 LTO 上。链接器在对程序跑编译器 pass,而不只是把已经编译好的文件合并起来。单是 OptModule 这一条就占了 10 分钟出头,里面包含生成机器码的那些 pass。

Rust 的 CI 构建有什么不同?

Zig 构建这么大一部分花在 LTO 上,我就想看 Rust 构建花在链接上的时间有多少。我也记录了那个构建:

完整的 Rust 时代 CI 构建

在 buildprof 中查看

只有 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 分钟。为什么链接还是这么贵?

回看编译器 trace,很多工作花在名字里带 JSC 的函数上。那是 JavaScriptCore,Bun 用来执行 JavaScript 的引擎。链接器也在花时间编译 JavaScript 引擎。

点开链接器调用,可以看到输入里有 WebKit 的库,包括 libJavaScriptCore.a:

链接器命令启用了 ThinLTO,但仍然包含 WebKit 的库,包括 libJavaScriptCore.a。

顺着这些输入回溯构建过程,我发现 Bun 并不自己编译这些库,而是从一个单独的 WebKit 构建里下载。我去查那个构建的标志,果然又是它:-flto=full。Rust 构建用的是更新的 WebKit 修订版,其构建配方选择了 ThinLTO。

虽然我改了 Bun 自己代码的编译方式,那些下载来的库仍然包含 Full LTO 的输入,所以链接器还是得优化那些代码并生成机器码。要改变这一点,我得连 WebKit 一起重建。

重建 WebKit

我检出那个历史 WebKit 修订版,用兼容的 ThinLTO 设置重建了它和它的 ICU 依赖。然后把我自己构建的库替换掉下载来的库,同时保留 Bun 的 ThinLTO 改动。

录到的构建如下:

| Zig 时代构建 | 整个构建 | 最终链接 | | --- | --- | --- | | 原始 Full LTO | 24m24s | 16m35s | | Bun 用 ThinLTO;原版 WebKit 归档 | 20m20s | 12m55s | | Bun 用 ThinLTO;重建为 ThinLTO 的 WebKit 和 ICU | 15m11s | 7m22s |

链接现在花 7 分 22 秒。仍比 Rust 构建慢,但改善幅度足够让我去看链接器之外的地方。

构建的其他部分呢?

构建仍然要 15 分钟,其中将近 8 分钟在链接器启动之前就过去了。它在等什么?我回到最初的 CI trace,追踪 Bun 自己代码的输入。

buildprof 还记录每个进程读写哪些文件。如果一个进程读了另一个进程写的文件,它会在底层把两者连起来。打开"Show on timeline",这些连接会画成箭头。这里链接器读的是 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 的各 crate,旁边是单个 zig build-obj 进程,完全不启动任何子进程。

这意味着 Zig 构建无法像 Rust 那样并行。我还怀疑(但没有证明)这也解释了链接为什么慢:链接器要优化一个巨大的 ThinLTO bitcode 模块,而不是把同样的工作分散到各个 crate。

到这里我只能停下:再往下走,我就得自己拆分 Zig 模块,而考虑到这些代码已经过时,我觉得不值得。

小结:

  • 最初 Zig 构建与 Rust 构建相比,最大的离群点是构建末尾独自运行的巨大链接步骤。
  • 只改 Bun 自己的 LTO 设置还不够,因为 WebKit 是构建中相当大的一部分,它仍然在用 Full LTO。
  • 改完之后,Zig 构建从 24 分钟降到 15 分钟。
  • 即便如此,链接仍要 7 分钟,整个构建仍要 15 分钟。
  • 剩下最主要的差距是结构性的:Rust 把编译分散到 90 多个 crate,而 Zig 构建把所有东西都塞进一个模块。

顺带说一句,这些 trace 还翻出了一些我忍不住想戳一戳的东西……

构建里藏着的其他东西

一次构建里几乎什么都有

在 Bun 的 CI 构建中间,我发现有命令向公网查询这台机器的 IP 地址、检查运行中的 Docker 容器、读取最新的 Git commit 消息。

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

这些加起来远不到一秒。没什么可优化的,只是我没想到会在构建 trace 里看到它们。

一次冷依赖拉取

上面的构建都复用了已经下载的依赖,所以我也记录了一次全新的 WebKit 拉取。下载并解压归档大约花了 20 秒。前 12 秒只能看到 Node 在跑。然后它启动 tar 和 gzip,我们就能单独看到解压过程。

一次冷的 WebKit 下载与解压

看一次 C++ 编译的内部

前面我们顺着链接器的输入回溯到 Bun 的 C++ 编译。也可以看那些编译器调用内部。我挑了最后完成的文件之一 ZigGeneratedClasses.cpp,用 --compiler-traces 重放了它的 Ninja 命令。对 Clang,buildprof 会启用 -ftime-trace,并把内部计时加到进程时间轴上。

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

重放花了约 12 秒,Clang 前端和后端几乎各占一半。再放大看,ModuleInlinerWrapperPass 是 Clang 的一个阶段,占后端工作 4 秒多。

buildprof 内部如何工作

buildprof 的记录端使用 ptrace,也就是调试器用的那个 Linux 接口。我考虑过 eBPF 和 ftrace,但 ptrace 恰好非常适合这类问题;eBPF 追踪需要 CAP_BPF 和 CAP_PERFMON 权限,还要挂到可能不稳定的 tracepoint/内核函数上。用 ftrace 则得协调多个 tracing 实例以免干扰其他用户,而且要为构建进程及其所有后代精确设置过滤条件也很麻烦。

用 ptrace,我可以直接启动构建并跟随它的子进程。它内置的事件会告诉 buildprof 进程何时 fork、何时 exec 新程序、何时退出。文件系统活动方面,buildprof 用 seccomp 过滤器只拦截需要的调用。

buildprof 的开销几乎完全取决于构建打开多少文件。对 ripgrep,记录几乎没改变构建时间。Redis 打开文件频繁得多,记录增加了约 5 秒:

| 构建 | 不追踪 | 只记录进程 | 进程 + 文件 | | --- | --- | --- | --- | | 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 的构建日志变成时间轴,显示跑了什么、多少是并行的。

这里是 Zig 时代构建的 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。如果你的构建也慢,试试看。我很想听听你发现了什么。

评论

整体气氛几乎是一边倒的肯定,没有人质疑这篇文章的价值;分歧不在结论对错,而在下一步该往哪走——是把可视化直接喂给 LLM 做自动调参,还是先把「链接」和「代码生成」这两段拆开看。

认可剖析深度,并打算亲自上手试用

  • 行文和呈现方式都得到称赞,有读者自己拼过简陋的耗时分析工具,认为远不如这份全面,表示要试 (较多人) > 这类编译耗时剖析该多来点,我自己搭的工具跟这份差距明显,准备试试。(@applfanboysbgon)

遗憾结论不是 Zig 构建快过 Rust

  • 有读者带着「zig build 应该碾压 Rust」的预期来读,结果没等到这个结论,但仍承认深挖很扎实 (少数人) > 本来盼着结论是 Zig 的构建远比 Rust 快,不过这篇深挖依然值回票价。(@anaqin)

想用 LLM 自动跑优化试验

  • 好奇什么样的输入最适合喂给 LLM 去做自动优化和参数试验,并追问可视化是不是最佳表达形式,还是另有更好的载体 (少数人)

这类工具的价值远不止「找出谁最慢」

  • 类比 Electric Insight 那类专有但出色的工具:可以估算增加核心数能带来多少加速,从而判断加核值不值;还能对比两次构建,查出哪些任务参数变了、哪些只出现在其中一次构建里 (较多人) > 期待有人能自动回答「这次为什么慢」——和 Electric Insight 一个路子的分析空间非常大。(@t43562)

追问可视化是否区分链接时间与代码生成时间

  • 指出 WebKit 链接阶段的 Full LTO 才是真正的串行瓶颈,问可视化有没有把 link time 从 codegen 里拆出来,否则优化方向可能看错 (少数人)

有价值的补充

  • Full LTO 会让链接阶段退化为串行瓶颈,这决定了剖析工具必须能区分链接时间与代码生成时间,两类瓶颈的优化手段完全不同。
  • 同类工具能反推的不仅是「最慢任务排行」,还有「再加几个核能快多少」以及跨构建的差异对比——哪些任务参数变了、哪些只在一次构建中出现。
  • 有人提到 Electric Insight 是这一思路的先行者,属于专有工具,说明该领域此前就存在真实需求,只是缺少开放实现。