WAMR虚拟机性能三驾马车之——字节码融合

更多精彩文章,欢迎关注作者微信公众号:码工笔记

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] i32.add -1,2,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分支则不需要。

参考链接

相关推荐
货拉拉技术2 小时前
你的骨架屏用对了吗?
前端·性能优化·浏览器
JINGWHALE16 小时前
设计模式 结构型 桥接模式(Bridge Pattern)与 常见技术框架应用 解析
前端·人工智能·后端·设计模式·性能优化·系统架构·桥接模式
JINGWHALE18 小时前
设计模式 结构型 组合模式(Composite Pattern)与 常见技术框架应用 解析
前端·人工智能·后端·设计模式·性能优化·系统架构·组合模式
JINGWHALE12 天前
设计模式 结构型 外观模式(Facade Pattern)与 常见技术框架应用 解析
前端·人工智能·后端·设计模式·性能优化·系统架构·外观模式
JINGWHALE12 天前
设计模式 结构型 代理模式(Proxy Pattern)与 常见技术框架应用 解析
前端·人工智能·后端·设计模式·性能优化·系统架构·代理模式
明月看潮生2 天前
青少年编程与数学 02-005 移动Web编程基础 14课题、性能优化
前端·青少年编程·性能优化·编程与数学·移动web
_明川2 天前
Android 性能优化:内存优化(实践篇)
android·性能优化
李宥小哥2 天前
ElasticSearch10-性能优化
运维·性能优化·jenkins
cui_win3 天前
Linux性能优化-系列文章-汇总
linux·网络·安全·性能优化
leluckys3 天前
flutter 专题二十四 Flutter性能优化在携程酒店的实践
flutter·性能优化