[博客翻译]WebAssembly Memory64值得使用吗?


原文地址:https://spidermonkey.dev/blog/2025/01/15/is-memory64-actually-worth-using.html


Memory64 真的值得使用吗?

经过多年的等待,WebAssembly 的 Memory64 提案 终于在 Firefox 134 和 Chrome 133 中 发布 了。简而言之,这个提案为 WebAssembly 添加了 64 位指针。
如果你和大多数读者一样,可能会问:“为什么 WebAssembly 一开始不是 64 位的?” 是的,现在是 2025 年,WebAssembly 才刚刚添加了 64 位指针。为什么花了这么长时间,尤其是在 64 位设备已经成为主流,8GB 内存被视为最低配置的情况下?
很容易认为 64 位 WebAssembly 在 64 位硬件上会运行得更好,但不幸的是,事实并非如此。WebAssembly 应用程序在 64 位模式下往往比在 32 位模式下运行得更慢。这种性能损失取决于工作负载,但可能从 10% 到超过 100% 不等——仅仅因为改变指针大小,性能就可能下降两倍。
这不仅仅是由于缺乏优化。相反,Memory64 的性能受到硬件、操作系统以及 WebAssembly 本身设计的限制。

Memory64 到底是什么?

要理解为什么 Memory64 更慢,我们首先需要了解 WebAssembly 如何表示内存。
当你将一个程序编译为 WebAssembly 时,结果是一个 WebAssembly 模块。模块类似于可执行文件,包含了启动和运行程序所需的所有信息,包括:

  • 需要多少内存的描述(内存部分
  • 要复制到内存中的静态数据(数据部分
  • 实际要执行的 WebAssembly 字节码(代码部分

这些信息以高效的二进制格式编码,但 WebAssembly 也有一个官方的文本语法,用于调试和直接编写。本文将使用文本语法。你可以使用 WABT(wasm2wat)或 wasm-tools(wasm-tools print)等工具将任何 WebAssembly 模块转换为文本语法。
以下是一个简单但完整的 WebAssembly 模块,它允许你在内存地址 16 处存储和加载一个 i32

(module
 ;; 声明一个大小为 1 页(64KiB,即 65536 字节)的内存
 (memory 1)
 ;; 声明并导出我们的存储函数
 (func (export "storeAt16") (param i32)
  i32.const 16 ;; 将地址 16 压入栈
  local.get 0  ;; 获取 i32 参数并将其压入栈
  i32.store   ;; 将值存储到地址
 )
 ;; 声明并导出我们的加载函数
 (func (export "loadFrom16") (result i32)
  i32.const 16 ;; 将地址 16 压入栈
  i32.load   ;; 从地址加载
 )
)

现在让我们修改程序以使用 Memory64:

(module
 ;; 声明一个大小为 1 页(64KiB,即 65536 字节)的 i64 内存
 (memory i64 1)
 ;; 声明并导出我们的存储函数
 (func (export "storeAt16") (param i32)
  i64.const 16 ;; 将地址 16 压入栈
  local.get 0  ;; 获取 i32 参数并将其压入栈
  i32.store   ;; 将值存储到地址
 )
 ;; 声明并导出我们的加载函数
 (func (export "loadFrom16") (result i32)
  i64.const 16 ;; 将地址 16 压入栈
  i32.load   ;; 从地址加载
 )
)

你可以看到,我们的内存声明现在包含了 i64,表示它使用 64 位地址。因此,我们也将 i32.const 16 改为 i64.const 16。这就是全部内容。这几乎就是 Memory64 提案的全部内容1

内存是如何实现的?

那么,为什么这个小小的改动会影响性能呢?我们需要了解 WebAssembly 引擎是如何实际实现内存的。
幸运的是,这非常简单。宿主(在这种情况下是浏览器)只需使用 mmapVirtualAlloc 等系统调用为 WebAssembly 模块分配内存。然后,WebAssembly 代码可以自由地在该区域内读写,宿主(浏览器)确保 WebAssembly 地址(如 16)被正确转换为分配内存中的地址。
然而,WebAssembly 有一个重要的限制:访问越界内存会触发 陷阱,类似于分段错误(segfault)。宿主的工作是确保这种情况发生,通常是通过 边界检查 来实现的。这些是在每次内存访问时插入到机器代码中的额外指令——相当于在每次加载之前写 if (address >= memory.length) { trap(); }2。你可以在 SpiderMonkey 为 i32.load 生成的 实际 x64 机器代码 中看到这一点3

movq 0x08(%r14), %rax    ;; 从实例 (%r14) 加载内存大小
 cmp %rax, %rdi       ;; 比较地址 (%rdi) 和限制
 jb .load          ;; 如果地址正常,跳转到加载
 ud2             ;; 陷阱
.load:
 movl (%r15,%rdi,1), %eax  ;; 从内存 (%r15 + %rdi) 加载 i32

这些指令有几个成本!除了占用 CPU 周期外,它们还需要额外的内存加载,增加了机器代码的大小,并占用了分支预测器资源。但它们对于确保 WebAssembly 代码的安全性和正确性至关重要。
除非……我们能找到一种完全移除它们的方法。

内存 真正 是如何实现的?

32 位整数的最大值约为 40 亿。因此,32 位指针允许你使用最多 4GB 的内存。而 64 位整数的最大值约为 1800 亿亿,允许你使用最多 18EB 的内存。这确实非常巨大,比当今最先进的消费级机器的内存还要大数千万倍。事实上,由于这种差异如此之大,大多数“64 位”设备实际上在实践中是 48 位的,仅使用内存地址的 48 位来映射虚拟地址到物理地址4
即使是 48 位内存也非常巨大:比最大的 32 位内存大 65,536 倍。这为每个进程提供了 281TB 的 地址空间,即使设备只有几 GB 的物理内存。
这意味着在 64 位设备上,地址空间是廉价的。如果你愿意,你可以从操作系统中 预留 4GB 的地址空间,以确保它以后可以自由使用。即使大部分内存从未使用过,这对大多数系统的影响也微乎其微。
浏览器如何利用这一事实?通过为每个 WebAssembly 模块预留 4GB 的内存。
在我们的第一个示例中,我们声明了一个大小为 64KB 的 32 位内存。但如果你在 64 位操作系统上运行这个示例,浏览器实际上会预留 4GB 的内存。这 4GB 块的前 64KB 将是可读写的,剩下的 3.9999GB 将被预留但不可访问。
通过为所有 32 位 WebAssembly 模块预留 4GB 内存,越界访问是不可能的。 最大的指针值 2^32-1 将简单地落在预留的内存区域内并触发陷阱。这意味着,在 64 位系统上运行 32 位 wasm 时,**我们可以完全省略所有边界检查[5](https://spiderm