Zig 的增量重編譯落在 37 到 70 毫秒之間,但同一次追蹤裡真正做語意分析與 codegen 只花了 1.6 毫秒——大頭反而是 flush 階段那趟把整張參照圖走完的 30 毫秒。
Zig 增量編譯的內部機制
Zig 的編譯器作者 mlugg 在部落格拆解了增量編譯怎麼把「改一行、重編一次」的成本從秒級壓到毫秒級,關鍵不是把某一步加速,而是把整條管線切成可以獨立追蹤、獨立失效的小單位,再把「確認什麼還活著」這件事跟「重新分析什麼」分開處理。以下五節照著這個順序拆:管線本身怎麼分五段、語意分析怎麼切出四種可以獨立失效的單位、依賴集合跟原始碼雜湊怎麼決定誰要重算、ZIR 快取與 link 階段的位元組級 patch 各自靠什麼機制省成本、以及為什麼即使前面全部做對,flush 這一步的花費還是甩不掉。
管線:從原始碼到機器碼之間的五段落差
Zig compiler 的起手式很直白:「Read in a source file from disk. Parse that file into an AST. Convert that AST into a format named 'ZIR'.」ZIR 是未型別化、SSA 形式的中介表示,不做型別檢查也不做 comptime 求值,純粹是原始碼的結構化版本。真正做語意工作的是下一段——語意分析直接解讀 ZIR,做型別檢查、跑 comptime,並且針對執行期函式的本體「building another intermediate representation (Analyzed Intermediate Representation, or AIR for short)」。codegen 再把 AIR 轉成「MIR (Machine Intermediate Representation)」,最後由 link 階段把 MIR 寫進輸出檔案的機器碼。五段落差各自解決自己那段的問題:AST 只管語法合不合法,ZIR 只管結構有沒有轉出來,AIR 只管型別跟 comptime 有沒有算完,MIR 只管指令選出來了沒有,link 只管位元組有沒有正確落地。下面這張圖把五個階段攤開,點任一段可以看到它的責任邊界,以及它刻意「不知道」的事。
這也解釋了為什麼把 AST 轉成 ZIR 這一步要單獨算一段:剖析器跟 ZIR 產生器都只看單一檔案,不需要知道專案裡其他檔案發生了什麼事,天然就是各檔案互不干擾的一步。等到語意分析開始解讀 ZIR,才第一次需要跨檔案的資訊——一個宣告的型別可能定義在另一個檔案,comptime 求值也可能呼叫到其他模組的函式。跨檔案的複雜度被刻意留到語意分析這一段才處理,前面兩段(AST、ZIR)保持單純,也更容易被個別快取或重用。
點任一段看責任邊界 · 5 個階段
click a stage above
AST · 責任邊界
把原始碼剖析成語法樹,只驗證 token 序列合不合乎 Zig 的文法。
不知道的事:型別、語意、任何跨宣告的依賴關係。
ZIR · 責任邊界
未型別化的 SSA 形式,逐檔案獨立產生,可以直接以一次 writev/readv 寫入或讀回磁碟快取,並帶著原始碼「有意義」區域的內容雜湊。
不知道的事:型別是否正確、comptime 的結果、哪些宣告最終真的會被使用到。
AIR · 責任邊界
語意分析解讀 ZIR、做型別檢查與 comptime 求值後,針對執行期函式本體產出的中介表示;同時就是在這一段,四種分析單元各自的依賴集合被建立起來。
不知道的事:最終要跑在哪種 CPU 上、輸出檔案裡的位元組要怎麼擺。
MIR · 責任邊界
codegen 把 AIR 轉成 MIR,這一步決定實際要用哪些指令。
不知道的事:輸出檔案裡的絕對位址、其他函式的重定位怎麼解。
link · 責任邊界
把 MIR 寫進輸出檔案的機器碼,透過 MappedFile 處理位元組級的原地修補與重定位,flush 階段還要重新走一遍 reference-graph 確認可達性。
不知道的事:語意分析階段做了什麼決定——它只認得最終要落進檔案的位元組。
「容器層級」這個詞在下一節會反覆出現,指的是寫在 struct、union、enum 或檔案頂層的宣告——跟寫在函式內部的區域變數是不同層級。這個區分很重要:只有容器層級的型別、值、佈局才會被拆成獨立的分析單元跟依賴集合;函式內部的區域計算,是被函式本體這個單元整個包起來的,不會再往下細分。
四種分析單元:把重新編譯這件事切成顆粒
語意分析不是把整個檔案當一個黑盒子重跑,而是拆成四種各自獨立追蹤的「分析單元」。mlugg 列出的邊界是:「struct 或 union 型別的 layout(大小、對齊等)、容器層級宣告的型別、容器層級 const 宣告的值、執行期函式的本體」。這四種單位彼此獨立——改一個函式本體不代表要重算它所在 struct 的 layout,改一個 struct 的 layout 也不代表每個引用它的函式本體都要重跑,除非那個函式真的依賴到變動的部分。這個切法把「整個檔案重編」的粒度打散成「哪些具體的東西真的變了」,也是接下來所有失效邏輯要操作的最小單位。下表把四種單元的追蹤內容、依賴邊的方向、跟觸發失效的條件並排列出來——特別是「依賴方向」這一欄,函式本體是唯一一種只進不出的單元,這一點下一節會展開。
把分析單位切成四種而不是一種,是因為它們的失效條件本來就不一樣——佈局只在乎大小跟對齊有沒有變,型別只在乎宣告的型別本身,值只在乎 const 算出來的結果,函式本體只在乎它會產生出什麼指令。如果把這四件事包在同一個單位裡,任何一種變化都會連帶讓其他三種一起被標記為可疑,即使它們根本沒受影響。拆開之後,每種單位只回答一個窄問題,依賴集合也就能記得更精確。
| 分析單元 | 追蹤內容 | 依賴方向 | 失效觸發 |
|---|---|---|---|
| struct / union 佈局 | 型別的大小、對齊 | 有進有出 | 依賴的型別或原始碼雜湊改變 |
| 宣告型別 | 容器層級宣告的型別 | 有進有出 | 依賴的型別/值或原始碼雜湊改變 |
| 宣告 const 值 | 容器層級 const 宣告的值 | 有進有出 | 依賴的值或原始碼雜湊改變 |
| 函式本體 | 執行期函式的程式碼 | 只有出邊 | 依賴的型別/值或原始碼雜湊改變 |
舉例說明這四種單元怎麼互相獨立:假設一個 struct 裡有一個 const 宣告的查找表,跟三個使用它的函式。改動查找表的值,只會讓「這個 const 宣告的值」這個分析單元失效,連帶重算三個函式本體——因為它們都依賴這個值。但如果只是替其中一個函式加一行 log,只有那個函式本體的分析單元失效,查找表的值、struct 的佈局、其他兩個函式完全不受影響。傳統「整個檔案重編」的做法分不出這個差異,因為它的最小單位是檔案,不是宣告。
依賴集合與原始碼雜湊:誰知道自己該重算
語意分析每處理一個單元,就順便記錄它依賴了誰——這些依賴分成兩種:依賴另一個分析單元(例如某個函式本體依賴另一個宣告的型別),或是依賴原始碼裡一段特定區域的內容雜湊。ZIR 本身就保留了這些雜湊:「ZIR contains hashes for specific 'interesting' regions of source code (e.g. the entire source code for each container-level declaration).」下次重新剖析同一份檔案,只要某段區域的雜湊對不上,跟它綁定的分析單元就直接判定失效,失效再沿著依賴集合往外傳播到依賴它的其他單元。這跟「檔案層級」的增量判斷完全不同——一個檔案裡十個宣告,改動落在第三個,只有依賴第三個的那條鏈需要重算,另外九個原封不動。
值得停下來看的是函式本體這一種單元的特殊之處:沒有任何東西可以依賴一個函式的本體。mlugg 講得直接:「Dependencies on the body of a runtime function are impossible」,他自己也標註這是簡化過的說法,但結論成立——函式本體在依賴圖裡只有出邊、沒有入邊。它可以依賴型別、依賴其他宣告的值,但反過來,不會有其他單元因為「這個函式的本體變了」而被拖著重算。這條規則直接把失效沿依賴圖繞回自己的風險擋掉:失效永遠是從「原始碼雜湊變了」出發,往外流向依賴它的單元,流到函式本體就是終點,不會再往回流,圖裡也就不存在循環失效鏈。下面這個 widget 用一張固定的小依賴圖模擬這個傳播過程——拖動滑桿改變失效沿依賴集合傳播的深度,看有多少下游單元被連鎖標記,以及函式本體節點為什麼永遠是傳播鏈的盡頭。
拖滑桿看失效沿依賴圖傳播多深 · 7 個節點
這裡的雜湊比對值得跟一個更粗糙的做法對照:如果編譯器只用「檔案的修改時間變了」判斷要不要重編,那麼一個檔案裡任何一處變動,包括加一個空白或改一個註解,都會讓整個檔案的所有宣告被視為可疑,逼得語意分析全部重跑一遍。ZIR 對「有意義」區域個別做內容雜湊,等於把「這個檔案是否被觸碰過」這種粗粒度的訊號,換成「這段具體的宣告內容有沒有真的變」這種細粒度訊號——空白、註解的變動不會讓雜湊改變,自然也就不會觸發任何失效。
ZIR 的 zero-copy 快取與 MappedFile 的指數成長 patch
ZIR 快取到磁碟這一步刻意設計成不做任何轉換:「ZIR can be trivially written to and read from disk with one writev/readv system call.」用 mlugg 自己的話說,中間「there is no 'serialization' step」。ZIR 在記憶體裡的佈局跟它寫到磁碟上的位元組佈局是同一個形狀,所以快取不需要一個把記憶體結構轉成檔案格式的中間步驟,讀寫各一次系統呼叫就完成——省下的不是 CPU 算力,是「序列化」這個步驟本身。下面這個 widget 把「記憶體裡的 ZIR」跟「磁碟上的 ZIR」畫成同一個位元組區塊圖形,切換兩種標籤時圖形本身完全不動,這正是「一次系統呼叫就夠」的原因。
點按鈕切換記憶體/磁碟呈現 · 形狀不變
link 階段面對的是另一個問題:怎麼在不重寫整個輸出檔案的前提下,把改動的機器碼位元組補進去。mlugg 描述的抽象是 MappedFile——「memory-maps the output file...tracks a tree of 'nodes' in that file」,每個 node 對應輸出檔案裡的一段區域。當某個 node 需要更多空間,直接原地擴充最省事,但原地擴充勢必要跟鄰居搶空間;MappedFile 的做法是「By using exponential growth factors on nodes (similar to how dynamic data structures like ArrayList work), we amortize this cost」——跟 dynamic array 擴充容量的邏輯一樣,每個 node 預留的容量比目前用到的還大一截,容量夠的更新永遠不必搬家,只有真的超出目前配額才觸發一次重新配置與搬遷,下一次的容量又會翻倍。攤提下來,大部分增量更新都能原地補位元組,完全不必動到檔案裡的其他區域。
MappedFile 追蹤的「node 樹」不是攤平的位元組陣列,而是一棵樹——輸出檔案裡巢狀的區段結構對應到樹裡的節點與子節點,一段位元組要搬遷時,牽動的只是它在樹裡對應的那一段,不必驚動整份檔案。這跟前面依賴集合的精神一致:範圍越窄,代價越低。下面這個 widget 讓你直接拖:拖著「目前用掉的位元組」這個把手往右走,一旦超出容量邊界就會觸發一次搬遷,容量翻倍,搬遷次數加一。
拖橙色把手模擬 node 用量成長 · 超出容量觸發搬遷
容量 64 · 已用 20 · 搬遷 0 次換成假設的具體數字感受一下這個指數成長:如果一個 node 目前的容量是 64 bytes、已經用了 60 bytes,這次更新只加 3 bytes,不會超出容量,原地寫完;等到某次更新把用量推過 64,MappedFile 才會把容量翻倍到 128,連帶搬遷這個 node 裡原本的內容。之後再寫多少次,只要沒超過 128,又是一路原地補位元組,直到下一次翻倍發生在 128 那個邊界。翻倍的間距越走越寬,搬遷發生的頻率也就越來越低——這正是「攤提」這個字的意思:少數幾次貴的搬遷,平攤到大量便宜的原地寫入上(這是依機制推算的假設例子,實際容量數字未見於原文)。
flush 階段的 reference-graph 全走訪:為什麼它才是大頭
一份針對 Fizzy(一個用 Zig 寫的 WASM 直譯器)的實測給出具體數字:「The initial build takes around 5 seconds, and then every time I make a change, a rebuild completes in 50–70ms.」mlugg 進一步追蹤了單一一次更新,量到「This particular update took 37ms」,而其中真正花在語意分析加 codegen 上的時間「comes in at around 1.6ms for this update」。1.6 毫秒跟 37 毫秒之間的落差,指向前面幾節講的機制確實有效:分析單元夠小、依賴集合夠精準,真正需要重算的東西非常少。
那剩下的 30 幾毫秒去了哪裡?mlugg 指出「Basically all of the remaining time is spent in one function, resolveReferencesInner」,這個函式在 flush 階段跑——每次要把更新寫進輸出檔案之前,編譯器得先確定哪些宣告現在仍然被引用得到,因為死碼不該留在輸出裡。這一步沒辦法只看「這次改了什麼」,它得把整張參照圖從進入點開始走一遍,才能確認整體的可達性沒有因為這次改動而變。分析單元的增量設計省下的是「重新分析」的成本,但「重新確認整張圖的形狀」是另一件事——即使圖本身結構沒變,走訪它仍然要付出跟圖大小成正比的時間。這解釋了為什麼即使語意分析壓到 1.6 毫秒,一次增量重編仍然要三十幾毫秒——瓶頸從「算什麼」搬到了「確認什麼還活著」。下面用一個簡化的動畫把這個對比畫出來:左邊是語意分析加 codegen 的 1.6 毫秒,幾乎一瞬間跑完;右邊是 flush 走訪整張參照圖的過程,逐一「訪問」每個節點,時間跟節點數成正比。
播放看 flush 走訪整張參照圖 · 對比語意分析的 1.6ms
「確認什麼還活著」這件事,本質上跟垃圾回收器判斷可達性是同一類問題——沒辦法只看「這次新增或刪除了什麼」就推出整張圖有沒有變,必須真的把圖走一遍。差別在於一般垃圾回收器可以選擇分代、增量標記等策略把單次走訪的成本攤開,而 flush 階段目前的做法是每次都做一次完整走訪。這也是為什麼即使前面四段分析單元跟依賴集合已經把「分析」的成本壓到 1.6 毫秒,30 毫秒的走訪還是原封不動地留在那裡,成為下一個可以被優化的目標。
這也留下一個原文沒有直接數字可以回答的問題:30 毫秒是對 Fizzy 這種規模的專案量到的,如果專案的宣告數量成長十倍,走訪整張圖的時間大機率也會跟著往上走,除非之後針對 flush 本身做進一步優化——例如把可達性判斷也改成增量式的,而不是每次都整張圖重走一遍。這一段是依照目前公開的機制推算的方向,原文並未對更大規模的專案給出對照數字。
目前這整套機制「will only really work if you target x86_64-linux, because our other code generation and linker backends are not mature enough yet」——mlugg 特別用「at the time of writing」限定時效。增量編譯本身的設計(分析單元、依賴集合、ZIR 快取、MappedFile)是通用的,卡住的是其餘 target 的 codegen 與 linker backend 還沒補齊到能承接這套依賴追蹤的成熟度。換句話說,這篇文章講的不是「Zig 已經全面變快」,而是「在一個 target 上,這套顆粒化的依賴追蹤已經把大部分編譯時間從語意分析搬到了 flush 的圖走訪」——這個搬運本身,就是接下來優化的下一個目標。
對寫 Zig 或設計自己的增量建置系統的工程師來說,這裡真正可複製的框架是三個問題:有哪幾種東西可以獨立分析、每種東西怎麼記錄自己依賴了什麼、有沒有一種類型的依賴邊被設計成只能往外以避免循環。flush 這種「即使沒事也要巡一遍」的步驟,也是評估任何增量系統時該主動去找的隱藏成本——它不會因為改動變小而跟著變快,圖有多大,走訪就要付多少。
能力解鎖:把首次建置的 5 秒跟增量重編的 37 到 70 毫秒放在一起看,這套機制真正解鎖的不是「更快的編譯器」,而是把 edit-compile-test 這個迴圈的成本壓到能跟得上輸入節奏的程度——目前只在 x86_64-linux 上算成熟。