使用 WebAssembly 取代應用程式中的熱路徑's JavaScript

速度一直很快

在我先前 文章中,我曾介紹 WebAssembly 如何可讓您將 C/C++ 的程式庫生態系統導入網路。想要大量使用 C/C++ 程式庫的應用程式是 squoosh。我們的網頁應用程式可讓您使用從 C++ 編譯至 WebAssembly 的各種轉碼器來壓縮圖片。

WebAssembly 是低階虛擬機器,執行了儲存在 .wasm 檔案中的位元碼。這個位元組碼具有強型別和結構,因此可比 JavaScript 更快,針對主機系統進行編譯和最佳化。WebAssembly 提供一個環境,可從一開始就考慮到沙箱及嵌入程式碼。

以我的經驗,大部分網路效能問題都是由強製版面配置和繪製過多情形造成。WebAssembly 可以提供協助

熱路徑

我們編寫了一個 JavaScript 函式,可將圖片緩衝區旋轉 90 度的倍數。雖然 OffscreenCanvas 最適合使用,但我們指定的瀏覽器都不支援這個模式,而且 Chrome 不穩定

這個函式會疊代輸入圖片的每個像素,並將其複製到輸出圖片中的不同位置來達成旋轉。如果是 4094px x 4096px 的圖片 (1,600 萬像素),則內部程式碼區塊需要超過 1,600 萬疊代,這就是所謂的「熱路徑」。儘管疊代次數相當龐大,我們卻在三個瀏覽器中完成工作,卻不到 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 內部,且網頁開發人員無法存取 (除了開發人員工具外),因此,您可以編寫完全不需要任何額外記憶體的 WebAssembly 模組,並且只使用 VM 內部堆疊。

我們會需要使用一些額外的記憶體,允許任意存取圖片像素,並產生該圖片的旋轉版本。這就是 WebAssembly.Memory 的用途。

記憶體管理

一般來說,當您使用額外的記憶體後,就會需要如何管理該記憶體。正在使用記憶體的哪些部分?哪些是免費的? 舉例來說,在 C 中,malloc(n) 函式會找出連續 n 位元組的記憶體空間。這種函式也稱為「配置器」。當然,使用中的配置器實作必須納入 WebAssembly 模組中,且會增加檔案大小。這些記憶體管理功能的大小和效能可能會因使用的演算法而有極大差異,因此許多語言都提供多個實作選項 (「dmalloc」、「emmalloc」、「wee_alloc」等)。

在本例中,我們在執行 WebAssembly 模組之前,已經知道輸入圖片的尺寸,以及輸出圖片的尺寸。我們可以看到這個機會:一般來說,我們會將輸入圖片的 RGBA 緩衝區做為參數傳遞至 WebAssembly 函式,並傳迴旋轉的圖片做為傳回值。為產生該傳回值,我們必須使用分配器。不過,因為我們知道需要的記憶體總量 (輸入圖片的兩倍,一次是輸入圖片,一次是輸出),因此可以使用 JavaScript 將輸入圖片放入 WebAssembly 記憶體中,然後執行 WebAssembly 模組來產生第 2 張旋轉圖片,然後使用 JavaScript 讀取結果。我們完全不必透過任何記憶體管理 就能解決這些問題!

刺激選擇

當您查看我們希望 WebAssembly-fy 的原始 JavaScript 函式時,您會發現這是純粹的計算程式碼,沒有 JavaScript 專用的 API。因此,這個程式碼應該 直接轉送至任何語言我們評估了編譯為 WebAssembly 的 3 種不同語言: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 生態系統提供了許多出色的工具。

其中一項工具是 rustwasm 工作團隊提供的 wasm-packwasm-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-to-WebAssembly 編譯器。不過,請特別注意,這不會消耗任何 TypeScript。AssemblyScript 採用與 TypeScript 相同的語法,但也會自行停用標準程式庫。他們的標準程式庫會建立 WebAssembly 的功能模型。這表示您不能只編譯指向 WebAssembly 的任何 TypeScript,但「確實」您不必學習新的程式設計語言,即可編寫 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。load<T>(ptr: usize)store<T>(ptr: usize, value: T) 函式是由 AssemblyScript 提供,以存取原始記憶體。如要編譯 AssemblyScript 檔案,我們只需安裝 AssemblyScript/assemblyscript npm 套件並執行

$ asc rotate.ts -b assemblyscript.wasm --validate -O3

AssemblyScript 會提供約 300 Bytes wasm 模組,且沒有黏附程式碼。模組只能與一般 WebAssembly API 搭配使用。

WebAssembly 鑑識

相較於其他語言,Rust 的 7.6 KB 超乎想像。WebAssembly 生態系統中有提供數種工具,可協助您分析 WebAssembly 檔案 (無論其建立的語言為何),並告知您現狀,這也有助於改善您的情況。

Twiggy

Twiggy 是 Rust 的 WebAssembly 團隊提供的另一項工具,可從 WebAssembly 模組擷取大量深入分析資料。這項工具並非 Rust 專用,可讓您檢查模組的呼叫圖、判斷未使用或多餘的部分,並瞭解哪些區段是影響模組總檔案大小的因素。後者可使用 Twiggy 的 top 指令完成:

$ twiggy top rotate_bg.wasm
Twiggy 安裝螢幕截圖

在本範例中,我們可以看到絕大多數的檔案大小都來自定位器。而我們之所以感到驚訝,因為我們的程式碼並未使用動態分配功能。 另一個影響因素就是「函式名稱」子區段。

