Zig Incremental Compilation Internals
Zig 的增量編譯內部機制
Zig 增量編譯概述
Zig 的增量編譯透過快取 ZIR、使用依賴圖以及增量連結,在毫秒內重新建構變更的程式碼。
編譯器首先將每個原始碼檔案轉換為 ZIR,快取結果,並僅在檔案的 hash 發生變化時才重新建構。
在語義分析期間,它會追蹤分析單元(layout, type, value, body)與原始碼區域之間的依賴關係,因此一個變更僅會使依賴於該變更 hash 的單元失效。
程式碼生成是以函數為單位進行的,產生 MIR 後立即交給連結器。
連結器對輸出二進位檔進行記憶體映射(memory-map),並使用節點樹(MappedFile)來插入或調整程式碼段的大小,僅在節點被標記為 dirty 時才進行修正(fix-ups)。
在程式碼生成之後,編譯器會遍歷引用圖(reference graph)以僅保留可達的符號,並呼叫連結器的 flush 來完成二進位檔。
Tracy profiling 顯示大部分時間花在引用圖的遍歷上,這顯示了進一步優化的機會。
若要現在嘗試增量編譯,請在針對 x86_64-linux 的近期 master build 上執行 zig build --watch -fincremental(或使用逐步選項)。
此功能尚未穩定;它在 debug build 上表現最佳,且需要手動清除快取,未來的開發重點將集中在穩定化、支援其他目標以及避免完整的引用圖重新計算。
檔案層級快取與並行處理
編譯器將每個原始碼檔案的 ZIR 快取在磁碟上,並並行處理檔案,因為解析(parsing)和 AstGen 是純函數(pure functions)。
讀取檔案、將其解析為 AST,並將該 AST 轉換為 ZIR 不涉及任何共享或外部狀態。
這些步驟非常快速——在單執行緒上對整個 Zig 編譯器 src/ 目錄進行解析和 AstGen 大約需要 920 ms。
由於 ZIR 可以透過單個 writev/readv 系統呼叫寫入和從磁碟讀取,因此快取的實作非常簡單。
純函數的特性使得這項工作具有極高的並行性:執行緒池可以獨立處理每個新發現的原始碼檔案,僅需使用 mutex 保護一個已見路徑的 hash set。
快取 ZIR 並僅在檔案原始碼 hash 改變時重新建構,這項功能已預設啟用多年,使得這部分流水線在大多數情況下幾乎是瞬間完成的。
透過依賴圖進行語義分析
增量重新編譯依賴於細粒度的分析單元(layout, type, value, body)圖,該圖透過追蹤原始碼 hash 來偵測變更。
在語義分析期間,編譯器會為每個單元填充一組它所依賴的其他單元。
例如,分析一個載入全域變數的函數主體會增加對該變數類型的依賴;如果該變數是 comptime 已知的,它還會增加對其值的依賴。
單元也依賴於定義它們的原始碼區域;編譯器在 ZIR 中儲存每個區域的 hash,因此原始碼的變更會改變 hash 並將依賴的單元標記為過時。
當編輯原始碼檔案時,編譯器會透過名稱將舊的 ZIR 映射到新的 ZIR,比較 hash,並僅重新分析 hash 已改變的單元,然後將變更傳播到任何依賴這些值的單元。
由於無法對執行期函數的主體產生依賴,函數主體單元在圖中僅有向外的邊。
這種設計刻意限制了依賴的類型,以使圖形是可處理且可增量的。
程式碼生成與 MIR
程式碼生成是以函數為單位進行的,因此不需要對 AIR 或 MIR 進行快取;輸出會直接饋送給連結器。 該階段將 AIR 從語義分析轉換為 MIR,這與機器指令密切對應。 由於 AIR 和 MIR 存在於單個函數的粒度(這與增量編譯使用的粒度相同),因此快取它們沒有好處;它們在完成後會被丟棄。 程式碼生成是極度並行的:每個函數的 AIR 可以排隊並由任意數量的執行緒處理,只需一個隊列大小守衛來防止無限制的記憶體增長。
使用 MappedFile 進行增量連結
連結器對輸出二進位檔進行記憶體映射,並使用節點樹(MappedFile)來插入或調整程式碼段的大小,僅在節點被標記為 dirty 時才進行修正。
如果沒有增量連結,連結器會在所有程式碼已知後才分配位址並應用重定位。
透過 MappedFile,在程式碼生成為函數產生機器碼後,連結器會在映射檔案中建立或調整一個足夠大的節點來容納該程式碼,將程式碼複製進去,並在節點上設置 dirty 標記。
如果父節點空間不足,MappedFile 會移動其他節點以騰出空間,並根據需要傳播 dirty 標記。
當連結器執行緒閒置或在編譯結束時,它會處理所有 dirty 節點:分配虛擬位址、更新 section/program headers、更新符號表條目,並重新應用重定位。
由於節點呈指數級增長(類似 ArrayList),在實作中移動操作很少見,從而保持了低攤銷成本。
這種方法避免了對整個物件檔案進行 diff 的需求;編譯器與連結器的整合讓連結器確切知道哪些部分發生了變更。
Flush 與引用解析
在程式碼生成之後,編譯器會遍歷引用圖以僅保留可達的符號,並呼叫連結器的 flush 來完成二進位檔。
flush 步驟決定哪些 Zig 宣告實際上被引用,忽略自上次建構以來變得不可達的任何宣告。
然後它會通知連結器每個匯出的全域符號,以便連結器可以添加適當的符號表條目。
最後,連結器的 flush 函數執行任何剩餘的工作——寫入 .dynamic section 和 ELF header entry field——同時目標是每次更新僅需 O(1) 的工作量。
任何仍被標記為 dirty 的 MappedFile 節點都會在此步驟中處理。
一次更新的 Tracy Profiling
Tracy 顯示大部分時間花在引用圖的遍歷上,這顯示了進一步優化的機會。
在一個耗時 37 ms 的範例增量更新中,前 6 ms 花在每個檔案的 ZIR 更新、語義分析、程式碼生成和連結上。
剩餘的 ~31 ms 被 resolveReferencesInner 消耗,它遍歷完整的引用圖來確定可達的宣告。
儘管圖形沒有改變,編譯器在每次更新時仍會重新計算它。
作者指出這提供了一個明確的優化目標:在圖形未改變時避免重新計算,或僅更新受影響的部分(這是一個動態單源最短路徑問題)。
現在如何使用增量編譯
若要嘗試增量編譯,請在針對 x86_64-linux 的近期 master build 上執行 zig build --watch -fincremental(或使用逐步選項)。
--watch 旗標會使建構系統在檔案系統變更時重新建構;-fincremental 告訴它在重新建構時使用增量編譯。
由於現有的磁碟快取與增量編譯不相容,啟用旗標後的第一次執行會重新建構所有內容。
在那之後,編輯並儲存一個原始碼檔案會觸發一個在數十毫秒內完成的重新建構。
對於逐步方法,可以在 build.zig 中暴露 -Dincremental 選項,並在選項為 true 時設置 exe.incremental = true,然後使用 -Dincremental 代替 -fincremental。
作者指出,此工作流程在執行時間較短的程式中效果最好;對於執行時間較長的圖形化應用程式,建構系統目前會等待前一個程序退出後才觸發重新建構。
目前的限制與未來工作
增量編譯尚未穩定,在 debug build 上表現最佳,且需要手動清除快取;未來的開發工作包括穩定化此功能、支援其他目標以及避免完整的引用圖重新計算。 作者明確表示增量編譯尚未穩定,並可能包含錯誤的編譯錯誤或錯誤編譯(miscompilations)。 雖然本文展示了在 debug build 上對一個像素編輯器 (Fizzy) 的快速重新建構,但它並未解決該功能是否適用於 release build 的問題。 一位評論者詢問,鑑於 Zig 編譯器可以編譯 C,該功能是否也適用於 C 程式碼;本文並未回答這個問題。 另一位評論者質疑為什麼連結器在 debug build 時會產生一個巨大的單一二進位檔,而不是使用許多小的共享函式庫;本文並未討論替代設計。 作者提到,計畫對建構系統進行未來增強,以支援其他工作流程,例如在不等待執行中的程式退出時觸發重新建