vatt'ghern jaskier's ballads

Roc 編譯器原本用 Rust 寫了三十幾萬行,因為一個叫 lambda set resolution 的架構問題卡到必須大改,團隊乾脆整個重寫成 Zig——487 天後功能追平,incremental build 從 10 秒變成理論上的 35 毫秒,記憶體損毀 bug 從 21 起降到 10 起,但編譯期少了 borrow checker 幫忙擋著。

Roc 用 487 天把 Rust 編譯器改寫成 Zig

Roc 是一個函式語言,編譯器原本整個用 Rust 寫。這篇是作者 Richard Feldman 事後寫的重寫回顧——為什麼放棄漸進式修補、改成整個搬到 Zig,487 天怎麼過的,換語言之後 build 時間與記憶體安全的數字實際上變了多少,以及拿到與弄丟的東西分別是什麼。沒有行銷式的「Zig 比 Rust 好」,作者自己在文章裡反覆強調這是專案特定的判斷,不是通用結論。

lambda set 卡住的地方

lambda set resolution 是 Roc 編譯器裡處理多型 closure 在編譯期該具體變成什麼實作的機制。作者的說法是:「這套機制對 Roc 的執行期效能幫助很大,但要正確做出來也異常困難;我們在原本的實作裡卡在一堆棘手的 bug 上,直到 Ayaz Hafiz 用 OCaml 做出一個新架構的原型,才終於在新編譯器裡把它做對。」問題不是某個函式寫錯,而是更深層的:「Ayaz 的原型顯示問題的根源橫跨好幾個編譯階段,是架構性的,要修好就得重寫大半個編譯器。」

這個結論撞上了另一個現實。團隊當時已經有多名貢獻者各自打算重寫編譯器的不同部分——理由跟 lambda set 無關,純粹是各自模組都到了該換的時候。作者寫道:「這是我們決定重寫的原因之一——加上好幾位貢獻者各自提到打算為了別的理由重寫編譯器的不同部分。我們意識到反正就要重寫幾乎整個編譯器了,與其繼續 Ship of Theseus 式的漸進替換,不如把整個重寫當成一個可以認真考慮的選項。」Ship of Theseus 在這裡的意思是:一塊一塊換掉零件,理論上系統從未停止運作,但換到最後也不知道還剩多少原本的東西。當「反正大部分都要重寫」已經是既定事實,選擇語言本身就變成了一個獨立的問題——而不是「要不要重寫」的問題。

作者沒有解釋為什麼原型選在 OCaml 而不是直接在 Rust codebase 裡繼續除錯,但從工程實務推測,當既有程式碼的抽象層本身可能就是問題根源時,換一個更精簡的語言環境重新驗證核心邏輯,往往比在原本的複雜度裡繼續除錯更快找到答案——這也可以合理解釋,為什麼原型驗證出結果之後,團隊願意接受「大半個編譯器要重寫」這個結論,而不是懷疑原型本身有問題。

從系統工程的角度看,Ship of Theseus 式的漸進替換有一個容易被忽略的成本:每次只換一個零件,代表舊零件與新零件必須維持相容的介面,讀寫雙方的資料結構都得同時支援兩種假設。當這種相容層本身就佔掉可觀的工程量,一次換完反而比分批換省下更多重複工作——這也是為什麼作者把「反正大部分都要重寫」當成觸發整個重寫的關鍵條件,而不是單純的直覺判斷。

選 Zig 而不是繼續留在 Rust,牽涉到的不只是語法偏好。Roc 編譯器的架構把不同編譯階段的資料分別放在獨立的 arena 裡管理,這種用法天生就需要對 allocator 有更細的控制權——後面拿到與弄丟的東西一節會再展開。這裡先記住一件事:重寫的起點不是「Zig 比較潮」,而是「反正要動大手術,不如順便換掉開刀的工具」。

這個判斷背後有一個更一般的模式,值得從業者記住:當需要重寫的範圍已經大到跨越多個模組、而且已經有多名工程師各自準備動手時,繼續用局部修補的方式,其實是在把開發資源投進一個沒有人再打算長期維護的舊架構裡。合理的推測是,這正是作者反覆強調「語言選擇」和「要不要重寫」是兩個獨立問題的原因——後者一旦確定,前者才有機會被誠實地重新評估,而不是被「已經寫了三十幾萬行 Rust」這種沉沒成本綁架。

