前言#
最近打算写编程语言(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
文件用于编译,接下来我们来看看内容
#!/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
rustup target add wasm32-wasi
然后使用 cargo 创建一个新的 rust 项目
cargo new --bin hello_wasm
接下来我们来写一个计算斐波那契的程序
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 的可以忽略)
brew install cmake && brew install ninja
在 wamr-compiler
路径下执行 build.sh
./build_llvm.sh
然后发现报错了 = =,这里我们跟着提示修复就好,看上去是我下载的 llvm 版本不支持 LLVM_CCACHE_BUILD 选项,这里需要改一下 build-scripts/build_llvm.py
路径下的编译选项,将 ccache 选项关闭
LLVM_COMPILE_OPTIONS.append("-DLLVM_CCACHE_BUILD:BOOL=OFF")
修改之后再 build 一次 llvm,然后我们开始编译 wamrc ,这块和编译 iwasm 没有什么太大的区别,按照官方 readme 里的编译步骤就行
mkdir build && cd build
cmake .. -DWAMR_BUILD_PLATFORM=darwin
make
执行之后在 build 路径下我们就得到了 wamrc 可执行文件,让我们用 wamrc 编译试试吧
./wamrc --size-level=3 -o hello_wasm.aot hello_wasm.wasm
这里因为我是 arm64 架构的芯片,所以得加上 --size-level = 3 的选项,不然没法编译(和文件大小有关)
aot 执行 wasm#
我们将上面编译的 aot 产物用 iwasm 来执行一下
./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 确实强