buildprof 揭示 Bun 的 Zig 編譯為何比 Rust 編譯慢了 5 倍

快速重點

buildprof 證明 Bun 的 Zig 編譯之所以慢,是因為巨大的 Full‑LTO 連結階段與單一模組的 Zig 編譯阻止了平行處理,而 Rust 編譯則使用 Thin‑LTO 和許多套件,將總時間從約 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 平台建構的網頁介面(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 filter 拦截檔案 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 版本。


平行處理:套件 vs 單一模組

檢視依賴箭頭可發現,連結器在等待 bun-zig.o 完成時,C++ 部分早已結束。關鍵觀察:Bun 的 Rust 編譯被拆分成超過 90 個套件,讓 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 filter 拦截檔案事件。此方法避免了 eBPF 或 ftrace 所帶來的權限與穩定性問題。
  • 額外負荷 – 主要與檔案開啟次數成正比。範例負荷:ripgrep(≈ 0.2 秒)、Redis(≈ 5 秒)。--no-file-events 參數可消除檔案追蹤的負荷。
  • UI – 基於 Perfetto UI 的插件系統建構,提供時間軸渲染、流程樹佈局,以及按需顯示生產者與消費者之間的箭頭。

相關工具與其不足之處

工具 優點 局限
ninjatracing 展示 Ninja 的邊界與平行性 忽略包裝腳本與子流程樹
Cargo 時間 提供詳細的 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 編譯速度變慢的主要因素是 Full‑LTO 連結步驟,該步驟獨自執行了約 16 分鐘。
  • 切換至 ThinLTO 雖減少連結時間,但仍留下巨大差距,因為預先建構的 WebKit 歸檔檔仍為 Full‑LTO。
  • 以 ThinLTO 重新建構 WebKit 後,總編譯時間降至 15 分鐘,但 Rust 編譯仍更快,原因是 超過 90 個套件的大量平行處理
  • buildprof 在揭露隱藏瓶頸、驗證假設與引導具體優化方面極為重要。

如果你遇到編譯速度慢的問題,不妨試試 buildprofbuildprof -- <your‑build‑command>,並於 https://buildprof.lalitm.com/ 探索時間軸。

Sources

相關

  • 專案
  • Dispatch
  • Dispatch
  • Dispatch
  • 專案