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

参考链接

  • 1\] WAMR介绍:[www.intel.com/content/www...](https://link.juejin.cn?target=https%3A%2F%2Fwww.intel.com%2Fcontent%2Fwww%2Fus%2Fen%2Fdeveloper%2Farticles%2Ftechnical%2Fwebassembly-interpreter-design-wasm-micro-runtime.html "https://www.intel.com/content/www/us/en/developer/articles/technical/webassembly-interpreter-design-wasm-micro-runtime.html")

相关推荐
桂月二二3 小时前
Vue3服务端渲染深度解析:从Nuxt3架构到性能优化实战
性能优化·架构
我有医保我先冲4 小时前
SQL复杂查询与性能优化全攻略
数据库·sql·性能优化
庸俗今天不摸鱼5 小时前
【万字总结】前端全方位性能优化指南(九)——FSP(First Screen Paint)像素级分析、RUM+合成监控、Lighthouse CI
前端·性能优化
Light606 小时前
深入剖析JavaScript多态:从原理到高性能实践
javascript·性能优化·多态·类型推断·代码复用·v8引擎
写代码写到手抽筋9 小时前
C++多线程的性能优化
java·c++·性能优化
Unlimitedz11 小时前
音乐缓存管理器的性能优化方法分析
缓存·性能优化
小戴同学11 小时前
实时系统降低延时的利器
后端·性能优化·go
杨筱毅1 天前
【性能优化点滴】odygrd/quill 中将 MacroMetadata 变量声明为 constexpr
c++·性能优化
rainoway1 天前
全量加载、懒加载、延迟加载、虚拟列表、canvas、异步分片
前端·算法·性能优化
豆浆whisky1 天前
Go语言内存管理揭秘:三级分配器架构与性能优化|Go语言进阶(2)
性能优化·架构·golang