速度一向很快
在先前的文章中,我談到 WebAssembly 如何讓您將 C/C++ 的程式庫生態系統帶入網際網路。squoosh 是廣泛使用 C/C++ 程式庫的應用程式,這個網頁應用程式可讓您使用從 C++ 編譯為 WebAssembly 的各種編解碼器壓縮圖片。
WebAssembly 是低階虛擬機器,可執行儲存在 .wasm
檔案中的位元碼。這個位元組碼具有強型別和結構,因此可針對主機系統進行編譯和最佳化,速度比 JavaScript 快上許多。WebAssembly 提供一個環境,可讓您執行從一開始就考慮到沙箱和嵌入功能的程式碼。
以我的經驗來說,網站上的大部分效能問題都是由強制版面配置和過度繪製所造成,但應用程式不時需要執行耗費大量時間的運算密集工作。這時 WebAssembly 就能派上用場。
熱門路徑
在 squoosh 中,我們編寫了一個 JavaScript 函式,以 90 度的倍數旋轉圖片緩衝區。雖然 OffscreenCanvas 是這項作業的理想選擇,但我們鎖定的瀏覽器不支援這項功能,而且 Chrome 中也有一些錯誤。
這個函式會對輸入圖片的每個像素進行迴迭,並將其複製至輸出圖片中的不同位置,以便旋轉。對於 4094 x 4096 像素 (1600 萬像素) 的圖片,內部程式碼區塊需要超過 1600 萬次的迭代,也就是我們稱為「熱門路徑」的部分。儘管迭代次數相當龐大,但我們測試的三個瀏覽器中,有兩個在 2 秒內完成此工作。這類互動可接受的時間長度。
for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
const in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
outBuffer[i] = inBuffer[in_idx];
i += 1;
}
}
但某個瀏覽器的載入時間超過 8 秒。瀏覽器最佳化 JavaScript 的方式非常複雜,而且不同引擎會針對不同項目進行最佳化。有些是針對原始執行作業進行最佳化,有些則是針對與 DOM 的互動進行最佳化。在本例中,我們在一個瀏覽器中找到未經最佳化的路徑。
另一方面,WebAssembly 則完全以原始執行速度為建構目標。因此,如果我們希望這類程式碼在各瀏覽器上都能提供快速且可預測的效能,WebAssembly 就能派上用場。
可預測效能的 WebAssembly
一般來說,JavaScript 和 WebAssembly 可以達到相同的最高效能。不過,JavaScript 的效能只能在「快速路徑」上達到這個水準,而且要維持「快速路徑」通常相當困難。WebAssembly 提供的一大優點,就是可預測的效能,即使跨瀏覽器也一樣。嚴格的型別和低階架構可讓編譯器提供更強的保證,因此 WebAssembly 程式碼只需進行一次最佳化,並且會一律使用「快速路徑」。
為 WebAssembly 編寫程式
先前我們曾將 C/C++ 程式庫編譯為 WebAssembly,以便在網路上使用其功能。我們並未實際接觸程式庫的程式碼,只編寫少量 C/C++ 程式碼,以便在瀏覽器和程式庫之間建立橋樑。這次的動機有所不同:我們想從頭開始編寫程式,並考量 WebAssembly 的特性,以便善用 WebAssembly 的優點。
WebAssembly 架構
撰寫 for WebAssembly 時,建議您進一步瞭解 WebAssembly 的實際運作方式。
引述自 WebAssembly.org:
將 C 或 Rust 程式碼編譯為 WebAssembly 時,您會取得包含模組宣告的 .wasm
檔案。這項宣告包含模組預期從其環境取得的「匯入」清單、這個模組提供給主機的匯出項目清單 (函式、常數、記憶體區塊),以及當中函式的實際二進位指令。
在查看這個問題時,我發現了一個自己之前沒有察覺的情況:讓 WebAssembly 成為「堆疊式虛擬機器」的堆疊並未儲存在 WebAssembly 模組使用的記憶體區塊中。堆疊完全位於 VM 內部,網頁開發人員無法存取 (除非透過開發人員工具)。因此,您可以編寫不需要任何額外記憶體,且只使用 VM 內部堆疊的 WebAssembly 模組。
在本例中,我們需要使用一些額外記憶體,以便任意存取圖片的像素,並產生該圖片的旋轉版本。這就是 WebAssembly.Memory
的用途。
記憶體管理
通常,一旦使用額外記憶體,就需要以某種方式管理記憶體。記憶體的哪些部分正在使用?哪些是免費的?舉例來說,在 C 中,您有 malloc(n)
函式,可找出 n
個連續位元組的記憶體空間。這類函式也稱為「分配器」。當然,您使用的配置器實作項目必須納入 WebAssembly 模組,這會增加檔案大小。這些記憶體管理函式的大小和效能會因所使用的演算法而有顯著差異,這也是許多語言提供多種實作方式 (例如「dmalloc」、「emmalloc」、「wee_alloc」等) 的原因。
在本例中,我們在執行 WebAssembly 模組前,就知道輸入圖片的尺寸 (因此也知道輸出圖片的尺寸)。我們在這裡發現了一個機會:以往,我們會將輸入圖片的 RGBA 緩衝區做為參數傳遞至 WebAssembly 函式,並將旋轉過的圖片做為傳回值傳回。如要產生該傳回值,我們必須使用配置器。不過,由於我們知道所需的記憶體總量 (輸入圖片的兩倍,一次用於輸入,一次用於輸出),因此可以使用 JavaScript 將輸入圖片放入 WebAssembly 記憶體,執行 WebAssembly 模組以產生第二個經過旋轉的圖片,然後使用 JavaScript 讀取結果。我們可以完全不使用任何記憶體管理功能就完成這項操作!
多元選擇
如果您查看我們要將其轉換為 WebAssembly 的原始 JavaScript 函式,就會發現這只是純粹的運算程式碼,沒有任何 JavaScript 專屬 API。因此,將這個程式碼移植至任何語言應該都相當簡單。我們評估了 3 種可編譯為 WebAssembly 的語言:C/C++、Rust 和 AssemblyScript。我們需要針對每種語言回答的唯一問題是:如何在不使用記憶體管理函式下存取原始記憶體?
C 和 Emscripten
Emscripten 是 WebAssembly 目標的 C 編譯器。Emscripten 的目標是取代 GCC 或 clang 等知名 C 編譯器,並在大多數情況下與標記相容。這是 Emscripten 的核心任務,因為它希望盡可能簡化將現有 C 和 C++ 程式碼編譯為 WebAssembly 的過程。
存取原始記憶體是 C 的本質,而指標正是基於這個原因而存在:
uint8_t* ptr = (uint8_t*)0x124;
ptr[0] = 0xFF;
我們會將數字 0x124
轉換為指向未經編碼的 8 位元整數 (或位元組) 的指標。這會有效地將 ptr
變數轉換為從記憶體位址 0x124
開始的陣列,我們可以像使用任何其他陣列一樣使用,讓我們可以存取個別位元組進行讀取和寫入。在本例中,我們會查看圖片的 RGBA 緩衝區,並重新排序以達到旋轉效果。如要移動一個像素,我們實際上需要一次移動 4 個連續位元組 (每個通道一個位元組:R、G、B 和 A)。為簡化這項作業,我們可以建立一組未經簽署的 32 位元整數。依照慣例,輸入圖片會從位址 4 開始,輸出圖片會直接從輸入圖片結束處開始:
int bpp = 4;
int imageSize = inputWidth * inputHeight * bpp;
uint32_t* inBuffer = (uint32_t*) 4;
uint32_t* outBuffer = (uint32_t*) (inBuffer + imageSize);
for (int d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
for (int d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
int in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
outBuffer[i] = inBuffer[in_idx];
i += 1;
}
}
將整個 JavaScript 函式移植至 C 後,我們可以使用 emcc
編譯 C 檔案:
$ emcc -O3 -s ALLOW_MEMORY_GROWTH=1 -o c.js rotate.c
一如往常,emscripten 會產生名為 c.js
的黏合程式碼檔案和名為 c.wasm
的 wasm 模組。請注意,wasm 模組經 gzip 壓縮後僅約 260 個位元組,而黏合程式碼經 gzip 壓縮後約為 3.5 KB。經過一番調整後,我們終於可以捨棄黏合程式碼,並使用一般 API 例項化 WebAssembly 模組。只要您不使用 C 標準程式庫的任何內容,通常就能透過 Emscripten 執行這項操作。
Rust
Rust 是一款新的現代程式設計語言,具有豐富的類型系統、沒有執行階段,以及可保證記憶體安全性和執行緒安全性的擁有權模型。Rust 也支援 WebAssembly 做為核心功能,Rust 團隊也為 WebAssembly 生態系統貢獻了許多優異的工具。
其中一個工具是 wasm-pack
,由 rustwasm 工作小組提供。wasm-pack
會將您的程式碼轉換為適合網頁的模組,可與 webpack 等套件組合器搭配使用。wasm-pack
提供極為便利的體驗,但目前僅適用於 Rust。這個團隊正在考慮為其他 WebAssembly 指定目標語言新增支援功能。
在 Rust 中,切片就等同於 C 中的陣列。就像在 C 中一樣,我們需要建立使用起始位址的切片。這會違反 Rust 強制執行的記憶體安全性模型,因此我們必須使用 unsafe
關鍵字,才能編寫不符合該模型的程式碼。
let imageSize = (inputWidth * inputHeight) as usize;
let inBuffer: &mut [u32];
let outBuffer: &mut [u32];
unsafe {
inBuffer = slice::from_raw_parts_mut::<u32>(4 as *mut u32, imageSize);
outBuffer = slice::from_raw_parts_mut::<u32>((imageSize * 4 + 4) as *mut u32, imageSize);
}
for d2 in 0..d2Limit {
for d1 in 0..d1Limit {
let in_idx = (d1Start + d1 * d1Advance) * d1Multiplier + (d2Start + d2 * d2Advance) * d2Multiplier;
outBuffer[i as usize] = inBuffer[in_idx as usize];
i += 1;
}
}
使用 編譯 Rust 檔案
$ wasm-pack build
產生 7.6 KB 的 wasm 模組,其中約有 100 個位元組的黏合程式碼 (兩者皆經過 gzip 壓縮)。
AssemblyScript
AssemblyScript 是一個相當新的專案,旨在成為 TypeScript 到 WebAssembly 的編譯器。不過,請務必注意,它不會只使用任何 TypeScript。AssemblyScript 使用的語法與 TypeScript 相同,但會將標準程式庫切換為自己的程式庫。他們的標準程式庫模擬 WebAssembly 的功能。這表示您無法將任何閒置的 TypeScript 編譯為 WebAssembly,但這也表示您不必學習新的程式設計語言,即可編寫 WebAssembly!
for (let d2 = d2Start; d2 >= 0 && d2 < d2Limit; d2 += d2Advance) {
for (let d1 = d1Start; d1 >= 0 && d1 < d1Limit; d1 += d1Advance) {
let in_idx = ((d1 * d1Multiplier) + (d2 * d2Multiplier));
store<u32>(offset + i * 4 + 4, load<u32>(in_idx * 4 + 4));
i += 1;
}
}
考量 rotate()
函式具有的小型類型介面,將此程式碼移植至 AssemblyScript 相當容易。AssemblyScript 提供 load<T>(ptr:
usize)
和 store<T>(ptr: usize, value: T)
函式,用於存取原始記憶體。如要編譯AssemblyScript 檔案,我們只需要安裝 AssemblyScript/assemblyscript
npm 套件並執行
$ asc rotate.ts -b assemblyscript.wasm --validate -O3
AssemblyScript 會提供約 300 個位元組的 wasm 模組和沒有黏合程式碼。這個模組只支援一般 WebAssembly API。
WebAssembly 鑑識
與其他 2 種語言相比,Rust 的 7.6KB 相當龐大。WebAssembly 生態系統中提供幾項工具,可協助您分析 WebAssembly 檔案 (不論使用哪種語言建立),並告知您發生了什麼事,同時協助您改善情況。
Twiggy
Twiggy 是 Rust 的 WebAssembly 團隊提供的另一項工具,可從 WebAssembly 模組中擷取大量精闢的資料。這項工具並非 Rust 專用,可讓您檢查模組的呼叫圖、判斷未使用的或多餘的部分,以及找出哪些部分會影響模組的總檔案大小。您可以使用 Twiggy 的 top
指令執行後者:
$ twiggy top rotate_bg.wasm
在這個案例中,我們可以看到檔案大小主要來自配置器。這很令人意外,因為我們的程式碼並未使用動態配置。另一個主要因素是「函式名稱」子區段。
wasm-strip
wasm-strip
是 WebAssembly 二進位元工具包 (簡稱 wabt) 的工具。其中包含幾個工具,可讓您檢查及操作 WebAssembly 模組。wasm2wat
是一種反組譯器,可將二進位 wasm 模組轉換為人類可讀的格式。Wabt 也包含 wat2wasm
,可讓您將該人類可讀格式轉換回二進位 wasm 模組。雖然我們確實使用這兩個互補工具來檢查 WebAssembly 檔案,但我們發現 wasm-strip
最實用。wasm-strip
會從 WebAssembly 模組中移除不必要的部分和中繼資料:
$ wasm-strip rotate_bg.wasm
這樣一來,Rust 模組的檔案大小就會從 7.5 KB 縮減至 6.6 KB (經過 gzip 壓縮後)。
wasm-opt
wasm-opt
是 Binaryen 提供的工具。這項工具會採用 WebAssembly 模組,並嘗試根據位元碼,針對大小和效能進行最佳化。Emscripten 等部分工具已執行這項工具,其他工具則未執行。因此,建議您使用這些工具,試著節省一些額外的位元組。
wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm
使用 wasm-opt
,我們可以減少其他一些位元組,在 gzip 後留下總共 6.2KB。
#![no_std]
經過諮詢和研究後,我們使用 #![no_std]
功能,在不使用 Rust 標準程式庫的情況下重新編寫 Rust 程式碼。這也會一併停用動態記憶體配置,從模組中移除配置程式碼。使用 這個 Rust 檔案編譯
$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs
在 wasm-opt
、wasm-strip
和 gzip 之後產生 1.6KB 的 wasm 模組。雖然它仍大於由 C 和 AssemblyScript 產生的模組,但已足夠小巧,可視為輕量模組。
成效
在我們根據檔案大小得出結論之前,我們先來談談這趟旅程的目的是為了提升效能,而不是檔案大小。那麼,我們如何評估成效,結果又是如何?
如何基準化
雖然 WebAssembly 是低階位元碼格式,但仍需透過編譯器傳送,才能產生特定主機的機器碼。就像 JavaScript 一樣,編譯器會在多個階段中運作。簡單來說,第一個階段的編譯速度較快,但產生的程式碼通常較慢。模組開始執行後,瀏覽器會觀察哪些部分經常使用,並透過更有效率但速度較慢的編譯器傳送這些部分。
我們的用途很有趣,因為用來旋轉圖片的程式碼會使用一次,也許兩次。因此,在大多數情況下,我們永遠無法獲得最佳化編譯器的好處。這一點在基準測試時非常重要。在迴圈中執行 WebAssembly 模組 10,000 次會產生不切實際的結果。為了取得實際的數字,我們應該執行一次模組,並根據該次執行的數字做出決策。
成效比較
這兩張圖表是同一組資料的不同檢視畫面。第一張圖表比較各瀏覽器,第二張圖表比較各使用語言。請注意,我選擇了對數時間軸。另外,所有基準測試都使用相同的 1600 萬像素測試圖片和主機,除了無法在同一台電腦上執行的某個瀏覽器。
無須過度分析這些圖表,我們就能清楚看出已解決原始效能問題:所有 WebAssembly 模組的執行時間約為 500 毫秒或更短。這證實了我們一開始所說的:WebAssembly 可提供可預測的效能。無論我們選擇哪種語言,瀏覽器和語言之間的差異都很小。具體來說:所有瀏覽器的 JavaScript 標準差約為 400 毫秒,而所有瀏覽器的所有 WebAssembly 模組標準差約為 80 毫秒。
難度
另一個指標是我們在建立並將 WebAssembly 模組整合至 squoosh 時所投入的努力程度。很難為努力程度指派數值,因此我不會建立任何圖表,但我想指出幾件事:
AssemblyScript 的整合過程非常順暢。這項功能不僅可讓您使用 TypeScript 編寫 WebAssembly,讓同事輕鬆進行程式碼審查,還可產生無黏著劑的 WebAssembly 模組,這些模組體積很小,且效能不錯。TypeScript 生態系統中的工具 (例如 prettier 和 tslint) 可能會正常運作。
Rust 與 wasm-pack
的結合也非常方便,但在需要繫結和記憶體管理的大型 WebAssembly 專案中,Rust 更能發揮優勢。我們必須稍微偏離正常途徑,才能達到競爭性的檔案大小。
C 和 Emscripten 建立了一個非常小且效能極高的 WebAssembly 模組,但沒有勇氣跳入膠水程式碼,並將其縮減至必要的程度,因此總大小 (WebAssembly 模組 + 膠水程式碼) 最終會變得相當大。
結論
因此,如果您有 JS 熱門路徑,且想讓該路徑更快或與 WebAssembly 更一致,應使用哪種語言?如同其他效能問題,答案是:視情況而定。那我們出貨了什麼?
比較我們使用的不同語言的模組大小 / 效能權衡,最佳選擇似乎是 C 或 AssemblyScript。我們決定推出 Rust。做出這項決定的原因有很多:到目前為止,Squoosh 中提供的所有編解碼都是使用 Emscripten 編譯。我們希望擴充對 WebAssembly 生態系統的瞭解,並在實際工作環境中使用不同的語言。AssemblyScript 是強大的替代方案,但該專案相對較新,且編譯器不如 Rust 編譯器成熟。
雖然在散布圖中,Rust 與其他語言的檔案大小差異看起來相當明顯,但實際上並沒有那麼嚴重:載入 500B 或 1.6KB (甚至超過 2GB) 的檔案只需花費不到 1/10 秒的時間。我們希望 Rust 能盡快縮小模組大小方面的差距。
就執行階段效能而言,在各瀏覽器中,Rust 的平均速度比 AssemblyScript 快。特別是在較大型專案中,Rust 更有可能產生速度更快的程式碼,而無需手動進行程式碼最佳化。但這不應阻止您使用最習慣的工具。
總而言之,AssemblyScript 是一項了不起的發現。讓網頁開發人員不必學習新語言,即可產生 WebAssembly 模組。AssemblyScript 團隊一直都非常積極回應,並積極改善工具鍊。我們日後一定會密切留意 AssemblyScript。
更新項目:Rust
這篇文章發布後,Rust 團隊的 Nick Fitzgerald 向我們推薦了他們優秀的 Rust Wasm 書籍,其中包含關於最佳化檔案大小的章節。按照該處的操作說明 (最主要的是啟用連結時間最佳化和手動恐慌處理),我們就能編寫「一般」的 Rust 程式碼,並且繼續使用 Cargo
(Rust 的 npm
),不會造成檔案大小膨脹。經過 gzip 壓縮後,Rust 模組的大小會縮減至 370B。詳情請參閱 我在 Squoosh 中提出的 PR。
特別感謝 Ashley Williams、Steve Klabnik、Nick Fitzgerald 和 Max Graey 在這段旅程中提供協助。