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分支则不需要。

参考链接

相关推荐
拥有一颗学徒的心5 小时前
鸿蒙第三方库MMKV源码学习笔记
笔记·学习·性能优化·harmonyos
独泪了无痕7 小时前
MySQL查询优化-distinct
后端·mysql·性能优化
纯爱掌门人9 小时前
鸿蒙Next复杂列表性能优化:让滑动体验如丝般顺滑
前端·性能优化·harmonyos
Eamonno10 小时前
深入理解React性能优化:掌握useCallback与useMemo的黄金法则
react.js·性能优化
Young soul211 小时前
第七章:JavaScript性能优化实战
性能优化
来恩100314 小时前
PHP 性能优化全攻略:提升 Web 应用速度的关键
前端·性能优化·php
hello_simon17 小时前
pdf转换成word在线 简单好用 支持批量转换 效率高 100%还原
性能优化·pdf·产品运营·word·pdf转换·自媒体·pdf转word
CoLiuRs19 小时前
微服务监控与Go服务性能分析
网络·微服务·性能优化·golang
安迪小宝1 天前
20 FastAPI 性能优化
oracle·性能优化·fastapi
明月看潮生2 天前
青少年编程与数学 02-009 Django 5 Web 编程 22课题、性能优化
python·青少年编程·性能优化·django·编程与数学