WebAssembly不是堆叠机(2019年)

2020-08-29 17:47:35

这是一个4集的迷你剧的第一部分,内容是关于WebAssembly的问题和解决这些问题的建议。这里是第2部分,这里是第3部分,这里是第4部分。本文假定您对虚拟机、编译器和WebAssembly有一定的了解,但我会在必要时尝试链接到相关信息,以便即使您不熟悉,也可以跟上。此外,本系列文章的结尾会让人觉得我不喜欢WebAssembly。我爱WebAssembly!我写了一整篇关于它有多棒的文章!事实上,我非常喜欢它,我希望它能做到最好,这个系列就是我解决我对设计的抱怨,希望这些问题中的一些或所有可以尽快得到解决,而规范上的墨水仍然有些湿。

我相信你们现在都熟悉WebAssembly了。它无处不在,从插件到区块链智能合同,当然,还有网络。如果你现在就去维基百科的WebAssembly文章,你会对这项技术有一个很好的概述,除了一件事:“设计”部分声明:

堆栈和寄存器机器之间的区别本质上是这样的:堆栈机器将值从堆栈中弹出,并将值推入堆栈,因此,例如,+操作将从堆栈中弹出最后两个值,将它们相加,然后将结果推回堆栈。寄存器机器有一些可以存储值的位置,操作读取和写入这些寄存器。SO+将接受3个参数,2个表示要从中读取操作数的寄存器,1个表示要将结果写入1的寄存器。

寄存器的问题是没有活跃性分析。如果您有一些代码,如下所示:

添加后是否使用了%0和%1?不向前看,你无从知晓。即使在像LLVMIR这样的SSA2寄存器机器中(其中每个寄存器只被写入一次),也必须不断地重新计算每个寄存器的活跃度。这意味着您有与编译相关的开销-了解变量的活跃度对于生成高效的汇编非常重要,但不是在创建IR时计算活跃度并将其存储为其中的一部分,而是每次都必须重新计算此数据。

如果IR完全在内存中,这并不是很糟糕,特别是如果您的代码是SSA格式的,但这意味着您必须为相同质量的代码做额外的工作,这意味着流式编译器(如Firefox的Wasm基线编译器)无法生成好的代码。

当然,您可以向机器添加活跃度分析元数据,但活跃度分析只有在代码为SSA格式时才有用-如果不是SSA格式,则活跃度是极其粗粒度的。

这就是WebAssembly的用武之地。看,WebAssembly有这些叫做本地人的东西。局部变量是在函数生命周期内存在的可变变量。因为WebAssembly块不能接受参数,所以它们是块从外部接收数据的唯一方式。这包括循环计数器之类的东西,这是SSA击败可变变量的经典示例,但它也包括常规块。例如:

(Func(Result I32)(Add(i32.const 1)(i32.const 2))(块(Result I32);无法读取上述加法的结果))。

