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

方法持續快速,

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

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

根據我的經驗,網路上大多數的效能問題都是由強製版面配置和過度繪製所引起,但現在每隔一次,應用程式就需要執行耗費大量時間的運算成本高昂工作。WebAssembly 可以在此提供協助

熱門路徑

簡單來說,我們編寫的 JavaScript 函式可將圖片緩衝區旋轉 90 度的倍數。雖然 OffscreenCanvas 是理想的此功能,但並不支援我們指定的瀏覽器以及 Chrome 的錯誤

這個函式會對輸入圖片的每個像素進行疊代,並將其複製到輸出圖片中的其他位置,以便達到旋轉效果。如果是 4094 x 4096 像素的圖片 (1600 萬像素),則需要超過 1, 600 萬個內部程式碼區塊疊代,也就是我們稱之為「熱路徑」。儘管疊代量十分龐大,但我們測試的三個瀏覽器中共佔 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,網頁開發人員無法存取 (開發人員工具除外)。因此,您可以編寫完全不需要任何額外記憶體的 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 模組。請注意,Jam 模組 gzip 只能處理約 260 位元組,而 gzip 後的黏附程式碼約為 3.5 KB。在一些小問題後,我們可以使用香草 API 拼接黏土程式碼,並將 WebAssembly 模組執行個體化。只要不使用 C 標準程式庫中的任何項目,Escripten 往往就能採取這種做法。

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

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

AssemblyScript

AssemblyScript 是一個相當年輕的專案,旨在做為 TypeScript 對 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 位元組的 wasm 模組,以及沒有黏附程式碼。這個模組只能與基本 WebAssembly API 搭配使用。

WebAssembly 鑑識

與另外 2 種程式語言相比,Rust 的 7.6 KB 驚訝地超出了預期。WebAssembly 生態系統中提供了幾項工具,可協助您分析 WebAssembly 檔案 (無論用來建立的語言為何),並說明您的情況,並且協助您改善情況。

托維吉

Twiggy 是 Rust WebAssembly 團隊的另一項工具,可從 WebAssembly 模組擷取大量具洞察性的資料。這項工具並非 Rust 專用,可讓您檢查模組的呼叫圖、判斷未使用或超繁雜的區段,並找出構成模組總檔案大小的區段。後者可透過 Twiggy 的 top 指令完成:

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

在這個範例中,我們發現大多數檔案大小都來自分配器。這個結果讓人感到驚訝,因為我們的程式碼並未使用動態分配。 另一個重要的因素是「函式名稱」子區段。

Wam-Strip

wasm-strip」是 WebAssembly Binary Toolkit 提供的工具,簡稱為 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-optBinaryen 的工具。它會接受 WebAssembly 模組,並僅嘗試根據位元碼,將模組大小和效能最佳化。有些工具 (例如 Emscripten) 已經執行這項工具 有些則沒有一般來說,建議您使用這些工具嘗試儲存更多位元組。

wasm-opt -O3 -o rotate_bg_opt.wasm rotate_bg.wasm

有了 wasm-opt,我們就能省下另外幾個位元組,在 gzip 後留下總計 6.2 KB。

#![no_std]

經過幾次諮詢和研究後,我們已不使用 Rust 的標準程式庫,透過使用 #![no_std] 功能重新編寫 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 模組,即使處理效能不彰也不成問題。TypeScript 生態系統中的工具 (例如絕佳和 Tlint) 可能只能正常運作。

將 Rust 與 wasm-pack 搭配使用也很方便,但更適合在規模較大的 WebAssembly 專案上,因為需要繫結和記憶體管理。我們必須與快樂路徑不同,才能取得具競爭力的檔案大小。

C 和 Emscripten 開發出非常小且高效能的 WebAssembly 模組,但缺乏勇氣來使用黏附程式碼,以減少總體大小 (WebAssembly 模組 + 黏合程式碼) 而變得非常大。

結論

因此,如果您有 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 帶到這段旅程的夥伴。