更多精彩文章,欢迎关注作者微信公众号:码工笔记
WAMR 简介
WAMR(WebAssembly Micro Runtime)是一款开源的WebAssembly虚拟机,它体积小、性能高,适用于IoT、云原生等各种场景。它采用栈机指令集,支持解释执行、JIT等多种运行模式。
解释执行的性能方面,根据[1]的介绍,它主要做了三方面的优化:
- 字节码快速派发(Fast Bytecode Dispatching)
- 字节码融合(Bytecode Fusion)
- 代码段中的整数预解码
本文主要讲第二部分:字节码融合。
字节码融合
WebAssembly的指令都没有操作数,这种设计可以最小化二进制文件大小,从而减少网络传输时长。但这种指令在执行时,需要一个操作栈来存储临时结果,并需要一个control block stack来存储上下文,这些都会造成运行时开销。
上图展示的是WAMR的传统解释器的栈帧结构。
在传统的解释器中,要完成一条简单的ADD操作,一般需要类似下面4条指令才能完成:
rust
get_local 0
i32.consnt 0
i32.add
set_local 0
其中前两条指令将操作数放到操作栈上,add指令执行加法运算,最后一条指令用于将数据写回局部变量。
每条指令在运行时都会有派发的开销,虽然在前文我们已经讲过如何尽可能加快派发速度,但指令数量多了以后开销还是很大,下面我们来看一下如何通过融合字节码来提升运行效率。
加载原始字节码并生成指令
通过观察这些指令可以发现,有些指令只负责将数据push进栈,如get_local、i32_const,它们只是向操作栈提供数据,没有其他逻辑,可以将它们称为"数据提供指令";而另外一些指令,如"i32.add",则会从栈上取一些数据并进行处理,可以称它们为"数据消费指令"。
如果数据消费指令自己知道数据的来源是哪里,那就可以干掉数据提供指令了。
WAMR的高速解释器就是在加载字节码文件时,修改并去除掉数据提供指令。
WAMR加载器在加载字节码时,会模拟栈机执行字节码的过程(但并不真正计算实际结果),它为每个操作数分配一个slot id。每个方法的栈帧包含三部分区域:常量区、局部变量区、动态区。
- 对局部变量来说,其slot id就是局部变量自身的序号(index);
- 对常量来说,其slot id是其在常量区内的偏移(负值);
- 对于由数据消费指令生成的中间值来说,他们的slot id是模拟执行过程中动态生成的
arduino
int foo(int a, int b) {
return a + b + 1;
}
以上述foo方法为例,其生成的字节码指令如下表:
WASM字节码 | Slot id栈内容 | 生成的指令 |
---|---|---|
get_local 0 | [0] | |
get_local 1 | [0, 1] | |
i32.add | [2] | i32.add 1,0,2 |
i32_const 1 | [2, -1] | |
i32.add | [2] | |
set_local 0 | [] | i32.add -1,2,0 |
其流程解释如下:
- get_local是数据提供指令,加载器(loader)在处理它们时,只将其数据的slot id压到栈上,而不真正生成指令
- i32.add指令从栈上消费两个操作数并生成一个新值并放到栈顶,新值是个临时值,没有固定的slot,所以需要为其分配一个新的slot id并将此id压入栈顶,这时,就需要生成一条新指令了:
ADD 1,0,2
,前两个代表操作数,它们是从slot栈中pop出来的,最后的2
代表的是结果所在的slot,它处于"动态区"中 - i32.const也是一个数据提供者,加载器将其slot id压入栈顶,常量值的slot id是负数,这一步也不生成新指令
- i32.add如前一步的i32.add,生成一条新指令
- set_local 0需要做一些特殊处理,加载器在发现set_local直接跟在另一条刚生成新值的指令后面时,会修改之前的指令,将指令的结果slot改为local 0
加载器在处理完成上述逻辑后,一共只生成了两条指令,就能完成与之前6条指令等价的逻辑,这一步相当于是完成了从栈机指令到寄存器指令的转换。
局部变量旧值保存问题
上述融合流程能使指令数大量减少,但还有一个问题,在某些场景下它可能会破坏代码的数据逻辑。
比如说,某个局部变量可能先被push到操作栈上(暂时不消费),然后有别的指令修改了局部变量的值,再然后又有指令从栈上pop出旧值进行消费。这个流程在原栈机逻辑上没问题,但是在字节码融合后,局部变量的旧值没有保存下来,导值无法取到旧值,因此需要一块新的区域来存储这些局部变量的旧值。
如上图代码片段,当加载器处理到set_local 0
指令时,会对局部变量进行分析,加载器遍历当前的slot栈,看栈中是否已有对当前要修改的局部变量slot id的引用,如果有,则生在一条copy
指令,将旧值拷到暂存区,同时将slot栈中的slot id改为暂存区的新slot id。
下图就是高速解释器中的栈帧结构,共包含4个区域:
- 常量区
- 局部变量区
- 动态区
- 暂存区
其中局部变量区大小是固定的,其大小由当前函数的定义决定(一个方法中声明了多少局部变量是编译期确定的),其他三个区域会在加载器处理过程中不断增加;
字节码中每遇到一个常量,则常量区往左扩大一格;
动态区的起点始于局部变量区的尾部,加载器每生成一个新的中间值,它就往右扩大一格,同时,如果中间值被其他指令消费了,则它会减少一格;
暂存区的起点位于动态区的尾部(这需要两次遍历,因为第一次遍历结束才能知道动态区的尾部在哪里)。
基本块边界
另外一个需要注意的地方是基本块(Basic Block)的边界。
在WebAssembly中,基本块是可以有结果值(包括类型)的,也就是说当这个基本块执行完成时,操作栈顶应该放着一个对应类型的结果数据。对于栈机指令来说按顺序执行就能达到这样的结果,但字节码融合之后,就需要做特殊处理了。
前面解释过,高速解释器在加载时会使用一个slot id栈来记录操作数,当一个基本块加载结束时,它还是以一个slot形式存在,别人可以用slot id来访问它,但如果这是个if-else块,那这两个分支的结果可能对应的是不同的slot id,这种情况下,需要在基本块结束前插入新的copy指令:
基本块的结果一定都在动态区,也就是说,整个基本块可以看作是一系列字节码指令,它从操作栈上消费了一些数据,并生成了结果。
基本块的结果slot可以在真正处理其内部具体字节码之前就计算出来,然后在基本块结束前,先检查slot栈顶位置,如果不等于之前计算出来的结果slot位置,则生成一条copy指令。因此如上图示,if分支需要生成一条copy指令,而else分支则不需要。
参考链接
- [1] WAMR介绍:www.intel.com/content/www...
- [2] Github: github.com/bytecodeall...