WebAssembly 目标:默认目标特性变更

2024 年 9 月 24 日 · Alex Crichton 代表 编译器团队

Rust 编译器 最近升级到使用 LLVM 19,此更改伴随着 Rust 编译器 WebAssembly 目标默认目标特性集的一些更新。今天的 Beta Rust,即将于 2024 年 10 月 17 日发布的 Rust 1.82,反映了所有这些变化,可用于测试。

WebAssembly 是一个不断发展的标准,其扩展通过 提案流程 随时间推移而增加。WebAssembly 提案成熟、合并到规范本身、在引擎中实现,并在生产者工具链(例如 LLVM)更新以默认启用这些足够成熟的提案之前,维持这种状态一段时间。在 LLVM 19 中,这发生在 LLVM/Rust 目标特性 multivaluereference-typesmulti-value 和 reference-types 提案上。这些现在在 LLVM 中默认启用,这间接意味着它在 Rust 中也默认启用。

Rust 的 WebAssembly 目标现在 有了改进的文档,介绍 WebAssembly 提案及其对应的目标特性。这篇文章将回顾这些变化,并深入探讨 LLVM 中的变化。

WebAssembly 提案与编译器目标特性

WebAssembly 提案是 WebAssembly 标准随时间推移而演进的正式手段。大多数提案需要某种形式的工具链集成,例如 LLVM 或 Rust 编译器中的新标志。今天使用 -Ctarget-feature=... 机制来实现这一点。这是向 LLVM 和 Rust 编译器指示哪些 WebAssembly 提案已启用或禁用。

提案名称(通常是提案 GitHub 仓库的名称)与 LLVM/Rust 使用的特性名称之间存在松散耦合。例如,存在 multi-value 提案,但对应的特性名称是 multivalue

Rust/LLVM 中特性实现的生命周期通常如下所示:

  1. 在一个新仓库中创建一个新的 WebAssembly 提案,例如 WebAssembly/foo。
  2. 最终 Rust/LLVM 在 -Ctarget-feature=+foo 下实现该提案
  3. 最终上游提案合并到规范中,并且 WebAssembly/foo 成为一个存档仓库
  4. Rust/LLVM 默认启用 -Ctarget-feature=+foo 特性,但通常也保留禁用它的能力。

Rust 中的 reference-typesmultivalue 目标特性现在处于这里的第 (4) 步,这篇文章正在解释这样做带来的后果。

默认启用 Reference Types

WebAssembly 的 reference-types 提案向 WebAssembly 引入了一些新概念,特别是 externref 类型,它是一种宿主定义的 GC 资源,WebAssembly 无法访问但可以传递。Rust 不支持 WebAssembly 的 externref 类型,LLVM 19 也没有改变这一点。从 Rust 生成的 WebAssembly 模块将继续不使用 externref 类型,也没有办法这样做。将来可能会启用此功能(例如,一个假设的 core::arch::wasm32::Externref 类型或类似类型),但这很可能只会在选择加入的基础上进行,并且默认情况下不会影响现有代码。

然而,reference-types 提案中还包含了在单个模块中拥有多个 WebAssembly 表的能力。在 WebAssembly 规范的原始版本中,只允许一个表,而 reference-types 提案放宽了这一限制。LLVM 和 Rust 使用 WebAssembly 表来实现间接函数调用。例如,WebAssembly 中的函数指针实际上是表索引,间接函数调用是带有此表索引的 WebAssembly call_indirect 指令。

随着 reference-types 提案的引入,call_indirect 指令的二进制编码得到了更新。在 reference-types 提案之前,call_indirect 指令中固定编码了一个零字节(必须精确为 0x00)。这个固定的零字节被放宽为一个 32 位 LEB,以指示 call_indirect 指令正在使用哪个表。对于不熟悉 LEB 的人来说,它是一种用较少字节编码较小整数的多字节整数编码方式。例如,32 位整数 0 可以使用 LEB 编码为 0x00LEB 灵活地允许“超长”编码,因此整数 0 还可以编码为 0x80 0x00

