使用 mimalloc 和 WasmFS 調整多執行緒 WebAssembly 應用程式的規模

Alon Zakai
Alon Zakai

發布日期:2025 年 1 月 30 日

許多網頁上的 WebAssembly 應用程式都能從多執行緒中受益,這點與原生應用程式相同。多個執行緒可讓更多工作同時進行,並將繁重的工作移出主執行緒,以避免延遲問題。直到最近,這類多執行緒應用程式才會出現一些常見的痛點,與配置和 I/O 相關。幸運的是,Emscripten 近期推出的功能可有效解決這些問題。本指南將說明這些功能如何在某些情況下,將速度提升 10 倍以上。

資源調度

下圖顯示純數學工作負載中,有效的多執行緒縮放功能 (取自我們在本文中使用的基準測試):

標題為「數學縮放」的折線圖,顯示核心數量 (X 軸) 與執行時間 (以毫秒為單位,Y 軸,標示為

這項指標會評估純粹的運算作業,也就是各個 CPU 核心可自行執行的作業,因此效能會隨著核心數量增加而提升。這種成效越高則越低的下降線,正是良好的調整規模方式。這也顯示,儘管網頁平台使用網頁工作者做為平行處理的基礎,使用 Wasm 而非真正的原生程式碼,以及其他可能不太理想的細節,但仍能順利執行多執行緒原生程式碼。

堆管理:malloc/free

mallocfree 是所有線性記憶體語言 (例如 C、C++、Rust 和 Zig) 中的重要標準程式庫函式,用於管理非完全靜態或位於堆疊上的所有記憶體。Emscripten 預設使用 dlmalloc,這是一種精簡但高效的實作方式 (也支援 emmalloc,這種方式更精簡,但在某些情況下速度較慢)。不過,dlmalloc 的多執行緒效能受到限制,因為它會鎖定每個 malloc/free (因為有單一全域分配器)。因此,如果您在許多執行緒中同時進行許多配置,就可能會發生爭用和速度變慢的問題。執行 malloc 密集的基準測試時,會發生以下情況:

標題為「dlmalloc 調整」的折線圖顯示核心數量 (x 軸) 與執行時間 (以毫秒為單位,y 軸,標示為「越低越好」) 之間的關係。趨勢顯示,增加核心數會導致執行時間增加,從 1 個核心到 4 個核心,呈現穩定的線性增加趨勢。

不僅效能不會因核心數增加而提升,反而會越來越糟,因為每個執行緒都會長時間等待 malloc 鎖定。這是最糟糕的情況,但如果有足夠的配置,實際工作負載可能會發生這種情況。

mimalloc

dlmalloc 有經過多執行緒最佳化的版本,例如 ptmalloc3,可為每個執行緒實作個別的配置器例項,避免發生爭用情形。其他幾個分配器也提供多執行緒最佳化功能,例如 jemalloctcmalloc。Emscripten 決定專注於最近的 mimalloc 專案,這是 Microsoft 設計的分配器,具有極佳的移植性和效能。使用方式如下:

emcc -sMALLOC=mimalloc

以下是使用 mimalloc 進行 malloc 基準測試的結果:

標題為「mimalloc 調整」的折線圖顯示核心數 (x 軸) 與執行時間 (以毫秒為單位,y 軸,標示為「越低越好」) 之間的關係。趨勢顯示,增加核心數量可縮短執行時間,從 1 個核心增加到 2 個核心時,執行時間會大幅下降,從 2 個核心增加到 4 個核心時,執行時間則會逐漸下降。

太棒了!如今效能可有效擴展,隨著核心數增加,效能也越來越快。

如果仔細查看上一個圖表和下一個圖表中單一核心效能的資料,您會發現 dlmalloc 耗時 2660 毫秒,而 mimalloc 只耗時 1466 毫秒,速度提升了近 2 倍。這表示即使在單執行緒應用程式中,您仍可透過 mimalloc 的更精細最佳化功能獲得好處,但請注意,這會影響程式碼大小和記憶體用量 (因此 dlmalloc 仍為預設值)。

檔案和 I/O

許多應用程式都需要使用檔案,原因各有不同。例如,在遊戲中載入關卡,或在圖片編輯器中載入字型。即使是 printf 這類作業,也會在幕後使用檔案系統,因為它會透過將資料寫入 stdout 來顯示內容。

在單執行緒應用程式中,這通常不是問題,如果您只需要 printf,Emscripten 會自動避免連結完整的檔案系統支援功能。不過,如果您使用檔案,則多執行緒檔案系統存取權會變得棘手,因為檔案存取權必須在執行緒之間同步。Emscripten 中的原始檔案系統實作項目稱為「JS FS」,因為它是使用 JavaScript 實作的,採用了只在主執行緒上實作檔案系統的簡單模型。當其他執行緒想要存取檔案時,就會將要求代理至主執行緒。這表示其他執行緒會在跨執行緒要求上遭到封鎖,而主執行緒最終會處理這項要求。

如果只有主執行緒存取檔案,這項簡單的模型就是最佳做法,這也是常見的模式。不過,如果其他執行緒執行讀取和寫入作業,就會發生問題。首先,主執行緒會為其他執行緒執行工作,導致使用者可見的延遲。接著,背景執行緒會等待主執行緒釋放,以便執行所需的工作,因此速度會變慢 (更糟的是,如果主執行緒目前正在等待該工作執行緒,可能會導致死結)。

WasmFS

為修正這個問題,Emscripten 推出了新的檔案系統實作方式:WasmFS。WasmFS 是以 C++ 編寫並編譯為 Wasm,這與原始檔案系統 (以 JavaScript 編寫) 不同。WasmFS 會將檔案儲存在 Wasm 線性記憶體中,並在所有執行緒之間共用,藉此支援多個執行緒的檔案系統存取作業,並盡可能減少額外負擔。所有執行緒現在都能以相同的效能執行檔案 I/O,而且通常還能避免彼此互相阻斷。

簡單的檔案系統基準測試顯示,與舊版 JS FS 相比,WasmFS 具有巨大優勢。

標題為「檔案系統效能」的長條圖,比較了 JS FS 和 WasmFS 在兩個類別 (主執行緒和 pthread) 的執行時間 (以毫秒為單位,y 軸,標示為越低越好),在 pthread 的情況下,JS FS 的耗用時間明顯較長,但在兩種情況下,WasmFS 的耗用時間都維持在低水準。

這會比較直接在主執行緒上執行的檔案系統程式碼,與在單一 pthread 上執行的程式碼。在舊版 JS FS 中,每個檔案系統作業都必須經過主執行緒的 Proxy,這會讓 pthread 的速度降低好幾個數量級!這是因為 JS FS 並非只讀取/寫入部分位元組,而是執行跨執行緒通訊,這涉及鎖定、佇列和等待。相較之下,WasmFS 可以從任何執行緒存取檔案,因此圖表顯示主執行緒和 pthread 之間幾乎沒有差異。因此,在 pthread 上,WasmFS 的速度比 JS FS 快 32 倍

請注意,執行緒的速度也不同,WasmFS 的速度比 2 倍 快。這是因為 JS FS 會針對每個檔案系統作業呼叫 JavaScript,而 WasmFS 會避免這類情況。WasmFS 只會在必要時 (例如使用 Web API) 使用 JavaScript,因此大部分的 WasmFS 檔案都會留在 Wasm 中。此外,即使需要 JavaScript,WasmFS 也可以使用輔助執行緒,而非主執行緒,以避免使用者可見的延遲。因此,即使應用程式不是多執行緒 (或為多執行緒,但只使用主執行緒的檔案),使用 WasmFS 仍可提升速度。

請按照下列方式使用 WasmFS:

emcc -sWASMFS

WasmFS 已在實際工作環境中使用,並被視為穩定,但尚未支援舊版 JS FS 的所有功能。另一方面,它確實包含一些重要的新功能,例如支援原始私人檔案系統 (OPFS,強烈建議用於持續性儲存空間)。除非您需要尚未移植的功能,否則 Emscripten 團隊建議使用 WasmFS。

結論

如果您有執行大量配置或使用檔案的多執行緒應用程式,使用 WasmFS 和/或 mimalloc 可能會帶來極大助益。只要使用本文所述的標記重新編譯,就能輕鬆在 Emscripten 專案中試用這兩種方法。

即使您未使用執行緒,也建議您試試這些功能:如先前所述,較新穎的實作方式會提供最佳化功能,在某些情況下,即使是單一核心,也能明顯看出差異。