Wasm-Strip

wasm-stripWebAssembly Binary Toolkit 的工具,簡稱「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-optBinaryen 的工具。它會採用 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-optwasm-strip 和 gzip 後得到 1.6 KB 的 wasm 模組。雖然這仍比 C 和 AssemblyScript 產生的模組還要大,但可視為輕量。

效能

畢竟,單憑檔案大小就做總結;我們一直在講解如何改善效能,而不是影響檔案大小。我們如何衡量效能? 結果如何?

如何基準測試

雖然 WebAssembly 是低階位元碼格式,仍需透過編譯器傳送,才能產生主機專屬的機器碼。就像 JavaScript 一樣,編譯器會處於多個階段運作。簡單來說,第一個階段的編譯速度會更快,但產生的程式碼速度通常較慢。模組開始執行後,瀏覽器會觀察哪些部分經常使用,並透過更有最佳化但速度較慢的編譯器傳送這些部分。

我們的用途值得注意,用於旋轉圖片的程式碼可能會使用一次 (可能兩次)。因此,在大多數情況下,我們永遠無法得到最佳化編譯器的優勢。執行基準測試時,請務必留意這一點。以迴圈執行 WebAssembly 模組 10,000 次會帶來不真實的結果。如要取得實際數據,我們應執行一次模組,然後根據單次執行的數據做出決策。

成效比較

各語言的速度比較
各瀏覽器的速度比較

這兩個圖表是呈現相同資料的不同檢視畫面。在第一個圖表中,我們會比較每個瀏覽器的資料,而第二個圖表中的是每一種使用語言的比較資料。請注意,我選擇的是對數制度。此外,所有基準測試均使用相同的 1600 萬像素測試映像檔和相同的主機機器,但只有一種瀏覽器無法在同一部機器上執行。

如果不分析這些圖表,我們很明顯可以解決原始的效能問題:所有 WebAssembly 模組都能在約 500 毫秒內執行。這樣可以確認我們一開始配置的內容:WebAssembly 可提供「可預測」的效能。無論選擇哪種語言,瀏覽器和語言之間的差距極小。精確來說:所有瀏覽器的 JavaScript 標準差約 400 毫秒,而所有瀏覽器上所有 WebAssembly 模組的標準差約為 80 毫秒。

難度

另一個指標則是我們為了建立 WebAssembly 模組,並將其整合至 squoosh 所花費的心力。要為使用者指派數值並不容易,因此我不會製作任何圖表,但需要特別留意:

AssemblyScript 不停順暢。它不僅可讓您使用 TypeScript 編寫 WebAssembly,讓我的同事輕鬆完成程式碼審查作業,還能產生極小且效能較低的 WebAssembly 模組。比較精美和 Tlint 等 TypeScript 生態系統中的工具可能都能正常運作。

將 Rust 與 wasm-pack 搭配使用也非常方便,但如果是更大型的 WebAssembly 專案,就必須具備繫結和記憶體管理能力。為了實現有競爭力的檔案大小,我們必須稍微從快樂的路徑做些調整。

C 和 Emscripten 可立即建立了一個非常小型且高效能的 WebAssembly 模組,但又不願意為了縮減總大小而縮減程式碼 (WebAssembly 模組 + glue Code) 的必備條件。

結語

所以如果擁有 JS 熱路徑,且想要加快或更與 WebAssembly 的一致性,應該使用什麼語言。如同以往的效能問題,答案是:不一定。那麼,我們究竟推出什麼呢?

比較圖表

比較我們所用不同語言的模組大小 / 效能取捨,最適合使用 C 或 AssemblyScript。我們決定推出 Rust,做出這項決定的原因有很多種:目前在 Squoosh 中提供的轉碼器都是使用 Emscripten 進行編譯。我們希望擴展對於 WebAssembly 生態系統的知識,並在實際工作環境中使用不同語言。AssemblyScript 是不錯的替代方案,但專案規模較小,且 Rust 編譯器的成熟程度也較低。

雖然 Rust 與其他程式設計語言的檔案大小差異在散佈圖中看起來有很大的差異,但實際上並不是如此:即使是透過 2G 載入 500B 或 1.6 KB,只需要一秒的 1/10 就能完成。Rust 希望很快就能縮小模組大小的差距。

就執行階段效能而言,Rust 在所有瀏覽器上的平均速度比 AssemblyScript 快。特別是在較大型的專案中,Rust 較可能產生更快的程式碼,而且無需手動最佳化程式碼。不過,這種情況不應該讓您以自己最熟悉的方式使用。

話說回來,AssemblyScript 是非常有效的發現。可讓網頁開發人員產生 WebAssembly 模組,而不必學習新語言。AssemblyScript 團隊回應速度非常快,正積極改善工具鍊。我們日後將持續密切留意 AssemblyScript。

更新項目:Rust

本文發布後,Rust 團隊的 Nick Fitzgerald 指出了我們製作的優秀 Rust Wasm 書籍,其中有關於最佳化檔案大小的章節。按照其中的操作說明 (最值得注意的是啟用連結時間最佳化和手動恐慌處理),我們得以編寫「一般」的 Rust 程式碼,然後改回使用 Cargo (Rust 的 npm),無需增加檔案大小。Rust 模組在 gzip 後以 370B 結束。詳情請參閱 Squoosh 上的 PR

特別感謝 Ashley WilliamsSteve KlabnikNick FitzgeraldMax Graey 在這段旅程中得到的助力。