「譯」使用 WebAssembly 替換應用程序中的Hot Path

原文連接: Replacing a hot path in your app's JavaScript with WebAssembly

February 15, 2019.javascript

以前的文章中我講述了 WebAssembly 是如何容許咱們將 C/C++ 生態中的庫應用於 web 應用中的。一個典型的使用了 C/C++ 擴展包的 web 應用就是 squoosh,這個應用使用了一系列從 C++ 語言編譯成 WebAssembly 的代碼來壓縮圖片。html

WebAssembly 是一個底層虛擬機,能夠用來運行 .wasm 文件中存儲的字節碼。這些字節碼是強類型、結構化的,相比 JavaScript 能更快速的被宿主系統編譯和識別。WebAssembly 能夠運行已知界限和依賴的代碼。java

據我所知,web 應用中的大多數性能問題都是由強制佈局和過分繪製形成的,但應用程序又時不時地須要執行一項計算成本高昂、須要大量時間的任務。這中狀況下 WebAssembly 就能夠派上用場了。webpack

Hot Path

在 squoosh 這個 web 應用中,咱們寫了一個 JavaScript 函數,將圖像以 90 度的倍數進行旋轉。儘管 OffscreenCanvas 是實現這一點的理想之選,但它在咱們使用的瀏覽器中並不支持該特性,並且在 Chrome 中也存在一些小 bug。git

爲了實現旋轉,該 JavaScript 函數在輸入圖片的每個像素上進行迭代,將每個像素複製到輸出圖片的相應位置上。對於一個 4094px * 4094px 的圖像(1600 萬像素)來講,內部代碼塊將迭代超過 1600 萬次,這些被屢次迭代的代碼塊就被稱之爲 hot path。通過測試,儘管此次計算須要大量的迭代,仍有 2/3 的瀏覽器能在兩秒之內完成。在此種交互中這是一個可接受的耗時。github

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 操做作優化的。在本例中,咱們遇到了一個未經優化的路徑。web

WebAssembly 正是圍繞原生計算的速度優化而生的。因此針對相似上述代碼,若是咱們但願其在瀏覽器中具備快速、可預測的性能,WebAssembly 就很是有用了。算法

WebAssembly 之於可預測的性能

通常來講,JavaScript 和 WebAssembly 能達到相同的性能峯值。可是 JavaScript 只有在 fast path 之下才能達到峯值性能,而且代碼老是處於 fast path 之下。WebAssembly 另外一個優點是,即便經過瀏覽器運行,它也能提供可預測的性能。強類型和低級語言保證了 WebAssembly 被優化一次,就能一直被快速執行。npm

WebAssembly 書寫

以前,咱們將 C/C++ 的庫編譯成 WebAssembly ,將其中的方法應用於 web 應用中。咱們尚未真正接觸到庫中的代碼,只是寫了一點 C/C++ 代碼來適配庫和瀏覽器的橋接。這一次咱們有另一個目標:要用 WebAssembly 從頭寫一段代碼,這樣就能應用上 WebAssembly 的一系列優點。編程

WebAssembly 的架構

在寫 WebAssembly 時,咱們最好多瞭解一下 WebAssembly 到底是什麼。

引用自 WebAssembly.org

 WebAssembly (縮寫 Wasm )是一種基於堆棧的虛擬機的二進制指令格式。將高級語言(如 C/C++/Rust )編譯爲 Wasm, 來支持在 web 應用中客戶端和服務端的開發.

當編譯一段 C 或者 Rust 代碼到 WebAssembly 時, 咱們將會獲得一個.wasm 文件,該文件是用於模塊聲明的。文件中包括模塊從環境中的導入列表、模塊提供給宿主系統的導出列表(函數、常量、內存塊),固然還有包含其中的函數的實際二進制指令。

仔細研究了一下我才意識到:WebAssembly 堆棧虛擬機的堆棧,並無存儲在 WebAssembly 模塊使用的內存中。這個堆棧徹底是 vm 內部的,web 開發人員沒法直接訪問(除非經過 DevTools )。所以,咱們能夠編寫徹底不須要任何額外內存只使用 vm 內部堆棧的 WebAssembly 模塊。

提示:(嚴格來講)例如 Emscripten 這樣的編譯器仍然是使用 WebAssembly 的內存來實現堆棧的。這是有必要的,由於如此一來咱們就能夠隨時隨地經過相似 C 語言中的指針這樣的東西來訪問堆棧了,而 VM-internal 堆棧倒是不能被這樣訪問的。因此,這裏有點使人困惑,當用 WebAssembly 跑一段 C 代碼時,兩個堆棧都會被使用到。