LLVM 对源代码单独编译成 WebAssembly 二进制文件的支持意味着,当发出目标文件时,它不知道最终二进制文件中将使用的表的最终索引。在 reference-types 之前,只有一个选项,即表 0,所以在编码 call_indirect 指令时总是使用 0x00。然而,在 reference-types 之后,LLVM 将发出一个超长的 LEB,形式为 0x80 0x80 0x80 0x80 0x00,这是 32 位 LEB 的最大长度。然后,链接器会用重定位信息填充此 LEB,指向最终模块使用的实际表索引。

将所有这些放在一起,这意味着使用默认启用了 reference-types 特性的 LLVM 19,任何包含间接函数调用的 WebAssembly 模块(Rust 代码几乎总是如此)将生成一个无法被不支持 reference-types 提案的引擎和工具解码的 WebAssembly 二进制文件。考虑到 reference-types 提案的提出时间和在引擎中的广泛实现,预计此更改影响较小。然而,鉴于 WebAssembly 引擎众多,建议所有 WebAssembly 用户试用 Rust 1.82 beta,看看生成的模块是否仍能在他们选择的引擎上运行。

LLVM、Rust 与多个表

一个值得一提的有趣之处是,尽管 reference-types 提案允许 WebAssembly 模块中包含多个表,但目前 LLVM 或 Rust 实际上并未利用这一点。生成的 WebAssembly 模块仍然最多只有一个函数表。这意味着目前实际上不需要将索引 0 编码为 5 字节的超长形式 0x80 0x80 0x80 0x80 0x00。LLD,LLVM 用于 WebAssembly 的链接器,希望以类似的方式处理所有 LEB 重定位,这目前强制使用了零的这种 5 字节编码。例如,当一个函数调用另一个函数时,call 指令将目标函数索引编码为一个 5 字节的 LEB,由链接器填充。通常有不止一个函数,因此 5 字节编码使得所有可能的函数索引都可以被编码。

将来 LLVM 可能也会开始使用多个表。例如,LLVM 将来可能有一种模式,每个函数类型有一个表,而不是一个单一的异构表。这可以使引擎更有效地实现 call_indirect。然而,目前这尚未实现。

对于想要最小化尺寸的 WebAssembly 模块的用户(例如,如果您在 Web 环境中通过网络传输字节),建议使用优化工具,例如 wasm-opt 来缩小 LLVM 输出的大小。即使在 reference-types 的变化之前,也建议这样做,因为 wasm-opt 通常可以进一步优化 LLVM 的默认输出。通过 wasm-opt 优化模块时,索引 0 的这些 5 字节编码都会被缩减为一个字节。

默认启用 Multi-Value

LLVM 19 中默认启用的第二个特性是 multivalueWebAssembly 的 multi-value 提案 例如使得函数可以拥有多个返回值。WebAssembly 指令也允许拥有多个返回值。该提案是在原始 MVP 之后首批合并到 WebAssembly 规范中的提案之一,并且已经在许多引擎中实现了相当长一段时间。

然而,在 LLVM 中默认启用此特性对 Rust 的影响比默认启用 reference-types 特性要小。即使启用 multivalue,LLVM 用于 WebAssembly 代码的默认 C ABI 也不会改变。此外,Rust 用于 WebAssembly 的 extern "C" ABI 也不会改变,并继续与 LLVM 匹配(或努力匹配,与 LLVM 的差异 被视为需要修复的错误)。尽管如此,此更改仍有可能影响 Rust 用户。

Rust 一段时间以来在 Nightly 版本上支持 extern "wasm" ABI,这是一种实验性的方法,用于暴露在 Rust 中定义返回多个值函数的能力(例如使用 multi-value 提案)。由于 LLVM 自身的基础设施变化和重构,Rust 的此功能 已被移除,并且在 Nightly 上完全不再支持。因此,目前没有任何可能的方法可以在 Rust 中编写一个在 WebAssembly 函数类型级别返回多个值的函数。

总而言之,除非您使用了 Nightly 版本的 extern "wasm" 功能,否则此更改预计不会影响任何现有的 Rust 代码;在这种情况下,您将被迫放弃对此功能的支持,转而使用 extern "C"。在 Rust 中支持 WebAssembly 多返回值函数是一个比本文更广泛的话题,但目前这是一个适合有动力的贡献者做出贡献的领域。

