Tips for Faster Rust Compile Times

Matthias Endler

加速 Rust 編譯時間的技巧

原文由 Matthias Endler 發布,訂閱此部落格

Rust 建置很慢?

這裡有一些加速編譯時間的技巧。這份清單最初發表在我的私人部落格上,但我決定更新後搬到這裡。

所有技巧大致依照影響程度排序,你可以從最上面開始依序往下嘗試。

一般技巧

更新 Rust 編譯器與工具鏈

請確保使用最新版本的 Rust:

rustup update

讓 Rust 編譯器變得更快是一項持續進行中的工作。多虧了他們的努力,編譯器速度今年至今已全面提升 30-40%,部分專案甚至提升了 45% 以上。保持工具鏈在最新狀態絕對值得。

改用 cargo check 而非 cargo build

# Slow 🐢
cargo build

# Fast 🐇 (2x-3x speedup)
cargo check

大多數時候,你根本不需要編譯整個專案;你只是想知道自己有沒有哪裡寫錯了。只要可以,就完全跳過編譯。你真正需要的是極速的程式碼檢查、型別與借用檢查。

盡可能改用 cargo check 而非 cargo build。它只會檢查程式碼是否有錯誤,而不會產生可執行檔。

比較一下左側 cargo check 與中間 cargo debug 在指令數量上的差異。(請注意兩者的刻度不同。)

加速倍數:check 1、debug 5、opt 20

我常用的一個小技巧是用cargo watch讓它在背景執行。這樣每當你修改檔案時,它就會自動執行 cargo check

小撇步:使用 cargo watch -c 在每次執行前清空畫面。

移除未使用的依賴

# Install cargo-machete 🔪️
cargo install cargo-machete && cargo machete

# Install cargo-shear ✂️🐑
cargo install cargo-shear

# Install cargo-udeps 🧼🧹️
cargo install cargo-udeps --locked

重構之後,有些依賴有時會變得不再需要。偶爾檢查一下是否能移除未使用的依賴會很有幫助。

上述工具會列出專案中所有未使用的依賴。每個工具都有其限制,可能會產生誤判與漏判。將三個工具搭配使用效果最好。

Analyzing dependencies of crates in this directory...
cargo-machete found the following unused dependencies in :
crate1 -- /Cargo.toml:
        clap
crate2 -- /crate2/Cargo.toml:
        anyhow
        async-once-cell
        dirs
        log
        tracing
        url

更多資訊請參考 cargo-machetecargo-shearcargo-udeps 的專案頁面。

感謝讀者Nicholas Nethercote 提到 cargo-shearcargo-udeps,他是《Rust Performance Book》以及知名系列文章《How to speed up the Rust compiler》的作者。

更新依賴

  1. 執行 cargo update 以更新至最新的 semver 相容版本。
  2. 執行 cargo outdated -wR 來找出更新、可能不相容的依賴。更新它們並視需要修正程式碼。
  3. 執行 cargo tree --duplicate 來找出存在多個版本的依賴。盡量透過更新依賴舊版本的套件,將其整合為單一版本。(感謝 /u/dbdr 指出這一點。)

(以上步驟由 Reddit 上的 /u/oherrala 提供。)

除此之外,請使用cargo audit來獲知需要處理的漏洞,或需要替換的已棄用 crate。

找出程式碼庫中編譯最慢的 crate

cargo build --timings

這會提供每個 crate 編譯所需時間的資訊。

cargo build --timings 的圖表

這張圖中的紅線顯示目前有多少個單元(crate)正在等待編譯(並被另一個 crate 卡住)。如果有大量 crate 都卡在同一個 crate 上,請專注於改善那個 crate 以提升並行度。

顏色的意義:

  • Waiting(紅色)—— 正在等待 CPU 空檔的 crate。
  • Inactive(藍色)—— 正在等待其依賴完成編譯的 crate。
  • Active(綠色)—— 正在編譯中的 crate。

更多資訊請參考文件

分析編譯時間

如果你想比 cargo --timings 更深入地挖掘,可以使用 cargo rustc -- -Zself-profile 來分析 Rust 編譯過程。產生的追蹤檔案可以用火焰圖或 Chromium 分析器來視覺化:

包含所有 crate 的 Chrome 分析器畫面

另一個很好用的工具是 cargo-llvm-lines,它會顯示最終執行檔中每個泛型函式所產生的程式碼行數與複製份數。這能幫你找出哪些函式的編譯成本最高。