在咱們的案例中,咱們須要一些額外的內存空間方便訪問圖像上的每個像素,並生成該圖像的旋轉版本,這就是 WebAssembly.Memory 的做用。

內存管理

一般,只要咱們使用了額外的內存,就須要作內存管理。哪部份內存正在被使用?哪些是空閒的?例如,在 C 語言中,有一個函數 malloc(n) 用於獲取 n 連續字節的空閒內存。這種函數也被叫作」內存分配器「。固然,被引用的內存分配器的實現必須包含在 WebAssembly 模塊中,它將增大文件的大小。內存分配器的大小和空間管理的性能會因所使用算法的不一樣而有顯著的差別,所以不少語言都提供了多種實現可供選擇("dmalloc", "emmalloc", "wee_alloc",...)。

在咱們的案例中,在跑 WebAssembly 模塊以前咱們就知道了輸入圖片的尺寸(同時也知道了輸出圖片的尺寸)。咱們發現: 一般,咱們應該把輸入圖片的 RGBA buffer 做爲參數傳給 WebAssembly 函數,並把輸出圖片的 RGBA buffer 返回出來。爲了生成返回值,咱們必須使用內存分配器。可是,由於已知所需內存空間的大小(兩倍於輸入圖片的大小,一半給輸入使用,一半給輸出使用),咱們能夠用 JavaScript 將圖片放到 WebAssembly 內存中,運行 WebAssembly 模塊生成第二個旋轉後的圖片,而後用 JavaScript 把返回值讀取出來。這樣咱們就能夠不使用內存管理了!(演示))

多種選擇

若是你查看一下原始的 JavaScript 函數,就會發現這是一段純邏輯函數,沒有使用任何 JavaScript 專屬 API。所以,這段代碼被移植爲其餘任何語言都應該沒太大問題。咱們評估了 3 種語言:C/C++、Rust 和 AssemblyScript。只有一個問題:對於每種語言,咱們如何在不使用內存管理的狀況下訪問原生內存。

提示:我跳過了示例代碼中一些繁瑣的部分,聚焦在真正的 hot path 和內存調用上。完整的示例和性能測試在這裏 gist.

C 與 Emscripten

Emscripten 是一個將 C 編譯成 WebAssembly 的編譯器。Emscripten 的目標是取代著名的 C 編譯器,如 GCC 或 clang,而且與它們基本上是兼容的。這是 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;
  }
}
提示:咱們選擇從地址 4 而不是 0 開始的緣由是地址 0 在許多語言中有特殊的含義:它是可怕的空指針。雖然從技術上講 0 是一個徹底有效的地址,但許多語言將 0 排除爲指針的有效值,並拋出異常或直接返回未定義行爲。

將整個 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.5KB。通過一些調整,咱們可以拋棄膠水代碼並使用普通 api 實例化 WebAssembly 模塊。在使用 Emscripten 時,這一般是能夠可行的,只要咱們不使用來自 C 標準庫的任何東西。

提示:咱們正和 Emscripten 團隊合做,來儘量減少膠水代碼文件的體積,甚至在某些狀況下能夠去掉這個文件。

Rust

提示:自本文發佈以來,咱們瞭解到更多關於如何爲 WebAssembly 優化 Rust 的知識。請參閱本文末尾的更新部分。

Rust 是一種新的、現代的編程語言,具備豐富的類型系統,沒有運行時,而且擁有一個保證內存安全和線程安全的全部權模型。Rust 仍是支持 WebAssembly 的一等公民,Rust 團隊爲 WebAssembly 生態貢獻了不少優秀的工具。

其中一個是  rustwasm working group 貢獻的 wasm-packwasm-pack 能夠將代碼轉換成 web 友好的模塊,像 webpack 同樣提供開箱即用的 bundlers。wasm-pack 提供了一種很是方便的體驗,但目前只適用於 Rust 。該團隊正在考慮添加對其餘想要轉爲 WebAssembly 的語言的支持。

在 Rust 中,slices 就是 C 中的數組。就像在 C 中同樣,咱們須要先使用起始地址建立一個 slices。這違背了 Rust 推崇的內存安全模型,所以爲了達到目的,咱們必須使用不安全關鍵字,編寫不符合該模型的代碼。

