這是 Rust 和 WebAssembly 的作業文件(尚未公開),公開的文件已發布於 Rust 和 WebAssembly 主要文件網站 中。此處的文件記錄的功能可能無法用在 Rust 和 WebAssembly 已發布的工具版本中。

縮小 .wasm 程式碼大小

本節將教您如何最佳化 .wasm 建置以取得較小的程式碼大小,以及如何找出可以變更 Rust 原始碼的機會,進而減少輸出的 .wasm 程式碼。

為什麼在意程式碼大小?

透過網路提供 .wasm 檔案時,檔案越小,用戶就能越快下載。.wasm 下載越快,網頁載入速度就越快,也會讓用戶更滿意。

不過,一定要記得,程式碼大小可能並非攸關一切的指標,而是像「首次互動時間」一樣較模糊且難以測量。雖然程式碼大小會大幅影響這個指標(如果甚至沒有任何程式碼,什麼事情都做不了!)但程式碼大小並非唯一的影響因子。

WebAssembly 通常以 gzip 壓縮的方式提供給使用者,因此您應確保比較透過網路傳輸時,壓縮後的 gzip 大小差異。也請注意 WebAssembly 二進位檔格式非常適合 gzip 壓縮,通常可以將大小縮小 50% 以上。

此外,WebAssembly 的二進位檔格式經過最佳化,以提供非常快速的剖析和處理速度。目前,瀏覽器均搭載「基線編譯器」,其可以剖析 WebAssembly 並發出編譯好的程式碼,速度快到接近 wasm 可以通過網路傳輸的速度。這表示 若您使用 instantiateStreaming,一旦網路要求完成,WebAssembly 模組很可能會準備就緒。另一方面,JavaScript 不僅可能在剖析上花費較長時間,還可能在 JIT 編譯等作業上耗費較多時間才能達到最佳執行速度。

最後,請記住 WebAssembly 在執行速度方面也比 JavaScript 經過更大幅度的最佳化。您應務必針對 JavaScript 和 WebAssembly 測量執行時間,並將測量結果視為程式碼大小的重要性考量因素之一。

總而言之,萬一您的 .wasm 檔案比預期大時,請勿立即灰心!最後程式碼大小可能只會是整個故事中許多因素之一。僅考量程式碼大小來比較 JavaScript 和 WebAssembly,有如見樹不見林。

最佳化建置以縮小程式碼大小

我們可以使用許多設定選項來讓 rustc 產生較小的 .wasm 二進位檔。在某些情況下,我們為了產生較小的 .wasm 檔案大小,而必須延長編譯時間。在其他情況下,我們為了產生較小的程式碼大小,而必須犧牲 WebAssembly 的執行速度。我們應該認知到每個選項的折衷,在以執行速度換取程式碼大小的情況下,進行設定值調整和測量以做出明智的決定,判斷這種取捨是否值得。

使用連結時間最佳化 (LTO) 進行編譯

Cargo.toml 中,在 [profile.release] 區段中新增 lto = true

[profile.release]
lto = true

這讓 LLVM 有更多機會內嵌和優化函式。它不僅能讓 .wasm 檔案縮小,還能提升其執行速度!缺點是編譯時間會拉長。

指示 LLVM 以大小為優先進行最佳化,而非速度

預設情況下,LLVM 的最佳化歷程會針對提升速度進行調整,而非大小。我們可以修改 Cargo.toml 中的 [profile.release] 區段來將目標改為程式碼大小

[profile.release]
opt-level = 's'

或者,更積極地針對大小進行最佳化,而可能進一步犧牲速度成本

[profile.release]
opt-level = 'z'

請注意,令人驚訝的是,opt-level = "s" 有時可能產生比 opt-level = "z" 更小的二進位檔。務必進行測量!

使用 wasm-opt 工具

Binaryen 工具包是 WebAssembly 專用的編譯器工具彙整。它的功能遠超出 LLVM WebAssembly 後端,使用它的 wasm-opt 工具後處理 LLVM 生成的 .wasm 二進制檔常常可以省下 15-20% 的程式碼大小。同時常常也能提升執行時間!

# Optimize for size.
wasm-opt -Os -o output.wasm input.wasm

# Optimize aggressively for size.
wasm-opt -Oz -o output.wasm input.wasm

# Optimize for speed.
wasm-opt -O -o output.wasm input.wasm

# Optimize aggressively for speed.
wasm-opt -O3 -o output.wasm input.wasm

