我喜歡 Rust 已經很久了,它的優點很明顯,同時缺點也很明顯。內存 Safe 無需 GC、零成本抽象、並發安全、幾乎現階段最好的工具鏈和配套外圍建設、超強的覆蓋面(上到 Web 雲伺服器,下到單片機),還有錯誤處理設計非常好。缺點嘛,學習陡峭?難以理解 Ownship,Borrow 之類的模型約束;寫起來很慢?
長久以來,我們都認為這是 Rust 的缺點,但是實際上前兩者都是可以透過鍛鍊思維和認知來解決的,而編譯速度慢卻一直拖慢著我的工作流,這一切在早幾年似乎很好,AI 席卷而來之前我可有的就是耐心,常常一個演算法一個功能寫到凌晨,可,現在呢?我很難說我沒有變,我開始變得一點都沒有耐心了,一點都不想思考 Rust 代碼怎麼寫,一點都等不了 Cargo 那個漫長的進度條,尤其是 ld 部分還沒有進度,每次就干卡著,這真的很難受。
這一切難道就沒有辦法了嗎?其實是有的。在大約一個月前,我嘗試了一些辦法讓我的 Rust 開發流程更快,包括工作流程優化之類的,但其實每次等待的時間都卡在 Cargo 上。如果不能解決這個就無法治本,那正好藉這個機會來稍微說一下關於 Rust 開發中 Cargo.toml 設定調優相關的內容和配套週邊設定吧。
後端理論
在 Rust 的設計中,LLVM 雖然有點偷懶,但選擇它是明智的,與 Go 自行維護產生機器碼的過程不同,讓 LLVM 這個龐大的生態系統來負責成熟的最佳化實際上效果很好。Rust 的整體編譯流程大致如下:原始碼先進行詞彙分析與語法分析,產生 AST,接著經過巨集展開與 HIR 降階平坦化。之後執行一般的型別檢查以及少量的借用檢查,最終產生 MIR;到此為止,全部由 Rust 編譯器的前端負責。MIR 之後的工作交給 LLVM,例如將 MIR 轉譯成 LLVM IR,接著 LLVM 執行一系列的最佳化 pass(從 O0 到 O3 不等),最後產生目標平台的機器碼,交給連結器拼湊成可執行的 BIN 檔案。
那麼到底問題出在哪裡呢?答案其實已經很簡單了。LLVM 是一個很重型的基礎設施,重型在生產環境下是正確的,用編譯時間換取執行時間是非常好的哲學,它銜接了上層 Rust,以及 C、C++、Swift 等一大堆語言。這意味著它的最佳化管線是為「產生最優機器碼」而設計的,而不是為「快速完成編譯」而設計的。那數十個最佳化 pass 在 release 模式下執行一遍非常耗時,即便是 debug 模式下的 O0,LLVM 依然要走完 IR 產生和機器碼產生的完整流程,而這個流程本身就並不輕量。再加上 Rust 的泛型單態化會在編譯期展開大量程式碼,交給 LLVM 的 IR 體量遠比你原始碼看起來的要大得多,LLVM 需要處理的工作量自然也隨之膨脹。
Cranelift
所以本質上,編譯慢的很大一部分時間不是花在 Rust 自己的前端而是花在了 LLVM 這個"重型後端"上。那麼問題來了,有沒有可能丟掉它嘛,答案是有的喵,有一個叫做 Cranelift 的後端就是一個為了這個需求做的。它最早是 Cretonne,2016 年啟動,由 Bytecode Alliance 開發,最初是為 Wasmtime 包的餃子,才設計的程式碼生成後端;後被 Rust 官方收編作為可選的 codegen 後端。

