Rust 編譯模型之殤

作者介紹:

Brian Anderson 是 Rust 編程語言及其姊妹項目 Servo Web 瀏覽器的共同創始人之一。他目前在 PingCAP 擔任高級數據庫工程師。

感謝 Rust 中文社區翻譯小組對本文翻譯及審校上的貢獻:

  • 翻譯:張漢東、黃珏珅
  • 審校 :吳聰

Rust 編譯緩慢的根由在於語言的設計。

我的意思並非是此乃 Rust 語言的設計目標。正如語言設計者們相互爭論時經常說的那樣,編程語言的設計總是充滿了各種權衡。其中最主要的權衡就是:運行時性能編譯時性能。而 Rust 團隊幾乎總是選擇運行時而非編譯時。

因此,Rust 編譯時間很慢。這有點讓人惱火,因爲 Rust 在其他方面的表現都非常好,唯獨 Rust 編譯時間卻表現如此糟糕。

Rust 與 TiKV 的編譯時冒險:第 1 集

PingCAP,我們基於 Rust 開發了分佈式存儲系統 TiKV 。然而它的編譯速度慢到足以讓公司裏的許多人不願使用 Rust。我最近花了一些時間,與 TiKV 團隊及其社區中的其他幾人一起調研了 TiKV 編譯時間緩慢的問題。

  • 通過這一系列博文,我將會討論在這個過程中的收穫:
  • 爲什麼 Rust 編譯那麼慢,或者說讓人感覺那麼慢;
  • Rust 的發展如何造就了編譯時間的緩慢;
  • 編譯時用例;
  • 我們測量過的,以及想要測量但還沒有或者不知道如何測量的項目;
  • 改善編譯時間的一些思路;
  • 事實上未能改善編譯時間的思路;
  • TiKV 編譯時間的歷史演進;
  • 有關如何組織 Rust 項目可加速編譯的建議;
  • 最近和未來,上游將對編譯時間的改進。

PingCAP 的陰影:TiKV 編譯次數 「餘額不足」

PingCAP,我的同事用 Rust 寫 TiKV。它是我們的分佈式數據庫 TiDB 的存儲節點。採用這樣的架構,是因爲他們希望該系統中作爲最重要的節點,能被構造得快速且可靠,至少是在一個最大程度的合理範圍內(譯註:通常情況下人們認爲快和可靠是很難同時做到的,人們只能在設計/構造的時候做出權衡。選擇 Rust 是爲了儘可能讓 TiKV 能夠在儘可能合理的情況下去提高它的速度和可靠性)。

這是一個很棒的決定,並且團隊內大多數人對此都非常滿意。

但是許多人抱怨構建的時間太長。有時,在開發模式下完全重新構建需要花費 15 分鐘,而在發佈模式則需要 30 分鐘。對於大型系統項目的開發者而言,這看上去可能並不那麼糟糕。但是它與許多開發者從現代的開發環境中期望得到的速度相比則慢了很多。TiKV 是一個相當巨大的代碼庫,它擁有 200 萬行 Rust 代碼。相比之下,Rust 自身包含超過 300 萬行 Rust 代碼,而 Servo 包含 270 萬行(請參閱 此處的完整行數統計)。

TiDB 中的其他節點是用 Go 編寫的,當然,Go 與 Rust 有不同的優點和缺點。PingCAP 的一些 Go 開發人員對於不得不等待 Rust 組件的構建而表示不滿。因爲他們習慣於快速的構建-測試迭代。

在 Go 開發人員忙碌工作的同時,Rust 開發人員卻在編譯時間休息(喝咖啡、喝茶、抽菸,或者訴苦)。Rust 開發人員有多餘的時間來跨越內心的「陰影(譯註:據說,TiKV 一天只有 24 次編譯機會,用一次少一次)。

概覽:TiKV 編譯時冒險歷程

本系列的第一篇文章只是關於 Rust 在編譯時間方面的歷史演進。因爲在我們深入研究 TiKV 編譯時間的具體技術細節之前,可能需要更多的篇章。所以,這裏先放一個漂亮的圖表,無需多言。

TiKV 的 Rust 編譯時間

造就編譯時間緩慢的 Rust 設計

Rust 編譯緩慢的根由在於語言的設計。