除錯資訊注意事項

造成 wasm 二進制檔大小的因素中,最大宗的就是 debug 資訊和 wasm 二進制檔的 names 部分。不過,預設情況下 wasm-pack 工具會移除除錯資訊。另外,除非另外指定了 -g,否則 wasm-opt 預設會移除 names 部分。

這表示如果你按照上述步驟操作,通常 wasm 二進制檔中就不會有除錯資訊或 names 部分。但是,如果你手動保留了 wasm 二進制檔中的 debug 資訊,請務必注意這點!

大小剖析

如果調整建置組態以最佳化程式碼大小還是無法讓 .wasm 二進制檔縮小到足夠,那就表示該做一些剖析,找出程式碼大小的來源。

⚡ 就像我們讓時間剖析來指引我們如何加速,我們也要讓大小剖析來指引我們如何縮小程式碼大小。如果你沒做到,你可能會浪費時間!

twiggy 程式碼大小剖析器

twiggy 是一個程式碼大小剖析器,支援 WebAssembly 輸入。它會分析二進制檔的呼叫圖表以回答下列問題:

  • 這個函式為什麼會出現在二進制檔中?

  • 這個函式的保留大小是多少?也就是說,如果我移除它和因移除而變為無效程式碼的所有函式,可以省下多少空間?

$ twiggy top -n 20 pkg/wasm_game_of_life_bg.wasm
 Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼────────────────────────────────────────────────────────────────────────────────────────
          9158 ┊    19.65% ┊ "function names" subsection
          3251 ┊     6.98% ┊ dlmalloc::dlmalloc::Dlmalloc::malloc::h632d10c184fef6e8
          2510 ┊     5.39% ┊ <str as core::fmt::Debug>::fmt::he0d87479d1c208ea
          1737 ┊     3.73% ┊ data[0]
          1574 ┊     3.38% ┊ data[3]
          1524 ┊     3.27% ┊ core::fmt::Formatter::pad::h6825605b326ea2c5
          1413 ┊     3.03% ┊ std::panicking::rust_panic_with_hook::h1d3660f2e339513d
          1200 ┊     2.57% ┊ core::fmt::Formatter::pad_integral::h06996c5859a57ced
          1131 ┊     2.43% ┊ core::str::slice_error_fail::h6da90c14857ae01b
          1051 ┊     2.26% ┊ core::fmt::write::h03ff8c7a2f3a9605
           931 ┊     2.00% ┊ data[4]
           864 ┊     1.85% ┊ dlmalloc::dlmalloc::Dlmalloc::free::h27b781e3b06bdb05
           841 ┊     1.80% ┊ <char as core::fmt::Debug>::fmt::h07742d9f4a8c56f2
           813 ┊     1.74% ┊ __rust_realloc
           708 ┊     1.52% ┊ core::slice::memchr::memchr::h6243a1b2885fdb85
           678 ┊     1.45% ┊ <core::fmt::builders::PadAdapter<'a> as core::fmt::Write>::write_str::h96b72fb7457d3062
           631 ┊     1.35% ┊ universe_tick
           631 ┊     1.35% ┊ dlmalloc::dlmalloc::Dlmalloc::dispose_chunk::hae6c5c8634e575b8
           514 ┊     1.10% ┊ std::panicking::default_hook::{{closure}}::hfae0c204085471d5
           503 ┊     1.08% ┊ <&'a T as core::fmt::Debug>::fmt::hba207e4f7abaece6

手動檢查 LLVM-IR

LLVM-IR 是編譯器工具鏈中 LLVM 產生 WebAssembly 前的最後中間表現,因此它與最終釋出的 WebAssembly 非常相似。LLVM-IR 越多通常代表 .wasm 越大,如果一個函式佔 LLVM-IR 的 25%,通常它也會佔 .wasm 的 25%。雖然這些數字通常都是這樣,但 LLVM-IR 有 .wasm 中沒有的重要資訊(因為 WebAssembly 缺少像是 DWARF 的除錯格式):哪些子常式內嵌在特定函式中。

你可以使用這個 cargo 指令產生 LLVM-IR

cargo rustc --release -- --emit llvm-ir

然後你可以使用 findcargotarget 目錄中找出包含 LLVM-IR 的 .ll

find target/release -type f -name '*.ll'

參考資料

更積極的工具和技巧

