折腾了一下 WAMR

前言

最近打算写编程语言（23 年终总结立的 flag），挑选了一下编译的 backend，之前计划编译到 c backend，再利用 clang 编译成二进制码，好处自然是 c 的工具链已经非常成熟了，基于 llvm 也能很方便的编译到各个 target，同时执行速度上也有保证 （画外音吐槽：你怎么不直接编译到 llvm ir = =）。后来我又想折腾了（叛逆🐻），看了一下 rust 实现的 cranelift 后端，看上去是个不错的选择，因为 cranelift 主打的是 jit 编译（也可以通过 object 模块直接生成二进制文件），因此在代码生成速度上要比 llvm 快的多（当然也没有那么多的优化 pass），同时依旧保持了非常不错的性能。因为 cranelift 没有提供其它语言的 binding，所以我又去学了一会儿 rust，然后放弃了（学 rust 第 n 次失败，我太菜了😭）。最终，我灵感一闪，想到了将 webassembly 作为编译的 backend ，因此开始调研 wasm runtime，最终选择了WAMR

为啥选择 WAMR

执行速度超快（非常接近原生的表现）

带有解释模式（刚好可以满足在 dev 模式下快速启动的需求）

带有 wasi libc 支持

非常小的 binary size

开始折腾

最开始打算先试用一下 WAMR ，因此就先不考虑 embed 了，直接使用官方写好的两个 cli 进行测试，因为官方在 macOS 下只提供了 x86_64 的 prebuild binary，而我恰好用的是 arm 芯片，因此需要自己手动编译一下，首先把 WAMR 的源码下载到本地，我们需要 iwasm 和 wamrc 这两个 cli，首先我们来编译 iwasm

编译 iwasm

首先我们找到 iwasm 的路径，在 product-mini/platforms/darwin 文件夹，可以看到 CMakeLists.txt 文件，感兴趣的大家可以打开文件，里面能看到编译时可以设置的选项，我根据我使用的 case ，在 product-mini/platforms/darwin 文件夹下新建了一个 make.sh 文件用于编译，接下来我们来看看内容

Copy
#!/bin/sh

mkdir build && cd build
# 传入编译选项
cmake .. -DWAMR_BUILD_TARGET=AARCH64 -DWAMR_BUILD_JIT=0
# 编译
make
cd ..

这段 shell 脚本里，重点在于 cmake 这一段，我们传入了两个编译选项，我们来解读一下这些编译选项的意思
WAMR_BUILD_TARGET=AARCH64 编译到 arm 64 指令集
WAMR_BUILD_JIT=0 不编译 JIT 功能 （其实最开始我是希望在 dev 模式下也可以使用 JIT 功能，这样 dev 模式和最终的 build 模式的速度不至于差的太远，目前 wamr 有两种 JIT 模式，一种是 Fast JIT，一种是 LLVM JIT，其中 LLVM JIT 的体积太大，因此从一开始我就不打算编译这个功能，毕竟只是在 dev 模式使用，没有必要。另外一种 Fast JIT 倒是比较轻量，只需要增加非常少的二进制体积，性能按照官方的说法也能达到 llvm 的 50%，这对于 dev 模式来说足够了，可惜在我的电脑上没有编译成功，后面再试试）
执行之后在 build 文件夹中可以看到 iwasm 可执行文件，在 AARCH64 架构下，纯解释执行的二进制文件只有 426 kb，可以说非常轻量了，接下来我们生成一个 webassembly 文件试试，这里我选择使用 rust 编译到 wasm32-wasi target
首先我们使用 rustup 添加一下 wasm32-wasi 的 target

Copy
rustup target add wasm32-wasi

然后使用 cargo 创建一个新的 rust 项目

Copy
cargo new --bin hello_wasm

接下来我们来写一个计算斐波那契的程序

Copy
use std::io;

fn fib_recursive(n: usize) -> usize {
match n {
0 | 1 => 1,
_ => fib_recursive(n - 2) + fib_recursive(n - 1),
}
}

