Rust 编译器 最近已升级到使用 LLVM 19,此更改伴随着 Rust 编译器 WebAssembly 目标默认启用的一组目标特性的更新。今天的 Beta 版 Rust 将于 2024-10-17 成为 Rust 1.82,它反映了所有这些更改,可用于测试。
WebAssembly 是一个不断发展的标准,通过 提案流程 随着时间的推移不断添加扩展。WebAssembly 提案达到成熟,被合并到规范本身中,在引擎中实现,并保持这种状态相当长一段时间,然后生产者工具链(例如 LLVM)更新为默认启用这些足够成熟的提案。在 LLVM 19 中,多值和引用类型提案 对应于 LLVM/Rust 目标特性 multivalue
和 reference-types
已经实现了这一点。现在它们在 LLVM 中默认启用,并且传递地意味着在 Rust 中也默认启用。
Rust 的 WebAssembly 目标现在改进了关于 WebAssembly 提案及其相应目标特性的文档。这篇文章将回顾这些更改,并深入探讨 LLVM 中正在发生的变化。
WebAssembly 提案和编译器目标特性
WebAssembly 提案是 WebAssembly 标准本身随时间演变的正式方式。大多数提案都需要某种形式的工具链集成,例如 LLVM 或 Rust 编译器中的新标志。-Ctarget-feature=...
机制用于实现今天的功能。这是 LLVM 和 Rust 编译器的信号,指示哪些 WebAssembly 提案已启用或禁用。
提案的名称(通常是提案的 github 存储库的名称)和 LLVM/Rust 使用的特性名称之间存在松散的耦合。例如,有 多值提案,但它对应的特性名称是 multivalue
。
Rust/LLVM 中特性实现的生命周期通常如下所示:
- 在新的存储库中创建新的 WebAssembly 提案,例如 WebAssembly/foo。
- 最终,Rust/LLVM 在
-Ctarget-feature=+foo
下实现该提案。 - 最终,上游提案被合并到规范中,WebAssembly/foo 成为一个存档存储库。
- Rust/LLVM 默认启用
-Ctarget-feature=+foo
特性,但通常也保留禁用它的能力。
Rust 中的 reference-types
和 multivalue
目标特性现在处于步骤 (4),这篇文章正在解释这样做的后果。
默认启用引用类型
WebAssembly 的引用类型提案 为 WebAssembly 引入了一些新概念,特别是 externref
类型,它是一种由主机定义的 GC 资源,WebAssembly 无法访问,但可以传递。Rust 不支持 WebAssembly 的 externref
类型,而 LLVM 19 不会改变这一点。Rust 生成的 WebAssembly 模块将继续不使用 externref
类型,也没有能力这样做。将来可能会启用它(例如,假设的 core::arch::wasm32::Externref
类型或类似类型),但它很可能仅在选择加入的基础上完成,并且默认情况下不会影响现有的代码。
但是,引用类型提案还包括在单个模块中拥有多个 WebAssembly 表的功能。在 WebAssembly 规范的原始版本中,只允许使用一个表,并且这种限制在引用类型提案中得到了放松。LLVM 和 Rust 使用 WebAssembly 表来实现间接函数调用。例如,WebAssembly 中的函数指针实际上是表索引,而间接函数调用是带有此表索引的 WebAssembly call_indirect
指令。
借助引用类型提案,call_indirect
指令的二进制编码得到了更新。在引用类型提案之前,call_indirect
在其指令中用固定的零字节进行编码(必须恰好是 0x00)。这个固定的零字节被放宽为 32 位 LEB,以指示 call_indirect
指令正在使用哪个表。对于那些不熟悉的人,LEB 是一种以较小的字节数编码多字节整数的方式,适用于较小的整数。例如,32 位整数 0 可以用 LEB 编码为 0x00
。LEB 具有灵活性,可以额外允许“过长”编码,因此整数 0 也可以编码为 0x80 0x00
。
LLVM 对将源代码单独编译为 WebAssembly 二进制文件的支持意味着,当发出目标文件时,它不知道最终二进制文件中将要使用的表的最终索引。在引用类型之前,只有一个选项,即表 0,因此在编码 call_indirect
指令时始终使用 0x00
。然而,在引用类型之后,LLVM 将发出一个过长的 LEB,其形式为 0x80 0x80 0x80 0x80 0x00
,这是 32 位 LEB 的最大长度。然后,链接器将使用重定位填充此 LEB,使其成为最终模块使用的实际表索引。
当将所有这些放在一起时,这意味着使用 LLVM 19,它默认启用了 reference-types
特性,任何具有间接函数调用的 WebAssembly 模块(几乎总是 Rust 代码的情况)都将生成一个无法被不支持引用类型提案的引擎和工具解码的 WebAssembly 二进制文件。预计此更改的影响很小,因为引用类型提案的年代久远以及在引擎中的实现范围很广。但是,考虑到 WebAssembly 引擎的数量众多,建议任何 WebAssembly 用户测试 Rust 1.82 beta,看看生成的模块是否仍然在他们选择的引擎上运行。
LLVM、Rust 和多个表
值得一提的一个有趣的点是,尽管引用类型提案允许 WebAssembly 模块中存在多个表,但目前 LLVM 或 Rust 实际上都没有利用这一点。发出的 WebAssembly 模块最多仍然只有一个函数表。这意味着此时实际上不需要将索引 0 的过长 5 字节编码为 0x80 0x80 0x80 0x80 0x00
。LLVM 的 WebAssembly 链接器 LLD 希望以类似的方式处理所有 LEB 重定位,这目前强制使用零的 5 字节编码。例如,当一个函数调用另一个函数时,call
指令将目标函数索引编码为 5 字节 LEB,该 LEB 由链接器填充。通常有多个函数,因此 5 字节编码可以对所有可能的函数索引进行编码。
将来,LLVM 也可能会开始使用多个表。例如,LLVM 将来可能有一种模式,其中每个函数类型都有一个表,而不是单个异构表。这可以使引擎更有效地实现 call_indirect
。但是,目前尚未实现这一点。
对于想要最小尺寸 WebAssembly 模块的用户(例如,如果您在 Web 环境中并通过网络发送字节),建议使用诸如 wasm-opt
之类的优化工具来缩小 LLVM 输出的大小。即使在此更改(带有引用类型)之前,也建议这样做,因为 wasm-opt
通常可以进一步优化 LLVM 的默认输出。当通过 wasm-opt
优化模块时,这些索引 0 的 5 字节编码都被缩小为一个字节。
默认启用多值
LLVM 19 中默认启用的第二个特性是 multivalue
。WebAssembly 的多值提案 允许函数具有多个返回值,例如。此外,WebAssembly 指令也允许具有多个返回值。该提案是第一个被合并到 WebAssembly 规范中的提案之一,在最初的 MVP 之后,并且已经在许多引擎中实现了一段时间。
但是,对于 Rust 而言,在 LLVM 中默认启用此特性的后果比默认启用 reference-types
特性要小得多。即使启用了 multivalue
,LLVM 的 WebAssembly 代码的默认 C ABI 也不会改变。此外,Rust 的 WebAssembly 的 extern "C"
ABI 也不会改变,并继续与 LLVM 的 ABI 匹配(或努力匹配,与 LLVM 的差异 被认为是需要修复的错误)。尽管如此,此更改仍有可能影响 Rust 用户。
Rust 一段时间以来在 Nightly 上支持 extern "wasm"
ABI,这是一种实验性的方式,用于公开在 Rust 中定义返回多个值的函数的功能(例如,使用了多值提案)。由于 LLVM 本身的基础设施变更和重构,Rust 的此特性 已被删除,并且在 Nightly 中不再受支持。因此,不再有任何可能的方法在 Rust 中编写在 WebAssembly 函数类型级别返回多个值的函数。
总而言之,除非您使用了 extern "wasm"
的 Nightly 特性,否则此更改预计不会影响任何实际的 Rust 代码,在这种情况下,您将被迫放弃对该特性的支持,而改用 extern "C"
。在 Rust 中支持 WebAssembly 多返回函数是一个比这篇文章可以涵盖的更广泛的主题,但是目前它是非常适合有足够积极性的贡献者贡献的领域。
题外话:ABI 稳定性和 WebAssembly
在讨论 ABI 和 multivalue
特性的主题时,或许也值得回顾一下 ABI 对于 WebAssembly 的含义。目前 WebAssembly 的 extern "C"
ABI 的定义记录在 tool-conventions 存储库 中,这也是 Clang 为 C 代码实现的内容。LLVM 也实现了足够的支持来降级到 WebAssembly,以支持所有这些内容。与所有 Rust 目标一样,extern "Rust
ABI 在 WebAssembly 上不稳定,并且会随着时间的推移而发生变化。目前没有关于 extern "Rust"
在 WebAssembly 上是什么的参考文档。
extern "C"
ABI,C 代码默认也使用这种 ABI,由于不同编译器版本之间通常需要保持稳定性,因此难以更改。例如,使用 LLVM 18 编译的 WebAssembly 代码可能需要与使用 LLVM 20 编译的代码一起工作。这意味着更改 ABI 是一项艰巨的任务,需要版本字段、显式标记等来帮助防止不匹配。
然而,extern "Rust"
ABI 随着时间的推移可能会发生变化。一个很好的例子是,当启用 multivalue
功能时,可以重新定义 extern "Rust"
ABI,以使用 WebAssembly 将支持的多返回值。这将能够更有效地返回大于 64 位的值。但是,实现这一点需要在 LLVM 中提供支持,而目前还没有。
这一切都意味着,在函数中使用多返回值,或者 multivalue
启用的 WebAssembly 功能,仍然遥遥无期,尚未实现。首先,LLVM 需要实现完整的降低支持,以生成具有多个返回值的 WebAssembly 函数,然后 extern "Rust"
才能在完全支持时使用此功能。在更远的未来,C 代码可能会改变,但这将需要相当长的时间,因为它涉及到跨版本兼容性。
启用 WebAssembly 的未来提案
这并不是第一次 WebAssembly 提案在 LLVM 中从默认关闭变为默认开启,也不会是最后一次。例如,LLVM 已经默认启用了 符号扩展提案,而 MVP WebAssembly 没有这个提案。预计在不久的将来,非陷阱浮点数到整数转换提案也可能会默认启用。这些更改目前没有严格的考虑标准(例如,必须有 N 个引擎在 M 年内实现此功能),并且可能会发生破坏。
如果您使用的 WebAssembly 引擎不支持 Rust 1.82 beta 和 LLVM 19 发出的模块,那么您的选择是
- 尝试查看您使用的引擎是否有任何可用的更新。您可能使用的是不支持某个功能的旧版本,但较新版本支持该功能。
- 提出问题,以引起人们对更改导致破坏的注意。这可以在您的引擎的存储库、Rust 存储库或 WebAssembly tool-conventions 存储库中完成。建议先搜索以确认是否已经存在未解决的问题。
- 禁用功能重新编译您的代码,下一节将对此进行详细介绍。
默认启用新功能背后的一般假设是,这对最终用户来说是一个相对无忧的操作,同时为每个人带来性能优势(例如,非陷阱浮点数到整数转换将使浮点数到整数的转换更优化)。如果更新最终导致麻烦,最好尽早标记出来,以便在需要时调整推出计划。
禁用默认开启的 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
这将重新编译 Rust 标准库以及您自己的代码,使用 “MVP CPU”,这是 LLVM 用于禁用所有 WebAssembly 提案的占位符。这将禁用 sign-ext、reference-types、multi-value 等。