提示:這不是最好的實現。根據以往的經驗,最好使用打包工具(相似於 embind in Emscripten 或者 wasm-bindgen ) 開發更高級的 Rust 代碼。
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.6KB 的 wasm 模塊和一個包含大約 100 字節的膠水代碼(都是在 gzip 以後)。

AssemblyScript

AssemblyScript 是一個至關年輕的 Typescript 到 WebAssembly 的編譯器。可是,須要注意的是,它不只僅編譯 TypeScript。AssemblyScript 使用與 TypeScript 相同的語法,可是擁有本身的標準庫。AssemblyScript 的標準庫爲 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 會至關容易。load<T>(ptr: usize)store(ptr: usize, value: T)  是用來訪問原生內存的。要編譯 Assemblyscript 文件,咱們只需安裝AssemblyScript/assemblyscript npm 包並運行以下命令便可:

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

Assemyscript 將爲咱們生成一個大約 300 字節的 wasm 模塊,沒有膠水代碼。該模塊只使用了普通的 WebAssembly api。

WebAssembly 分析

與其餘兩種語言相比,Rust 的 7.6KB 大得驚人。在 WebAssembly 生態系統中有一些工具能夠幫助咱們分析 WebAssembly 文件(無論使用的是什麼語言),並告訴咱們它是作什麼的,還能夠幫助咱們進行優化。

Twiggy

Twiggy 是 Rust WebAssembly 團隊的另外一個工具,它從 WebAssembly 模塊中提取大量有價值的數據。該工具不是專門用於 Rust 的,它還能夠用來檢查模塊調用關係圖等,識別出未使用或多餘的部分,並分析出哪些部分對模塊的體積形成主要影響。後者能夠經過 Twiggy 的 top 命令完成:

$ twiggy top rotate_bg.wasm

twiggy

在這個案例中,咱們能夠看到大部分空間佔用都來自於內存分配器。這有些使人驚訝,由於咱們的代碼並無使用動態分配。第二大空間佔用來自於 「function names」 部分。

wasm-strip

wasm-strip 是 WebAssembly Binary Toolkit (簡稱 wabt )中的一個工具。wabt 包含一系列工具,用於檢查和操做 WebAssembly 模塊。wasm2wat 是一種反彙編工具,它將二進制 wasm 模塊轉換爲人類可讀的格式。Wabt 還包含 wat2wasm,它用於將人類可讀的格式轉換回二進制 wasm 模塊。雖然咱們確實會使用這兩個互補的工具來分析 WebAssembly 文件,但咱們發現 wasm-strip 是最有用的。wasm-strip 能夠從 WebAssembly 模塊中刪除沒必要要的部分和元數據:

$ wasm-strip rotate_bg.wasm

這將 Rust 模塊的文件大小從 7.5KB 減小到 6.6KB (在 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]

通過一系列分析、研究,咱們在沒有使用 Rust 的標準庫的狀況下,使用#![no_std] 特性重寫了 Rust 代碼。也徹底禁用了動態內存配置器,從模塊中刪除了內存配置器的代碼。編譯這個 Rust 文件:

$ rustc --target=wasm32-unknown-unknown -C opt-level=3 -o rust.wasm rotate.rs

在通過 wasm-optwasm-stripgzip 以後生成 1.6KB 的 wasm 模塊。雖然它仍然比 C 和 AssemblyScript 生成的模塊大,但它已經足夠小,能夠被認爲是輕量級的了。

性能

在咱們僅僅根據文件大小得出結論以前——咱們的一些列操做是爲了優化性能,而不只僅是優化文件大小。那麼咱們該如何衡量性能優劣?性能又到底如何呢?

怎樣進行基準測試

儘管 WebAssembly 是一種底層字節碼格式,它仍然須要經過編譯器來生成特定於主機的機器碼。就像 JavaScript 同樣,編譯器的工做分爲多個階段。簡單地說:第一階段的編譯速度要快得多,但生成的代碼每每較慢。一旦模塊開始運行,瀏覽器就會觀察哪些部分是常用的,並經過一個更優化但速度更慢的編譯器編譯這部分。

咱們的用例的有趣之處在於,旋轉圖片的代碼只運行了一次,或者是兩次。因此,在絕大多數狀況下,永遠也體現不出優化編譯器的優點。在進行基準測試時,這一點很是重要。在一個循環中運行咱們的 WebAssembly 模塊 10000 次獲得的數據可能並不確切。爲了獲得確切的數據,咱們應該運行該模塊一次,並根據這一次運行計算出相應的數據。