fn main() {
println!("请输入一个数字来计算斐波那契数列：");

let mut input = String::new();
io::stdin().read_line(&mut input).expect("无法读取输入");

let n: usize = input.trim().parse().expect("请输入一个有效的数字");

// 计算斐波那契数列并测量时间
let start_time = std::time::Instant::now();
let result = fib_recursive(n);
let elapsed_time = start_time.elapsed();

println!("斐波那契数列第 {} 项的值为: {}", n, result);
println!("计算耗时: {:?}", elapsed_time);
}

编译到 wasi
cargo build --target wasm32-wasi --release
编译之后可以在 target/wasm32-wasi/release 找到编译后的 hello_wasm.wasm 文件，让我们使用刚刚编译好的 iwasm 来执行一下这个 wasm 文件
iwasm --interp hello_wasm.wasm
可以看到程序顺利执行了，在我的 mac mini (m1 芯片) 上，执行 fib (40) 耗时大约 3.7 s，而 rust 原生程序执行耗时为 337 ms，可以看到，纯解释器执行的 webassembly 效率大概是原生程序的 1 / 10 （其实已经是个不错的成绩了，这是因为 wamr 内部有一个快速解释器的实现，将 webassembly 的栈式虚拟机指令先转换成内部的 ir 再执行）

编译 wamrc

接下来是优化性能的重点，我们来编译 wamrc，也就是将 wasm 文件转换成 aot 文件，再使用上文编译好的 iwasm 执行，来获得更快的执行速度，因为 wamrc 依赖 llvm 做编译优化，因此我们需要先编译 llvm，在 macOS 下，我们先来安装一下编译 llvm 需要的依赖（已经安装了 cmake 和 ninja 的可以忽略）

Copy
brew install cmake && brew install ninja

在 wamr-compiler 路径下执行 build.sh

Copy
./build_llvm.sh

然后发现报错了 = =，这里我们跟着提示修复就好，看上去是我下载的 llvm 版本不支持 LLVM_CCACHE_BUILD 选项，这里需要改一下 build-scripts/build_llvm.py 路径下的编译选项，将 ccache 选项关闭

Copy
LLVM_COMPILE_OPTIONS.append("-DLLVM_CCACHE_BUILD:BOOL=OFF")

修改之后再 build 一次 llvm，然后我们开始编译 wamrc ，这块和编译 iwasm 没有什么太大的区别，按照官方 readme 里的编译步骤就行

Copy
mkdir build && cd build
cmake .. -DWAMR_BUILD_PLATFORM=darwin
make

执行之后在 build 路径下我们就得到了 wamrc 可执行文件，让我们用 wamrc 编译试试吧

Copy
./wamrc --size-level=3 -o hello_wasm.aot hello_wasm.wasm

这里因为我是 arm64 架构的芯片，所以得加上 --size-level = 3 的选项，不然没法编译（和文件大小有关）

aot 执行 wasm

我们将上面编译的 aot 产物用 iwasm 来执行一下

Copy
./iwasm hello_wasm.aot

我们再来试着调用一下 fib (40) ，这次在我的机器上执行耗时只花了 337 ms，和 rust 原生程序一致，虽然这一个简单的例子无法完全代表 aot 和 rust 原生程序的执行速度差异，但是这也说明再经过 llvm 的优化之后，webassembly 也能达到接近原生的执行速度

小插曲

node.js 使用的 v8 也是优化程度非常高的 jit 编译器，但是和 aot 不同，v8 会先对 js 生成字节码解释执行，只有热点函数才会进行 jit 编译，同时因为 js 是动态类型的语言，比起 webassembly 这种静态类型的语言也更加难以优化，那么，作为动态语言的巅峰，执行上面的 fib 函数需要多久呢，在我的机器上，执行 fib (40) 大概耗费 959 ms，达到了 rust 原生程序的 30%，这里也可以看出 v8 确实强