vatt'ghern jaskier's ballads

Nethercote 把 2025 年 12 月到 2026 年 7 月的 rustc 效能進度算成平均 wall-time 進步 5.59%,但拿掉 rustdoc 的貢獻,核心編譯路徑只剩 2.90%——半年的進步,一半不是編譯器本體做出來的。

半年 5.59%:rustc 的效能帳本

Nethercote 這篇效能報告開頭很平淡:「上一篇談 rustc 效能的文章寫於 2025 年 12 月,來看看之後發生了什麼。」半年後的總帳是平均 wall-time 進步 5.59%,健康,不算驚豔。真正讓人停下來的是下一句:把 rustdoc 的貢獻拿掉,平均只剩 2.90%;rustdoc 自己則單獨衝出 37.92% 的平均漲幅。如果核心編譯路徑半年只擠出 2.90%,問題就變得具體:是 rustc 本體已經被前幾年的優化榨乾,還是這半年的力氣本來就沒有往核心編譯路徑上使?這篇報告本身沒有明講答案,只留下一份逐條列出的 PR 清單——正好可以拿來對著兩個假設一條一條核對。

清單攤開來:十三個 PR,多數只給「最佳情況」

把半年裡合入的效能 PR 排開,會發現一個重複出現的用詞。除了 rustdoc 那幾個 PR 跟 new trait solver 那一項,幾乎每個核心編譯路徑的 PR 描述都套用同一句公式:在多個 benchmark 上減少指令數,最好的情況下減少 X%。這句話裡藏著兩個限定詞——「最好的情況」跟「指令數」,兩者都不等於「整體 wall-time」。合理的推測是,指令數是靜態量測,不需要真的把程式跑到底,噪音比實際計時的 wall-time 小很多,因此才會被拿來當作 PR 說明裡預設引用的指標;真正計入 5.59% 跟 2.90% 這兩個平均值的,是另一組跑滿全程的 wall-time 量測。報告本身沒有解釋指令數的改善最終怎麼折算成 wall-time,這段換算,只能從個別案例裡回推。

十三個 PR 的「最佳情況」百分比,對照兩條平均線 核心編譯路徑(AST/增量編譯/HIR) rustdoc trait solver(例外)
Y 軸為十三個具名 PR(依最佳情況百分比由小到大排序),底部另列一個尚未合入、只給範圍的 Clippy 提案;虛線標出 2.90%(不含 rustdoc 的平均)與 5.59%(含 rustdoc 的平均)兩條參考線。標籤前綴照原文的措辭轉寫:「<」代表原文寫「低於/將近」(上限,實際數字更小)、「>」代表原文寫「超過」(下限,實際數字更大),沒有前綴的是原文給的確切數字。核心編譯路徑(AST、增量編譯、HIR)的長條集中在個位數,rustdoc 的兩根長條落在同一個量級,唯一明顯脫隊的是 trait solver 那一根。資料來源:Nethercote 效能報告與對應 PR 頁面。

圖裡放了十三個有明確「最佳情況」數字的 PR,外加一個還沒合入、只給範圍的 Clippy 提案。兩條參考線標出這半年的兩個平均值:2.90%(不含 rustdoc)跟 5.59%(含 rustdoc)。多數核心編譯路徑的長條,其實跟兩條參考線落在同一個量級;真正遠遠超過參考線的,只有 new trait solver 那一根。rustdoc 那兩根長條看起來平凡,但別忘了 rustdoc 自己的平均漲幅是 37.92%——這批具體 PR 只是 37.92% 裡有留下明確數字的那一部分,還有一批貢獻沒被量化成單一的「最佳情況」百分比,下一節會回頭處理。

這是我自己抓的粗估,不是報告裡的數字:把十個核心編譯路徑 PR(AST 三個、增量編譯六個、HIR 一個,不含 rustdoc 跟 new trait solver 那個例外)的「最佳情況」數字加起來除以十,照原文字面換算——「低於 1%」算 0.5、「將近 3%」算 2.8,其餘取原數字——落在 4.9% 左右,仍然比整體核心路徑的 2.90% 高出一截。要提醒的是,這十個數字裡九個是指令數,只有 #158720 那個是 wall-time,單位本來就不完全一致,這個估計只能當粗略的量級參考,不是嚴謹的平均值。這正好呼應「最佳情況」這個詞的字面意思:那是每個 PR 挑出來最亮眼的那個 benchmark,不是所有 benchmark 的平均。真正計入 2.90% 的,是把每個 PR 在全部 benchmark 上的效果都算進去,包含那些幾乎沒感覺的案例——峰值跟平均本來就不是同一個數字。

