作者在自己機器上重現了那個常被引用的數字——Bun 從 Zig 換成 Rust,Linux 上的建置時間從 24 分 24 秒掉到 5 分 40 秒。追下去才發現,三分之二的落差其實出在一支連結器身上。
buildprof 起底 Bun 建置五倍速
推特上流傳一個數字:Bun 把建置系統從 Zig 換成 Rust 之後,Linux 上的建置時間從三十分鐘出頭掉到不到六分鐘,快了五倍多。這種「換語言就是換效能」的敘事很好轉發,問題是建置時間牽涉的環節太多,語言只是其中一個變數。Lalit Maganti 寫了一支叫 buildprof 的工具,把整棵建置的 process tree 攤成時間軸,親自把這個數字拆開來看——一開始他也以為答案會落在「Rust 編譯比 Zig 快」這種簡單結論上,拆到最後才發現完全不是那回事。
這類發布會等級的效能數字特別容易被拿去佐證各種立場——支持 Rust 的人拿去說語言優越,質疑 Zig 的人拿去說生態不成熟。buildprof 這篇文章有意思的地方,不是它站哪一邊,而是它示範了怎麼把一句「快了五倍」拆成可以逐項覆核的證據鏈:哪個 process、哪個 pass、耗了多久,每一步都能重新量測。
連結器吃掉了三分之二的建置時間
要看懂 buildprof 怎麼查案,得先知道它怎麼記錄一次建置。它用 ptrace——除錯器在用的那個 Linux 介面——把建置指令整個接管,子行程一路 fork、exec、結束都逃不掉;檔案讀寫則交給 seccomp filter 只攔它要的那幾個 syscall,不必像 eBPF 那樣拿 CAP_BPF、CAP_PERFMON 這種權限。整棵 process tree 因此能攤成一條時間軸:時間往右走,方塊寬度是耗時,誰生出誰就疊在下面,不管建置系統是 Cargo、Ninja、Zig 還是 Make 都通吃。遇到 Clang,它還會順手打開 -ftime-trace,把編譯器自己回報的內部耗時也塞進同一條時間軸,LLD 和 nightly Rust 的 compiler trace 一樣能吃。整個 UI 是拿 Perfetto 改的,查詢、時間軸渲染這些苦工都交給 Perfetto,buildprof 自己只管「怎麼讓這些資訊對建置有用」。這一點也不是隨便選的:Perfetto 的 UI 本來主要是給 Android 或 Chrome 的 trace 用的,查詢語法、時間軸縮放、多執行緒怎麼擺在同一條軸上這些介面問題早就被磨過一輪。buildprof 站在這個基礎上,不用重新發明時間軸怎麼畫、怎麼滑動這些工程細節,可以把力氣花在「怎麼從一次建置裡榨出有用的訊號」這件事上。
傳統效能分析工具大多盯著單一 process 內部——哪一行程式、哪一個函式吃掉最多 CPU。建置這種工作卻是幾百上千個短命 process 接力跑完,單一 process 內部再怎麼優化,也回答不了「整個建置為什麼慢」這種問題。buildprof 選擇從 process tree 的角度切入,等於是把觀察的單位從「一行程式碼」換成「一個 process 花了多久、被誰卡住」,這正是它能一路把問題從「Bun 建置慢」拆到「某個連結器 pass 慢」的原因。
ftrace 是另一個候選,但作者嫌它要同時盯著好幾個獨立的 tracing instance,操作成本比 ptrace 加 seccomp 這條路線高出一截。三個方案擺在一起比,buildprof 最後選的組合,說到底是拿「這台機器上就能跑,不用額外權限」換「追蹤精細度夠不夠」,而不是技術上唯一正確的答案。這種先求不干擾建置、再談精細度的設計順序,其實是效能工具的共通取捨:錄製開銷本身如果大到改變被量測系統的行為,量出來的數字就沒有意義。作者自己測過,對 ripgrep 這種檔案存取不算頻繁的專案,開著 buildprof 錄製幾乎不影響建置時間;但換成 Redis,因為程式裡開檔案的次數多很多,錄製本身就多花了大約五秒——從 26.78 秒拉到 31.89 秒。buildprof 選擇犧牲一部分細節換取低開銷,代價是像 OptModule 內部更細的子步驟,它未必拆得出來——這篇文章能停在「OptModule 十分鐘」這個細緻程度,某種程度上也是工具本身的極限。
作者拿這支工具去查一個他自己在推特上看到的說法:Bun 的作者說 1.3.14 版 Linux 建置中位數要三十分零六秒,換成用 Rust 重寫的 1.4.0 版只要五分三十七秒。他在自己機器上重跑一次,數字不完全一樣——Zig 版是 24 分 24 秒,Rust 版是 5 分 40 秒,落差四倍多;他自己只說這組數字跟推特那組在同一個量級,沒有再往下斷言什麼。把這次 Zig 版建置攤開來看,答案幾乎立刻寫在時間軸上:最後那支 ld.lld 連結器自己跑了超過十六分半,占掉整個建置時間三分之二。
先說在前面:這整篇文章的量測都來自同一個人、同一台機器、同一支自己寫的工具,不是跨團隊的獨立驗證。這不代表數字不可信——恰好相反,正因為連結器、pass、時間都攤在同一條時間軸上可以互相對照,任何一步算錯都容易被抓出來;但讀者要記得,這裡談的是「這台機器上這次建置」發生了什麼,不是「所有 Linux 機器上 Bun 建置」的普遍規律。
三根柱子擺在一起看,有一件事很清楚:前段「編譯階段」的高度三次都差不多,落在七分半上下;真正在動的一直是柱子下半段的連結時間。這也是為什麼故事不能只停在「連結器吃掉三分之二時間」——接下來要問的是,這段連結時間到底在做什麼,能不能被壓下去。
連結器內部的 compiler trace 講得更精確:這不是單純把已經編譯好的檔案兜起來,而是在跑實打實的編譯 pass。裡面一個叫 OptModulebuildprof 從連結器內部的 compiler trace 讀出來的一個 pass 名稱——原文說它「單獨就吃掉十分鐘出頭,負責產生實際機器碼」。 的 pass,單獨就吃掉十分鐘出頭,負責產生實際機器碼——十六分半扣掉這一段,其餘不到七分鐘原文沒有再往下拆。連結器不是在等,是在算。
能看到 OptModule 這麼細的粒度,其實要歸功於 buildprof 願意直接吃編譯器跟連結器自己吐出來的內部 trace,而不是只靠外部量測行程的起訖時間。如果只看「ld.lld 這個 process 跑了多久」,答案永遠只有一個數字;把 -ftime-trace 這類編譯器內部紀錄疊上去,才能追問「這個數字裡面,哪一段在做什麼」——這也是這篇調查能一路拆到 crate 平行度的原因。
拖曳圓點沿時間軸移動 · 16 分 35 秒的連結過程
知道 OptModule 吃掉十分鐘,只是找到了戰犯,還沒找到病因。下一個問題是:為什麼這個 pass 會這麼貴——是這個 pass 本身效率不好,還是餵給它的輸入結構有問題?
換成 ThinLTO,只省下三分之一
OptModule 這種級別的最佳化 pass,通常來自 LTO——連結時期最佳化,把好幾個編譯單元湊在一起再最佳化一次,才能拿到跨檔案內聯之類的好處。Zig 版建置用的是 Full LTO,Rust 版用的是 ThinLTO。作者自己的說法是:「Full LTO 把這些編譯單元兜成一個超大的最佳化工作,ThinLTO 則保留比較多的切分,讓大部分工作可以平行跑。」聽起來就是這裡的差——把 Bun 自己的 LTO 設定從 Full 換成 Thin,理論上應該能讓那段十六分半大幅縮短。
實際把 Bun 換成 ThinLTO 之後,總建置時間從 24 分 24 秒降到 20 分 20 秒,連結器那段也從十六分半降到 12 分 55 秒。省是省了,但連結那段只省了三分四十秒——連原本落差的三分之一都不到,離「LTO 設定差異解釋整個五倍速」還遠得很。
這個結果本身就值得記一筆:同一支程式,只調連結器的最佳化策略,連結時間就能差到三分四十秒——這代表 LTO 設定不是那種「調了也差不多」的旋鈕,它直接決定連結階段能不能吃滿多核心。但整個建置也只縮短了大約四分鐘,離「五倍速」的敘事還差得遠,說明推特上那句話背後,至少還有另一個變數在起作用。
ThinLTO 能平行,代價是最佳化的視野比 Full LTO 窄一點——每個編譯單元看到的「全局資訊」是摘要而不是完整內容,某些需要看到整個程式才能做的最佳化,Full LTO 理論上抓得比較準。工程團隊會選 Full LTO,往往就是在拿連結時間換一點點執行期效能;buildprof 這篇文章提醒的是,這筆交換的價碼,遠比想像中昂貴。這也帶出一個容易被忽略的角度:這次 Rust 版建置之所以走 ThinLTO,看的是連結指令上掛著的 -plugin-opt=thinlto,跟 Rust 的語法沒有關係;合理的推測是,這類選擇多半來自工具鏈與建置腳本的預設值,而不是語言本身的屬性。同一顆 LLVM 後端,C++ 專案一樣可以選 ThinLTO,Rust 專案也一樣可以手動切回 Full LTO——把 LTO 策略當成語言的一部分來討論,本身就已經搞錯了層次。
點擊按鈕切換兩種連結模式 · 示意圖,非實測曲線
如果只換 Bun 自己的 LTO 設定,理論上連結階段應該從「退化成序列」變成「還能分工」,省下的時間不該只有三分之一。缺口一定還在別的地方——而且多半不在 Bun 自己身上,而在跟它一起連進去的東西上。
還在用 Full LTO 的是 WebKit,不是 Bun
缺口的第一塊拼圖是 WebKit。作者發現,建置系統裡「占了很大一部分的 WebKit,仍然是用 Full LTO 編的」——下載回來直接連進去的那批預編函式庫,裡面裝的還是 Full LTO 的輸入。就算 Bun 自己的程式碼都改成了 ThinLTO,連結器手上還是有一大包 Full LTO 編出來的 WebKit 物件——這批東西沒辦法走 ThinLTO 的平行路徑,連結器只能把它們當成一個巨大的模組整個最佳化一遍。換句話說,前一步只做了一半:改的是 Bun,沒改到 Bun 依賴的東西。
作者把 WebKit 和 ICU 也用 ThinLTO 重新編過,再跑一次同樣的建置。總時間從 20 分 20 秒繼續掉到 15 分 11 秒,連結器那段從 12 分 55 秒掉到 7 分 22 秒——這一步比單獨換 Bun 的 LTO 設定有效得多。
這也是為什麼作者這次連 ICU 都一起重建:靜態連結進同一個執行檔的每一個函式庫,理論上都得用同一套 LTO 策略,才能真正享受到 ThinLTO 的平行度——漏掉任何一個,連結器就得為了那一個例外退回比較慢的路徑,效果會打折扣。這一段其實是很多專案都會踩到的通用陷阱:把效能優化的範圍畫在自己的程式碼上,卻忘了打包進來的第三方函式庫——WebKit、ICU 這種體積龐大的依賴,一樣要跟著調整編譯設定,否則優化只做了一半,連結器還是得為了相容那批舊設定的物件檔,退回最保守、最慢的路徑。
當然,把 WebKit 這種規模的第三方專案重新編過,現實裡不是打一個 flag 就結束——牽涉到要不要一起維護一份客製化的建置設定、往後 WebKit 升級版本時這份客製化要不要跟著改。作者選擇動手驗證,某種程度上也是在示範:要拿到這最後一段效能,得先有人願意承擔維護這份設定的長期成本。如果沒有 buildprof 把連結器內部的耗時攤開來看,這個問題多半會被歸類成「連結器就是慢」,沒有人會想到答案藏在一個沒被同步升級的依賴身上。
拖曳分隔線比較兩種設定 · WebKit 有沒有一起重建
兩次實驗擺在一起看,連結時間的降幅集中在「把 WebKit 也換成 ThinLTO」這一步,而不是「把 Bun 換成 ThinLTO」那一步——真正卡住平行化的,是那顆沒換掉的依賴。只是把兩次修正都算進去,24 分 24 秒也才降到 15 分 11 秒,跟 Rust 版實測的 5 分 40 秒之間還有一大段路。
剩下的十分鐘,是九十幾個 crate 能不能平行編譯
把 LTO 設定和 WebKit 依賴都修正之後,Zig 版建置從 24 分 24 秒降到 15 分 11 秒——確實明顯,但離 Rust 版實測的 5 分 40 秒還差將近十分鐘。這段缺口,作者歸給了建置系統本身的結構:「Rust 把編譯工作攤在九十幾個 crate 上,Zig 版建置卻把所有東西塞進單一個 module。」單一模組沒辦法分片平行編譯,也沒辦法用 ThinLTO 那種切分方式處理連結——不管怎麼調 LTO 設定,天花板都卡在那裡,跟語言本身能不能快沒有直接關係。
這一點對寫 Rust 或維護大型 C++、Zig 專案的工程師特別實際:模組邊界怎麼切,直接決定編譯系統能不能用滿機器上的核心。一個單體模組哪怕邏輯上分得再清楚,對編譯器跟連結器來說還是一塊,平行度就卡在那裡;反過來,一個被拆成上百個獨立單元的專案,即使語言本身編譯速度普通,也能靠平行把總時間壓下來。
要說清楚,九十幾個 crate 能不能真正撐起那將近十分鐘的落差,作者沒有另外拆一次時間軸去驗證——這段是把兩個已知數字(15 分 11 秒跟 5 分 40 秒)相減之後,套上「Rust 那篇提過的 crate 數字」做出的合理推測,不是像前面兩個假說那樣有實驗前後對照。這個區分很重要:前兩段每一個數字都能在時間軸上指出來,最後這段是邏輯推論,信心程度自然要打折扣。
這種拆解方式,也替「語言遷移該不該做」這類決策提供了一個更誠實的評估框架:與其比較兩個語言寫出來的程式跑多快,不如先確認兩邊的建置設定、依賴版本、平行策略是不是站在同一個起跑點上。省下來的時間有多少比例是語言帶來的,剩下的又是工程配置帶來的,分開算,才不會把配置紅利誤記成語言優勢。對日常寫程式的工程師來說,這篇文章真正有用的不是「Bun 到底快在哪」這個八卦,而是拆解的方法本身。下次團隊內部有人拿著一張效能圖表說「我們把 X 換成 Y,快了 N 倍」,可以照著同樣的順序問三個問題:這個數字是在哪一台機器、哪一次執行量出來的?時間都花在哪個步驟上?換掉的東西,是不是真的只換了一個變數,還是連帶動了好幾個設定?
點擊任一假說看解釋 · 3 個假說
三個假說,誰解釋了多少落差
把 Bun 自己的 LTO 從 Full 換成 Thin。省是省了,但不到預期的三分之一,代表問題不只在 Bun 自己的 LTO 設定上。
WebKit 那塊一直是 Full LTO,連結器被迫把它當一個巨大模組整個最佳化。把 WebKit、ICU 也重建成 ThinLTO 後,效果比只改 Bun 明顯得多。
兩個 LTO 相關因素都修正後仍有落差,作者把它歸給結構:Rust 把工作攤在九十幾個 crate 上能平行編譯,Zig 版是單一 module,天生沒辦法。這一段是作者的歸因推斷,不是直接量測出的單一數字。
三個假說擺在一起看,誰解釋了多少落差就比較清楚了:LTO 設定差異解釋了大約四分鐘,WebKit 依賴解釋了再多五分鐘出頭,兩個加起來,能把 Zig 版建置從 24 分 24 秒推到 15 分 11 秒。剩下那將近十分鐘,合理的推測是結構性的——crate 數量帶來的平行度,而不是 Rust 這個語言本身在編譯速度上比 Zig 快。換句話說,「換語言五倍速」這句話裡,語言只占了一小塊,真正的重量在建置系統能不能把工作攤開來跑。
下次遇到:看到「換語言之後建置快了 N 倍」這種說法,先去看建置系統本身能不能平行——crate 數量、模組邊界、LTO 設定,往往比語言本身更能解釋落差;真要下結論,先把耗時攤成時間軸再說,而且要分清楚哪些數字是實測、哪些是推算出來的,兩者的信心不該混在一起。