487 天,怎麼搬

重寫從決定的那一刻算起,到功能對等總共花了 487 天。作者自己拿這個數字跟另一個方向的重寫做對照:「說到時間:我們 487 天的重寫,比 Bun 從約 50 萬行 Zig 重寫成 Rust 多花了 476 天。」兩邊都是把一個編譯器 / 執行環境從一種系統語言搬到另一種,Bun 11 天就搬完,Roc 花了超過一年半。作者沒有替這個差距辯護,只是把數字放在那裡——下面這個時間軸可以拖動看看,如果把 Bun 的 11 天當作參照尺,Roc 的 487 天走到每一天大概是什麼感覺。

拖動時間軸看每一天的進度感——487 天中的任一天

day 0 · 重寫開始 day 11 · Bun 反向重寫已完工 day 487 · 功能對等 day 0
day 0——lambda set 的架構問題已經找到根因,團隊決定不再繼續 Ship of Theseus 式的局部替換,正式啟動整個重寫。

數字本身很簡單,但拆開來看,這 487 天不是線性進度。lambda set 的重新架構、序列化格式的設計、allocator 傳遞規則的重寫——這些都是彼此依賴的:先把資料結構改成不含指標的陣列+32-bit index,才有可能談後面的零反序列化;allocator 的傳遞方式定案,才能決定每個編譯階段要不要獨立 arena。這也是為什麼「重寫」跟「修一修就好」在這個案例裡幾乎是同一個工作量——按作者的判斷,反正大部分都要動,語言選擇變成了獨立於「要不要重寫」的另一個問題。

把 487 天單獨拿出來看容易產生誤判,好像天數越少就代表決策越好。但重寫牽涉的幾個環節彼此鎖死順序,任何一個環節提早或延後都會讓後面的工作重做一次。這也是為什麼作者沒有把 487 天拆成進度百分比來報告,只給了起點與終點兩個錨點——中間的天數對讀者來說,意義只在於「跟 Bun 的 11 天相比,這件事顯然不輕鬆」,而不是精確到某一天完成了哪個子任務。

數字對得起這趟重寫嗎——build 時間與記憶體損毀 bug

先看 build 時間,這是作者自己整理的一張表:Rust 1.85.0(重寫起點當時的版本)在 35.4 萬行程式碼上,完整 build 要 32.4 秒,incremental build 要 10.0 秒;同一份規模的程式碼、換到現在的 Rust 1.97.0,完整 build 降到 25.4 秒,incremental 降到 3.4 秒——這是 18 個月裡 Rust 工具鏈自己進步的幅度,作者形容比預期的還要顯著。功能對等當下的 Zig 版(0.16.0),32 萬行程式碼,完整 build 39.6 秒、incremental 8.6 秒——單看這個數字,Zig 版並沒有比同期的 Rust 快。

真正的差距在下一個版本。作者寫道:「zig build --watch -fincremental 可以把我們目前約 45 萬行 Zig 程式碼的一次改動,在大約 35 毫秒內重新 build 完。」跟 Rust 1.97.0 的 3.4 秒相比,「不只是快了 3.4 秒的 1/100,而是完全不同的效能量級。」但這裡有一個重要的但書:這個 35 毫秒是在 Zig 0.17.0 上量到的,而 Roc 目前留在 stable 的 0.16.0——-fincremental 在那個版本上還有 bug,團隊選擇先關掉它。作者自己承認:「選 Zig 做這次重寫,原本預期的 build 時間紅利,我們還沒真正拿到。」35 毫秒是可以看見的方向,不是已經在生產環境兌現的數字。

incremental build 時間對讀者的意義,不在於單一次省下幾秒,而在於它是每天被觸發成百上千次的動作——每次卡住的幾秒,都會打斷正在腦中組裝的那段程式邏輯。10.0 秒到 3.4 秒是 Rust 工具鏈這 18 個月的真實進步,這件事本身也值得記住:語言選型不是一次性判斷,被選中的語言後續會不會持續進化,跟當初選型時的判斷同樣重要。

讀這類跨版本的 build 時間表時,容易掉進的陷阱是只看終點的兩個數字、忽略中間的進步曲線本身也是資訊。Rust 從 10.0 秒進步到 3.4 秒發生在同一段時間視窗裡,跟 Zig 從功能對等的 8.6 秒到理論上的 35 毫秒不是同一種進步——前者是既有工具鏈的持續優化,後者是版本升級才解鎖的量級跳躍,兩種進步對讀者評估該不該換語言的意義並不相同。

