速度一向很快
在先前的文章中,我談到 WebAssembly 如何讓您將 C/C++ 的程式庫生態系統帶入網際網路。squoosh 是廣泛使用 C/C++ 程式庫的應用程式,這個網頁應用程式可讓您使用從 C++ 編譯為 WebAssembly 的各種編解碼器壓縮圖片。
WebAssembly 是低階虛擬機器,可執行儲存在 .wasm
檔案中的位元碼。這個位元組程式碼屬於強型別和結構,能以比 JavaScript 更快的速度針對主機系統進行編譯和最佳化。WebAssembly 提供的環境可讓您執行程式碼,從一開始就考慮採用沙箱機制和嵌入功能。
以我的經驗來說,網頁上的大部分效能問題都是由強制版面配置和過度繪製所造成,但應用程式不時需要執行耗費大量時間的運算密集工作。WebAssembly 可以在此提供協助
熱門路徑
在 squoosh 中,我們編寫了 JavaScript 函式,以 90 度的倍數旋轉圖片緩衝區。雖然 OffscreenCanvas 是這項作業的理想選擇,但我們鎖定的瀏覽器不支援這項功能,而且 Chrome 中也有一些錯誤。
這個函式會對輸入圖片的每個像素進行迴迭,並將其複製至輸出圖片中的不同位置,以便旋轉。對於 4094 x 4096 像素 (1600 萬像素) 的圖片,內部程式碼區塊需要超過 1600 萬次的迭代,也就是我們稱為「熱門路徑」的部分。儘管疊代量十分龐大,但我們測試的三個瀏覽器中共佔 2 個,不到 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 架構
在撰寫 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.6 KB 驚訝地超出了預期。WebAssembly 生態系統中提供了幾項工具,可協助您分析 WebAssembly 檔案 (無論用來建立的語言為何),並說明您的情況,並且協助您改善情況。
托維吉
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
這會將信任模組的檔案大小從 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.2 KB。
#![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 在這段旅程中提供的協助。