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

速度一向很快

先前的文章中,我談到 WebAssembly 如何讓您將 C/C++ 的程式庫生態系統帶入網際網路。squoosh 是廣泛使用 C/C++ 程式庫的應用程式,這個網頁應用程式可讓您使用從 C++ 編譯為 WebAssembly 的各種編解碼器壓縮圖片。

WebAssembly 是低階虛擬機器,可執行儲存在 .wasm 檔案中的位元碼。這個位元組程式碼屬於強型別,且結構明確 但 LLM 的編譯和最佳化作業速度比 主機系統更快 JavaScript 可以。WebAssembly 提供一個環境,可讓您執行從一開始就考慮到沙箱和嵌入功能的程式碼。

以我的經驗來說,網路上大多數的效能問題是強制使 但重複繪製過多畫面 但現在每隔一段時間,應用程式都需要 需要耗費大量時間的運算成本高昂這時 WebAssembly 就能派上用場。

熱門路徑

在 squoosh 中,我們編寫了 JavaScript 函式,以 90 度的倍數旋轉圖片緩衝區。雖然 OffscreenCanvas 是這項作業的理想選擇,但我們鎖定的瀏覽器不支援這項功能,而且 Chrome 中也有一些錯誤

這個函式會對輸入圖片的每個像素進行迴圈,並將其複製至輸出圖片的不同位置,以便旋轉。4094px x 4096 像素 (1600 萬像素) 的圖片,需要超過 1,600 萬個 內部程式碼區塊,也就是我們所謂的「熱路徑」就算尺寸較大 我們針對三個不同的瀏覽器當中測試 其中兩個是完成整個任務的時間 秒以內。這類互動可接受的時間長度。

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 內部,網頁開發人員無法存取 (除非透過開發人員工具)。因此 編寫完全不需額外記憶體的 WebAssembly 模組 僅使用 VM 內部堆疊

在本例中,我們需要使用一些額外記憶體,以便任意存取圖片的像素,並產生該圖片的旋轉版本。這是 「WebAssembly.Memory」的用途

記憶體管理

通常,一旦使用額外記憶體,就需要以某種方式管理記憶體。記憶體的哪些部分正在使用?哪些是免費的?例如,在 C 中,malloc(n) 函式會尋找記憶體空間 共計 n 個位元組。此類型的函式也稱為「配置器」。 當然,採用的配置器也必須包含在 WebAssembly 模組,且會增加您的檔案大小。這個大小和效能 當中的記憶體管理功能 所以許多語言提供多個實作 可選擇 (「dmalloc」、「emmalloc」、「wee_alloc」等)。

在這個範例中,我們知道輸入圖片的尺寸 ( 輸出影像的尺寸),再執行 WebAssembly 模組。我們在這裡 我們看到了商機:一般來說,我們會將輸入圖像的 RGBA 緩衝區做為 參數傳送至 WebAssembly 函式,並將旋轉的圖片傳回,做為傳回 值。如要產生該傳回值,我們必須使用配置器。不過,因為我們知道所需的記憶體總量 (輸入大小是輸入量的兩倍 分別用於輸入和輸出),我們可以將輸入圖片放入 使用 JavaScript 的 WebAssembly 記憶體,執行 WebAssembly 模組來產生 第二,旋轉圖片,然後使用 JavaScript 來讀回結果。我們可以 不必管理任何記憶體!

已停用

如果您查看原始 JavaScript 函式 會讓 WebAssembly-fy 這個模型 不含 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 的黏附程式碼檔案和 wasm 模組 名為 c.wasm。請注意, wasm 模組 gzip 只能存放約 260 位元組, gzip 後的 glue 程式碼約為 3.5 KB。經過一番調整後,我們終於可以捨棄黏合程式碼,並使用一般 API 將 WebAssembly 模組例項化。使用 Emscripten 通常只要不利用任何東西即可 來自 C 標準程式庫

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

會產生約 100 位元組的黏合程式碼 (兩者都是 gzip 之後) 的 7.6 KB 模組。

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
Twiggy 安裝螢幕截圖

在這個案例中,我們可以看到檔案大小主要來自配置器。這個情況讓人感到驚訝,因為我們的程式碼並未使用動態分配。 另一個影響因素是「函式名稱」子節。

Warm-Strip

wasm-strip」是 WebAssembly Binary Toolkit 提供的工具,簡稱為 Wabt。這個 SDK 包含 數種能檢查及操控 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]

經過諮詢和研究後,我們重新編寫 Rust 程式碼,不使用 Rust 的標準程式庫 (採用 #![no_std] 而不是每個特徵的分數這樣會一併停用動態記憶體配置功能,移除 擷取自解碼器程式碼編譯這個 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 模組,這些模組體積很小,且效能不錯。TypeScript 生態系統中的工具,例如更美觀與扭曲的 應該就能正常運作

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 是一項絕佳的發現。允許 開發人員不必學習 語言。AssemblyScript 團隊一直都很積極回應,並積極改善工具鏈。我們日後一定會密切留意 AssemblyScript。

更新項目:Rust

這篇文章發布後,Rust 團隊的 Nick Fitzgerald 向我們推薦了他們優秀的 Rust Wasm 書籍,其中包含關於最佳化檔案大小的章節。請確實按照 操作說明 (最值得注意的是,連結時間最佳化和手動 恐慌處理) 讓我們可以編寫「一般」Rust 程式碼,並改回使用 Cargo (Rust 的 npm),且不會縮減檔案大小。Rust 模組結束時 安裝 gzip 後產生 3,70B 美金詳情請參閱 我在 Squoosh 中提出的 PR

特別感謝 Ashley WilliamsSteve KlabnikNick FitzgeraldMax Graey 帶到這段旅程的夥伴。