$ cargo llvm-lines | head -20

  Lines        Copies         Function name
  -----        ------         -------------
  30737 (100%)   1107 (100%)  (TOTAL)
   1395 (4.5%)     83 (7.5%)  core::ptr::drop_in_place
    760 (2.5%)      2 (0.2%)  alloc::slice::merge_sort
    734 (2.4%)      2 (0.2%)  alloc::raw_vec::RawVec::reserve_internal
    666 (2.2%)      1 (0.1%)  cargo_llvm_lines::count_lines
    490 (1.6%)      1 (0.1%)  ::pipe_to
    476 (1.5%)      6 (0.5%)  core::result::Result::map
    440 (1.4%)      1 (0.1%)  cargo_llvm_lines::read_llvm_ir
    422 (1.4%)      2 (0.2%)  alloc::slice::merge
    399 (1.3%)      4 (0.4%)  alloc::vec::Vec::extend_desugared
    388 (1.3%)      2 (0.2%)  alloc::slice::insert_head
    366 (1.2%)      5 (0.5%)  core::option::Option::map
    304 (1.0%)      6 (0.5%)  alloc::alloc::box_free
    296 (1.0%)      4 (0.4%)  core::result::Result::map_err
    295 (1.0%)      1 (0.1%)  cargo_llvm_lines::wrap_args
    291 (0.9%)      1 (0.1%)  core::char::methods::::encode_utf8
    286 (0.9%)      1 (0.1%)  cargo_llvm_lines::run_cargo_rustc
    284 (0.9%)      4 (0.4%)  core::option::Option::ok_or_else

排查緩慢的增量建置

如果你的增量建置比預期慢,可能是卡在 rustc 後端的某個特定 crate。

要診斷這個問題,你可以將 samply-Zhuman_readable_cgu_names=yes 旗標搭配使用,對編譯器進行分析,找出哪個程式碼產生單元(codegen unit)的編譯時間最長:

samply record cargo build

這能幫你判斷是否有某個 crate 是建置過程中的瓶頸。一旦找出瓶頸,就可以透過重構或拆分有問題的 crate 來改善編譯時間。

關於 Rust 編譯器後端並行處理的更多細節,請參考 Nicholas Nethercote 的部落格文章

另一個有用的旗標是 -Zprint-mono-items=yes,它會在編譯期間印出所有單型化(monomorphized)項目。這能幫你了解每個 CGU 中產生了哪些內容。

RUSTFLAGS="-Zprint-mono-items=yes" cargo +nightly build                                               ⏎

感謝 Caspar(在 Reddit 上的 asparck)提供這個技巧!

替換重量級依賴

偶爾逛逛,尋找熱門 crate 更輕量的替代方案也會很有幫助。

同樣地,cargo tree 在這裡是你的好幫手,能幫你了解哪些依賴相當重量級:它們引入了許多其他 crate,造成大量網路 I/O 並拖慢建置。接著再去尋找更輕量的替代方案。

另外,cargo-bloat 有個 --time 旗標,可以顯示每個 crate 的建置時間。非常實用!

以下是幾個例子:

Crate替代方案
serdeminiserde, nanoserde
reqwestureq
claplexopt

這裡有個替換 crate 後將編譯時間從 2 分 22 秒縮短到 26 秒的範例。

使用 Workspaces 將大型 crate 拆分成較小的 crate

Cargo 有個很棒的功能叫做工作區(workspaces),它能讓你將一個大型 crate 拆分成多個較小的 crate。這種程式碼拆分對於避免重複編譯很有幫助,因為只有有變更的 crate 需要重新編譯。像 servovector 這樣的大型專案就大量利用工作區來縮短編譯時間。

停用 crate 依賴中未使用的功能

cargo-features-manager 是個相對較新的工具,能幫你停用依賴中未使用的功能。

cargo install cargo-features-manager
cargo features prune

不時檢查一下依賴的功能旗標。許多函式庫維護者會用心將 crate 拆分成可按需開關的獨立功能。也許你並不需要每個 crate 提供的所有預設功能?

例如,tokio一大堆功能,如果不需要就可以停用。

另一個例子是 bindgen,它預設會為其二進位用途啟用 clap 支援。這在常見的函式庫用法中其實不需要。停用該功能讓 rust-rocksdb 的 debug 與 release 建置編譯時間分別縮短了約 13 秒與 9 秒。感謝讀者Lilian Anatolie Moraru 提到這點。

特別提醒

關閉功能似乎並不總是能改善編譯時間。(請參考 tikv 在此的經驗。)但為了透過縮小程式碼的攻擊面來提升安全性,這仍可能是個好主意。此外,停用功能也有助於精簡依賴樹。

當你用 cargo add 安裝 crate 時,就會看到它的功能清單。

如果你想查詢某個 crate 的功能旗標,可以在 docs.rs 上找到。例如,請查看 tokio 的功能旗標

移除未使用的功能後,檢查 Cargo.lock 檔案的差異,看看清除了哪些不必要的依賴。

