縮減 .wasm 程式碼大小

本節將教你如何最佳化 .wasm 建置,以求更小的程式碼大小,並找出變更 Rust 原始碼的機會,以減少產生的 .wasm 程式碼。

為什麼要關心程式碼大小?

透過網路傳送 .wasm 檔案時,檔案越小,客戶端就能下載得越快。.wasm 下載得越快,網頁載入時間就越快,這樣使用者就會更滿意。

不過,務必記住,程式碼大小可能並非你真正有興趣的終極指標,而是更模糊且難以測量的指標,例如「首次互動時間」。程式碼大小在這項測量中扮演了重要因素(無法在擁有所有程式碼之前做任何事!)但並非唯一因素。

WebAssembly 通常採用 gzip 壓縮的方式提供給使用者,因此你需要確保比較傳輸時的 gzip 大小差異。此外,請記住,WebAssembly 二進位格式非常適合 gzip 壓縮,通常能將大小壓縮超過 50%。

此外,WebAssembly 的二進位格式針對極快速的剖析和處理進行了最佳化。現今的瀏覽器有「基線編譯器」,它會剖析 WebAssembly 並以 wasm 可以從網路進入的速度快速發佈已編譯的程式碼。這表示如果您正在使用 instantiateStreaming,Web 要求一完成,WebAssembly 模組就會準備好進行。

另一方面,JavaScript 通常需要較長的時間才能完成剖析,並且隨著 JIT 編譯,等才能逐漸加快速度。

最後,請記住,WebAssembly 的執行速度也遠遠超過 JavaScript。您需要確定在評量 JavaScript 和 WebAssembly 的執行時間時考量到這些因素,來評估程式碼大小的重要性。

說這麼多,其實是想說,如果您的 .wasm 檔案比預期的大,無需馬上感到沮喪!最終,程式碼大小可能只是整體流程的眾多考量因素之一。只看程式碼大小來比較 JavaScript 和 WebAssembly 就會見樹不見林了。

針對程式碼大小最佳化建置

我們可以使用許多組態選項來讓 rustc 產生更小的 .wasm 二進位檔。某些情況下,我們用更長的編譯時間來換取更小的 .wasm 尺寸。其他情況下,我們會犧牲 WebAssembly 的執行速度來換取更小的程式碼大小。我們應該了解每個選項的取捨,如果我們用執行速度來換取程式碼大小,請進行概要分析和評量來做出明智的決策,判斷這個交換是否值得。

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

[profile.release]
lto = true

Cargo.toml 中,在 [profile.release] 區段加入 lto = true

這會讓 LLVM 更能內嵌和修剪函式。它不僅會縮小 .wasm,在執行時速度也會變快!缺點是編譯時間會變長。

要求 LLVM 針對大小進行最佳化(而非速度)

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

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

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

或者,更激進地針對大小進行最佳化,但可能會進一步犧牲速度

請注意,令人訝異的是,opt-level = "s" 有時候會產生比 opt-level = "z" 更小的二進位檔。一定要進行評量!

使用 wasm-opt 工具

# 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

Binaryen 工具集中包含許多針對 WebAssembly 的編譯器工具。它的功能比 LLVM 的 WebAssembly 後端更豐富,而且我們可以用其中的 wasm-opt 工具來後處理 LLVM 產生的 .wasm 二進位檔,這麼做通常可以減少 15-20% 的程式碼大小。它甚至還會提高執行速度!

導致 wasm 二進制檔大小最大的原因之一在於除錯資訊和 wasm 二進制檔的 names 區段。不過, wasm-pack 工具預設會移除偵錯資訊。另外,wasm-opt 預設會移除 names 區段,除非也指定 -g

這表示,如果你遵循上述步驟,預設情況下 wasm 二進制檔中不應有偵錯資訊或名稱區段。不過,如果你手動在 wasm 二進制檔中保留這些偵錯資訊,務必記得這一點!

大小設定檔

如果調整建置設定以最佳化程式碼大小仍然無法產生夠小的 .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 大小,如果函式佔用 25% 的 LLVM-IR,則通常會佔用 25% 的 .wasm。儘管這些數字只在一般情況下適用,但 LLVM-IR 具有 .wasm 中沒有的重要資訊(因為 WebAssembly 缺少像 DWARF 這樣的偵錯格式):哪些子程式已內嵌到某個給定的函式中。

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

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

然後,你可以使用 find 來找到 cargotarget 目錄中包含 LLVM-IR 的 .ll

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

參考資料

更多侵入性的工具及技巧

調整建置設定以取得更小的 .wasm 二進制檔的作法相當簡單。不過,當你需要更進一步時,就要準備採取更侵入性的技巧,例如改寫原始碼以避免膨脹。以下是你可以套用以取得更小程式碼大小的一些實務技巧。

避免字串格式化

format!to_string 等可能會造成大量程式碼膨脹。如果可能的話,只在除錯模式中進行字串格式化,而在發布模式中使用靜態字串。

避免發生恐慌

這說起來絕對比做起來容易,但像 twiggy 這樣的工具和手動檢查 LLVM-IR 可以幫助你找出哪些函式會恐慌。

恐慌並不總是作為 panic!() 巨集呼叫出現。它們是由許多建構隱含產生的,例如

  • 在超出邊界索引上對切片進行索引會恐慌:my_slice[i]

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

  • 解開 OptionResultopt.unwrap()res.unwrap()

前兩種可以轉換成第三種。索引可以替換為有缺陷的 my_slice.get(i) 作業。除法可以替換為 checked_div 呼叫。現在我們只需應付一個案例即可。

不恐慌地解開 OptionResult 有兩種方式:安全的方式和不安全的方式。

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


# #![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 中恐慌最終會轉換為中止,因此這會給你帶來相同的行為,但沒有程式碼肥大。

或者,unreachable crateOptionResult 提供了一個不安全的unchecked\_unwrap 擴充方法,它告訴 Rust 編譯器假設OptionSomeResultOk。如果這個假設不成立,會發生什麼是未定義的行為。真的只有在你 110%知道這個假設成立時,你才想要使用這種不安全的方法,而編譯器並不足以看到它。即使你走這條路,你也應該有一個偵錯建置組態,它仍然會進行檢查,並且只在發行建置中使用未檢查的作業。

避免分配或切換到 wee_alloc

Rust 的 WebAssembly 預設配置器是將 dlmalloc 移植到 Rust。它重約十千位元組。如果你可以完全避免動態分配,那麼你應該可以減掉這十千位元組。

完全避免動態分配可能非常困難。但是,從熱門程式碼路徑中移除分配通常容易得多(並且通常也有助於使這些熱門程式碼路徑更快)。在這些情況下,wee_alloc 替換預設的全球配置器 可以為你節省大部分(但並非全部)這十千位元組。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 指令。如果你瞇著眼睛看,這對於看起來像釘子的函式來說是一個相當笨重的鈍錘。

也許你知道某些函式在執行時期永遠不會被呼叫,但編譯器無法在編譯時期證明嗎?剪掉它!之後,使用 --dce 標記再次執行 wasm-opt,而且所剪除的函式暫態呼叫的所有函式(在執行時期也永遠不會被呼叫)也會被移除。

特別適合用這個工具移除恐慌基礎架構,因為恐慌最終會轉換為陷阱。