跳到主要内容

WebAssembly 支持高达 4GB 的内存

· 阅读需 8 分钟
Andreas Haas、Jakob Kummerow 和 Alon Zakai

引言

得益于 Chrome 和 Emscripten 的近期工作,现在您可以在 WebAssembly 应用程序中使用高达 4GB 的内存。这比之前 2GB 的限制有了很大的提升。或许您会觉得奇怪为什么会有这种限制——毕竟人们无需特殊工作就可以使用 512MB 或 1GB 的内存!——但事实证明,从 2GB 跳到 4GB 不仅浏览器端需要一些特殊处理,工具链端也有挑战,这些内容将在本文中详细介绍。

32 位

在详细探讨之前,我们来了解一下背景:新的 4GB 限制是 32 位指针(WebAssembly 当前支持)能支持的最大内存量,其在 LLVM 和其他地方被称为“wasm32”。目前也有关于“wasm64”(在 wasm 规范中称为 “memory64”)的工作,允许使用 64 位指针,这样我们就可以使用超过 16 百万 TB 的内存(!),但在此之前,4GB 是我们在现有条件下能访问的最大可能内存。

表面上看,我们似乎从一开始就应该能够访问 4GB,因为这是 32 位指针的能力。但为何我们一直被限制在一半,也就是仅仅 2GB?这涉及浏览器端和工具链端的多种原因。我们先从浏览器端说起。

Chrome/V8 的工作

从原理上讲,对 V8 的改动听起来很简单:只需确保为 WebAssembly 函数生成的所有代码以及所有内存管理代码使用无符号 32 位整数来表示内存索引和长度,就可以完成。然而,实际上并不简单!由于 WebAssembly 内存可以作为 ArrayBuffer 导出到 JavaScript,我们还必须更改 JavaScript ArrayBuffers、TypedArrays 及所有使用它们的 Web API 的实现,如 Web Audio、WebGPU 和 WebUSB。

我们首先需要解决的问题是,V8 对 TypedArray 的索引和长度使用 Smi(即 31 位有符号整数),因此最大大小实际上是 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及以上),将返回false。为了发现此类问题,我们审计了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的内存是让网络功能与原生平台同样强大的又一步,它允许32位程序像往常一样使用相同数量的内存。仅凭这一点并不能启用一个全新的应用类别,但它确实支持更高端的体验,例如游戏中的一个非常大的关卡或在图形编辑器中处理大内容。

正如前面提到的,也计划支持64位内存,这将允许访问更大于4GB的内存。然而,wasm64将与原生平台上的64位一样有一个缺点,那就是指针需要占用两倍的内存。这就是为什么在wasm32中支持4GB如此重要:我们可以比以前访问多两倍的内存,同时代码大小仍然保持像wasm一样紧凑!

一如既往,请在多个浏览器上测试你的代码,并记住,2-4GB是非常多的内存!如果你需要那么多,那就充分利用,但不要不必要地这样做,因为很多用户的机器上可能没有足够的可用内存。我们建议从尽可能小的初始内存开始,并在需要时增长;如果允许增长,则优雅地处理 malloc() 失败的情况。