原文鏈接: Replacing a hot path in your app's JavaScript with WebAssemblyFebruary 15, 2019.
在之前的文章中我講述了 WebAssembly 是如何允許我們將 C/C++ 生態中的庫應用於 web 應用中的。一個典型的使用了 C/C++ 擴展包的 web 應用就是 squoosh,這個應用使用了一系列從 C++ 語言編譯成 WebAssembly 的代碼來壓縮圖片。
WebAssembly 是一個底層虛擬機,可以用來運行 .wasm 文件中存儲的字節碼。這些字節碼是強類型、結構化的,相比 JavaScript 能更快速的被宿主系統編譯和識別。WebAssembly 可以運行已知界限和依賴的代碼。
據我所知,web 應用中的大多數性能問題都是由強制布局和過度繪制造成的,但應用程序又時不時地需要執行一項計算成本高昂、需要大量時間的任務。這中情況下 WebAssembly 就可以派上用場了。
Hot Path
在 squoosh 這個 web 應用中,我們寫了一個 JavaScript 函數,將圖像以 90 度的倍數進行旋轉。盡管 OffscreenCanvas 是實現這一點的理想之選,但它在我們使用的瀏覽器中並不支持該特性,而且在 Chrome 中也存在一些小 bug。
為了實現旋轉,該 JavaScript 函數在輸入圖片的每一個像素上進行迭代,將每一個像素復制到輸出圖片的相應位置上。對於一個 4094px * 4094px 的圖像(1600 萬像素)來說,內部代碼塊將迭代超過 1600 萬次,這些被多次迭代的代碼塊就被稱之為 hot path。經過測試,盡管這次計算需要大量的迭代,仍有 2/3 的瀏覽器能在兩秒以內完成。在此種交互中這是一個可接受的耗時。
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 只有在 fast path 之下才能達到峰值性能,並且代碼總是處於 fast path 之下。WebAssembly 另一個優勢是,即使通過瀏覽器運行,它也能提供可預測的性能。強類型和低級語言保證了 WebAssembly 被優化一次,就能一直被快速執行。
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-pack
。wasm-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
在這個案例中,我們可以看到大部分空間占用都來自於內存分配器。這有些令人驚訝,因為我們的代碼並沒有使用動態分配。第二大空間占用來自於 “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-opt
、wasm-strip
和 gzip
之后生成 1.6KB 的 wasm 模塊。雖然它仍然比 C 和 AssemblyScript 生成的模塊大,但它已經足夠小,可以被認為是輕量級的了。
性能
在我們僅僅根據文件大小得出結論之前——我們的一些列操作是為了優化性能,而不僅僅是優化文件大小。那么我們該如何衡量性能優劣?性能又到底如何呢?
怎樣進行基准測試
盡管 WebAssembly 是一種底層字節碼格式,它仍然需要通過編譯器來生成特定於主機的機器碼。就像 JavaScript 一樣,編譯器的工作分為多個階段。簡單地說:第一階段的編譯速度要快得多,但生成的代碼往往較慢。一旦模塊開始運行,瀏覽器就會觀察哪些部分是經常使用的,並通過一個更優化但速度更慢的編譯器編譯這部分。
我們的用例的有趣之處在於,旋轉圖片的代碼只運行了一次,或者是兩次。所以,在絕大多數情況下,永遠也體現不出優化編譯器的優勢。在進行基准測試時,這一點非常重要。在一個循環中運行我們的 WebAssembly 模塊 10000 次得到的數據可能並不確切。為了得到確切的數據,我們應該運行該模塊一次,並根據這一次運行計算出相應的數據。
注意:理想情況下,我們應該自動化這個重新加載頁面並運行一次模塊的過程,並多次執行該過程。我們相信多次測量的平均值足以說明問題。
性能對比
這兩個圖是同一數據上的不同視圖。在第一個圖中,我們比較每個瀏覽器,在第二個圖中,我們比較每種使用的語言。請注意,我選擇了對數時間尺度。同樣重要的是,所有基准測試都使用相同的 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,您應該使用什么語言。對於性能問題,答案總是:視情況而定。那么我們選擇了什么呢?
注意:請注意圖中兩個坐標軸都是對數增長的,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 Williams、Steve Klabnik、Nick Fitzgerald 和 MaxGraey 在本次探索中給予的幫助。