我的意思並非是此乃 Rust 語言的設計目標。正如語言設計者們相互爭論時經常說的那樣,編程語言的設計總是充滿了各種權衡。其中最主要的權衡就是:運行時性能編譯時性能。而 Rust 團隊幾乎總是選擇運行時而非編譯時。

刻意的運行時/編譯時權衡不是 Rust 編譯時間差勁的唯一原因,但這是一個大問題。還有一些語言設計對運行時性能並不是至關重要,但卻意外地有損於編譯時性能。Rust 編譯器的實現方式也抑制了編譯時性能。

所以,Rust 編譯時間的差勁,既是刻意爲之的造就,又有出於設計之外的原因。儘管編譯器的改善、設計模式和語言的發展可能會緩解這些問題,但這些問題大多無法得到解決。還有一些偶然的編譯器架構原因導致了 Rust 的編譯時間很慢,這些需要通過大量的工程時間和精力來修復。

如果迅速地編譯不是 Rust 的核心設計原則,那麼 Rust 的核心設計原則是什麼呢?下面列出幾個核心設計原則:

  • 實用性(Practicality) :它應該是一種可以在現實世界中使用的語言;
  • 務實(Pragmatism):它應該是符合人性化體驗,並且能與現有系統方便集成的語言;
  • 內存安全性(Memory-safety) :它必須加強內存安全,不允許出現段錯誤和其他類似的內存訪問違規操作;
  • 高性能(Performance) :它必須擁有能和 C++ 比肩的性能;
  • 高併發(Concurrency) :它必須爲編寫併發代碼提供現代化的解決方案。

但這並不是說設計者沒有爲編譯速度做任何考慮。例如,對於編譯 Rust 代碼所要做的任何分析,團隊都試圖確保合理的算法複雜度。然而,Rust 的設計歷史也是其一步步陷入糟糕的編譯時性能沼澤的歷史。

講故事的時間到了。

Rust 的自舉

我不記得自己是什麼時候纔開始意識到,Rust 糟糕的編譯時間其實是該語言的一個戰略問題。在面對未來底層編程語言的競爭時可能會是一個致命的錯誤。在最初的幾年裏,我幾乎完全是對 Rust 編譯器進行 Hacking(非常規暴力測試),我並不太關心編譯時間的問題,我也不認爲其他大多數同事會太關心該問題。我印象中大部分時間 Rust 編譯時總是很糟糕,但不管怎樣,我能處理好。

針對 Rust 編譯器工作的時候,我通常都會在計算機上至少保留三份存儲庫副本,在其他所有的編譯器都在構建和測試時,我就會 Hacking 其中的一份。我會開始構建 Workspace 1,切換終端,記住在 Workspace 2 發生了什麼,臨時做一下修改,然後再開始構建 Workspace 2,切換終端,等等。整個流程比較零碎且經常切換上下文。

這(可能)也是其他 Rust 開發者的日常。我現在對 TiKV 也經常在做類似的 Hacking 測試。

那麼,從歷史上看,Rust 編譯時間有多糟糕呢?這裏有一個簡單的統計表,可以看到 Rust 的自舉(Self-Hosting)時間在過去幾年裏發生了怎樣的變化,也就是使用 Rust 來構建它自己的時間。出於各種原因,Rust 構建自己不能直接與 Rust 構建其他項目相比,但我認爲這能說明一些問題。

首個 Rust 編譯器 叫做 rustboot,始於 2010 年,是用 OCaml 編寫的,它最終目的是被用於構建第二個由 Rust 實現的編譯器 rustc,並由此開啓了 Rust 自舉的歷程。除了基於 Rust 編寫之外,rustc 還使用了 LLVM 作爲後端來生成機器代碼,來代替之前 rustboot 的手寫 x86 代碼生成器。

Rust 需要自舉,那樣就可以作爲一種「自產自銷(Dog-Fooding)」的語言。使用 Rust 編寫編譯器意味着 Rust 的作者們需要在語言設計過程的早期,使用自己的語言來編寫實用的軟件。在實現自舉的過程中讓 Rust 變成一種實用的語言。

Rust 第一次自舉構建是在 2011 年 4 月 20 日。該過程總共花了 一個小時,這個編譯時間對當時而言,很漫長,甚至還覺得有些可笑。

