发布时间01十一月2023·标记为WebAssembly
最近一篇关于WebAssembly Garbage Collection(WasmGC)的文章从高层次上解释了垃圾收集(GC)提案如何旨在更好地支持Wasm中的GC语言,这对于它们的流行非常重要。在本文中,我们将深入了解如何将Java、Kotlin、Dart、Python和C#等GC语言移植到Wasm的技术细节。实际上主要有两种方法:
- “传统”移植方法,即将该语言的现有实现编译为WasmMVP,即2017年推出的WebAssembly最小可行产品。
- WasmGC移植方法,在这种方法中,语言被编译成Wasm本身的GC结构,这些结构在最近的GC提案中定义。
我们将解释这两种方法是什么,以及它们之间的技术权衡,特别是关于大小和速度。在这样做的同时,我们将看到WasmGC有几个主要的优势,但它也需要在工具链和虚拟机(VM)方面进行新的工作。本文后面的部分将解释V8团队在这些领域所做的工作,包括基准测试数据。如果你对Wasm、GC或两者都感兴趣,我们希望你会发现这很有趣,并确保在最后查看演示和入门链接!
“传统”移植方法
语言通常如何移植到新的体系结构?假设Python想要在ARM架构上运行,或者Dart想要在MIPS架构上运行。一般的想法是将VM重新编译到该架构。除此之外,如果VM具有特定于架构的代码,例如即时(JIT)或提前(AOT)编译,那么您还可以为新架构实现JIT/AOT后端。这种方法很有意义,因为通常代码库的主要部分可以为您移植到的每个新架构重新编译:
在此图中,解析器、库支持、垃圾收集器、优化器等,都在主运行时的所有架构之间共享。移植到一个新的架构只需要一个新的后端,这是一个相对少量的代码。
Wasm是一个低级编译器目标,因此可以使用传统的移植方法并不奇怪。自从Wasm第一次出现以来,我们已经在许多情况下看到了这一点,比如Python的Pyodide和C#的Blazor(注意Blazor支持AOT和JIT编译,所以它是上述所有的一个很好的例子)。在所有这些情况下,该语言的运行时都被编译到WasmMVP中,就像编译到Wasm的任何其他程序一样,因此结果使用WasmMVP的线性内存、表、函数等。
如前所述,这是语言通常如何移植到新架构的方式,因此它非常有意义,因为通常的原因是您可以重用几乎所有现有的VM代码,包括语言实现和优化。然而,事实证明,这种方法有几个Wasm特有的缺点,这就是WasmGC可以提供帮助的地方。
WasmGC移植方法
简单地说,WebAssembly的GC提案(“WasmGC”)允许您定义结构和数组类型并执行操作,例如创建它们的实例,读取和写入字段,类型之间的转换等(有关更多详细信息,请参阅提案概述)。这些对象由WasmVM自己的GC实现管理,这是这种方法与传统移植方法的主要区别。
这样想可能会有所帮助:如果传统的移植方法是如何将语言移植到架构中,那么WasmGC方法非常类似于如何将语言移植到VM中。例如,如果您想将Java移植到JavaScript,那么您可以使用像J2CL这样的编译器,它将Java对象表示为JavaScript对象,然后这些JavaScript对象就像所有其他对象一样由JavaScript VM管理。将语言移植到现有的VM是一项非常有用的技术,这一点可以从编译为JavaScript、JVM和JavaScript的所有语言中看出。
这个架构/VM的比喻并不准确,特别是因为WasmGC打算比我们在上一段提到的其他VM更低级别。尽管如此,WasmGC定义了VM管理的结构体和数组以及用于描述它们的形状和关系的类型系统,而移植到WasmGC是用这些原语表示语言结构的过程;这肯定比传统的WasmMVP移植(将所有内容降低到线性内存中的非类型化字节)更高级。因此,WasmGC非常类似于将语言移植到VM,并且它共享这些端口的优点,特别是与目标VM的良好集成以及对其优化的重用。
比较两种方法
现在我们已经了解了GC语言的两种移植方法,让我们来看看它们是如何比较的。
装运内存管理代码
在实践中,许多Wasm代码都在已经有垃圾收集器的VM中运行,这在Web上是如此,在Node.js、workerd、Deno和Bun等运行时中也是如此。在这种情况下,交付GC实现会给Wasm二进制文件增加不必要的大小。事实上,这不仅仅是WasmMVP中GC语言的问题,也是使用线性内存的语言(如C、C++和Rust)的问题,因为这些语言中执行任何有趣分配的代码最终都会捆绑malloc/free来管理线性内存,这需要几个字节的代码。例如,dlmalloc需要6 K,甚至像emmalloc这样权衡速度和大小的malloc也需要1 K。另一方面,WasmGC让VM自动为我们管理内存,所以我们根本不需要Wasm中的内存管理代码,既不需要GC也不需要malloc/free。在前面提到的关于WasmGC的文章中,测量了fannkuch基准的大小,WasmGC比C或Rust小得多-2.3 K vs 6.1-9.6 K-正是因为这个原因。
循环收集
在浏览器中,Wasm经常与JavaScript(以及通过JavaScript、Web API)交互,但在WasmMVP中(甚至在引用类型提案中),Wasm和JS之间没有双向链接,无法以细粒度的方式收集循环。指向JS对象的链接只能放在Wasm表中,而指向Wasm的链接只能将整个Wasm实例作为一个大对象引用,如下所示:
这不足以有效地收集对象的特定周期,其中一些恰好在编译的VM中,一些在JavaScript中。另一方面,使用WasmGC,我们定义了VM知道的Wasm对象,因此我们可以在Wasm和JavaScript之间进行适当的引用:
堆栈上的GC引用
GC语言必须知道堆栈上的引用,也就是说,来自调用作用域中的局部变量的引用,因为这样的引用可能是保持对象存活的唯一因素。在GC语言的传统端口中,这是一个问题,因为Wasm的沙箱阻止程序检查自己的堆栈。对于传统的端口,有一些解决方案,比如影子堆栈(可以自动完成),或者只在堆栈上没有东西时收集垃圾(这是JavaScript事件循环之间的情况)。未来可能增加的一个对传统端口有帮助的功能可能是Wasm中的堆栈扫描支持。目前,只有WasmGC可以在没有开销的情况下处理堆栈引用,并且它完全自动地这样做,因为Wasm VM负责GC。
GC效率
一个相关的问题是执行GC的效率。这两种移植方法都有潜在的优势。传统的端口可以重用现有VM中的优化,这些优化可能是针对特定语言定制的,例如重点关注优化内部指针或短期对象。另一方面,在Web上运行的WasmGC端口具有重用所有使JavaScript GC快速的工作的优点,包括分代GC,增量收集等技术。WasmGC还将GC留给VM,这使得高效写屏障等事情变得更简单。
WasmGC的另一个优点是,GC可以感知内存压力等情况,并可以相应地调整其堆大小和收集频率,就像Web上的JavaScript VM一样。
内存碎片
随着时间的推移,特别是在长时间运行的程序中,WasmMVP线性内存上的malloc/free操作可能会导致碎片。假设我们总共有2 MB的内存,在它的中间,我们有一个只有几个字节的小分配。在像C、C++和Rust这样的语言中,不可能在运行时移动任意的分配,所以我们在该分配的左边有大约1MB,在右边有大约1MB。但是这是两个独立的片段,所以如果我们尝试分配1.5 MB,我们将失败,即使我们有这么多的未分配内存:
这样的碎片会迫使Wasm模块更频繁地增加其内存,这会增加开销并可能导致内存不足错误;正在设计改进措施,但这是一个具有挑战性的问题。这在所有WasmMVP程序中都是一个问题,包括传统的GC语言移植(注意GC对象本身可能是可移动的,但不是运行时本身的一部分)。另一方面,WasmGC避免了这个问题,因为内存完全由VM管理,VM可以移动它们来压缩GC堆并避免碎片。
开发人员工具集成
在WasmMVP的传统端口中,对象被放置在线性内存中,开发人员工具很难提供有用的信息,因为这些工具只能看到没有高级类型信息的字节。另一方面,在WasmGC中,VM管理GC对象,因此可以实现更好的集成。例如,在Chrome中,您可以使用堆分析器来测量WasmGC程序的内存使用情况:
在Chrome堆探查器中运行的WasmGC代码
上图显示了Chrome DevTools中的Memory选项卡,其中我们有一个运行WasmGC代码的页面的堆快照,该代码在[链表](https://gist.github.com/kripken/5cd3e18b6de41c559d590e44252eafff)中创建了1,001个小对象。您可以看到对象类型的名称`$Node`,以及引用列表中下一个对象的字段`$next`。所有常见的堆快照信息都存在,如对象的数量,浅大小,保留大小等,让我们很容易看到WasmGC对象实际使用了多少内存。其他Chrome DevTools功能(如调试器)也适用于WasmGC对象。语言语义
当您在传统端口中重新编译VM时,您将获得所期望的确切语言,因为您正在运行实现该语言的熟悉代码。这是一个主要的优势!相比之下,对于WasmGC端口,您可能最终会考虑在语义上做出妥协以换取效率。这是因为使用WasmGC我们定义了新的GC类型-结构和数组-并编译为它们。因此,我们不能简单地将用C、C++、Rust或类似语言编写的VM编译成这种形式,因为它们只能编译到线性内存,因此WasmGC无法帮助大多数现有的VM代码库。相反,在WasmGC端口中,您通常会编写新代码,将语言的构造转换为WasmGC原语。有多种方法可以实现这种转变,需要进行不同的权衡。
是否需要妥协取决于如何在WasmGC中实现特定语言的构造。例如,WasmGC结构字段具有固定的索引和类型,因此希望以更动态的方式访问字段的语言可能会遇到挑战;有各种方法可以解决这个问题,在解决方案的空间中,一些选项可能更简单或更快,但不支持语言的完整原始语义。(WasmGC当前还有其他局限性,例如,它缺乏内部指针;随着时间的推移,这些问题预计会得到改善。)
正如我们所提到的,编译到WasmGC就像编译到现有的VM,在这样的端口中有许多妥协的例子。例如,dart2js(Dart编译为JavaScript)数字的行为与Dart VM中的不同,而IronPython(Python编译为.NET)字符串的行为与C#字符串相似。因此,并非所有语言的程序都可以在这样的端口中运行,但这些选择有很好的理由:将dart2js数字实现为JavaScript数字可以让VM很好地优化它们,并且在IronPython中使用.NET字符串意味着您可以将这些字符串传递给其他.NET代码而无需开销。
虽然在WasmGC移植中可能需要妥协,但WasmGC作为编译器目标也有一些优势,特别是与JavaScript相比。例如,虽然dart2js有我们刚才提到的数字限制,但dart2wasm(Dart编译为WasmGC)的行为完全符合它的要求,没有妥协(这是可能的,因为Wasm对Dart所需的数字类型有有效的表示)。
为什么这对traditional ports来说不是问题?这仅仅是因为它们将现有的VM重新编译到线性内存中,其中对象存储在非类型化字节中,这比WasmGC更低。当你拥有的都是非类型化的字节时,那么你就有了更大的灵活性来做各种各样的低级(可能不安全的)技巧,通过重新编译现有的VM,你可以得到VM所拥有的所有技巧。
工具链工作
正如我们在上一小节中提到的,WasmGC端口不能简单地重新编译现有的VM。您可能能够重用某些代码(例如解析器逻辑和AOT优化,因为它们在运行时不与GC集成),但通常WasmGC端口需要大量的新代码。
相比之下,传统的移植到WasmMVP可以更简单更快:例如,您可以在几分钟内将Lua VM(用C编写)编译为Wasm。另一方面,Lua的WasmGC移植需要更多的努力,因为您需要编写代码将Lua的构造降低到WasmGC结构和数组中,并且您需要决定如何在WasmGC类型系统的特定约束内实际做到这一点。
因此,更大的工具链工作量是WasmGC移植的一个显着缺点。然而,考虑到我们前面提到的所有优点,我们认为WasmGC仍然非常有吸引力!理想的情况是,WasmGC的类型系统可以有效地支持所有语言,并且所有语言都可以实现WasmGC端口。第一部分将通过未来对WasmGC类型系统的添加来帮助,第二部分,我们可以通过尽可能多地共享工具链端的工作来减少WasmGC端口所涉及的工作。幸运的是,事实证明WasmGC使得共享工具链工作变得非常实用,我们将在下一节中看到。
优化WasmGC
我们已经提到WasmGC端口具有潜在的速度优势,例如使用更少的内存和重用主机GC中的优化。在本节中,我们将展示WasmGC相对于WasmMVP的其他有趣的优化优势,这可能会对WasmGC端口的设计方式以及最终结果的速度产生很大影响。
这里的关键问题是WasmGC比WasmMVP级别更高。为了直观地理解这一点,请记住我们已经说过,传统的WasmMVP端口就像是移植到一个新的架构,而WasmGC端口就像是移植到一个新的VM,VM当然是架构上的更高级别的抽象-更高级别的表示通常更可优化。我们可以通过一个具体的伪代码例子更清楚地看到这一点:
func foo() {
let x = allocate<T>(); // Allocate a GC object.
x.val = 10; // Set a field to 10.
let y = allocate<T>(); // Allocate another object.
y.val = x.val; // This must be 10.
return y.val; // This must also be 10.
}
正如注释所示,x.val将包含10,y.val也将包含10,因此最终返回值也是undefined,然后优化器甚至可以删除分配,导致以下结果:
func foo() {
return 10;
}
好极了!然而,可悲的是,这在WasmMVP中是不可能的,因为每个分配都变成了对malloc的调用,这是Wasm中一个庞大而复杂的函数,对线性内存有副作用。由于这些副作用,优化器必须假设第二次分配(针对y)可能会更改x.val,该分配也驻留在线性内存中。内存管理是复杂的,当我们在Wasm内部以低级别实现它时,我们的优化选项就会受到限制。
相比之下,在WasmGC中,我们在更高的级别上操作:每个分配执行struct.new指令,这是一个我们实际上可以推理的VM操作,优化器也可以跟踪引用,从而得出结论,x.val只被写入一次,值为10。因此,我们可以将该函数优化为简单的返回10!
除了分配之外,WasmGC还添加了显式函数指针(ref.func)和使用它们的调用(call_ref),结构和数组字段的类型(不像非类型化线性内存)等等。因此,WasmGC是比WasmMVP更高级的中间表示(IR),并且更可优化。
如果WasmMVP的优化能力有限,为什么它能这么快?毕竟,Wasm的运行速度可以非常接近原生速度。这是因为WasmMVP通常是LLVM等强大的优化编译器的输出。LLVM IR,像WasmGC,不像WasmMVP,有一个特殊的分配表示等,所以LLVM可以优化我们一直在讨论的事情。WasmMVP的设计是,大多数优化发生在Wasm之前的工具链级别,Wasm VM只做优化的“最后一英里”(比如寄存器分配)。
WasmGC是否可以采用与WasmMVP类似的工具链模型,特别是使用LLVM?不幸的是,不,因为LLVM不支持WasmGC(已经探索了一些支持,但很难看出完全支持如何发挥作用)。此外,许多GC语言不使用LLVM-在该领域有各种各样的编译器工具链。所以我们需要为WasmGC做点别的。
幸运的是,正如我们所提到的,WasmGC是非常可优化的,这开辟了新的选择。这里有一个方法来看待这一点:
WasmMVP和WasmGC工具链工作流程
WasmMVP和WasmGC工作流开始都是从左边相同的两个框开始的:我们从以特定于语言的方式处理和优化的源代码开始(每种语言最了解自己)。然后出现了一个差异:对于WasmMVP,我们必须先执行通用优化,然后降低到Wasm,而对于WasmGC,我们可以选择先降低到Wasm,然后再优化。这很重要,因为在降低后进行优化有一个很大的优势:然后我们可以在编译为WasmGC的所有语言之间共享工具链代码,以进行通用优化。下图显示了它的样子:
多个WasmGC工具链由Binaryen优化器优化
由于我们可以在编译到WasmGC后进行常规优化,因此Wasm-to-Wasm优化器可以帮助所有WasmGC编译器工具链。出于这个原因,V8团队在Binaryen中投资了WasmGC,所有工具链都可以将其用作wasm-opt命令行工具。我们将在下一小节中重点讨论这一点。
工具链优化
Binaryen是WebAssembly工具链优化器项目,已经对WasmMVP内容进行了广泛的优化,例如内联、常量传播、死代码消除等,几乎所有这些都适用于WasmGC。然而,正如我们之前提到的,WasmGC允许我们做比WasmMVP更多的优化,我们已经相应地编写了很多新的优化:
- 转义分析将堆分配移动到局部变量。
- Devirtualization将间接调用转换为直接调用(然后可以潜在地内联)。
- 更强大的全局死代码消除。
- 全程序类型感知内容流分析(GUFA)。
- 强制转换优化,例如删除冗余强制转换并将其移动到较早的位置。
- 类型修剪。
- 类型合并。
这只是我们所做工作的一个简短清单。有关Binaryen新的GC优化以及如何使用它们的更多信息,请参阅Binaryen文档。
为了衡量Binaryen中所有这些优化的有效性,让我们看看使用和不使用wasm-opt的Java性能,在将Java编译为WasmGC的J2Wasm编译器的输出上:
使用和不使用wasm-opt的Java性能
在这里,“without wasm-opt”意味着我们不运行Binaryen的优化,但我们仍然在VM和J2 Wasm编译器中进行优化。如图所示,`wasm-opt`在这些基准测试中的每一个上都提供了显著的加速,平均使它们快了1.9倍。总之,wasm-opt可以被任何编译为WasmGC的工具链使用,并且它避免了在每个工具链中重新实现通用优化的需要。而且,随着我们继续改进Binaryen的优化,这将使所有使用wasm-opt的工具链受益,就像LLVM的改进帮助所有使用LLVM编译为WasmMVP的语言一样。
工具链优化只是其中的一部分。正如我们接下来将看到的,Wasm VM中的优化也是绝对关键的。
正如我们所提到的,WasmGC比WasmMVP更可优化,不仅工具链可以从中受益,虚拟机也可以从中受益。这很重要,因为GC语言与编译为WasmMVP的语言不同。例如,考虑内联,这是最重要的优化之一:像C,C++和Rust这样的语言在编译时内联,而像Java和Dart这样的GC语言通常在运行时内联和优化的VM中运行。这种性能模型影响了语言设计和人们如何用GC语言编写代码。
例如,在像Java这样的语言中,所有调用开始都是间接的(子类可以覆盖父类函数,即使使用父类类型的引用调用子类)。只要工具链可以将间接调用转换为直接调用,我们就可以从中受益,但实际上,在现实世界的Java程序中,代码模式通常会有一些路径,这些路径实际上确实有很多间接调用,或者至少不能静态地推断为直接调用。为了很好地处理这些情况,我们在V8中实现了推测性内联,也就是说,间接调用在运行时发生时会被记录下来,如果我们看到一个调用站点有相当简单的行为(很少的调用目标),那么我们就在那里内联适当的保护检查,这比我们完全把这些事情留给工具链更接近Java的正常优化方式。
现实世界的数据验证了这种方法。我们测量了Google Sheets Calc Engine的性能,它是一个用于计算电子表格公式的Java代码库,到目前为止,它已经使用J2CL编译为JavaScript。V8团队一直在与Sheets和J2CL合作,将这些代码移植到WasmGC,这既是因为Sheets的预期性能优势,也是为了为WasmGC规范过程提供有用的真实反馈。看看那里的性能,事实证明,推测性内联是我们在V8中为WasmGC实现的最重要的单独优化,如下图所示:
不同V8优化的Java性能
这里的“其他选项”是指除了推测性内联之外的优化,我们可以出于测量目的禁用这些优化,其中包括:负载消除、基于类型的优化、分支消除、常量折叠、转义分析和公共子表达式消除。“No opts”意味着我们已经关闭了所有这些以及推测性内联(但是V8中存在其他优化,我们不能轻易关闭;因此,这里的数字只是一个近似值)。由于推测性内联,性能有了很大的提高--大约30%的加速(!)--与所有其他选项相比,至少在编译的Java上,内联是多么重要。
除了推测性内联之外,WasmGC构建在V8中现有的Wasm支持之上,这意味着它受益于相同的优化器管道、寄存器分配、分层等。除此之外,WasmGC的特定方面可以从其他优化中受益,其中最明显的是优化WasmGC提供的新指令,例如有效实现类型转换。我们所做的另一项重要工作是在优化器中使用WasmGC的类型信息。例如,ref.test在运行时检查引用是否是特定类型的,在这样的检查成功之后,我们知道ref.cast,转换为相同类型,也必须成功。这有助于在Java中优化这样的模式:
if (ref instanceof Type) {
foo((Type) ref); // This downcast can be eliminated.
}
这些优化在推测性内联之后特别有用,因为这样我们就可以看到比工具链生成Wasm时更多的东西。
总的来说,在WasmMVP中,工具链和VM优化之间有一个相当清晰的分离:我们在工具链中做了尽可能多的优化,只为VM保留必要的优化,这是有意义的,因为它使VM更简单。对于WasmGC,这种平衡可能会有所改变,因为正如我们所看到的,需要在运行时为GC语言做更多的优化,而且WasmGC本身也更可优化,允许我们在工具链和VM优化之间有更多的重叠。看看这里的生态系统是如何发展的,这将是很有趣的。
演示和状态
您现在就可以使用WasmGC!在W3C达到第四阶段后,WasmGC现在是一个完整的标准,Chrome 119也支持它。使用该浏览器(或任何其他支持WasmGC的浏览器;例如,Firefox 120预计将在本月晚些时候推出WasmGC支持),您可以运行此Flutter演示,其中编译为WasmGC的Dart驱动应用程序的逻辑,包括其小部件,布局和动画。
在Chrome 119中运行的Flutter演示。
入门
- 目前,各种工具链都支持WasmGC,包括Dart,Java (J2Wasm)Java(J2Wasm),Kotlin,OCaml (wasm_of_ocaml), 和Scheme (Hoot).
- 我们在开发人员工具部分中展示的小程序的输出源代码是一个手工编写“hello world”WasmGC程序的示例。(In特别是,您可以看到定义了
$Node类型,然后使用struct.new创建。 - Binaryen wiki有关于编译器如何生成优化良好的WasmGC代码的文档。早期到各种WasmGC目标工具链的链接也可以用来学习,例如,您可以查看Java,Dart和Kotlin使用的Binaryen传递和标志。
总结
WasmGC是一种在WebAssembly中实现GC语言的新方法。在某些情况下,将VM重新编译为Wasm的传统端口仍然是最有意义的,但我们希望WasmGC端口将成为一种流行的技术,因为它们的好处:WasmGC ports可以比传统ports更小,甚至比用C、C++或Rust编写的WasmMVP程序更小,而且它们在循环收集、内存使用、开发工具、和更多. WasmGC也是一种更可优化的表示,它可以提供显着的速度优势以及在语言之间共享更多工具链工作的机会。