為耗時的程式碼加上功能開關

[features]
# Basic feature for default functionality
default = []

# Optional feature for JSON support
json = ["serde_json"]

# Another optional feature for more expensive or complex code
complex_feature = ["some-expensive-crate"]

專案中並非所有程式碼的編譯成本都一樣高。你可以使用 Cargo 的功能(features)將程式碼在比 crate 更細的粒度上拆成較小的區塊。這樣你就只需要編譯真正需要的功能。

這是函式庫常見的做法。例如,serde 有個名為 derive 的功能,用來啟用序列化與反序列化的程式碼產生。它並非總是需要,所以預設是關閉的。同樣地,Tokioreqwest 也有許多可以啟用或停用的功能。

你也可以在自己的程式碼中這麼做。在上面的範例中,Cargo.toml 裡的 json 功能會啟用 JSON 支援,而 complex_feature 功能則會啟用另一條較耗時的程式碼路徑。

找出觸發重新建置的根本原因

有時即使程式碼沒有任何變更,許多 crate 仍會重新建置,這通常是因為不同建置流程之間的環境變數不一致所導致(例如你的 Makefile 建置 vs rust-analyzer vs CI 建置)。

使用 cargo 的 fingerprint 日誌來精確找出觸發重新建置的原因:

export CARGO_LOG="cargo::core::compiler::fingerprint=info"
export RUST_LOG=trace
cargo build -vv

尋找顯示觸發重新建置原因的那一行。例如:

INFO prepare_target: cargo::core::compiler::fingerprint: dirty: EnvVarChanged { name: "VIRTUAL_ENV", old_value: None, new_value: Some("/path/to/.venv") }

Dirty pyo3-build-config v0.24.1: the env variable VIRTUAL_ENV changed

看到那行「Dirty」了嗎?它會精確告訴你是什麼導致了重新建置。你可以用 grep 搜尋那一行來找出所有重新建置的原因。

常見的 виновники包括:

  • 環境變數(CC、CXX、VIRTUAL_ENV、PATH 變更)
  • 不同建置工具之間的功能旗標不一致
  • 使用了不同的 cargo profile
  • 產生的檔案中的時間戳記差異

一旦找出原因,請確保所有建置流程的一致性。對於 VS Code 中的 rust-analyzer,你可以在 .vscode/settings.json 中設定對應的環境變數:

{
  "rust-analyzer.check.extraEnv": {
    "CC": "clang",
    "CXX": "clang++", 
    "VIRTUAL_ENV": "/path/to/your/.venv"
  },
  "rust-analyzer.cargo.features": "all"
}

這種除錯方式可以大幅減少因不同工具使用不一致的環境而導致的不必要重新建置。

致謝:此技巧出自 這篇文章,作者為 Ishan Bhanuka

使用 sccache 快取依賴

另一個很棒的專案是 Mozilla 的 sccache,它會快取已編譯的 crate 以避免重複編譯。

我在筆電上跑過一陣子,但老實說效益相當有限。如果你在許多共享相同版本依賴的獨立專案之間切換,它的效果最好。常見的使用情境是共享的建置伺服器。

Cranelift:替代的 Rust 編譯器

你知道嗎?Rust 專案在每次 CI 建置時,都會同時使用一個替代編譯器與 rustc 並行執行?

rustc_codegen_cranelift,也稱為 CG_CLIF,是 Rust 編譯器的一個實驗性後端,基於 Cranelift 編譯器框架。

以下是一些熱門 crate 在 rustc 與 Cranelift 之間的比較(藍色代表較佳):

rustc 與 cranelift 之間的 LLVM 編譯時間比較,cranelift 較快

該編譯器會產生可正常運作的可執行檔。它們的最佳化程度不會那麼高,但非常適合本地開發。

更詳細的說明請見 Jason Williams 的頁面,專案程式碼則在 GitHub 上。

改用更快的連結器

什麼是連結器?

連結器是一種將多個目的檔組合成單一可執行檔的工具。
它是編譯過程的最後一個步驟。

你可以透過執行以下指令來檢查連結器是否為瓶頸:

cargo clean
cargo +nightly rustc --bin  -- -Z time-passes

它會輸出每個步驟所需的時間,包括連結時間:

...
time:   0.000   llvm_dump_timing_file
time:   0.001   serialize_work_products
time:   0.002   incr_comp_finalize_session_directory
time:   0.004   link_binary_check_files_are_writeable
time:   0.614   run_linker
time:   0.000   link_binary_remove_temps
time:   0.620   link_binary
time:   0.622   link_crate
time:   0.757   link
time:   3.836   total
    Finished dev [unoptimized + debuginfo] target(s) in 42.75s

如果 link 步驟很慢,你可以嘗試改用更快的替代方案:

連結器平台可用於正式環境說明
lldLinux/macOS系統連結器的直接替代品
moldLinux針對 Linux 最佳化
zldmacOS否(已棄用)Apple ld 連結器的直接替代品

僅適用於 macOS:更快的增量除錯建置

Rust 1.51 新增了一個旗標,可在 macOS 上加速增量除錯建置。它可以讓除錯建置快上數秒(取決於你的使用情境)。有些工程師回報光是這個旗標就在 macOS 上將編譯時間縮短了 70%

將以下內容加入你的 Cargo.toml

[profile.dev]
split-debuginfo = "unpacked"

這個旗標可能很快就會成為 macOS 的預設值。它在 nightly 版上已經是預設值了。

僅適用於 macOS:將 Rust 編譯排除於 Gatekeeper 之外

Gatekeeper 是 macOS 上會對執行檔進行安全性檢查的系統。這可能會讓每次 Rust 建置都慢上幾秒。解決方法是將你的終端機加入 Developer Tools,這會讓由它啟動的處理程序被排除在 Gatekeeper 檢查之外。

  1. 在終端機中執行 sudo spctl developer-mode enable-terminal
  2. 前往「系統偏好設定」,然後進入「安全性與隱私」。
  3. 在「隱私」標籤下,前往 Developer Tools
  4. 確保你的終端機已列在清單中並已啟用。如果你使用 iTerm 或 Ghostty 等第三方終端機,也請一併加入清單。
  5. 重新啟動終端機。

在 macOS 的 Developer Tools 中將終端機排除於 Gatekeeper 檢查之外

感謝 nextestZed 開發者提供的技巧。

僅適用於 Windows:為 Rust 設定 Dev Drive

Windows 11 包含了 Dev Drive,一種為開發最佳化的檔案系統。根據微軟的說法,使用 Dev Drive 可望獲得約 20-30% 的速度提升

Dev Drive 效能圖表

為了提升 Rust 編譯速度,請將以下內容移至 Dev Drive:

  • Rust 工具鏈資料夾(CARGO_HOME
  • 你的專案程式碼
  • Cargo 的 target 目錄

你可以更進一步,也將上述資料夾加入防毒軟體的排除清單,以獲得額外的加速效果。你可以在 Windows 安全性中的「病毒與威脅防護設定」下找到排除設定。

Windows 上的防毒軟體排除設定

感謝 nextest 團隊提供的技巧。

調整 Codegen 選項與編譯器旗標

Rust 提供了大量程式碼產生(code generation)設定。瀏覽一下清單並為你的專案調整參數會很有幫助。

完整的 codegen 選項清單中有許多寶藏。作為靈感來源,這裡有 bevy 為了更快編譯的設定

避免使用程序宏 crate

如果你在專案中大量使用程序宏(例如使用 serde),可以試著在 Cargo.toml 中調整 opt-level。

[profile.dev.build-override]
opt-level = 3

如讀者 jfmontanaroGitHub 上所提到的:

我認為這能縮短建置時間的原因是,它只會套用至建置腳本和程序宏。建置腳本和程序宏的獨特之處在於,在一般建置過程中,它們不僅會被編譯,還會被執行(而就程序宏而言,它們可能會被重複執行)。當你的專案使用大量程序宏時,從理論上來說,最佳化這些宏本身就能節省大量時間。

另一種做法是嘗試使用 watt 來避開宏對編譯時間的影響,這是一個將宏編譯工作轉移至 WebAssembly 的工具。

引自文件:

透過將宏預先編譯為 Wasm,我們讓所有使用該宏的下游使用者都不必自行編譯宏的邏輯或其依賴。

相反地,他們需要編譯的只是一個小型的、獨立的 Wasm 執行環境(約 3 秒,由所有宏共用)以及每個宏 crate 一個微小的程序宏橋接層(每個依賴的 proc-macro crate 約 0.3 秒)。這遠比編譯複雜的程序宏及其依賴可能需要的 20 多秒要少得多。

請注意,此 crate 仍處於實驗階段。

找出耗時的程序宏

RUSTFLAGS="-Zmacro-stats" cargo +nightly build

有些宏的編譯成本很高;但到底有多高?量化這些成本有助於判斷某個宏是否值得最佳化(或移除)。一種方法是了解它們產生了多少程式碼。

一種方法是使用 cargo expand 來查看產生的程式碼,但這在大型的程式碼庫中難以擴展,且輸出難以量化。

另一種替代方案是使用 -Zmacro-stats 旗標來識別產生大量程式碼的程序宏。這個工具已經在 BevyArbitrary 等專案中帶來了成功的最佳化。

更多資訊請閱讀 Nicholas Nethercote 的部落格文章

程序宏的條件編譯

程序宏需要解析 Rust 程式碼,這是相對複雜的工作。依賴程序宏的 crate 必須等到程序宏編譯完成後才能編譯。例如,serde 可能會成為編譯時間的瓶頸,並限制 CPU 的使用率。

為了改善 Rust 的編譯時間,可以考慮在處理 Serde 序列化時採取策略性的方法,特別是在具有共享 crate 結構的專案中。與其將 Serde 直接放在專案各處共用的共享 crate 中,不如透過 Cargo 功能將 Serde 設為可選依賴。

使用 cfgcfg_attr 屬性,讓共享 crate 中的 Serde 用法與 derive 變成由功能開關控制。這樣它就成為一個可選依賴,只有在實際執行序列化/反序列化的最末端 crate 中才會啟用。

這種做法可以避免整個專案都等待 Serde 依賴編譯完成,而這正是當 Serde 作為共享 crate 的非可選、直接依賴時會發生的情況。

讓我們用一個簡化的範例來說明。想像你有一個 Rust 專案,包含一個共享的函式庫 crate 和其他幾個依賴它的 crate。你不會希望在建置不需要它的部分時,還不必要地編譯 Serde。

以下是如何透過 Cargo 的可選功能來組織專案:

在共享 crate 的 Cargo.toml 中,將 serde 宣告為可選依賴:

[package]
name = "shared"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1.0", optional = true }

在此 crate 中,使用條件編譯,只有在啟用該功能時才引入 serde:

#[cfg(feature = "serde")]
use serde::{Serialize, Deserialize};

#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MySharedStruct {
    // Your struct fields
}