Nethercote 在文章裡順帶說明了為什麼這些數字要靠人工把關:「機器人發訊息說『這個 PR 讓九個 benchmark 退步了』是一回事;有經驗的人發訊息說『前四個可以忽略,那幾個 benchmark 最近本來就吵』,是完全不同的另一回事。」每一根「最佳情況」的長條,背後都經過這一層過濾——這些數字不是原始測量,而是已經被判斷為真訊號的那部分。清單排出來後,問題變得具體:核心編譯路徑這批 PR,是不是真的把能榨的都榨乾了?下表把清單裡對得上編號的項目攤開成十六列,按類別分組:AST 三列、增量編譯六列、HIR 一列、trait solver 一列、rustdoc 三列、Clippy 兩列——一列已合入,一列還在等審查。

點擊欄位標題排序 · 5 欄 × 16 列

PR 類別 作者 量測指標 結果
#158942ASTNicholas Nethercote指令數低於 1%
#158720ASTNicholas Nethercotewall-time(AST-heavy)超過 10%(cache miss 降 29%)
#159266ASTNicholas Nethercote指令數低於 1%
#153122增量編譯Zalathar指令數6%
#153521增量編譯Zalathar指令數超過 2%
#154304增量編譯zetanumbers指令數6%
#157781增量編譯xmakro指令數5%
#158794增量編譯xmakro指令數10%
#159115增量編譯xmakro指令數6%
#155678HIRaerooneqq指令數近 3%
#160005new trait solverNicholas Nethercotewall-time40%(兩個最慢 crate)
#159623 等 4 個rustdocNoah Levwall-time雙位數/個位數不等
#159091rustdocJakub Beránekwall-time均 2.85%,最佳超過 6%
#157179rustdocNicholas Nethercote指令數超過 6%
#17124/#17132Clippyxmakro執行時間已合入(無單一公開數字)
#157762(未合入)ClippyNicholas Nethercote執行時間10-30%(分支預測錯誤降 20-80%,最高 97%)

假設一:rustc 本體榨乾了嗎——AST、dep graph、trait solver 逐一驗屍

先看 AST 那三個 PR。#158942 拿掉一個 AST 節點欄位,把資料暫時存到別處,換來的是「多數情況下低於 1% 的指令數減少」;#159266 重寫 AST attribute 的表示方式,結果同樣是「低於 1% 的指令數減少」。這是典型的榨乾案例:改動的方向沒有錯,只是可以擠的空間已經很小,兩個 PR 加起來的效果,落在噪音邊緣。

這三個 AST PR 背後其實是同一個人:Nicholas Nethercote 自己。連同 new trait solver 那個 #160005,他一個人就佔了圖裡十三個 PR 中的五個——某種程度上,這份報告的作者也是報告裡最主要的其中一個工人,這解釋了為什麼他對指令數跟 wall-time 的差異寫得特別謹慎:他清楚知道自己每一個 PR 的真實份量。

點按或觸碰底線文字看說明 · cache line 與 64 bytes 的關係

#158720 是這批裡少數有感的一個,把 AST expression 節點從 72 bytes 縮到 64 bytesx86-64 常見的一條 cache line 是 64 bytes;結構體只要超過這個大小,一次存取經常要多讀一條 cache line。expression 是最常見的 AST 節點種類,#158720 把它從 72 bytes 縮到 64 bytes 之後,少數 AST-heavy 的 benchmark 上 wall-time 有的改善超過 10%,同一批 benchmark 的 cache miss 率最多掉了 29%。。 72 bytes 已經跨進第二條 cache line 的門檻,64 bytes 剛好塞進一條——差別不是省了一點記憶體,是少讀一次 cache line。少數 AST-heavy 的 benchmark 因此在 wall-time 上改善「超過 10%」,同一批 benchmark 的 cache miss 率最多掉了「29%」——這兩件事都是實測結果,但效果只出現在一小撮 AST 密集的 benchmark 上,不是全面性的。