incremental build 時間,對數座標(跨三個數量級)。35 毫秒那一根是 Zig 0.17.0 的量測值,尚未在 Roc 目前使用的 stable 0.16.0 上啟用,畫成淺色以區別。

記憶體安全的數字放在同一份回顧裡。作者統計了整段開發期間所有跟記憶體相關的 bug:Rust 版編譯器出現記憶體損毀的次數是 21 起,沒有出現損毀的是 2,575 起;Zig 版分別是 10 起與 421 起。乍看 Zig 版的損毀次數比較少,但作者自己先戳破這個直覺——「18 個月的開發、數百份 bug report、數十萬行程式碼之後,我從這張表回顧出的主要心得是:換成任何一種都不會對這個專案有明顯差別。」重點不在絕對次數,而在次數背後的性質。

Zig 版 10 起記憶體損毀裡,「8 起同樣是 miscompilation,剩下 2 起才是編譯器本身的問題。」這 2 起「都是錯誤報告裡的 use-after-free,症狀一樣:錯誤訊息裡的檔名會被印成一堆沒有意義的問號菱形字元。」作者接著補一句:「Rust 的 borrow checker 兩起都會抓到。」換句話說,Rust 版 21 起損毀全部不是 unsafe 程式碼造成——它們驗證的是 borrow checker 本身沒有漏洞;Zig 版真正暴露出「少了 borrow checker」代價的,只有那 2 起,而且都發生在錯誤訊息這種相對邊緣的路徑上,不是編譯器的核心邏輯。

編譯器 損毀發生 未發生 損毀性質
Rust 21 2,575 全部是 miscompilation,沒有一起源自編譯器自身的 unsafe 程式碼
Zig 10 421 8 起是 miscompilation;2 起是編譯器本身的 use-after-free(都在錯誤訊息的檔名處理),Rust 的 borrow checker 兩起都會攔下
兩個編譯器版本各自追蹤到的記憶體相關 bug 統計,取自作者自己維護的重寫回顧表。

這張表對想引用「某語言更安全」當論點的讀者是一個提醒。損毀次數的絕對值受樣本數、程式碼規模、審查嚴謹度太多變因影響,真正該追問的是損毀的性質——是核心邏輯的漏洞,還是像這次一樣,落在錯誤訊息渲染這種邊緣路徑上的疏漏。把這兩種問題混在同一個數字裡比較,得到的結論通常經不起深究。

拿到的與弄丟的

換語言不是單方向的交易。作者列的得失清單裡,最直接的一條是 allocator 的掌控權。他寫道:「Zig 的生態系一貫地把 allocator 傳來傳去,這正是我們要的;而現成的 Rust crate 幾乎都預設用單一的全域 allocator。」對 Roc 編譯器的架構來說,這不是抽象偏好——編譯器把不同階段的資料放進各自獨立的 arena,需要的正是「每個呼叫點都可以指定用哪個 allocator」這種語言層級的預設習慣,而不是事後在 Rust 裡想辦法繞過全域 allocator 的假設。

allocator 傳遞的慣例差異,實務上會反映在一個具體的檢查點:換語言之前,先看看目前依賴的函式庫裡,有多少個介面預設假設記憶體由誰配置這件事是不需要傳的。如果答案是幾乎全部,換到一個把 allocator 當成第一等公民的生態,代表原本要繞著全域假設寫的 workaround 可以整批刪掉;如果答案是本來就很少,這條理由對評估者來說就沒那麼有分量。

第二條是快取與反序列化。Roc 的編譯器用不含指標的陣列加 32-bit index 儲存內部資料結構,可以直接整塊序列化到磁碟。重新載入時,作者的說法是:「我們把這些位元組讀進記憶體、對既有資料結構做幾次 relocation 指過去,就緒了」——效果是「反序列化的速度等於把位元組從硬碟載入記憶體的速度,換句話說,其實是被 I/O 限制住的。」這件事在 Rust 裡也不是做不到,但 Zig 對記憶體佈局的直接控制、加上 allocator 傳遞的習慣,讓這條路徑寫起來自然得多。