微調建置組態以取得更小的 .wasm 二進位檔 довольно hands off。但是,當您需要付出更多的努力時,您已準備好使用更具侵入性的技術,例如改寫原始碼以避免膨脹。以下是您可以執行的動手技術集合,以取得更小的程式碼大小。

避免字串格式化

format!to_string,等等會帶來大量的程式碼膨脹。如果可以的話,僅在偵錯模式下執行字串格式化,而在釋出模式下使用靜態字串。

避免恐慌

這絕對是說起來容易做起來難,但像 twiggy 等工具和手動檢查 LLVM-IR 可以協助您找出哪些函式會恐慌。

恐慌並不總是顯示為 panic!() 巨集呼叫。它們隱含地從許多結構產生,例如

  • 對超過邊界索引進行陣列索引時會恐慌:my_slice[i]

  • 如果除數為零,除法會恐慌:dividend / divisor

  • 拆封一個 OptionResultopt.unwrap()res.unwrap()

前兩個可以轉譯為第三個。索引可以更換為有錯誤的 my_slice.get(i) 執行。除法可以更換為 checked_div 呼叫。現在我們只需要處理一個案例即可。

拆封一個 OptionResult 而不需要恐慌有兩種方法:安全和不安全。

安全的方法是在遇到 NoneError 時,abort 而不是恐慌


# #![allow(unused_variables)]
#fn main() {
#[inline]
pub fn unwrap_abort<T>(o: Option<T>) -> T {
    use std::process;
    match o {
        Some(t) => t,
        None => process::abort(),
    }
}
#}

最終來說,恐慌還是會在 wasm32-unknown-unknown 中轉譯成 abort,因此這提供了給您相同的行為,但沒有程式碼膨脹。

或者,unreachable 箱子提供一個不安全的unchecked_unwrap 延伸方法用於 OptionResult,它告訴 Rust 編譯器假設 OptionSomeResultOk。如果該假設不成立,則未定義會發生什麼情況。您真的只有在 110% 知道 該假設成立時,才想要使用這個不安全的方法,而編譯器還不夠聰明無法看到它。即使您採取這條途徑,您也應該有一個除錯建置組態,它仍然可以執行檢查,並且僅在釋出建置中使用未檢查的執行。

避免配置或切換到 wee_alloc

Rust 的預設 WebAssembly 配置器是 Rust 的 dlmalloc 埠。它的重量約為 10KB。如果您能完全避免動態配置,那麼您應該可以減少那 10KB。

徹底地避免動態配置可能非常困難。不過,移除熱門程式碼路徑中的配置通常容易得多(且通常也有助於使這些熱門程式碼路徑加快)。在這些情況下,使用 wee_alloc 取代預設的整體配置器 應該可以為您節省大部分(但不是全部)那些 10 KB。wee_alloc 是一種配置器,專為需要 *某種* 配置器,但不需要特別快速配置器的情境所設計,並樂於以較小的程式碼大小兌換配置速度。

改用特質物件,而非一般類型參數

如果您建立一種使用類型參數的一般函數,如下所示


# #![allow(unused_variables)]
#fn main() {
fn whatever<T: MyTrait>(t: T) { ... }
#}

接著,rustc 和 LLVM 將會為該函數使用的每一個 T 類型建立一個新的函數副本。這提供了許多基於各副本所處理的特定 T 類型的編譯器最佳化機會,但這些副本在程式碼大小方面的累積速度很快。

如果您改用特質物件,而非類型參數,如下所示


# #![allow(unused_variables)]
#fn main() {
fn whatever(t: Box<MyTrait>) { ... }
// or
fn whatever(t: &MyTrait) { ... }
// etc...
#}

那麼將會透過虛擬呼叫使用動態派遣,且只會在 .wasm 中發射函數的單一版本。缺點是失去了編譯器最佳化機會,以及間接、動態派遣函數呼叫的額外成本。

使用 wasm-snip 工具

wasm-snip 會將 WebAssembly 函數的主體替換為 unreachable 指令。對於看起來有點像釘子的函數而言,這是一個相當不精確、粗暴的工具,只要您瞇著眼看夠久了就能看到。

也許您知道有些函數在執行階段永遠不會被呼叫,但編譯器無法在編譯階段證明這一點?那就將它剪裁掉!接著,再次執行 wasm-opt,並使用 --dce 參數,那麼所有剪裁函數轉接呼叫的函數(其也永遠不會在執行階段被呼叫)也會一併被移除。

此工具特別有利於移除異常基礎架構,因為異常最終會轉換為陷阱。