最初那個超級慢的自舉程序慢的有些反常,在於其包含了糟糕的代碼生成和其他容易修復的早期錯誤(可能,我記不清了)。rustc 的性能很快得到了改善,Graydon 很快就 拋棄了舊的 rustboot 編譯器 ,因爲沒有足夠的人力和動力來維護兩套實現。

在 2010 年 6 月首次發佈的 11 個月之後,Rust 漫長而艱難的編譯時代就此開始了。

注意

我本想在這裏分享一些有歷史意義的自舉時間,但在經歷了數小時,以及試圖從2011年開始構建 Rust 修訂版的障礙之後,我終於放棄了,決定在沒有它們的情況下發布這篇文章。作爲補充,這裏作一個類比:

  • 兔子飛奔幾米(7):rustboot 構建 Rust 的時間;

  • 倉鼠狂奔一公里(49):在 rustboot 退役後使用 rustc 構建 Rust 的時間;

  • 樹獺移動一萬米(188):在 2020 年構建 rustc 所需的時間。

反正,幾個月前我構建 Rust 的時候,花了五個小時。

Rust 語言開發者們已經適應了 Rust 糟糕的自舉時間,並且在 Rust 的關鍵早期設計階段未能識別或處理糟糕編譯時間問題的嚴重性。

(非)良性循環

在 Rust 項目中,我們喜歡能夠增強自身基礎的流程。無論是作爲語言還是社區,這都是 Rust 取得成功的關鍵之一。

一個明顯非常成功的例子就是 Servo。Servo 是一個基於 Rust 構建的 Web 瀏覽器,並且 Rust 也是爲了構建 Servo 而誕生。Rust 和 Servo 是姊妹項目。它們是由同一個(初始)團隊,在(大致)同一時間創造的,並同時進化。不只是爲了創造 Servo 而創建 Rust,而且 Servo 也是爲了解 Rust 的設計而構建的。

這兩個項目最初的幾年都非常困難,兩個項目都是並行發展的。此處非常適合用 忒修斯之船 做比喻——我們不斷地重建 Rust,以便在 Sevro 的海洋中暢行。毫無疑問,使用 Rust 構建 Servo 的經驗,來構建 Rust 語言本身,直接促進了很多好的決定,使得 Rust 成爲了實用的語言。

這裏有一些關於 Servo-Rust 反饋迴路的例子:

Rust 和 Servo 的共同發展創造了一個 良性循環 ,使這兩個項目蓬勃發展。今天,Servo 組件被深度集成到火狐(Firefox)中,確保在火狐存活的時候,Rust 不會死去。

任務完成了。

前面提到的早期自舉對 Rust 的設計同樣至關重要,使得 Rust 成爲構建 Rust 編譯器的優秀語言。同樣,Rust 和 WebAssembly 是在密切合作下開發的(我與 Emscripten 的作者,Cranelift 的作者並排工作了好幾年),這使得 WASM 成爲了一個運行 Rust 的優秀平臺,而 Rust 也非常適合 WASM。

遺憾的是,沒有這樣的增強來縮短 Rust 編譯時間。事實可能正好相反——Rust 越是被認爲是一種快速語言,它成爲最快的語言就越重要。而且,Rust 的開發人員越習慣於跨多個分支開發他們的 Rust 項目,在構建之間切換上下文,就越不需要考慮編譯時間。

直到 2015 年 Rust 1.0 發佈並開始得到更廣泛的應用後,這種情況才真正有所改變。

多年來,Rust 在糟糕的編譯時間的「溫水中」被慢慢「烹煮」,當意識到它已經變得多麼糟糕時,已爲時已晚。已經 1.0 了,那些(設計)決策早已被鎖定了。

這一節包含了太多令人厭倦的隱喻,抱歉了。

運行時優先於編譯時的早期決策

如果是 Rust 設計導致了糟糕的編譯時間,那麼這些設計具體又是什麼呢?我會在這裏簡要地描述一些。本系列的下一集將會更加深入。有些在編譯時的影響比其他的更大,但是我斷言,所有這些都比其他的設計耗費更多的編譯時間。