在其他 crate 中,如果需要,請為共享 crate 啟用 serde 功能:

[package]
name = "other"
version = "0.1.0"
edition = "2021"

[dependencies]
shared = { path = "../shared", features = ["serde"] }

現在你可以在不增加不需要它的 crate 編譯負擔的情況下,使用已啟用 Serde 功能的 MySharedStruct

泛型:使用內部的非泛型函式

如果你有一個泛型函式,它會針對你使用的每種型別各編譯一次。如果你有許多不同的型別,這可能會成為問題。

一個常見的解法是使用內部的非泛型函式。這樣編譯器就只需要編譯內部函式一次。

這是在標準函式庫中常用的技巧。例如,以下是 read_to_string 的實作:

pub fn read_to_string>(path: P) -> io::Result {
    fn inner(path: &Path) -> io::Result {
        let mut file = File::open(path)?;
        let size = file.metadata().map(|m| m.len() as usize).ok();
        let mut string = String::with_capacity(size.unwrap_or(0));
        io::default_read_to_string(&mut file, &mut string, size)?;
        Ok(string)
    }
    inner(path.as_ref())
}

你也可以在自己的程式碼中這麼做:外部函式是泛型的,而它呼叫的內部非泛型函式則負責實際的工作。

使用 cargo-hakari 改善 Workspace 建置時間

你的大型 Rust 工作區是否有以下情況的依賴:

  1. 在多個 crate 中被使用
  2. 在這些 crate 之間具有不同的功能組合?

這種情況可能會導致建置時間變長,因為 cargo 會根據正在建置的 crate,以不同的功能組合將每個依賴建置多次。這時 cargo-hakari 就派上用場了。它是一個能自動管理「workspace-hack」crate 的工具。

在某些情境下,這可以將連續建置時間縮短多達 50% 或更多。想了解更多,請查看官方 cargo-hakari 文件中的使用說明與效能測試。

使用動態連結函式庫加速 Rust 增量編譯

# Install the tool
cargo install cargo-add-dynamic

# Add a dynamic library to your project
cargo add-dynamic polars --features csv-file,lazy,list,describe,rows,fmt,strings,temporal

這會為 polars 建立一個封裝 crate,並將其編譯為動態函式庫(在 Linux 上為 .so,在 macOS 上為 .dylib,在 Windows 上為 .dll)。

本質上,它是用以下設定來修補該依賴

[lib]
crate-type = ["dylib"]

透過這個技巧,當你只修改自己的程式碼時,就可以省下該依賴的連結時間。只有在你變更功能或版本時,該依賴本身才會重新編譯。當然,這對任何 crate 都適用,而不只是 polars

更多內容請參考 Robert Krahn 的這篇部落格文章以及工具首頁

切換至新的平行編譯器前端

在 nightly 版中,你現在可以啟用新的平行編譯器前端。若要試用,請使用 -Z threads=8 選項執行 nightly 編譯器:

RUSTFLAGS="-Z threads=8" cargo +nightly build

如果你覺得效果不錯,可以將 -Z threads=8 加入你的 ~/.cargo/config.toml 檔案,將其設為預設值:

[build]
rustflags = ["-Z", "threads=8"]

