Rust 編譯模型之殤

做者介紹:html

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

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

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

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

個人意思並不是是此乃 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 的校對和編輯。

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

相關文章
相關標籤/搜索