(Func(Result I32)(Local I32)(local.set 0(add(i32.const 1)(i32.const 2)(block(Result I32)(local.get 0)。

这是个问题。通常,堆栈机器可以通过活跃性分析简单地转换为SSA形式-当堆栈上的项用作操作符的参数时,堆栈上的项就是死的-没有如果,就没有但是。即使是Pick运算符3也可以用这些术语来考虑,只要Pick的参数是编译时常量,尽管包含Pick指令的堆栈机器的SSA转换通常会使用refcount来实现-这意味着读取变量不需要创建不必要的新变量。然而,当地人是个问题。它们是可变的,所以您不能简单地将它们转换为SSA,并且它们总是在函数的生存期内存在。

这给优化带来了问题。SSA Form是最强大的优化工具之一。本地变量是可变的这一事实证明了这一点,而对于函数来说它们是全局的这一事实也证明了活跃性分析是无效的。关于为什么这是一个问题的示例,假设您正试图将WebAssembly编译到某个本机体系结构中。您将把代码编译成在寄存器中使用一定数量的参数的代码,但是现在除非您自己对该函数进行活跃性分析,否则这些寄存器将被这些参数永久标记为“正在使用”。如果没有额外的分析,您就不知道该参数的最后一次使用是什么时候,因此您永远不能将该参数使用的寄存器重用于其他用途。即使是从不使用任何参数的函数也不能将这些参数使用的空间重用于其他任何东西。

这实质上使WebAssembly成为一台没有活跃性分析的注册机器,但不仅如此,它还是一台甚至不是SSA形式的注册机器--我们可以使用的两种优化工具都不可用。在真正的优化编译器中,我们可以重新创建该信息,但是WebAssembly已经由生成该信息一次的编译器发出。没有什么技术上的理由让我们必须重新做这项工作,这只是语言上的一个缺陷。必须对WebAssembly流进行操作的编译器重新创建此信息的能力较弱,最终将无缘无故地生成更糟糕的代码。

WebAssembly规范的开发人员并不愚蠢。在很大程度上,它是一个设计得非常好的规范。然而,它们被WebAssembly的传统所拖累。WebAssembly一开始不是字节码,而更像是asm.js的简化二进制表示。从本质上讲,它最初是设计成源代码的,就像JavaScript一样。这将是一种更有效的表示,但它仍然不是一个合适的虚拟机指令集。然后,它变成了寄存机4,直到最后一刻才为操作员切换到基于堆栈的编码。在这一点上,像本地人这样的概念在规范中相当根深蒂固。不仅如此,WebAssembly规范团队在很大程度上也是盲目的。还没有构建流式编译器,见鬼,还没有构建编译器。不清楚拥有局部变量是否会有问题-毕竟,C使用编译器为其构造SSA图的局部变量就可以很好地工作。

只有通过开发与Wasmtime集成的流式WebAssembly编译器,我才意识到这些问题,以及我将在本系列后面的文章中介绍的更多问题。在此之前,我认为WebAssembly的设计绝对是坚如磐石的,事实上,我仍然坚信做出的大多数决定都是正确的。虽然它有问题,但考虑到在编写规范时它还是一个相对未知的领域,WebAssembly工作组的正确之处是令人难以置信的。

因此,目前局部变量同时表示函数参数和局部变量。有人建议允许块具有参数,并且函数和块都可以返回多个值。有了这一点,就有可能完全摆脱当地人。循环计数器是通过将计数器作为参数给定来实现的,然后当您跳到循环的头部时,必须在堆栈上拥有计数器的新值。它的作用类似于φ函数,但在概念上要简单得多。因为在WebAssembly中不可能获得本地人的地址,所以不需要任何形式的alloca-与LLVM不同,您根本不需要本地人。剩下的唯一一件事就是通过让函数从堆栈上的参数开始来实现函数参数,并且您根本不再需要局部变量。

这极大地降低了WebAssembly规范及其编译器的复杂性,而不会降低表现力,并允许生成WebAssembly的编译器将更多关于原始源的知识编码到WebAssembly本身中。虽然优化编译器可能不会由此生成更好的代码,但它们将变得更简单、更容易维护,而流式编译器将变得明显更简单并生成更好的代码。

最后,这样的堆栈机器不仅会自动采用SSA形式,而且还会自动采用严格的SSA形式。这意味着静态上不可能访问未定义的变量。在WebAssembly中(因为它现在存在),除非您可以静态地证明本地变量总是在访问之前设置,否则在输入函数时必须将本地变量置零。同样,编译器几乎总是拥有这些信息,删除局部变量使得静态访问未定义的内存变得不可能。

是的,我知道很多寄存器机器(如x86)将输出到其中一个输入寄存器中,但这在概念上仍然等同于为输入和输出接受单独的参数,只是更有限。↩︎。

如果您不知道ssa表单是什么,可以查看维基百科上的文章↩︎。

拾取操作符将堆栈的第n个项目复制到堆栈的顶部。不幸的是,它在WebAssembly中不可用。↩︎。

我相信它有无限的寄存器,所以有可能以SSA的形式生成它,但是它没有活跃性分析。↩︎