或者,你也可以在 shell 的設定檔(例如 ~/.bashrc~/.zshrc)中為 cargo 設定別名:

alias cargo="RUSTFLAGS='-Z threads=8' cargo +nightly"

當前端在以 -Z threads=8 的多執行緒環境中執行時,對實際程式碼的效能測試顯示編譯時間最多可縮短 50%。不過,實際的提升幅度會因編譯的程式碼而異。但絕對值得一試。

以下是平行編譯器前端運作時的視覺化呈現:

平行編譯器的執行結果

更多資訊請參考 Rust 官方部落格上的公告

使用暫存磁碟加速建置

你的檔案系統可能是瓶頸。可以考慮為建置目錄使用記憶體內檔案系統。

傳統的暫存檔案系統如 tmpfs 受限於你的 RAM 加上交換空間的大小,對於會產生大量中繼產物的建置來說可能會有問題。

相反地,在 Linux 上,請使用以下選項掛載 ext4 磁碟區:

-o noauto_da_alloc,data=writeback,lazytime,journal_async_commit,commit=999,nobarrier

如果你有足夠的 RAM,這會將檔案儲存在頁面快取中,並在之後才寫回。請將其視為暫存檔案系統,因為在當機或斷電後,資料可能會遺失或損毀。

致謝:Reddit 上的 /u/The_8472

投資更好的硬體

如果你已經試到這裡,想進一步改善編譯時間,最簡單的方法大概就是花錢購買頂級的硬體。

就筆記型電腦而言,Apple 新款 MacBook 的 M 系列 晶片在 Rust 編譯上的表現非常出色。

Rik Arends 在 Twitter 上的推文

搭載 M1 Max 的 MacBook Pro 的效能測試結果非常驚人——即使與已經很快的 M1 相比也是如此:

專案M1 MaxM1 Air
Deno6m11s11m15s
MeiliSearch1m28s3m36s
bat43s1m23s
hyperfine23s42s
ripgrep16s37s

這是扎扎實實的 2 倍效能提升。

但如果你比較想繼續使用 Linux,大家在使用多核心 CPU 如 AMD Ryzen Threadripper 加上 32 GB 記憶體上也有很好的成果。

在可攜式裝置上,編譯會耗盡電池且速度較慢。為了避免這個問題,我在家中使用一台 6 核心 AMD FX 6300 搭配 12GB 記憶體的電腦作為建置主機。我可以搭配 Visual Studio Code Remote Development 來使用它。

在雲端編譯

如果你自己沒有專屬的機器,也可以將編譯過程轉移到雲端。GitHub Codespaces 能在 GitHub 中直接為你提供一個完整設定好的雲端開發環境。你可以點擊 Code 按鈕並選擇 Open with Codespaces,從任何儲存庫啟動一個 codespace。機器規格從 2 核心到 32 核心都有,每個 GitHub 帳號都包含免費的每月額度(目前在 2 核心機器上為 60 小時)。

Codespaces 支援透過 .devcontainer/devcontainer.json 檔案進行開發容器設定,讓你可以預先安裝 Rust 工具鏈和任何其他依賴,環境一啟動就準備就緒。這在審查 Pull Request 時特別有用——直接在 codespace 中開啟 PR,一切都已經設定好了。

如果你想要對建置環境有更多掌控,Northflank 是另一個選擇。它讓你在專用的雲端基礎設施上執行任意的建置工作,方案從共享的 0.1 vCPU 容器到 32 個專用 vCPU 都有。有免費的沙盒方案,付費方案則以每秒計費,因此你只需為實際的建置時間付費。

在本地快取所有 crate

如果你有較慢的網路連線,初始建置過程中很大一部分時間都花在從 crates.io 取得那些閃亮的 crate 上。為了解決這個問題,你可以預先下載所有 crate 以便在本地快取。criner 正是為此而生:

git clone https://github.com/the-lean-crate/criner
cd criner
cargo run --release -- mine

封存檔的大小出乎意料地合理,所需的磁碟空間大約為 50GB(以目前為準)。

測試執行

改用 Cargo Nextest 而非 cargo test

cargo install cargo-nextest
cargo nextest run

cargo 內建一個小型的測試執行器固然不錯,但特別是當你需要建置多個測試執行檔時,cargo nextest 憑藉其平行執行模型可以比 cargo test 快上多達 60%。以下是一些快速的效能測試

專案版本測試數量cargo test(秒)nextest(秒)提升幅度
cruciblecb228c2b4835.141.523.38×
guppy2cc51b412716.422.802.29×
mdBook0079184c1993.851.662.31×
meilisearchbfb1f92772157.0428.991.96×
omicrone7949cd1619444.08202.502.19×
penumbra4ecd94cc144125.3890.961.37×
reqwest3459b8941135.572.262.48×
ring450ada2817913.129.401.39×
tokio1f50c571113824.2711.602.09×

