縮小 .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
然後你可以使用 find
在 cargo
的 target
目錄中找出包含 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
-
拆封一個
Option
或Result
:opt.unwrap()
或res.unwrap()
前兩個可以轉譯為第三個。索引可以更換為有錯誤的 my_slice.get(i)
執行。除法可以更換為 checked_div
呼叫。現在我們只需要處理一個案例即可。
拆封一個 Option
或 Result
而不需要恐慌有兩種方法:安全和不安全。
安全的方法是在遇到 None
或 Error
時,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
延伸方法用於 Option
和 Result
,它告訴 Rust 編譯器假設 Option
為 Some
或 Result
為 Ok
。如果該假設不成立,則未定義會發生什麼情況。您真的只有在 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
參數,那麼所有剪裁函數轉接呼叫的函數(其也永遠不會在執行階段被呼叫)也會一併被移除。
此工具特別有利於移除異常基礎架構,因為異常最終會轉換為陷阱。