意外的收穫是重用了 Zig 自己編譯器的一段程式碼。要做前面提到的快取策略,還需要一個跟 LLVM C++ 函式庫解耦、不受 LLVM 版本破壞性變更影響的 LLVM bitcode 序列化器。作者說:「我只知道野外有一個這樣的實作:Zig 的編譯器,而它當然是用 Zig 寫的。現在野外有兩個實作了,因為 Roc 的新編譯器重用了同一份 Zig 程式碼。」這是一種只有「兩邊都用同一種語言」才拿得到的紅利——換成 Rust 版就得自己重寫一份 LLVM bitcode 序列化器,或是繼續承受 LLVM API 每次升級帶來的破壞性變更。

弄丟的東西同樣具體。作者自己承認:「我確實想念 unsafe 和 borrow checker 的某些面向,儘管它們的好處也伴隨著我不想念的壞處。」少了 borrow checker,意味著每一處索引操作都得自己審視是否安全,而不是靠編譯器在編譯期擋下來。第二條是 private struct 欄位:「我也想念 private struct 欄位。我理解不設計這個機制的理由,但我還是想念『用了一個標記為不該被這樣直接存取的東西』就跳出編譯錯誤的感覺。」Zig 沒有可見性修飾詞,這意味著封裝邊界只能靠命名慣例與團隊紀律維持,不是編譯器強制的。

第三條是向後相容。作者寫得很平靜:「向後相容在 Zig 目前這個開發階段不是目標,這是我們一開始就知道、也預期到的事。」升級 Rust 的 minor 版本幾乎不需要花力氣;升級 Zig 的版本則要有心理準備吃下破壞性變更——這也是為什麼團隊到現在還留在 stable 0.16.0,沒有立刻跳去有 35 毫秒 incremental build 的 0.17.0。

拖動分隔線比對留下的與換到的能力 · 各 3 項

舊(Rust)留下的 borrow checker 編譯期擋下記憶體錯誤 private 欄位 封裝邊界由編譯器強制 向後相容 升級 minor 版本幾乎免費 新(Zig)換到的 granular allocator 每個 arena 各自控管 zero-parse 快取 反序列化速度≈I/O 速度 LLVM bitcode 重用 借用 Zig 編譯器自己的序列化器

把兩邊擺在一起看,作者自己的總評不是「Zig 贏了」,而是回到那句已經引過的話:換一條路線,這個專案的結局大概不會有明顯差別。他接著補了一個更寬的框架:「每個專案的需求都不一樣!」同一篇文章裡提到 Bun 往反方向重寫——從 Zig 換成 Rust——用的是完全相反的判斷,同樣沒有被當成錯誤決定。語言選擇跟着的是專案自己的 allocator 模式、生態系依賴、以及團隊願意吃下多少破壞性變更,不是哪種語言在抽象意義上「更安全」。

這不代表作者對結果沒有意見。他寫的最後一句是:「整體來說我在 Zig 上的體驗一直很正面,回頭看我很高興這次重寫選了 Zig。」這句話跟前面「換哪條路線都沒差」的結論並不矛盾——後者是說「風險層面沒有本質差異」,前者是說「在這些條件下,這個選擇讓團隊拿到了自己真正在意的東西」:allocator 的直接控制、可以整塊序列化的資料結構、以及一條長期可能把 build 時間壓到毫秒級的路。

如果只能記住一個決策依據,作者自己給的其實是最省力的一種:把記憶體安全 bug 的性質表攤開,看清楚有多少起真正命中了自己在意的那個攻擊面,而不是被損毀次數的絕對值嚇到或安慰到。這個習慣同樣適用於任何語言遷移的評估,不只是 Rust 與 Zig 之間。

對照著讀,這篇重寫回顧提供的不是「選 Zig」或「選 Rust」的答案,而是一套可以複用的核算方法:把 build 時間、記憶體安全 bug 數、生態系慣例、向後相容承諾,全部攤開列成同一張表,逐項核對哪些是自己專案真正在意的,哪些只是聽起來安心而已。這套方法比任何單一語言的結論都更值得帶走。

值不值得:如果你的專案跟 Roc 一樣,本來就準備動大手術、對記憶體佈局有精細要求、也能接受一個還在破壞性變更的生態,這筆帳划算;如果你只是想要「更安全」,作者自己的資料反而說明,Rust 的 21 起與 Zig 的 10 起,差的不是安全,是誰把哪一種錯誤攔在哪一層。