將所有整合測試合併為單一執行檔

有任何整合測試嗎?(就是那些在你 tests 資料夾中的測試。)你知道 Rust 編譯器會為它們每一個都建立一個執行檔嗎?而且每個執行檔都必須個別連結。這可能會佔用你大部分的建置時間,因為連結非常慢 🐢。原因是許多系統連結器(如 ld)是單執行緒的。

為了讓連結器的工作輕鬆一點,你可以將所有測試放在同一個 crate 中。(基本上就是在你的 test 資料夾中建立一個 main.rs 並將測試檔案作為 mod 加入其中。)

如此一來,連結器就只需要建置單一的執行檔。聽起來不錯,但請小心:這仍然是一種取捨,因為你需要將內部型別與函式公開(即設為 pub)。

如果你有大量整合測試,這可以帶來 50% 的加速

此技巧由 Luca PalmieriLucio FrancoAzriel Hoh 提供。感謝!

將慢速測試放在環境變數後面

#[test]
fn completion_works_with_real_standard_library() {
  if std::env::var("RUN_SLOW_TESTS").is_err() {
    return;
  }
  ...
}

如果你有執行緩慢的測試,可以將它們放在環境變數後面,預設停用它們。這樣你就可以在本地跳過它們,只在 CI 上執行。

(這個好用的技巧是我從 matklad(Alex Kladov)的文章中學到的。)

CI 建置

CI 建置技巧

本文中的許多技巧也適用於 CI 建置。關於 CI 專屬的最佳化與最佳實踐,請查看我另外撰寫的《加速 CI 建置的技巧》專文,其中涵蓋了快取策略、工作流程最佳化以及針對 GitHub Actions 的改進。

為依賴使用快取

特別是對於 GitHub Actions,你也可以使用 Swatinem/rust-cache

它使用起來非常簡單,只需在你的工作流程中加入一個步驟:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - run: cargo test --all

如此一來,你的依賴就會在建置之間被快取,你可以預期獲得顯著的加速。

將編譯與測試步驟分開

- name: Compile
  run: cargo test --no-run --locked

- name: Test
  run: cargo test -- --nocapture --quiet

這能讓你更容易看出有多少時間花在編譯上,多少時間花在執行測試上。

在 CI 中停用增量編譯

env:
  CARGO_INCREMENTAL: 0

由於 CI 建置更像是從零開始的建置,增量編譯會增加不必要的依賴追蹤與 I/O 開銷,降低快取的效果。這裡說明了如何停用它。

關閉除錯資訊

[profile.dev]
debug = 0
strip = "debuginfo"

避免連結除錯資訊以加速建置過程,特別是當你很少實際使用除錯器時。有兩種方式可以避免連結除錯資訊:設定 debug=0 以跳過編譯它,或設定 strip="debuginfo" 以跳過連結它。可惜的是,更改這些選項可能會觸發 Cargo 的完整重新建置。

  • 在 Linux 上,兩者都設定可獲得較佳的建置時間。
  • 在 Mac 上,請使用 debug=0,因為 rustc 使用外部的 strip 指令。
  • 在 Windows 上,請測試兩種設定,看看哪一種更快。

請注意,沒有除錯資訊時,回溯(backtrace)將只會顯示函式名稱,而不會顯示行號。如果需要,可以使用 split-debuginfo="unpacked" 作為折衷方案。

作為額外的好處,這也有助於縮小 ./target 的大小,提升快取效率。

這裡有一個範例設定,說明如何套用這些設定。

透過環境變數將警告視為錯誤

避免在程式碼中使用 #![deny(warnings)] 以免重複宣告。此外,在本地開發時出現警告是沒關係的。

相反地,請在 CI 上將 -D warnings 加入 RUSTFLAGS,以在所有 crate 中全域地將警告視為錯誤。

env:
  RUSTFLAGS: -D warnings

改用更快的 GitHub Actions Runner

- runs-on: ubuntu-latest
+ runs-on: ubicloud

UbicloudBuildJetRunsOn 這類服務能為你的 GitHub Actions 建置提供更快的執行器。特別是對於 Rust 專案,核心數量對編譯時間可能有非常大的影響,因此值得一試。

以下是來自 Facebook Folly 專案使用 Ubicloud 的範例。雖然這是一個 C++ 專案,但它展現了更快執行器的潛力:

facebook/folly 建置時間

在向該服務註冊後,你只需要在 GitHub Actions 的工作流程檔案中更改 runner 即可。

更快的 Docker 建置

使用 cargo-chef 加速 Docker 建置

