Rust賦能前端:為WebAssembly 瘦身
1. 準備工作
前端項目
由于我們今天的主要任務是做WebAssembly的優化處理,前端項目不是我們重點
針對于我來說,我直接就用之前的OCR前端項目了。當然,你如果不想翻看之前的文章,你也可以使用f_cli_f[1]來構建的前端Vite+React+TS項目。
然后在src/pages構建一個文件上傳的頁面,在src目錄下構建一個wasm目錄來存放在前端項目中要用到的各種wasm。
Rust項目
我們是用之前的OCR的Rust項目。當然,你也可以拿你自己的項目來進行驗證。因為,我們此篇文章的內容,都不涉及具體的業務邏輯。
2. 前置知識點
之所以將后面可能涉及到的知識點和概念提前寫出來,是想讓行文更加明了。也是為了照顧不同階段的同學。如果你對這些概念都熟悉,那么可以直接跳過該節內容。如果你還是Rust新手,那么這節內容也算是一種知識的鞏固。
2.1 Rust Channel
Rust 被發布到三個不同的channel:
- stable(穩定版):穩定版本每 6 周發布一次
- beta(測試版):測試版是即將成為下一個穩定版的版本
- nightly(夜間版):夜間版則是每天晚上構建的最新版本
我們在安裝Rust后,它會安裝一個名為rustup的工具,這個工具能讓我們管理多個不同版本的 Rust。
默認情況下,rustup會安裝stable版本到我們本機環境。
我們可以在rust 版本信息[2]中查看每個版本的各種信息。
圖片
2.2 安裝nightly版本
Nightly 版本,可以幫助我們嘗試 Rust 的最新特性,我們后面在編譯的時候,需要用到該版本
我們可以通過下面命令來安裝nightly.
rustup toolchain install nightly
系統將下載所需組件,包括 rustc、rust-std、cargo 等,最后安裝它們。
然后我們可以通過rustup toolchain list來查看我們本機安裝的Rust版本。
下面是我本機的各個Rust信息。
stable-x86_64-apple-darwin (default) (override)
stable-x86_64-unknown-linux-gnu
nightly-x86_64-apple-darwin
1.75.0-x86_64-apple-darwin
細心的同學,可以看到在stable-x86_64-apple-darwin后面有default/override的字樣。
2.3 切換工具鏈
- 使用 rustup default 切換工具鏈
- 使用 rustup override 在特定項目中使用不同的工具鏈。
我們可以使用 rustup default 命令來切換到指定的工具鏈。
例如,要切換到 stable-x86_64-apple-darwin:
rustup default stable-x86_64-apple-darwin
或者如果切換到 nightly 版本:
rustup default nightly-x86_64-apple-darwin
我們可以使用以下命令,檢查 rustc 當前使用的版本:
rustc -V
然后它就會返回當前使用的版本信息。例如我本機的返回信息為rustc 1.81.0 (eeb90cda1 2024-09-04)
臨時使用不同工具鏈
如果我們只想在某個項目使用不同的工具鏈,不改變全局的默認設置,可以使用:
rustup override set <toolchain>
例如:
rustup override set nightly-x86_64-apple-darwin
這個命令只會在當前目錄下使用指定的工具鏈,不會影響其他項目或全局的默認設置。
切換為特定版本的 Rust
我們也可以切換到已安裝的特定版本,比如 1.75.0:
rustup default 1.75.0-x86_64-apple-darwin
2.4 SIMD
在v8官網中有這么一篇文章 - Fast, parallel applications with WebAssembly SIMD[3]里面就介紹了很多關于SIMD的內容,我們來將與我們相關的內容做一下總結。
SIMD(單指令多數據)指令是一類特殊指令,能夠通過同時對多個數據元素執行相同操作來利用數據并行性。這類指令廣泛應用于計算密集型應用中,比如音頻/視頻編解碼器、圖像處理器等,能夠加速性能。大多數現代體系結構都支持某種形式的 SIMD 指令。
我們可以從caniuse[4]中看到它的兼容性情況。
圖片
將Rust編譯為WebAssembly SIMD
在Fast, parallel applications with WebAssembly SIMD文章中,它介紹了如何將c/c++的代碼編譯為SIMD以供前端環境使用。當然,也有將Rust編譯為SIMD的方式。其實我們比較關心這部分。
此舉有助于將 Rust 代碼高效地編譯為 WebAssembly并利用底層硬件的并行性
當我們將 Rust 代碼編譯為目標 WebAssembly SIMD 時,需要啟用simd128 LLVM 特性[5]。
我們可以直接控制 rustc 的標志或通過環境變量 RUSTFLAGS,可以傳遞 -C target-feature=+simd128:
rustc … -C target-feature=+simd128 -o out.wasm
或者使用 Cargo:
RUSTFLAGS="-C target-feature=+simd128" cargo build
當啟用了 simd128 特性時,LLVM 的自動矢量化器會默認在優化代碼時啟用。
2.5 binaryen
Binaryen[6] 是一個為 WebAssembly 設計的編譯器和工具鏈基礎庫,由 C++ 編寫。它旨在讓編譯為 WebAssembly 變得簡單、快速且高效
Binaryen為我們提供了很多優化工具,而今天我們選擇其中的一個也就是-wasm-opt。
安裝wasm-opt
wasm-opt 是 Binaryen 工具的一部分,可以通過多種方式安裝,下面列出了幾種常用的安裝方法:
- 使用 npm 安裝:安裝完成后,wasm-opt 會成為全局命令,直接在終端中使用
npm install -g binaryen
- 通過 Homebrew 安裝(適用于 macOS 和 Linux)
brew install binaryen
- 通過預編譯二進制文件安裝:前往 Binaryen的GitHub releases 頁面[7],下載與你的操作系統相匹配的壓縮包。
驗證安裝
安裝完成后,我們可以通過以下命令檢查 wasm-opt 是否安裝成功:
wasm-opt --version
// wasm-opt version 119 (version_119)
如果返回版本號,說明安裝成功。
3. 常規編譯
我們之前在Rust 編譯為 WebAssembly 在前端項目中使用就介紹過,如何將一個Rust項目編譯為WebAssembly。
當時我們使用常規的編譯方式。
cargo build --release --target wasm32-unknown-unknown --package xxx
wasm-bindgen target/wasm32-unknown-unknown/release/xxx.wasm --out-dir yyy --target web
上面我們是通過cargo和wasm-bindgen編譯Rust文件為WebAssembly,然后在yyy的文件下生成相關的文件資源。
- xxx.wasm
- xxx.js
- xxx.d.ts
然后,我們就可以將yyy的相關文件引入到前端項目中,通過配置Webpack/Vite的Wasm相關內容,就可以通過import引入對應的實例或者方法了。
release的默認profiles配置
在Cargo Book[8]中對release的默認profiles配置有相關介紹。
當我們在使用cargo build --release對項目進行打包處理時候,它內部默認是根據下面的配置優化相關項目的。
[profile.release]
opt-level = 3
debug = false
split-debuginfo = '...' # Platform-specific.
strip = "none"
debug-assertions = false
overflow-checks = false
lto = false
panic = 'unwind'
incremental = false
codegen-units = 16
rpath = false
針對上面各個屬性的解釋,大家可以翻看release相關解釋[9]去了解更多,這里就不在贅述了。
效果展示
下面的所有的效果展示,和自己的本機環境息息相關,也就是如果你在編譯/執行項目時,電腦資源被占用的很多或者電腦過熱。這個時間也是有波動的。最終的時間對比,按自己的情況而定。
資源大小
首先,我們先看編譯后的文件大小
編譯的文件大小為1.4M
編譯時間
編譯時間為40秒
運行時間
我們將上面編譯好的文件引入到之前我們的OCR的前端項目。然后,運行相關代碼。
圖片
在執行相關的操作后,整體的運行時間為4秒
4. 優化編譯詳解
寫在最前面,下面的一些配置,有最大力度的優化方案,但是可能根據項目性質的不同,你使用了,卻沒達到想要的效果。這就是一個取舍問題,也是一個實踐出真知的問題。要想將自己的項目配置成最好,下面的配置方案可能適用你,也可能不使用。如果不適用,你可以根據下面的配置方向,找出符合你的最佳方案。
我們來針對上面的打包做一次優化處理。我們先把相關的優化方案列舉出來,然后最后給一個最終的解決方案。
4.1 刪除符號或調試信息
這部分,我們可以通過設置release-strip的信息來優化編譯結果。
[profile.release]
strip = true
在 Rust 項目中,strip它決定了 rustc是否從生成的二進制文件中刪除符號或調試信息。這個選項主要用于減小生成文件的大小,特別是在發布(release)模式下打包時。
strip 的選項
- "none": 不剝離任何信息(默認設置)
- "debuginfo": 剝離調試信息,但保留符號。
- "symbols": 剝離符號信息,保留調試信息。
除了字符串值外,還可以使用布爾值進行設置:
- strip = true 等同于 strip = "symbols"。
- strip = false 等同于 strip = "none",完全禁用剝離
場景說明
在 Linux 和 macOS 系統上,編譯生成的 .elf 文件中默認會包含符號信息。這些符號信息通常不需要在執行二進制文件時使用,因此可以選擇剝離,以減小文件大小。尤其是在發布模式下,剝離符號信息是常見的做法,用來生成更小、更優化的可執行文件。
4.2 設置opt-level
Rust 的 opt-level 設置控制 rustc 的 -C opt-level 標志,它用于決定編譯時的優化級別。
優化級別越高,生成的代碼在運行時可能越快,但同時也會增加編譯時間,并且更高的優化級別可能會對代碼進行重排和改動,這可能會使調試更加困難。
可用的優化級別
- 0: 無優化,適合快速編譯,常用于開發階段。
- 1: 基礎優化,平衡編譯速度和性能,適合某些性能需求不高的場景。
- 2: 中度優化,進行一些優化,提供較好的性能,通常用于測試環境。
- 3: 完全優化,進行所有可能的優化,適合需要最高性能的發布代碼,但編譯時間會增加。
- "s": 優化二進制文件大小,通過減少代碼體積來優化。
- "z": 進一步優化二進制大小,并關閉循環向量化,使得編譯產物更小。
這里我們選擇大力出奇跡直接使用最高級別的優化。
[profile.release]
opt-level = "s"
其實,每個項目的優化力度是不同的,這個需要根據自己項目去決定
4.3 Link Time Optimization (LTO)
Link Time Optimization (LTO) 是一種優化技術,它將編譯單元在鏈接階段進行優化。通常情況下,Cargo 會將每個編譯單元獨立編譯和優化,而 LTO 允許在整個程序的鏈接階段對其進行優化。這可以去除不需要的代碼(例如死代碼),并且在許多情況下會減小二進制文件的大小。這個和我們前端的TreeSharke是一個道理。
lto 設置的選項
- false: 執行“局部精簡 LTO”,即在本地 crate 的代碼生成單元上執行精簡 LTO。如果 codegen-units 是 1 或者 opt-level 為 0,則不會進行 LTO。
- true 或 "fat": 執行“胖 LTO”,嘗試對整個依賴圖中的所有 crate 進行跨 crate 優化。
- "thin": 執行“精簡 LTO”,類似于“胖 LTO”,但消耗的時間大大減少,同時仍能獲得類似的性能提升。
- "off": 禁用 LTO。
這里我們也是下猛藥。直接使用最大力度的優化方案。
LTO 的配置方法
[profile.release]
lto = true
4.4 設置并行代碼生成單元
在 Rust 中,代碼生成單元(codegen-units) 是編譯器將 crate 拆分為多個部分并行處理的機制。通過增加代碼生成單元,編譯器可以并行處理多個部分,從而加快編譯速度。然而,更多的代碼生成單元會限制某些全局優化的能力,從而可能導致較大的二進制文件或運行速度稍慢的代碼。
減少代碼生成單元數,尤其是在發布模式下,將有助于 Rust 編譯器執行更深入的全局優化,生成更高效和更小的二進制文件。在性能需求高或者文件大小敏感的場景下,將 codegen-units 設置為 1 是一種常見的優化手段。
并行代碼生成單元的設置
Rust 默認在發布構建中將 crate 分成 16 個并行代碼生成單元。這種設置有助于加快編譯速度,特別是在多核 CPU 上,因為多個單元可以同時生成代碼。然而,這會限制編譯器進行某些全局優化,例如跨模塊優化,影響代碼運行時的性能或二進制文件的大小。
權衡
- 更多并行單元:編譯速度更快,但可能會損失全局優化的機會。
- 更少并行單元(如 1):編譯速度較慢,但生成的代碼經過更多全局優化,可能運行速度更快,并且二進制文件更小。
我們的選擇
我們選擇將codegen-units設置為1,犧牲編譯速度,減少文件大小。
[profile.release]
codegen-units = 1
4.5 修改panic!()行為
當 Rust 代碼執行 panic!() 時,默認的行為是 展開棧(unwinding the stack),從而生成有用的回溯信息(backtrace),以幫助我們定位問題。然而,棧展開過程需要額外的代碼,這增加了二進制文件的大小。
為了減少二進制文件的大小,Rust 提供了另一種策略,即在程序出現 panic!() 時,立即 終止進程(abort),而不是展開棧。通過啟用這種行為,可以完全去掉棧展開代碼,顯著減少程序的二進制大小。
啟用 Abort on Panic
在 Cargo.toml 中通過在發布配置下設置 panic = "abort" 來啟用此功能:
[profile.release]
panic = "abort"
使用 "abort" 策略可以有效減少二進制文件的大小,特別適合生產環境和資源受限的場景,但會犧牲部分調試能力和安全性,所以這就要求我們在前端環境做一些容錯機制。
4.6 移除位置信息
在 Rust 中,默認情況下,panic!() 和 #[track_caller] 特性會生成文件、行號和列號的位置信息,用于在代碼運行出錯時提供更有用的回溯信息(traceback)。這些信息對調試非常有幫助,但也會增加二進制文件的大小。
為了進一步減小生成的二進制文件的大小,Rust 提供了一個實驗性的功能,可以移除這些位置信息。這通過使用 rustc 的不穩定選項 -Zlocation-detail 來實現。
有效的 location-detail 選項:
- none:移除所有文件、行號和列號信息。適合在不需要回溯信息的環境中使用。
- file:僅保留文件信息。
- file,line:保留文件和行號信息。
- file,line,column(默認值):保留完整的文件、行號和列號信息,用于調試。
移除位置信息
通過設置 RUSTFLAGS 環境變量并將其值設為 -Zlocation-detail=none,我們可以在構建二進制時移除這些位置信息,從而減少文件大小。這種優化特別適用于生產環境,或者對二進制文件大小有較高要求的項目。
示例命令如下:
$ RUSTFLAGS="-Zlocation-detail=none" cargo +nightly build --release
從上面可以看到,有一段cargo +nightly。這說明啥,這需要我們切換到nightly版本。
4.7 移除 fmt::Debug
在 Rust 中,#[derive(Debug)] 和 {:?} 格式化符號用于調試輸出,幫助我們打印結構體和枚舉的內部信息。然而,調試功能會在生成的二進制文件中包含大量類型信息和格式化函數,這可能會增加文件大小。
fmt-debug 選項說明:
- **full**(默認):#[derive(Debug)] 遞歸打印類型及其字段的詳細信息。
- **shallow**:僅打印類型名稱或枚舉的變體名稱,不打印詳細的類型字段信息。此行為不穩定,未來可能會有變化。
- **none**:完全不打印任何信息,{:?} 格式化符號也不起作用。此選項可以顯著減少二進制文件大小,并移除沒有被符號剝離移除的類型名稱,但可能導致 panic! 和 assert! 消息不完整。
移除 fmt::Debug:
Rust 提供了一個實驗性選項 -Zfmt-debug,允許將 #[derive(Debug)] 和 {:?} 格式化操作變成空操作(no-op),即不輸出任何調試信息。通過這種方式,派生的 Debug 實現和相關的字符串將被移除,從而減小二進制文件的大小。
可以使用如下命令啟用該功能:
$ RUSTFLAGS="-Zfmt-debug=none" cargo +nightly build --release
和之前的location-detail一樣,開啟該項目功能,我們也需要使用Rust的nightly版本。
4.8 進一步優化 panic
panic_immediate_abort,旨在徹底移除 panic!() 相關的字符串格式化邏輯。這是 panic = "abort" 選項的進一步優化,即便已指定了 panic = "abort",Rust 仍然會默認將一些與 panic!() 相關的字符串和格式化代碼包含在最終的二進制文件中。這會導致二進制文件中存在不必要的占用空間,尤其是在極致優化二進制大小的場景下。
如何使用
配置方式如下:
$ cargo +nightly build \
-Z build-std=std,panic_abort \
-Z build-std-features=panic_immediate_abort
- **使用 build-std 重新構建 libstd**:按照 build-std 的流程,重新編譯標準庫,同時啟用 panic_abort 行為。
- 進一步縮小二進制大小:啟用 panic_immediate_abort 特性后,所有與 panic!() 相關的字符串信息和格式化邏輯都將被移除。
4.9 開啟simd128
之前我們就說過,我們可以對Rust開啟simd128。
RUSTFLAGS="-C target-feature=+simd128" cargo build
這里就不在過多解釋了。
4.10 優化wasm-bindgen
之前的優化都是針對Rust部分,下面我們來看看,針對wasm-bindgen的優化角度。
之前我們不是,使用wasm-bindgen為 Rust 編寫的 WebAssembly 模塊生成 JavaScript 綁定。它可以幫助 Rust 和 JavaScript 之間進行高效的數據交互。
wasm-bindgen target/wasm32-unknown-unknown/release/audioAndVideo.wasm --out-dir js/dist/ --target web
我們還可以在后面添加一下配置,來優化生成的代碼。
--reference-types
- 此選項啟用了 WebAssembly 的引用類型,這允許 WebAssembly 代碼可以直接引用 JavaScript 對象(如 DOM 元素),無需對這些對象進行包裝或轉換,從而提高了內存管理和交互的效率。
- 引用類型擴展了 WebAssembly 中的基本值類型(如 i32, i64, f32, f64),引入了可以引用 JavaScript 對象(如函數、外部引用)的能力。
--reference-types 通過允許 WebAssembly 直接引用 JavaScript 對象,提高了效率,減少了不必要的轉換。
--weak-refs
- 此選項啟用了 WebAssembly 中的弱引用。弱引用允許我們引用對象而不會阻止它們被垃圾回收。在 JavaScript 中,這一特性被用于防止內存泄漏,可以持有對象的弱引用,當沒有強引用時,垃圾回收器可以回收這些對象。
- 對于 Rust 和 WebAssembly,弱引用有助于在跨 JS 和 WebAssembly 邊界的對象跟蹤中進行更有效的內存管理,避免不必要的對象占用內存。
--weak-refs 通過啟用弱引用,改善了內存管理,防止內存泄漏,確保不必要的對象能被及時回收。
4.11 使用wasm-opt
由于,我們在前面已經下載了wasm-opt了。所以,這里我們就直接上代碼了。
我們在Rust項目中構建一個tools/optimize-wasm.sh文件。
內容如下:
#!/bin/sh
set -eu
BIN_PATH="${1:-}"
WASMOPT_BIN=$(which wasm-opt || true)
if [ -z "$BIN_PATH" ]; then
echo "Usage: $(basename "$0") <WASM binary>"
exit 1
fi
if [ -z "$WASMOPT_BIN" ]; then
echo '由于未找到 `wasm-opt` 二進制文件,因此跳過編譯后優化。'
exit
fi
if [ -n "${SKIP_WASM_OPT:-}" ]; then
echo "由于設置了 SKIP_WASM_OPT,所以跳過了編譯后優化"
exit
fi
wasm-opt --enable-simd --enable-reference-types -O2 "$BIN_PATH" -o "$BIN_PATH".optimized
mv "$BIN_PATH.optimized" "$BIN_PATH"
上面代碼,最關鍵的就是
wasm-opt --enable-simd --enable-reference-types -O2 "$BIN_PATH" -o "$BIN_PATH".optimized
mv "$BIN_PATH.optimized" "$BIN_PATH"
- 使用以下選項運行 wasm-opt 對提供的 WASM 二進制文件進行優化:
--enable-simd: 啟用 SIMD 支持,以便更快地進行并行數據處理。
--enable-reference-types: 啟用 WASM 模塊中的引用類型。
-O2: 應用中等級別的優化 (O2),在性能和二進制大小之間進行平衡。
優化結果寫入臨時文件 "$BIN_PATH.optimized"。
- 最后,通過移動 (mv) 優化后的文件替換原始文件。
5. 最終方案
配置Cargo.toml
[profile.release]
strip = true
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
構建shell 文件
注意下文中的xxx需要替換成你項目的名稱
build.sh
我們在項目根目錄構建一個build.sh文件,內容如下
#!/bin/bash
# 執行 optimize-rust.sh
echo "執行 optimize-rust.sh..."
./tools/optimize-rust.sh
# 檢查是否成功執行
if [ $? -ne 0 ]; then
echo "optimize-rust.sh 構建失敗."
exit 1
fi
# 執行 optimize-wasm.sh
echo "執行 optimize-wasm.sh..."
./tools/optimize-wasm.sh js/dist/xxx_bg.wasm
# 檢查是否成功執行
if [ $? -ne 0 ]; then
echo "optimize-wasm.sh 構建失敗."
exit 1
fi
echo "Rust 已構建成功,到指定目錄查看相關信息."
我們構建一個tools文件,然后新建兩個文件
- optimize-rust.sh
- optimize-wasm.sh
optimize-rust.sh
#!/bin/bash
# 獲取項目名稱
PACKAGE_NAME="xxx"
# 編譯 Rust 代碼
RUSTFLAGS="-Zlocation-detail=none -Zfmt-debug=none -C target-feature=+simd128" cargo +nightly build \
-Z build-std=std,panic_abort \
-Z build-std-features=panic_immediate_abort \
--release --target wasm32-unknown-unknown --package "$PACKAGE_NAME"
# 生成 wasm 綁定
wasm-bindgen target/wasm32-unknown-unknown/release/"$PACKAGE_NAME".wasm --out-dir js/dist/ --target web
optimize-wasm.sh
#!/bin/sh
set -eu
BIN_PATH="${1:-}"
WASMOPT_BIN=$(which wasm-opt || true)
if [ -z "$BIN_PATH" ]; then
echo "Usage: $(basename "$0") <WASM binary>"
exit 1
fi
if [ -z "$WASMOPT_BIN" ]; then
echo '由于未找到 `wasm-opt` 二進制文件,因此跳過編譯后優化。'
exit
fi
if [ -n "${SKIP_WASM_OPT:-}" ]; then
echo "由于設置了 SKIP_WASM_OPT,所以跳過了編譯后優化"
exit
fi
wasm-opt --enable-simd --enable-reference-types -O2 "$BIN_PATH" -o "$BIN_PATH".optimized
mv "$BIN_PATH.optimized" "$BIN_PATH"
然后,我們就可以在Rust項目根目錄執行./build.sh來執行編譯任務了。
運行結果
文件大小
編譯的文件大小為900KB
可以看到,我們將之前1.4MB的資源縮小到了900KB。
如果我們還想減少二進制文件的大小,我們還可以繼續更改上面的配置信息。如果單純的追求資源大小的話,我們可以將其縮小到300kb,但是,其運行時間會比沒瘦身之前還長
魚和熊掌不能兼得,我們只能根據實際情況而定。也就是實踐出真知
運行時間
圖片
可以看到,雖然文件大小變小了,但是我們運行性能卻沒有打折扣。那就充分說明,我們此次的瘦身是成功的。
Reference
[1]f_cli_f: https://www.npmjs.com/package/f_cli_f
[2]rust 版本信息: https://releases.rs/
[3]Fast, parallel applications with WebAssembly SIMD: https://v8.dev/features/simd
[4]caniuse: https://caniuse.com/?search=simd
[5]LLVM 特性: https://llvm.org/
[6]Binaryen: https://github.com/WebAssembly/binaryen
[7]Binaryen的GitHub releases 頁面: https://github.com/WebAssembly/binaryen/releases
[8]Cargo Book: https://doc.rust-lang.org/cargo
[9]release相關解釋: https://doc.rust-lang.org/cargo/reference/profiles.html