那所以 Cranelift 到底應該快在哪裡呢?最好的階段就是開發時期。這個時候也許你根本不需要接近完美的、極限優化執行效率的機器碼,也許我們只需要一個快速的反饋而已,只要這個不是那麼好的機器碼和 LLVM 正經算出來的機器碼語意等價就行了。早期的 Cranelift 其實做不到,因為有很多 Edge Case,雖然現在還是有,但是已經改善很多了,現在出現 Cranelift 可以跑但是 LLVM 壞掉的機率差不多和你寫 rust 觸發 rustc ICE 差不多了。LLVM 為了生成極致優化的機器碼,會跑幾十個優化 pass,比如迴圈展開、向量化、常數傳播、消除死程式碼之類的…… 很多很多,一層一層磨,而 Cranelift 的設計哲學完全不同,它大幅削減了這些優化步驟,只做最基本的暫存器分配和指令選擇,用一趟線性掃描就完成程式碼生成,不反覆迭代。同時它的 IR 設計也更輕量,是專門為快速從上層 IR 翻譯到機器碼設計的,不像 LLVM IR 那樣承載著幾十年的通用化包袱。
那麼好處說完了,代價呢?首先顯而易見的是 Cranelift 生成的程式碼執行期效能比 LLVM 差,根據場景不同大概會慢 10%~30%。但想開了其實就會發現,這在開發階段根本不重要,我要那麼快幹嘛,你是跑分還是 CI release 啊,我就 tm 要跑 cargo build 快點,看看邏輯出沒出來,而且我賭,你寫的大部分程式不可能 80% 以上的時間占滿計算資源,肯定是要有業務來的,你開發的時候業務能有多大?cpu 怕不是全程都在摸魚呢,反倒是我要看效果、驗證邏輯。那編譯時間換執行期效能,這筆帳在開發迴圈裡是不是應該倒過來算嘛。
程式碼生成單元
另外說說 codegen-units,這其實就是一個控制編譯器把 crate 拆成多少個最小單元給後端並行處理的參數,預設情況下,debug 模式是 256 個,release 模式是 16 個。數字越大,並行度越高,編譯越快——因為可以同時利用多個 CPU 核心來跑後端程式碼生成。但代價是優化效果變差,因為每個單元是獨立優化的,LLVM(or Cranelift)看到的上下文變小了,跨單元的內聯和優化機會就少了。
但是在真實的 Release 下,一般來說都應該使用 codegen-units = 1 這樣一個極端的參數,只有這樣才能讓產物得到最大化的優化,畢竟你都用 Rust 了,編譯時間換執行時效能難道不是理所當然的嘛。
優化等級
另一個經常被忽略但是可以調整的設定就是 opt-level,預設情況下應該是 "3",而作為 Release,更常見的做法是開到 "z",這就是要在不修改程式碼的前提下,可以獲得的免費產物體積最佳化,為什麼不開呢;但是呢,開發模式下這裡應該設定為 "0",完全不最佳化才能獲得最快的速度。
另外還有一個小技巧就是 Cargo.toml 其實可以給依賴和自己的程式碼設定不同的優化等級:
[profile.dev.package."*"]
opt-level = 3這樣就可以給第三方相依套件用 O3 編譯,這樣只會增加第一次冷編譯的時間,後續就是增量編譯的。外部相依套件一般不會頻繁更新,這裡用 O3 自己業務程式碼用 O0 可以獲得一般情況下權衡速度和體積的良好 dev 模式體驗,但是 Release 模式下我還是建議 O3 或者 Z 拉滿沒啥好說的。
連結段優化
LTO(Link‑Time Optimization)是另一項非常佔用記憶體與編譯效能的功能,而且大多數專案在 Release 時都建議開啟。正常編譯時,每個 crate 會獨立優化,編譯後端看不到 crate 之間的呼叫關係,某些跨 crate 的內聯與死碼消除做不到,這種情況相當常見;LTO 便是打破這個界限,讓優化器在連結時取得所有 crate 的 IR,進行全域最佳化。但千萬不要在 profile 上無腦掛 lto = true,這會讓你的 Rust 編譯變得慢到懷疑人生,只要在使用 release profile 時掛上即可。
去除除錯符號
和其他東西一樣,Rust 的 debuginfo 和 symbols 就是存在於這裡的,release 模式開啟 strip 可減少非常可觀的體積(通常是50MB vs 5MB)這種極大的差距,去除 symbols 也更加安全,畢竟大部分時候前端 source code 都是從 map 裡面不小心 push 上 npm 洩漏的 :(
所以 dev profile 下沒什麼好說的,你總不能 strip 掉 debuginfo 吧,那樣你就沒法用 gdb / lldb 正常調試了,backtrace 裡也看不到有意義的函數名。但是注意,ArchLinux 打包的時候會預設幫你 strip,但是這裡就會和我們有一點小摩擦,那就是我們已經 strip 掉了,後面可能會報錯。這個如果寫 AUR 的時候可以注意一下顯式跳過這一步。
放棄 panic!
在正常的業務中,panic 應該是不能存在的,正常業務程式碼 runtime 遇到錯誤的時候,但凡是設計了容錯的 Err 都應該被安全地傳回,而不是暴力 panic 掉,就和 React ErrorBoundary 一樣,應該被正常當作錯誤處理,panic 只有在程式碼堅信進入了不可能的狀態,且無法挽回的時候才應該觸發。我個人哲學是放棄 panic,為什麼呢?因為實際上業務程式碼可以作為 3 層測試,第一層 happy path,第二層錯誤路徑(無限種可能中的某一種),第三層才是 fuzz or 碰撞測試窮舉出來的 Edge Case。工程學上來說,正確路徑可以做到 100% 測試,錯誤路徑可以一類 n 種變體中取 1 種;碰撞測試純粹是時間問題,大部分時候不值得,等你的使用者體量真的上來了,要做的時候你自己就會知道,通常我一律不做。
因此,對於優質的程式碼而言,應該覆蓋一類正確路徑的測試與至少一種錯誤路徑的測試,而只要覆蓋此類錯誤路徑中的一個,即可測試 Err 被返回的情況,從而編寫 fallback 邏輯,自然可以 thiserror 列舉或 anyhow 截取並平攤列印記錄。總之,你的程式碼會迫使你為這些情況寫出處理機制;如果已經做到這一層,panic 對你就沒用了,可以理解為「理論上不可能」,但這僅止於理論。實際世界中仍有 OS 錯誤、記憶體錯誤、宇宙射線單電子位元翻轉、各種奇怪的溢位…… 這些情況永遠不可能全部覆蓋。那為什麼不使用 panic 呢?因為這些情況大多不是你的程式碼問題,約有 90% 的機率不是由你造成的。panic 的本質在於發生時,Rust 會沿著呼叫堆疊逐層回溯,依序呼叫每一幀的析構函式(drop)以釋放資源。這個過程需要編譯器產生額外的 unwind 表格,協助你找出業務邏輯錯誤的地方。如果錯誤本身大多不是你的,那這資訊的價值何在?它也會增加編譯產物大小,並給連結器帶來額外工作量,關閉才是正解;當你的程式碼測試到位且足夠自信時,建議設定 panic = abort。
實際測試
那麼多說無益,實際來看看這些組合下的提升吧,這裡拿一個我前幾個月做的小玩具專案作為參考.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Language Files Lines Code Comments Blanks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CSS 5 152 113 16 23
Go 77 14556 12409 584 1563
JavaScript 23 2506 1999 232 275
JSON 93 1780 1780 0 0
Just 1 408 275 71 62
Makefile 1 15 8 3 4
Shell 16 1440 1075 166 199
SVG 1 9 9 0 0
TOML 20 502 419 3 80
TSX 75 3257 2718 132 407
TypeScript 390 36643 30350 1627 4666
─────────────────────────────────────────────────────────────────────────────────
HTML 2 41 32 7 2
|- CSS 1 37 37 0 0
(Total) 78 69 7 2
─────────────────────────────────────────────────────────────────────────────────
Markdown 95 5344 0 3578 1766
|- BASH 3 8 8 0 0
|- Go 2 21 21 0 0
|- HTML 1 25 15 10 0
|- JSON 6 317 317 0 0
|- Rust 1 10 9 0 1
|- TSX 1 11 9 0 2
(Total) 5736 379 3588 1769
─────────────────────────────────────────────────────────────────────────────────
Rust 181 31654 26951 1032 3671
|- Markdown 86 581 0 564 17
(Total) 32235 26951 1596 3688
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total 980 99317 78554 8025 12738
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━這個項目裡面 Rust 的部分差不多有 32K SCoL Rust,不包含依賴體積。
這個倉庫實際上是一個有很多子項目的 monorepo,但是其中可以找出 2 個典型的例子,首先看看 Skeleton 這個包特點就很明顯,代碼量大,依賴極少,這個包會是整個項目裡面 codegen 佔比最高的,這個就會很適合 Cranelift 發揮,另外就是這個包也很乾淨,乾淨指的是純 Safe Rust。
反觀下面這個 CLI 包就不是很合適,相依性多倒不是什麼問題,理論上這樣更能體現效率,但是這裡有一個經典的二選一,可以在左上角看到 ring 這個相依性標記為選配了;那是因為專案本來用的是 aws-lc-rs,這兩個東西都是 Rust 中的加密演算法 Backend;但是為什麼要可選呢?那是因為 ring 是純 Rust 寫的,而另外一個是 Asm 組合語言 FFI 進來的,專為 x86 arm64 等主流 cpu 架構組合語言加速,但是敗也在此,因為 Cranelift 的魔法只局限於純 Rust,一旦你引進 Unsafe Code,or FFI C、ASM 之類的,此時實際上 Cranelift 相容性極差。
但是這也不是無解,其實可以用 cfg 選後端,Cranelift 模式下給 ring 來編譯就好了。在按照上述描述配置好 dev 和 release profile 後,就可以簡單跑一下 CLI 的編譯對比,在這個情況下,開發至少比發布快 3 倍,這還都是冷編譯,沒有增量的情況下;並且使用 O0 和關閉 LTO,dev profile 在後續的增量更新中都應該比 release 快幾十倍不止。因為 LTO 之類的魔法實際上是透過拍平各個 crate 的邊界得來的,那麼都變成一個整體後,修改一處程式碼,當前一起都要重新編譯無法被正確增量更新。
![這是一張終端機視窗的螢幕截圖,視窗標題為「~/C/P/seam」,顯示名為「seam」的專案執行 `cargo build --release` 的最後階段。捲軸列出數十個依賴 crate 按順序編譯——walkdir、rand、tungstenite、sha2、toml、notify、rquickjs、clap 等——接著是專案本身的工作區成員,如 seam-codegen、seam-skeleton、seam-injector-wasm、seam-engine-wasm、seam-server、seam-server-axum、seam-cli,以及數個範例後端(demo-server-rust、github-dashboard-axum、markdown-demo-rust、i18n-demo-axum)。最後幾行確認成功:`Finished `release` profile [optimized] target(s) in 1m 05s`,接著是一個閒置的 Shell 提示字元 `canmi@xyy ~/C/P/seam (main)>`。這證明了這個包含 WASM 與 Axum 伺服器元件的多 crate Rust monorepo 已完成乾淨、完整的 Release 建置。](https://cdn.ffoni.com/image/e8b530aa153a78f1cdea3d2980f80ecd.jpeg)
![終端畫面截圖顯示了 Rust 工作區「seam」的 Cargo 建置日誌尾端,目錄為 `~/C/P/seam`,使用者 `canmi` 在 `main` 分支上執行 shell。數十個依賴 crate 依序編譯(如 console、reqwest、clap、tokio-tungstenite、notify、indicatif 等),接著是專案自己的 crate —— seam-codegen、seam-server-axum、seam-skeleton、seam-injector-wasm、seam-engine-wasm、seam-cli,全部為 0.5.38 版 —— 以及數個示例後端(github-dashboard-axum、i18n-demo-axum、markdown-demo-rust、demo-server-rust)。日誌以 `Finished \`dev\` profile [unoptimized + debuginfo] target(s) in 19.23s` 結束,表示整個工作區包括其示例應用程式已成功完成除錯建置。](https://cdn.ffoni.com/image/71aa2631cd4c616d2ecca6e510cf06f4.jpeg)
綜合結果看下來這兩玩意其實差不多,談不上質變但是絕對能明顯感知,主要原因是 Apple Silicon 太猛了,M 系列晶片的單核效能、記憶體頻寬都強得離譜,編譯這種重計算+重 I/O 的任務剛好吃到這些優勢。如果用 Linux 跑跑通常來說差距會更大,但是如果在一台 MacBook 刷 Asahi Linux 之類的肯定是 Linux 贏。
老舊的連結器
在 Linux 上,Rust 預設使用的是 GNU ld(bfd),對,就是那個最老最慢的。單執行緒且處理符號解析與重定位都是串行掃描,專案一大就明顯拖後腿了。Linux 上可以試試 @Rui Ueyama 寫的 mold,設定起來非常簡單,只是在 .cargo/config.toml 裡加一行的事。
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
提升還是很明顯的,但很遺憾 macOS 上只有 sold 這個商業專案,不過好消息是 macOS 上的 lld 或 Apple 自帶的 ld64 本身已經不慢了,特別是 Xcode 15 之後那個新連結器,速度提升超級明顯,只不過這玩意唯一的缺點就是 macOS 無人值守 CI 和更新後每次都要 sudo Accept 一個條款,導致我好幾次 CI 定時任務掛掉。
MUSL
我本人是一個 MUSL 廚,非常討厭 GNU glibc,但是開發我還是推薦你使用 x86_64-unknown-linux-gnu。為什麼呢,因為快。MUSL 最大的賣點是能生成完全靜態連結的二進位檔——不依賴系統上的任何動態庫,編譯出來的東西拷到任何 Linux 機器上就能跑,特別適合容器和嵌入式。但是慢也慢在靜態連結,因為連結器需要把所有東西都打包進去,連結階段的工作量比動態連結大不少。
不過好消息是如果你用 macOS 的話,那就完全不用擔心了,aarch64-apple-darwin 就是目前唯一的選擇了,macOS 從設計上就不支援完全靜態連結,從編譯速度的角度來說,這反而是好事。所以 dev profile 在 linux 上 gnulibc,macOS 上 libSystem,release profile 在 macOS 和 linux 上都可以 musl,因為 macOS 預設選擇 linker 和 ar 的問題,唯一值得注意的就是可能需要設定一下 .cargo/config.toml
[target.x86_64-unknown-linux-musl]
linker = "x86_64-linux-musl-gcc"
ar = "x86_64-linux-musl-ar"順帶一提,千萬別使用 zig,nightly Rust 上的 zbuild 目前問題相當嚴重。需要交叉編譯且 host 為 Linux x86 時,建議使用 cross。配合 Docker 進行編譯非常好,環境完整。如果你偏好 glibc,這也是不錯的選擇,因為 glibc 只能向後相容。一般會依照 Debian 大版本(例如 Debian‑2)的 glibc 版本來編譯,否則太新的 glibc 會讓大量機器跑不動;這並不是因為用了新功能,而純粹是透過判斷字串版本來讓你煩惱。host 為 mac 時請使用 rustup 原生 target + cargo build --release .
UPX 是個壞東西
還有一個值得討論的點就是關於 UPX 的使用;我知道我說我喜歡 musl,但是又追求小體積實際上很衝突,聽起來沒道理...
ooooo ooo ooooooooo. ooooooo ooooo
`888' `8' `888 `Y88. `8888 d8'
888 8 888 .d88' Y888..8P
888 8 888ooo88P' `8888'
888 8 888 .8PY888.
`88. .8' 888 d8' `888b
`YbodP' o888o o888o o88888o
The Ultimate Packer for eXecutables
Copyright (c) 1996-2026 Markus Oberhumer, Laszlo Molnar & John Reiser
https://upx.github.io但是實際上 upx 的存在讓這個問題得到了解決,UPX 的原理實際上是把你的 BIN 壓縮後包上一個解壓 stub,執行時先在記憶體裡解壓再執行,這樣就可以把二進位體積壓縮到原來的 30%~50% 左右,體積優勢就會非常明顯。但也正是因為這樣,UPX + GNU glibc 動態鏈結就會有大問題,因為 glibc 動態鏈結的二進位檔案裡有一些特殊的 section 和動態載入機制,UPX 壓縮之後可能會破壞這些結構,導致執行時找不到動態程式庫 segment fault;但是反觀 musl,這個就非常適合 UPX,本來就是全靜態,內部穩定,UPX 後解壓和壓縮都很乾淨。所以 musl 的 release CI 其實可以多加一步 UPX 是非常加分的。
另外就是 UPX 也不能濫用,UPX 只適合長任務或低頻任務。長任務是因為 UPX 啟動時需要在記憶體中解壓縮一次,這個過程雖然是全自動的,但需要一點冷啟動時間,這個影響雖然微小,但在高頻路徑上還是很致命。所以長任務啟動一次並在背景執行就很適合這個;但我並不推薦無腦地給 Docker 映像檔這種長服務的封包塞 UPX,因為實際上這是用記憶體換取磁碟空間的行為,屬於血虧,而且容器 Layer 的壓縮實際上跟 UPX 是一樣的,能減少分發時的下載體積。反而推薦的環境是某些 CLI 的用途:節省超多磁碟空間,並且啟動解壓縮時間幾乎無感,而且 musl 天然也很適合做 CLI。不然我就要點名批評 AUR 助手了——Go 寫的那個玩意兒卻沒有靜態連結,導致我曾經遇到過 ArchLinux 上的 yay 壞掉了、找不到動態函式庫,本身又是系統套件管理器,無解只能另外用 LiveISO 啟動最後 chroot 救磚。
總結
最後這些好用的配置雖然可以幫助你快速區分 dev 和 release profile,在專案代碼上來後只會越來越明顯,但是我還是建議你多寫一個 CI 來跑 Rust Stable LLVM Build,最好再加上你的各種測試一起跑,這樣本地 nightly 的 rust 也不用切換了,Edge Case 也坑不到你,push 上去有問題就被 CI 攔了,這樣就是目前最舒服的 DX 模式了;那這次就說到這裡了,下次有空再摸吧