注意:理想狀況下,咱們應該自動化這個從新加載頁面並運行一次模塊的過程,並屢次執行該過程。咱們相信屢次測量的平均值足以說明問題。

性能對比

img

img

這兩個圖是同一數據上的不一樣視圖。在第一個圖中,咱們比較每一個瀏覽器,在第二個圖中,咱們比較每種使用的語言。請注意,我選擇了對數時間尺度。一樣重要的是,全部基準測試都使用相同的 1600 萬像素的測試圖像和相同的主機,除了一個不能在這臺機器上運行的瀏覽器。

無需過多地分析這兩張圖表,就能夠清楚地看到咱們解決了最初的性能問題:全部 WebAssembly 模塊的運行時間都在大約 500ms 或更少。這證明了咱們在一開始的論調: WebAssembly 提供了可預測的性能。不管咱們選擇哪一種語言,耗時都是最小的。準確地說:JavaScript 在全部瀏覽器上的標準耗時是大約 400ms,而咱們全部 WebAssembly 模塊在全部瀏覽器上的標準耗時是大約 80ms。

易用性

另一個衡量標準是易用性。這個東西是很難量化的,因此我不會給出任何圖表,可是我想指出幾點:

AssemblyScript 的使用幾乎是絲般順滑的。不只僅是由於咱們可使用 TypeScript 來開發,讓同事間能夠輕鬆完成代碼 review,還由於它的產物中不須要膠水代碼,因此體積很小性能很高。TypeScript 生態中的工具(例如 prettier、tslint)彷佛也能正常的爲 AssemblyScript 所用。

Rust 和 wasm-pack 結合使用也是至關方便的,可是它比較擅長的是大型項目中打包,而且須要內存管理。咱們不得不稍微違背 Rust 的初衷,來獲取具備競爭力的輸出文件的大小。

C 和 Emscripten 建立了一個開箱即用的即小又高效的 WebAssembly 模塊,可是若是不努力將膠水代碼文件的體積減少到可忍受的大小的話,產物的整體積(包括 WebAssembly 模塊和膠水代碼文件)仍是太大了。

結論

所以,若是您有一個 JS Hot Path,並但願使它更快或更符合 WebAssembly,您應該使用什麼語言。對於性能問題,答案老是:視狀況而定。那麼咱們選擇了什麼呢?

img

注意:請注意圖中兩個座標軸都是對數增加的,x 軸從 200 到 2000 比特,y 軸從 0.1 秒到 10 秒。

對比了不一樣語言的中模塊的大小/性能,最好的選擇彷佛是 C 或 AssemblyScript。可是咱們最終決定選擇 Rust。作出這個決定有不少緣由:到目前爲止,在 Squoosh 中提供的全部編解碼器都是使用 Emscripten 編譯的。咱們想要擴充關於 WebAssembly 生態系統的知識,在生產中使用不一樣的語言。AssemblyScript 是一個強大的替代方案,但它還相對較年輕,編譯器不如 Rust 編譯器成熟。

儘管在散點圖中,看起來 Rust 和其餘語言之間的文件大小差別很是大,但實際上這並非什麼大問題:加載 500B 或1.6KB,甚至超過 2G,所需時間也不到十分之一秒。而在不久的未來,Rust 有望縮小模塊體積方面的差距。

就運行時性能而言,在不一樣瀏覽器之間,Rust 的平均速度要快於 AssemblyScript。特別是在較大的項目中,Rust 更有可能在不須要手動代碼優化的狀況下生成更快的代碼。但這不影響你選擇你以爲最舒服的那個。

綜上所述:AssemblyScript 是一個偉大的發明。它容許 web 開發人員無需學習一種新語言就能夠生成 WebAssembly 模塊。而且,AssemblyScript 團隊的響應很是迅速,他們正在積極改進他們的工具鏈。未來咱們也必定會繼續關注 AssemblyScript 的。

更新: Rust

在這篇文章發佈以後,Rust 團隊的 Nick Fitzgerald 向咱們推薦他們的 Rust Wasm 手冊,其中包含一章文件體積優化。按照手冊的指引咱們可使用 Cargo(Rust 的 npm 包)正常編寫 Rust 代碼,而不用擔憂文件大小。最終 Rust 模塊在 gzip 以後只有 370B。有關詳細信息,請查看我在 Squoosh 上開的 PR

特別感謝 Ashley WilliamsSteve KlabnikNick FitzgeraldMaxGraey 在本次探索中給予的幫助。

相關文章
相關標籤/搜索