增量編譯那批六個 PR 走的是同一種節奏。Zalathar 的 disk-cache promotion(#153122)最好情況下省 6% 指令數;他另一個 PR(#153521)最好情況下省超過 2%;zetanumbers 調整 typeck query(#154304)最好情況下省 6%;xmakro 前後兩次處理 dep graph read 去重(#158794、#159115)分別在最好情況下省 10% 跟 6%——dep graph 去重要處理的,是同一份依賴在圖裡被重複記錄、重複讀取的狀況,把重複的讀取去掉,省下的是遍歷跟比對的成本;xmakro 另一個增量編譯調整(#157781)最好情況下省 5%。六個 PR,六個「最好情況」,全部落在個位數到低雙位數之間——沒有一個聲稱整體 wall-time 改善,全部是多個 benchmark 上的指令數。aerooneqq 把幾個 HIR 層級的 query 合併(#155678),最好情況下省了「將近 3%」指令數,同樣是這個量級。

Zalathar 那兩個增量編譯 PR,方向跟字面上的直覺猜測相反:#153122 改善的是「增量編譯把磁碟快取的數值提升進記憶體的方式」——不是把記憶體結果寫到磁碟保存,而是把磁碟快取的值更有效率地載回記憶體;#153521 是「另一次同類型的增量編譯調整」,源文沒有給更多機制細節。兩者都不是新演算法,而是把既有機制裡多餘的搬移或延遲拿掉,這正是核心編譯路徑半年來最常見的修正形狀。

六個 PR 裡數字最高的 #158794,GitHub 上的討論比部落格文章講得更細。它的問題陳述是:「每一次依賴讀取都會經過 DepGraph::read_index,必須判斷目前的 task 是不是已經記錄過這次讀取。小的 task 用線性掃描;大的 task 每次都重建一個 FxHashSet,隨著讀取數量增加不斷重新雜湊,讀取本身則收在一個每個 task 各自配置、用完就釋放的 EdgesVec 裡。現在較大的 task 會從一個全域池子裡重複使用同一個記錄器,雜湊集合跟讀取清單的記憶體配置因此可以跨 task 攤提。」正式合併時的 perf run 顯示,主要 benchmark 的平均指令數只掉了 1.2%(區間從 -10.0% 到 0.3%),次要 benchmark 平均只掉 1.0%——跟部落格引用的「最好情況 10%」差了一個數量級。同一份 perf run 裡,這個修正還讓 Max RSS(記憶體峰值)主要套件平均上升 1.6%、次要套件平均上升 3.6%(這是兩個各自獨立的平均數,不是同一個範圍),等於是拿記憶體換指令數。

typeck query 跟 HIR query 合併這兩個修正,處理的是 rustc 增量編譯模型裡同一種浪費。rustc 把型別檢查、借用檢查、程式碼產生等步驟都包成一個一個的 query,每個 query 的輸入輸出都被記錄進 dep graph,下次編譯時只要輸入沒變就直接用快取結果,不必重跑。問題是 query 拆得太細,同一段邏輯常常被拆成好幾個各自快取的小 query,光是 query 系統本身的排程、雜湊、比對開銷就會疊加起來。zetanumbers 調整 typeck query 的做法(#154304)跟 aerooneqq 合併 HIR 層級 query 的做法(#155678),方向相反但目的相同:前者是把一個 query 內部算得更精準,後者是把幾個 query 直接黏成一個,省下中間的排程成本。兩個修正的最好情況都在個位數,正好印證假設一的判斷——這類系統性重構的空間,比起 rustdoc 撞到的那種孤立 bug,天生就小得多。

真正的例外是 new trait solver 那一個。#160005 用 Cachegrind 挖出:new solver 在編譯 nacl-0.5.3、ijson-0.1.6、nvml-wrapper-sys-0.9.1 這幾個 crate 時,花了大量時間在跑 memcpy。根因是 PendingObligations 裡一對資料原本是 136 bytes,超過門檻就會觸發整段複製;PR 把其中的 Vec 欄位換成 ThinVec,壓到 104 bytes,另外還拿掉一個多餘的 partition 操作——原本的寫法會把整個 vector 搬到一份內容其實一模一樣的新 vector 裡,PR 改成直接沿用原本的 vector,省掉這一趟沒必要的複製。

拖曳分隔線比較 PR #160005 前後 · new trait solver memcpy 修正

#160005 之前

  • PendingObligations pair:136 bytes
  • 熱點 crate:nacl-0.5.3、ijson-0.1.6、nvml-wrapper-sys-0.9.1
  • 其中一個 benchmark:約 27 秒(三個月前)

#160005 之後

  • PendingObligations pair:104 bytes(Vec → ThinVec)
  • 多餘的 partition 操作:拿掉,改成直接沿用原本 vector
  • 兩個最慢 crate wall-time:-40%
  • 同一個 benchmark:現在低於 1 秒

結果是兩個最慢的 crate wall-time 掉了 40%——這是少數幾個直接用 wall-time 回報的核心編譯路徑成績之一,其中一個 benchmark 在三個月內從 27 秒掉到不到 1 秒。GitHub 上的討論留了一個但書,但講的是另一件事:同一份 PR 討論串裡還有一組指令數的 CI 對本地比較,數字溫和得多——wg-grammar「-4.81% vs -7.31%」、deeply-nested-multi「-4.07% vs -5.77%」,作者把 CI 落後本地的落差歸因於 PGO(profile-guided optimization)造成的變異。這組指令數的數字跟前面 wall-time 的 40% 是不同指標、不同 benchmark,不是同一件事縮水成 4-5%。範圍寫得很清楚:兩個最差的 crate,是修一條尾端的病理案例,不是把所有使用 new trait solver 的程式碼都變快。

這個修正給寫 Rust 的人一個具體提醒:如果自己的程式碼裡也有大量小型 struct 塞進一個大 Vec、又頻繁複製整個 Vec 的模式,#160005 的做法值得抄——把容易整段複製的欄位換成 ThinVec,或直接縮小 struct 本身的大小,往往比重寫演算法便宜得多。這也是為什麼 #160005 能一次跳到 40%:它動的不是演算法邏輯,而是資料版面配置,跟 #158720 縮小 AST 節點的邏輯同源,只是換了一個更極端的案例。

如果核心編譯路徑半年的貢獻幾乎都是個位數的最佳情況,加上一個範圍侷限的例外,那 2.90% 這個平均數字,某種程度上已經是這批 PR 能給的上限——以我自己的統計,十個核心 PR 裡,八個的最佳情況低於 10%,一個(dep graph 去重,#158794)正好 10%,一個(AST 縮節點,#158720)超過 10%。假設一因此只成立了一半:rustc 本體沒有停止進步,但確實已經進入報酬遞減的區間。問題於是轉向另一邊:rustdoc 那 37.92%,是怎麼跳出這個量級的?

假設二:rustdoc 是不是只是撿到一個特例 bug

Nethercote 引用 Guillaume Gomez 的說法:這一波 rustdoc 改善的起點,是「有人在用 rustdoc 的時候撞到一個怪 bug」。Noah Lev 追著這個 bug 往下挖,合入四個 PR(#159623、#159721、#159779、#159854),描述是「大幅減少處理 impl 時所做的工作量」,換回「許多雙位數跟個位數百分比的 wall-time 減少」。這句話裡沒有出現「最佳情況」,寫的直接是 wall-time,而且是「許多」benchmark,不是一兩個特例。

四個獨立 PR 才把這個 bug 修完,這件事本身也值得注意。合理的推測是,「處理 impl 時做了多餘的工作」這個問題不是單一個函式的錯,而是散落在 impl 相關的好幾條路徑上——每個 PR 修的是其中一段。這跟 #157179 那種一次到位的修正不一樣:後者是換掉一個排序鍵就結束,前者則是花了四次提交才把同一類浪費從不同角落挖乾淨。這也部分解釋了為什麼 Noah Lev 那批 PR 的總貢獻量能疊到「許多雙位數」的規模——不是單一個聰明的修正,而是同一個洞見被系統性地套用到好幾個地方。

Nethercote 自己那個 PR(#157179)更直白:rustdoc 排序 impl 列表時,原本拿「產生出來的長串 HTML 字串」當排序鍵去比較;他改成用短得多的純文字表示法當 key,結果是「在多個 benchmark 上減少指令數,最好情況下超過 6%」。這不是深奧的演算法突破,是拿掉一個不必要的高複雜度操作——排序鍵原本包了不必要的內容,排序成本自然也跟著虛高。

Jakub Beránek 的 #159091 走另一條路:把 rustdoc 的 benchmark 加進 PGO 訓練集,讓編譯器有機會針對 rustdoc 常跑的程式碼路徑做剖繪最佳化。效果是「所有 rustdoc benchmark 平均減少 2.85% 的 wall-time,最大的一個減少超過 6%」——這個數字反而跟核心編譯路徑那批 PR 同一量級,只是計入的 benchmark 集合換成了 rustdoc 自己那一份。

PGO 的原理是先用有代表性的工作負載跑一輪編譯器,記錄哪些程式碼路徑最常被執行,再把這份剖繪資料回饋給下一輪編譯,讓熱路徑得到更積極的內聯與分支預測優化。rustc 自己的建置流程本來就用 PGO,但訓練集裡原本沒有涵蓋 rustdoc 常跑的程式碼路徑——rustdoc 雖然共用 rustc 的前端,實際執行時踩的熱路徑跟一般編譯並不完全重疊。Beránek 把 rustdoc benchmark 補進訓練集之後,等於是讓編譯器第一次「看過」rustdoc 真正常跑的那些函式,優化器才有機會針對這些路徑做決策。這是一種訓練資料的偏差修正,不是演算法上的突破,跟 #157179 拿掉多餘複雜度的邏輯是同一類做法:真正的瓶頸不在演算法本身,而在過去沒人花時間去對齊的細節。

把三種 rustdoc 改善擺在一起看:合理的推測是,37.92% 這個數字裡多數份量來自 Noah Lev 那四個 PR 撞到的 bug;PGO 訓練集調整跟排序鍵修正,量級都跟核心編譯路徑差不多,並不特別出眾。若真是如此,rustdoc 半年的漲幅,與其說是這個工具本來就比較好優化,不如說是一個過去沒被踩到的效能 bug,剛好在這半年被人踩到、修掉。

結論:兩種進步節奏,一邊榨乾、一邊還有懶 bug 可挖

兩個假設都對了一部分。核心編譯路徑沒有停止進步——AST、dep graph、typeck query 半年內都有 PR 合入,但每一個都在跟同一種天花板打交道:多年優化下來,剩下的多半是特定 benchmark 子集上的個位數指令數改善;少數撞出雙位數 wall-time 漲幅的兩個 PR——new trait solver 那個範圍侷限在兩個最慢的 crate,AST 節點縮到 64 bytes 那個範圍侷限在少數 AST-heavy 的 benchmark——都是尾端案例,不是普遍提速。這比較像是把能榨的榨乾了,不是沒有人在做。rustdoc 那邊是完全不同的節奏:一個沒人踩過的效能 bug,一撞就是雙位數。把 5.59%、2.90%、37.92% 三個數字放在同一張帳本上看,答案不是「rustc 停滯」,而是兩條完全不同的進步曲線疊在一起。

Clippy 那邊的故事比部落格寫的更曲折。Clippy 把絕大多數 lint 塞進一個叫 RuntimeCombinedLateLintPass 的巨大清單,PR 頁面自己說預設啟用大約兩百三十條 late lint(部落格正文沒有寫這個數字);每次編譯要檢查三十二種語法節點(像 check_item),系統就把整份清單從頭跑一遍,逐一呼叫每個 pass 對應的方法。問題是大多數 lint 只實作了三十二個方法裡的一兩個,其餘全是空的預設實作——換句話說,絕大多數的虛擬派發呼叫,呼叫到的都是什麼也不做的空函式。Nethercote 自己也差不多在同一時間查到這個問題,寫了一個修正方案(#157762),做法是把單一清單拆成每種語法節點各自一份、只放真正實作該方法的 lint;測到的結果是指令數最好情況減少 15%,多組 wall-time 結果超過 30%,分支預測錯誤率最好情況掉了 97%——這個 97% 部落格正文本身也寫了,跟 PR 頁面是同一個人講兩次,不是兩個獨立來源互相印證,但落差本身仍然解釋了為什麼 wall-time 的漲幅會比指令數漲幅更明顯:省下的主要是分支誤判,不是純運算量。這個 PR 最後被 Nethercote 自己關掉,原因寫在討論串裡:xmakro 幾天前已經用更漂亮的寫法,在已合入的 #17124、#17132 裡解決了同一個問題。這個數字目前沒有計入 5.59% 或 2.90% 的任何一邊,但方向一致:周邊工具還有整塊沒被踩過的效能區域,rustc 本體已經走到按 cache line 大小、按 memcpy 門檻找漏洞的階段。對照這兩種模式來看:核心編譯路徑的每一個 PR 都值得看,但把單一 PR 的「最佳情況」數字直接套用到自己的專案,風險不小——那些數字通常是在少數幾個 AST-heavy 或記憶體配置特別重的 benchmark 上量到的峰值,不是普遍保證。

下次看到「平均進步 X%」:先問這個 X% 有多少是「最佳情況」湊出來的,再問剩下的部分踩到的是門檻效應(像 cache line),還是一個過去沒被踩過的舊 bug——兩種進步用完的速度完全不一樣。