题外话:ABI 稳定性与 WebAssembly

在讨论 ABI 和 multivalue 特性时,也许值得回顾一下 ABI 对 WebAssembly 意味着什么。WebAssembly 的 extern "C" ABI 的当前定义记录在 tool-conventions 仓库中,这也是 Clang 为 C 代码实现的内容。LLVM 也实现了足够的 WebAssembly 降低支持来支持这一切。与所有 Rust 目标一样,WebAssembly 上的 extern "Rust" ABI 是不稳定的,并且会随时间变化。目前没有关于 WebAssembly 上 extern "Rust" 的参考文档。

extern "C" ABI,也就是 C 代码默认使用的 ABI,很难改变,因为通常需要在不同编译器版本之间保持稳定性。例如,用 LLVM 18 编译的 WebAssembly 代码可能需要能与 LLVM 20 编译的代码一起工作。这意味着改变 ABI 是一项艰巨的任务,需要版本字段、显式标记等来帮助防止不匹配。

然而,extern "Rust" ABI 会随时间变化。一个很好的例子是,当启用 multivalue 特性时,extern "Rust" ABI 可以重新定义为使用 WebAssembly 支持的多返回值。这将使得返回大于 64 位的数值效率更高。但这需要在 LLVM 中实现支持,目前尚未实现。

这一切意味着,实际在函数中使用多返回值,或者 multivalue 特性启用的 WebAssembly 功能,仍在未来,尚未实现。首先 LLVM 需要实现完整的降低(lowering)支持来生成具有多返回值的 WebAssembly 函数,然后才能在完全支持后修改 extern "Rust" 来使用此功能。在更遥远的未来,C 代码可能能够改变,但这需要相当长的时间,因为要考虑其跨版本兼容性。

启用 WebAssembly 未来提案

这并非 WebAssembly 提案首次在 LLVM 中从默认关闭变为默认开启,也不会是最后一次。例如,LLVM 已经默认启用了 符号扩展提案,这是 MVP WebAssembly 没有的。预计在不远的将来,无陷阱浮点到整数转换提案 也可能被默认启用。这些更改目前并非依据严格标准(例如,N 个引擎必须实现 M 年)进行,并且可能会导致中断。

如果您使用的 WebAssembly 引擎不支持 Rust 1.82 beta 和 LLVM 19 生成的模块,那么您的选择是:

  • 尝试查看您使用的引擎是否有可用的更新。您可能正在使用不支持某个功能的旧版本,而新版本支持该功能。
  • 提交一个 issue,以提高对该更改导致中断的认识。这可以在您的引擎仓库、Rust 仓库或 WebAssembly tool-conventions 仓库中进行。建议先搜索以确认是否已有相关的开放 issue。
  • 禁用某些特性重新编译您的代码,详情请见下一节。

默认启用新特性背后的普遍假设是,这对最终用户来说是一个相对省心的操作,同时为所有人带来性能优势(例如,无陷阱浮点到整数转换将使浮点到整数转换更优化)。如果更新最终造成麻烦,最好及早标记,以便在需要时调整推广计划。

禁用默认开启的 WebAssembly 提案

由于各种原因,您可能有动机禁用默认开启的 WebAssembly 特性:例如,您的引擎可能难以更新或不支持新特性。不幸的是,禁用默认开启的特性并非最简单的任务。仅仅使用 -Ctarget-features=-sign-ext 来禁用您自己项目编译中的特性是不够的,因为预编译形式提供的 Rust 标准库仍然是启用该特性编译的。

要禁用默认开启的 WebAssembly 提案,您需要使用 Cargo 的 -Zbuild-std 特性。例如:

$ export RUSTFLAGS=-Ctarget-cpu=mvp
$ cargo +nightly build -Zbuild-std=panic_abort,std --target wasm32-unknown-unknown

这将使用“MVP CPU”重新编译 Rust 标准库以及您自己的代码,该“MVP CPU”是 LLVM 中禁用所有 WebAssembly 提案的占位符。这将禁用 sign-ext、reference-types、multi-value 等特性。