正在從你的 Rust 程式碼建置 Docker 映像檔嗎?這些建置可能非常慢,因為 cargo 還不支援僅建置專案的依賴,如果不注意,每次建置都會使 Docker 快取失效。cargo-chef 來救援了!⚡

cargo-chef 可用於充分利用 Docker 的層快取,從而大幅加速 Rust 專案的 Docker 建置。在我們的商業程式碼庫(約 14k 行程式碼、約 500 個依賴)上,我們測得 5 倍的加速:將 Docker 建置時間從約 10 分鐘縮短至約 2 分鐘。

如果你有興趣,這裡有一個 Dockerfile 範例:

# Step 1: Compute a recipe file
FROM rust as planner
WORKDIR app
RUN cargo install cargo-chef
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

# Step 2: Cache project dependencies
FROM rust as cacher
WORKDIR app
RUN cargo install cargo-chef
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json

# Step 3: Build the binary
FROM rust as builder
WORKDIR app
COPY . .
# Copy over the cached dependencies from above
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo
RUN cargo build --release --bin app

# Step 4:
# Create a tiny output image.
# It only contains our final binary.
FROM rust as runtime
WORKDIR app
COPY --from=builder /app/target/release/app /usr/local/bin
ENTRYPOINT ["/usr/local/bin/app"]

cargo-chef 能協助加速你使用 GitHub Actions 的持續整合或部署至 Google Cloud 的流程。

考慮使用 Earthly 來獲得更好的建置快取

Earthly 是一個相對較新的建置工具,旨在取代 Makefile、Dockerfile 和其他建置工具。它為 CI 提供了快速、增量的 Rust 建置。

Earthly 透過有效實作 Cargo 的快取與 Rust 的增量編譯來加速 CI 中的 Rust 建置。這種方法顯著減少了 CI 中不必要的重新建置,重現了本地 Rust 建置的效率。

來源:Earthly for Rust

他們使用一套名為 Satellites 的系統,這是會在本地保留快取資料的持久性遠端建置執行器。這可以透過消除快取的上傳與下載,大幅縮短 CI 建置時間。他們不是將快取資料帶到運算端,而是將快取資料與運算放在一起,從根本上消除了快取傳輸。更少的 I/O 意味著更快的建置。

Earthly 還提供了一個 lib/rust 函式庫,它完全抽象化了快取設定。它能確保 Rust 在 CI 中正確地進行快取與增量建置。你可以在你的 Earthfile 中這樣使用它:

IMPORT github.com/earthly/lib/rust

如果你有興趣,Earthly 的 Rust 指南詳細介紹了一個具有最佳化快取與編譯步驟的簡單 Rust 範例。

針對 IDE 的最佳化

如果你發現開發環境中的建置時間很慢,這裡還有一些可以嘗試的額外技巧。

Visual Studio Code 中緩慢的除錯階段

如果你使用 Visual Studio Code 並發現除錯階段很慢,請確保你沒有設定太多中斷點。每個中斷點都可能會拖慢除錯階段

關閉不相關的專案

如果你在 Visual Studio Code 中同時開啟多個專案,每個視窗都會執行各自的 rust-analyzer 實例。這可能會拖慢你的電腦。如果不需要,請關閉不相關的專案。

修正 Rust Analyzer 快取失效問題

如果你在 VS Code 中使用 rust-analyzer,並發現儲存變更時建置時間變慢,可能是快取失效所導致。這也會導致像 serde 這樣的依賴被頻繁地重新建置。

你可以透過為 rust-analyzer 設定獨立的 target 目錄來修正這個問題。將以下內容加入你的 VS Code 設定(建議加在使用者設定中):

{
    "rust-analyzer.cargo.targetDir": true
}

這會讓 rust-analyzer 在 target/rust-analyzer 而非預設的 target/ 目錄中進行建置,避免與你一般的 cargo run 建置互相干擾。

有些使用者回報因此獲得了顯著的加速

before: 34.98s user 2.02s system 122% cpu 30.176 total
after:   2.62s user 0.60s system 84% cpu 3.803 total

這也可能有助於解決 rust analyzer 阻擋除錯建置的問題。

致謝:此技巧由 Reddit 上的 asparck 分享。

總結

在本文中,我們涵蓋了許多內容。我們探討了如何透過使用更好的硬體、最佳化程式碼和使用更好的工具來加速 Rust 的建置。

關於 CI 專屬的最佳化,別忘了查看《加速 CI 建置的技巧》,它補充了本文討論的各種技術。

希望你能運用這些技巧來加速你的 Rust 建置。如果你找到了其他加速 Rust 建置的方法,或有任何問題或回饋,我很樂意聽聽你的想法。

延伸資源

本文章由 muse-spark-1.2-contributor 進行翻譯

留言