最多可在 WebAssembly 中使用 4GB 記憶體
簡介
由於 Chrome 和 Emscripten 的近期工作,您現在可以在 WebAssembly 應用程式中使用最多 4GB 的記憶體。這比之前的 2GB 限制有所提升。聽起來可能有些奇怪,記憶體的限制似乎本不應該存在——畢竟,使用 512MB 或 1GB 的記憶體並不需要特別的工作!——但事實證明,在從 2GB 跨越到 4GB 的過程中,無論是在瀏覽器還是工具鏈中,都發生了一些特別的變化,我們將在本篇文章中介紹。
32 位元
在深入細節之前,先介紹一些背景知識:新的 4GB 限制是 32 位元指標(目前 WebAssembly 支援的,也被稱為 LLVM 和其他地方的 “wasm32”)所能支援的最大記憶體大小。目前也正針對 “wasm64”(在 wasm 規範中稱為 “memory64”)展開研究,該技術將允許指標達到 64 位元,從而使用超過 1600 萬 TB 的記憶體(!),但是在這之前,4GB 是我們最多能夠期望訪問到的記憶體大小。
看起來我們應該一直能訪問到 4GB,畢竟這是 32 位元指標能夠支援的最大限度。那麼為什麼我們之前只能使用一半,也就是 2GB?這背後有多重原因,涉及瀏覽器端和工具鏈端。讓我們先從瀏覽器開始講起。
Chrome/V8 的工作
原則上,對 V8 的更改聽起來很簡單:只要確保為 WebAssembly 函數生成的所有程式碼,以及所有記憶體管理程式碼,都使用無符號 32 位元整數處理記憶體索引和長度就可以了。然而,實際上,事情並不那麼簡單!由於 WebAssembly 記憶體可以作為 ArrayBuffer 導出到 JavaScript,我們還需要改變 JavaScript 中 ArrayBuffer、TypedArray 以及所有使用 ArrayBuffer 和 TypedArray 的 Web API(例如 Web Audio、WebGPU 和 WebUSB)的實現。
我們首先遇到的問題是,V8 使用 Smis(即 31 位元有符號整數)作為 TypedArray 的索引和長度,因此最大大小實際上是 230-1,約 1GB。另外,僅僅將所有內容切換到 32 位元整數仍然不足,因為 4GB 記憶體的長度其實無法容納在 32 位元整數中。舉個例子:在十進制數中,兩位數字共有 100 個(0 到 99),但 "100" 本身是一個三位數。同樣,4GB 可以被 32 位元地址範圍內的地址所指向,但 4GB 本身卻是一個 33 位元數字。我們本可以降低這個限制,但由於我們必須改動所有 TypedArray 的程式碼,我們決定在改動時也為未來更大的限制做好準備。因此,我們改變了處理 TypedArray 索引或長度的所有程式碼,使其使用 64 位元寬整數類型,或者在需要與 JavaScript 交互時使用 JavaScript 的 Number。這項更改的附加好處是,未來支援更大記憶體(對 wasm64)將相對簡單!
第二個挑戰是處理 JavaScript 中對於陣列元素相較於常規命名屬性的特殊情況,這在我們對物件的實現中得到了體現。(這是一個相當技術性、與 JavaScript 規範相關的問題,因此如果您無法完全理解細節,請不要擔心。)考慮以下範例:
console.log(array[5_000_000_000]);
如果 array
是一個普通的 JavaScript 物件或陣列,那麼 array[5_000_000_000]
會被當作字串屬性查詢。執行時會查找字串命名的屬性 “5000000000”。如果找不到這樣的屬性,它會沿著原型鏈上查找這個屬性,直到最終返回 undefined
。然而,如果 array
本身或其原型鏈上的某個物件是 TypedArray,那麼執行時必須在索引 5,000,000,000 上查找一個索引的元素,如果這個索引超出邊界,則立即返回 undefined
。
換句話說,TypedArray 的規則與普通陣列有很大不同,而且這種區別主要在巨大索引下顯現。因此,當我們僅允許較小的 TypedArray 時,我們的實現可以相對簡單;特別是,只需要查看一次屬性鍵就足以決定是採取 "索引" 還是 "命名" 查找路徑。為了允許更大的 TypedArray,我們現在需要在遍歷原型鏈時反覆進行這種區分,這需要精心設計的快取以避免對現有 JavaScript 程式碼造成過多的重複工作和額外負擔。
工具鏈的工作
在工具鏈方面,我們也必須進行一些工作,大部分工作集中在 JavaScript 支援代碼,而不是 WebAssembly 中的編譯代碼。主要問題在於 Emscripten 一直以以下形式進行記憶體存取:
HEAP32[(ptr + offset) >> 2]
這段代碼以帶符號整數的形式從地址 ptr + offset
中讀取 32 位(4 個字節)。其工作方式是,HEAP32
是一個 Int32Array,這意味着數組中的每個索引有 4 個字節。因此,我們需要將字節地址 (ptr + offset
) 除以 4 才能獲得索引,這正是 >> 2
的作用。
問題在於 >>
是一個帶符號操作!如果地址在 2GB 標記或更高,則輸入將溢出為負值:
// 略低於 2GB 時是可以的,這會輸出 536870911
console.log((2 * 1024 * 1024 * 1024 - 4) >> 2);
// 2GB 溢出,我們得到 -536870912 :(
console.log((2 * 1024 * 1024 * 1024) >> 2);
解決方案是進行無符號移位,用 >>>
:
// 這給出了我們想要的結果 536870912!
console.log((2 * 1024 * 1024 * 1024) >>> 2);
Emscripten 在編譯時知道您是否可能使用 2GB 或更多的記憶體(取決於您使用的標誌;詳見後面的說明)。如果您的標誌使 2GB+ 地址成為可能,編譯器會自動重寫所有記憶體存取以使用 >>>
而不是 >>
,這不僅包含如上例中的 HEAP32
等存取,還包括像 .subarray()
和 .copyWithin()
這樣的操作。換句話說,編譯器會切換以使用無符號指針而不是帶符號指針。
這種轉換會稍微增加代碼大小——每次移位多一個額外的字元——這也是為什麼如果您不使用 2GB+ 地址,我們不做這件事的原因。雖然差異通常少於 1%,但這完全是不必要的,且容易避免——許多細小的優化積累起來也是很重要的!
JavaScript 支援代碼中還可能出現其他罕見問題。雖然如前所述,正常的記憶體存取會自動處理,但手動比較一個帶符號指針和無符號指針(在 2GB 地址或更高)會返回假值。為了定位這些問題,我們審查了 Emscripten 的 JavaScript,並以特殊模式運行測試套件,其中所有內容均放置在 2GB 或更高地址。(請注意,如果您編寫自己的 JavaScript 支援代碼,而您在正常記憶體存取之外手動處理指針,可能也需要修復這些代碼。)
試用
為了測試這點,下載最新的 Emscripten 版本,或者至少是版本 1.39.15。然後使用以下標誌進行構建:
emcc -s ALLOW_MEMORY_GROWTH -s MAXIMUM_MEMORY=4GB
這些標誌啟用記憶體增長,並允許程序分配最多 4GB 的記憶體。請注意,默認情況下您只能分配最多 2GB——您必須明確選擇使用 2-4GB(這使我們能夠發出更為緊湊的代碼,即,以 >>
而不是 >>>
方式發出,如上所述)。
請確保您在 Chrome M83(目前是 Beta)或更高版本上進行測試。如果發現任何問題,請提交問題報告!
結論
支援最多 4GB 的記憶體是讓 Web 與原生平臺性能接近的又一步驟,允許 32 位程序使用與其平時相同數量的記憶體。僅憑這步並不能啟用完全新的應用程序類型,但確實可以啟用更高端的體驗,例如遊戲中的超大關卡或在圖形編輯器中操作大型內容。
如前所述,還計劃支援 64 位記憶體,這將允許存取超過 4GB 的記憶體。然而,wasm64 會有與原生平臺上的 64 位一樣的缺點,即指針佔用的記憶體是原來的兩倍。這正是 wasm32 下 4GB 支援如此重要的原因:我們可以存取比以前多兩倍的記憶體,同時代碼大小仍保持緊湊,與 wasm 一向的特性一致!
像往常一樣,請在多個瀏覽器上測試您的代碼,並且要記住 2-4GB 是大量的記憶體!如果您需要那麼多記憶體就使用它,但不要不必要地使用,因為許多使用者的計算機上可能沒有足夠的空閒記憶體。我們建議您從初始記憶體盡可能小開始,如果需要,則進行增長;並且如果允許增長的話,也要能夠優雅處理 malloc()
失敗的情況。