首頁 > 旅遊
C++、Rust 編譯一樣糟糕?我用 1.7 萬行程式碼試了試
由 CSDN 發表于 旅遊2023-02-03
簡介我們來嘗試一下以下手段,以最佳化 Rust 專案的編譯時間:更快的聯結器Cranelift 後端編譯器和聯結器的標誌不同的工作區和測試佈局儘可能減少依賴特性cargo-nextest透過 PGO 定製的工具鏈更快的聯結器第一步是對構建進行效
rust怎麼換門
【CSDN 編者按】程式語言界,在編譯上,有兩種語言比較出名,一個是老牌的 C++,一個是近幾年因安全的效能而流行起來的 Rust,其都是被評為編譯很慢的語言。那麼這兩種語言相較而言,究竟孰優孰劣,本文作者進行了測試,我們不妨透過其實驗一探究竟。
原文連結:https://quick-lint-js。com/blog/cpp-vs-rust-build-times/
宣告:本文為 CSDN 翻譯,未經允許,禁止轉載。
作者 | quick-lint-js譯者 | 彎月 責編 | 屠敏
出品 | CSDN(ID:CSDNnews)
眾所周知,C++ 的編譯十分緩慢。程式設計圈子有一個著名的梗:“程式碼正在編譯”,這個梗就來自 C++。
像 Google Chromium 這樣的專案在最新的硬體上也需要一個小時才能構建完成,在舊硬體上則需要長達六個小時。文件裡記載了數不清的加速編譯的技巧,還有許多很容易出錯的捷徑,用來減少每次編譯的程式碼量。即使使用數千美元的雲計算,Chromium 的構建時間也需要幾十分鐘。我完全無法接受這一點。這樣如何能正常工作?
Rust 也有類似的傳言:編譯時間是個大問題。但這真的是 Rust 的問題,還是黑 Rust 的謠言?跟 C++ 的編譯時間相比,Rust 又如何呢?
我很關心編譯速度和執行時效能。更快的構建測試迴圈可以提高生產力,而且可以讓程式設計更快樂,那麼我就能讓軟體的執行速度更快,客戶也能更開心。所以,我決定親眼看看 Rust 是否真的像他們說的那麼差。我的計劃如下:
找一個開源 C++ 專案;
將專案的某一部分單獨分離出來變成一個小專案;
用 Rust 逐行重寫 C++ 程式碼;
最佳化 C++ 專案和 Rust 專案的編譯時間;
比較兩個專案的編譯和測試時間。
我的假設是(猜測,不是結論):
Rust 的程式碼行數比 C++ 略少。
因為C++中大多數函式和方法都需要定義兩次(標頭檔案中一次,實現中一次)。Rust 則不需要這樣做,因此程式碼行數就會減少。
需要進行完整編譯時,C++ 比 Rust 需要更多時間(即 Rust 勝出)。
這是因為 C++ 的 #include 和模板需要在每個 。cpp 中進行編譯。雖然可以並行進行,但並行並不完美。
對於增量構建,Rust 的編譯時間比 C++ 多(即 C++ 勝出)。
這是因為 Rust 一次編譯一個 crate,而不像 C++ 那樣一次編譯一個檔案,所以即使只有很小的變化,Rust 也要重新編譯更多的程式碼。
你認為如何?我進行了一項調查:
42% 的人認為 C++ 會獲勝,35% 的人認為需要具體分析,17% 的人認為 Rust 會獲勝。
下面,實驗開始!
尋找 C++ 和 Rust 的實驗物件尋找專案
如果需要花一個月來移植程式碼,我要移植哪個呢?我的挑選條件如下:
很少或沒有第三方依賴(標準庫沒關係);
可以在 Linux 或 macOS 上執行(我不太關心在 Windows 上的編譯時間);
有大量的測試用例(沒有測試用例,我沒辦法知道我寫的Rust程式碼是否正確);
涉及多種技術:FFI、指標、標準和自定義容器、工具類和函式、I/O、併發、泛型、宏、SIMD、繼承。
最後的選擇很簡單:選我前幾年寫過的專案!我將移植之前在 quick-lint-js(https://quick-lint-js。com/blog/cpp-vs-rust-build-times/#:~:text=quick%2Dlint%2Djs%20project)專案中編寫的 JavaScript 詞法分析器。
修剪 C++ 程式碼
quick-link-js 的 C++ 部分包含大約 10 萬行程式碼。我不會把這麼多程式碼全都移植到 Rust,否則要花費一年時間!所以只選了 JavaScript 詞法分析器部分。這需要涉及專案中的其他部分:
診斷系統
翻譯系統(用於診斷)
多種記憶體分配器和容器(如 bump 分配器、適用於 SIMD 的字串等)
多種工具函式(如 UTF-8 解碼器、SIMD 封裝等)
測試輔助程式碼(如自定義的斷言宏)
C API
不幸的是,這個子集並不包含任何併發或 I/O 程式碼。也就是說,我沒辦法測試 Rust 的 async/await 在編譯時間上的額外開銷。不過在 quick-lint-js 中這種程式碼並不多,所以不是什麼大問題。
首先,我複製了所有 C++ 程式碼,然後刪掉了與詞法分析器無關的東西,如語法分析器和 LSP 伺服器等,直到無法刪除任何程式碼為止。整個過程中都要保證 C++ 測試透過。
將 quick-lint-js 的程式碼精簡到詞法分析器(以及它所需的任何其他程式碼)之後,得到了大約 1。7 萬行 C++ 程式碼:
重寫
如何重寫數千行 C++ 程式碼呢?只能一次重寫一個檔案。下面是具體的過程:
從某個模組著手;
複製程式碼和測試,用查詢替換的方法修正某些語法,然後不斷執行 cargo test,直到編譯透過、測試透過;
如果需要先轉換其他模組,則返回第二步對其進行轉換,然後再回到該模組;
如果還有模組尚未轉換,則返回第一步。
Rust 和 C++ 專案有一個主要區別可能會影響編譯時間。在 C++ 專案中,診斷系統中包含許多程式碼生成、宏和 constexpr。而在 Rust 移植中,我採用了程式碼生成、proc 宏、普通的宏,還有一些 const。我聽說 proc 宏很慢的原因只是它們很難寫好。我希望我的 proc 宏寫得還不錯。
最後的 Rust 專案要比 C++ 專案略大一些。C++有 16,600 行程式碼,而 Rust 有 17,100 行。
最佳化 Rust 的編譯時間
我很在意編譯時間。因此,我的 C++ 專案已經針對編譯時間做了許多最佳化。我需要針對 Rust 專案進行類似的最佳化。
我們來嘗試一下以下手段,以最佳化 Rust 專案的編譯時間:
更快的聯結器
Cranelift 後端
編譯器和聯結器的標誌
不同的工作區和測試佈局
儘可能減少依賴特性
cargo-nextest
透過 PGO 定製的工具鏈
更快的聯結器
第一步是對構建進行效能測試。首先透過 -Zself-profile 標誌進行測試。在我的專案中,該標誌會輸出兩個不同的檔案。在其中一個檔案中,run_linker 階段的時間最長:
我曾經將聯結器換成 mold linker,成功地改善了 C++ 的編譯時間。我們在 Rust 專案上試試看:
很可惜,幾乎看不到顯著的改善。
上面是 Linux 的情況。macOS 也有另一個聯結器:lld 和 zld。我們試試看:
在 macOS 上,換成另一種聯結器也沒有任何顯著的改善。可能是因為 Linux 和 macOS 的預設聯結器對於我的小專案來說已經非常優秀了。進一步最佳化的聯結器(Mold、lld、zld)可能在大型專案上表現更好。
Cranelift 後端
我們再來看看 -Zself-profile 效能測試。對於另一個檔案來說,LLVM_module_codegen_emit_obj 和 LLVM_passes 階段時間最長:
我聽說,除了預設的 rustc 後端 LLVM 之外,還有一個名為 Cranelift 的後端。我用 rustc Cranelift 後端嘗試編譯了一下,-Zself-profile 的結果很令人振奮:
但很可惜,使用 Cranelift 的實際編譯時間甚至還不如 LLVM:
編譯器和聯結器選項
編譯器有許多開關,可以加速編譯(或減緩編譯)。我們來嘗試一部分:
-Zshare-generics=y (rustc) (實驗性質的選項)
-Clink-args=-Wl,-s (rustc)
debug = false (Cargo)
debug-assertions = false (Cargo)
incremental = true and incremental = false (Cargo)
overflow-checks = false (Cargo)
panic = ‘abort’ (Cargo)
lib。doctest = false (Cargo)
lib。test = false (Cargo)
注意:quick, -Zshare-generics=y 相當於 quick, incremental=true 加上啟用 -Zshare-generics=y 標誌。其他條形圖沒有啟用 -Zshare-generics=y,因為該選項仍不穩定(因此只能用仍在開發中的Rust編譯器)。
大部分選項都有文件,但我沒看到有人說過使用 -s 連線選項。-s 能刪除除錯資訊,包括靜態連線的 Rust 標準庫中的除錯資訊。這就意味著聯結器的工作量更少,從而能減少連線的時間。
工作區和測試佈局
Rust 和 Cargo 對於檔案的位置有一定的靈活性。該專案有三種合理的佈局:
理論上,如果將程式碼分割到多個 crate 中,Cargo 就能並行呼叫 rustc。由於我的 Linux 機器有一個 32 執行緒的 CPU,macOS 機器有一個 10 執行緒 CPU,所以感覺啟用並行應該能降低構建時間。
對於給定的 crate,Rust 專案中也有多個地方可以放置測試用例:
由於依賴迴圈,我沒辦法針對 tests 位於 src 內的佈局進行測試。但我針對其他佈局的各種組合進行了測試:
工作區配置(不論是分離的測試可執行檔案(即多個測試用的exe檔案)或合併成一個測試可執行檔案(只有一個測試用的exe))似乎效果最好。所以我們後文採用工作區、多個測試可執行檔案的配置。
儘可能減少依賴特性
許多 crate 支援可選的特性。有時,可選特性是預設啟用的。我們用 cargo tree 看看啟用了哪些特性:
libc crate 有一個特性名為 std。我們將其禁用並測試,看看構建時間是否有改善:
構建時間並沒有任何提高。也許std特性並沒有什麼有意義的工作?
cargo-nextest
cargo-nextest工具宣稱“相較於cargo test,速度最多可以提高60%”。我的Rust程式碼中有44%都是測試,也許cargo-nextest有用。我們來試試並比較一下構建和測試的時間。
在我的Linux機器上,cargo-nextest並沒有改善,也沒有變差。雖然輸出結果漂亮了許多……
在macOS上會怎樣呢?
在我的MacBookPro上,cargo-nextest的確快了那麼一點點。不知道為什麼加速跟作業系統有關。也許實際上跟硬體有關?
採用透過PGO定製的工具鏈
對於C++構建來說,我發現透過PGO(profile-guided optimizations,根據效能測試進行的最佳化,有時也稱FDO)編譯出的C++編譯器,在效能上有很大提升。我們針對Rust工具鏈嘗試一下PGO,然後再嘗試用LLVM BOLT最佳化rustc,以及-Ctarget-cpu=native。
與C++編譯器相比,似乎透過rustup釋出的Rust工具鏈已經最佳化得很好了。PGO+BOLT帶來的效能提升不到10%。但提升就是提升,所以接下來我們使用最佳化後的工具鏈與C++作比較。
最佳化C++構建
在原始的C++專案quick-lint-js上工作時,我已經使用常見的技術對其進行了最佳化,如PCH、禁用異常和RTTI、調整構建選項、刪除無用的#include、將程式碼移出標頭檔案、將模板例項化外接等。但C++有多種編譯器和聯結器。我們來比較一下它們,然後選擇最好的一個跟Rust進行比較。
在Linux上,GCC顯然是個異類。Clang要快得多。而我自己構建的Clang(與Rust構建一樣,採用了PGO和BOLT)比Ubuntu自帶的Clang又有很大提升。libstdc++構建平均而言比libc++快一點點。我們採用自己構建的Clang和libstd++,代表C++與Rust進行比較。
在macOS上,Xcode自帶的Clang似乎比LLVM網站上提供的Clang工具鏈更好。我採用Xcode的Clang與Rust比較。
C++20模組
我的C++程式碼使用了#include。但C++20的import怎樣呢?C++20的模組會讓編譯更快嗎?
我在專案中嘗試了C++20。截至目前,Linux上的CMake對於模組的支援仍然處於早期試驗階段,就連基本的helloworld都不能正常工作。
也許2023年C++20的模組會有長足發展。我非常希望如此,但至少目前,我只能用C++傳統的#include。
C++和Rust的構建時間比較
我把C++專案移植到了Rust,並儘可能優化了Rust的構建時間。現在哪個編譯器更快,C++還是Rust?
在我的Linux機器上,Rust構建有時候比C++快,但有時慢,或者不相上下。在incremental lex測試中(該測試修改的檔案最大),Clang比rustc更快。但對於其他增量測試,rustc領先。
但是,在macOS上,結論完全不同。C++構建通常比Rust構建快得多。在incremental test-utf-8測試中(該測試修改的檔案為中等大小),rustc編譯得比Clang略快。但在所有其他增量測試以及完整構建中,Clang顯然要快得多。
對於超過1。7萬行的大專案
我只測試了1。7萬行程式碼的小專案。對於大專案(比如10萬行),構建時間如何呢?
為了測試C++和Rust編譯器在大專案上的表現,我選擇了最大的模組(詞法分析器)並將其程式碼和測試用例複製了多個副本(8個、16個以及24個)。
由於我的效能測試也包括了執行測試的時間,所以我認為時間應該會線性增加。
Rust和Clang的編譯時間都是線性增長的,符合我的預期。
對於C++而言,標頭檔案(incremental diag-types)的變化對構建時間的影響最大,這一點符合預期。在其他增量測試中,構建時間增長的幅度較小,這要歸功於Mold聯結器。
我對Rust感到失望,即使在incremental test-utf-8測試中,rust的表現也不盡如人意(該測試添加了一些不相關的檔案,因此不應該會受到太大影響)。該測試使用了工作區、多個測試exe檔案,這意味著test-utf-8應該有自己的可執行檔案,應該是單獨編譯的。
結論
Rust的編譯時間是問題嗎?是。有許多技巧可以加快構建,但我沒找到任何方法能夠帶來數量級上的提升。
Rust的構建時間是否和C++一樣差?是。對於大型專案,開發的編譯時間甚至比C++更差,至少對於我的程式設計風格是這樣。
回顧一下我的假設,可以看到假設的所有方面都錯了:
Rust移植版本比C++版本的程式碼行數更多,而不是更少。
對於完整編譯(1。7萬行程式碼),C++消耗的時間基本上與Rust相同,甚至更少(10萬+程式碼),而不會更多。
對於增量構建,Rust有時候消耗時間更短,有時更長(1。7萬行程式碼),或者長得多(10萬+程式碼),但也不一定。
是不是很失望?是的。在移植的過程中我學到了許多Rust的知識。例如,proc宏可以替換三種不同的程式碼生成器,從而簡化構建流水線,也可以讓貢獻者更容易提交程式碼。我並不想念標頭檔案,我也很感謝Rust的工具(特別是Cargo、rustup和miri)。
我決定不再移植quick-lint-js的其餘部分到Rust。但如果構建時間能顯著改善,也許我會改變主意。
《2022-2023 中國開發者大調查》重磅啟動,歡迎掃描下方二維碼,參與問卷調研,更有 iPad 等精美大禮等你拿!