現在回想起來,我不禁會想,「當然,Rust 必須有這些特性」。確實,如果沒有這些特性,Rust 將會是另一門完全不同的語言。然而,語言設計是折衷的,這些並不是註定要成 Rust 的部分。

  • 借用(Borrowing)——Rust 的典型功能。其複雜的指針分析以編譯時的花費來換取運行時安全。

  • 單態化(Monomorphization)——Rust 將每個泛型實例轉換爲各自的機器代碼,從而導致代碼膨脹並增加了編譯時間。

  • 棧展開(Stack unwinding)——不可恢復異常發生後,棧展開向後遍歷調用棧並運行清理代碼。它需要大量的編譯時登記(book-keeping)和代碼生成。

  • 構建腳本(Build scripts)——構建腳本允許在編譯時運行任意代碼,並引入它們自己需要編譯的依賴項。它們未知的副作用和未知的輸入輸出限制了工具對它們的假設,例如限制了緩存的可能。

  • 宏(Macros)——宏需要多次遍歷才能展開,展開得到的隱藏代碼量驚人,並對部分解析施加限制。過程宏與構建腳本類似,具有負面影響。

  • LLVM 後端(LLVM backend)——LLVM 產生良好的機器代碼,但編譯相對較慢。

  • 過於依賴LLVM優化器(Relying too much on the LLVM optimizer)——Rust 以生成大量 LLVM IR 並讓 LLVM 對其進行優化而聞名。單態化則會加劇這種情況。

  • 拆分編譯器/軟件包管理器(Split compiler/package manager)——儘管對於語言來說,將包管理器與編譯器分開是很正常的,但是在 Rust 中,至少這會導致 cargo 和 rustc 同時攜帶關於整個編譯流水線的不完善和冗餘的信息。當流水線的更多部分被短路以便提高效率時,則需要在編譯器實例之間傳輸更多的元數據。這主要是通過文件系統進行傳輸,會產生開銷。

  • 每個編譯單元的代碼生成(Per-compilation-unit code-generation)——rustc 每次編譯單包(crate)時都會生成機器碼,但是它不需要這樣做,因爲大多數 Rust 項目都是靜態鏈接的,直到最後一個鏈接步驟才需要機器碼。可以通過完全分離分析和代碼生成來提高效率。

  • 單線程的編譯器(Single-threaded compiler)——理想情況下,整個編譯過程都將佔用所有 CPU 。然而,Rust 並非如此。由於原始編譯器是單線程的,因此該語言對並行編譯不夠友好。目前正在努力使編譯器並行化,但它可能永遠不會使用所有 CPU 核心。

  • trait 一致性(trait coherence)——Rust 的 trait(特質)需要遵循「一致性(conherence)」,這使得開發者不可能定義相互衝突的實現。trait 一致性對允許代碼駐留的位置施加了限制。這樣,很難將 Rust 抽象分解爲更小的、易於並行化的編譯單元。

  • 「親密」的代碼測試(Tests next to code)——Rust 鼓勵測試代碼與功能代碼駐留在同一代碼庫中。由於 Rust 的編譯模型,這需要將該代碼編譯和鏈接兩次,這份開銷非常昂貴,尤其是對於有很多包(crate)的大型項目而言。

改善 Rust 編譯時間的最新進展

現狀並非沒有改善的希望。一直有很多工作在努力改善 Rust 的編譯時間,但仍有許多途徑可以探索。我希望我們能持續看到進步。以下是我最近一兩年所知道的一些進展。感謝所有爲該問題提供幫助的人。

對於未上榜的人員或項目,我需要說一聲抱歉。

下集預告

所以多年來,Rust 把自己深深地逼進了一個死角,而且很可能會持續逼進,直到玩完。Rust 的編譯時能否從 Rust 自身的運行時成功中得到拯救?TiKV 的構建速度能否讓我的管理者滿意嗎?

在下一集中,我們將深入討論 Rust 語言設計的細節,這些細節會導致它編譯緩慢。

繼續享受 Rust 吧,朋友們!

鳴謝:

很多人蔘與了本系列博客。特別感謝 Niko Matsakis、Graydon Hoare 和 Ted Mielczarek 的真知卓見,以及 Calvin Weng 的校對和編輯。

💡 有興趣可點擊查看 英文原版