kuma

kuma

front-end developer / 躺平的一只🐻

折腾了一下 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 文件用於編譯,接下來我們來看看內容

#!/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 確實強

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。