自作Wasm Runtimeで自作Lisp処理系を動かしてみた
この記事は WebAssembly Advent Calendar 2023 6日目の記事です。
はじめ
以前にRustで自作Wasm Runtimeであるchibiwasm
を実装しました。
また、それをcontainerdで動かしたりもしました。
今回は以前に作った簡単なLisp処理系であるrisp
を動かしてみたので、どんな感じのことをやっていたのかについて軽く書いていきます。
rispをwasmにコンパイル
まず最初にrisp
をwasmにコンパイルするところからですが、
risp
はREPLにrustyline
とatty
というクレートを使っていて、これらをwasmにコンパイルできないのでcfg!
マクロを使ってwasmの場合はstd
クレートのみを使うようにしました
実装
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustyline = "10.0.0"
atty = "0.2"
#[cfg(not(target_arch = "wasm32"))]
fn repl(evaluator: &mut Evaluator, env: &mut ExprEnv) {
use rustyline::error::ReadlineError;
use rustyline::Editor;
use std::env;
use std::fs::File;
use std::io::{self, BufRead};
if atty::is(atty::Stream::Stdin) {
let args = env::args().collect::<Vec<String>>();
if args.len() == 1 {
let mut rl = Editor::<()>::new().unwrap();
_ = rl.load_history("history.txt");
loop {
let readline = rl.readline("risp>> ");
match readline {
Ok(line) => {
rl.add_history_entry(line.as_str());
let result = eval(evaluator, env, &line);
println!("{}", result);
}
Err(ReadlineError::Interrupted) => {
break;
}
Err(ReadlineError::Eof) => {
break;
}
Err(err) => {
println!("Error: {:?}", err);
break;
}
}
}
rl.save_history("history.txt").expect("cannot save history");
} else {
let arg = args.get(1);
if let Some(filename) = arg {
let file = File::open(filename).unwrap();
for line in io::BufReader::new(file).lines() {
let result = eval(evaluator, env, &line.unwrap());
println!("{}", result);
}
}
}
}
}
#[cfg(target_arch = "wasm32")]
fn repl(evaluator: &mut Evaluator, env: &mut ExprEnv) {
use std::io;
let stdin = io::stdin();
for line in stdin.lines() {
let result = eval(evaluator, env, &line.unwrap());
println!("{}", result);
}
}
こうすることでwasmにコンパイルできるようになります。
$ cargo build --target wasm32-wasi --release
Finished release [optimized] target(s) in 0.18s
$ ll target/wasm32-wasi/release/risp.wasm
.rwxr-xr-x skanehira staff 2.2 MB 2023-12-02 23:53:18 target/wasm32-wasi/release/risp.wasm
しかし見て分かるとおり、コンパイルしたwasmがかなり大きいので、twiggyを使ってプロファイルしてみます。
$ twiggy top -n 10 target/wasm32-wasi/release/risp.wasm
Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼─────────────────────────────────────────────────────────────────────────
747295 ┊ 32.83% ┊ custom section '.debug_str'
501560 ┊ 22.04% ┊ custom section '.debug_info'
326508 ┊ 14.35% ┊ custom section '.debug_line'
300028 ┊ 13.18% ┊ custom section '.debug_pubnames'
191432 ┊ 8.41% ┊ custom section '.debug_ranges'
46229 ┊ 2.03% ┊ data[0]
27439 ┊ 1.21% ┊ "function names" subsection
6538 ┊ 0.29% ┊ dlmalloc
6273 ┊ 0.28% ┊ core::num::flt2dec::strategy::dragon::format_shortest::h91322b6bec90f401
5327 ┊ 0.23% ┊ core::num::flt2dec::strategy::dragon::format_exact::h8d2742335d5486ee
117287 ┊ 5.15% ┊ ... and 448 more.
2275916 ┊ 100.00% ┊ Σ [458 Total Rows]
どうやら、カスタムセクションがほとんど容量を占めているということがわかりました。
カスタムセクションはユーザが好きにデータを配置できる領域となっていて、ここにコンパイラがデバッグ用の情報を書き込んでいるようですね。
今回は特に不要なので、wasm-stripをつかってそれらを削除します。
$ wasm-strip target/wasm32-wasi/release/risp.wasm
$ ll target/wasm32-wasi/release/risp.wasm
.rwxr-xr-x skanehira staff 173 KB 2023-12-03 00:14:26 target/wasm32-wasi/release/risp.wasm
$ twiggy top -n 10 target/wasm32-wasi/release/risp.wasm
Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼────────────────────
46229 ┊ 26.14% ┊ data[0]
6538 ┊ 3.70% ┊ code[217]
6273 ┊ 3.55% ┊ code[290]
5327 ┊ 3.01% ┊ code[292]
3808 ┊ 2.15% ┊ code[9]
2609 ┊ 1.48% ┊ code[14]
2266 ┊ 1.28% ┊ code[23]
2201 ┊ 1.24% ┊ code[293]
2168 ┊ 1.23% ┊ code[11]
1921 ┊ 1.09% ┊ code[369]
97526 ┊ 55.14% ┊ ... and 425 more.
176866 ┊ 100.00% ┊ Σ [435 Total Rows]
ちなみに他にもサイズを小さくする方法があるので、詳細はこちらを参照してください。
wasi
関数を確認
必要なwasmは作れたので、次にchibiwasm
で動かせるようにしていきました。
動かそうとした時点では次の関数を実装しています。
関数 | 概要 |
---|---|
fd_write | 線形メモリのデータをfdに書き出す |
proc_exit | プロセスを終了 |
environ_get | 環境変数を{key}={value}\0 形式で線形メモリに書き込む |
environ_sizes_get | 環境変数のバイト列の長さを線形メモリに書き込む |
fd_write
があれば任意のfd
にデータを書き出せるが、risp
はstdin
から文字列を受け取るため、これだけでは足りないだろうなと思っていました。
なので、実際どんなwasi
が必要なのでwasm2wat
で見ました。
$ wasm2wat examples/risp.wasm 2>&1 | grep wasi_
(import "wasi_snapshot_preview1" "fd_read" (func (;0;) (type 7)))
(import "wasi_snapshot_preview1" "fd_write" (func (;1;) (type 7)))
(import "wasi_snapshot_preview1" "random_get" (func (;2;) (type 3)))
(import "wasi_snapshot_preview1" "environ_get" (func (;3;) (type 3)))
(import "wasi_snapshot_preview1" "environ_sizes_get" (func (;4;) (type 3)))
(import "wasi_snapshot_preview1" "proc_exit" (func (;5;) (type 2)))
どうやら、fd_raed
とrandom_get
を実装すれば、risp.wasm
を動かせそうということがわかりました。
ファイル管理について
実装について説明する前に、前提知識としてファイルの管理について少し説明します。
chibiwasm
ではfd_read
やfd_write
するfd
をFileTable
という配列を用意して管理しています。
インデックスがfd
の番号になるので、fd: 0
が指定された場合は0
番目のファイルを取得してそれに対して読み書きをします。
ちなみにwasi
モジュール初期時に0: stdin, 1: stdout, 3:stderr
がFileTable
に入っている状態になります。
それ以降はpath_open
でファイルを開く度に配列に追加される想定です。
実装
#[derive(Debug, Clone)]
pub enum FileCaps {
DataSync = 0b1,
Read = 0b10,
...
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum FileType {
Unknown = 0,
BlockDevice = 1,
CharacterDevice = 2,
...
}
pub trait File: Send + Sync {
fn write(&mut self, data: &[u8]) -> Result<usize>;
fn read(&mut self, data: &mut [u8]) -> Result<usize>;
fn seek(&mut self, pos: u64) -> Result<u64>;
fn filetype(&self) -> Result<FileType>;
fn fdflags(&self) -> Result<FdFlags>;
fn read_string(&mut self) -> Result<String>;
}
pub struct FileEntry {
caps: FileCaps,
file: Box<dyn File>,
}
pub struct FileTable(Vec<Arc<Mutex<FileEntry>>>);
impl Default for FileTable {
fn default() -> Self {
Self(vec![
// stdin
Arc::new(Mutex::new(FileEntry::new(
Box::new(WasiFile::from_raw_fd(0)),
FileCaps::Sync,
))),
// stdout
Arc::new(Mutex::new(FileEntry::new(
Box::new(WasiFile::from_raw_fd(1)),
FileCaps::Sync,
))),
// stderr
Arc::new(Mutex::new(FileEntry::new(
Box::new(WasiFile::from_raw_fd(2)),
FileCaps::Sync,
))),
])
}
}
wasi
関数の実装
fd_read
とrandom_get
を実装していきますが、正直Specを読んでもどう実装すればいいのかさっぱりわからないので、Denoのwasiモジュールやwasmtimeの実装を参考にしつつ実装しました。
ただ、fd_write
は線形メモリのデータをfd
に書き出すので、fd_read
はfd
から取得したデータを線形メモリに書き込むことが容易に想像つきます。
なので同じ要領でfd_read
を実装していけば良さそうなので、まずfd_read
のシグネチャを確認します。
wasm2wat
でみるとどうやら4つのi32
型を受け取るようです。
(type (;7;) (func (param i32 i32 i32 i32) (result i32)))
...
(import "wasi_snapshot_preview1" "fd_read" (func (;0;) (type 7)))
これらがそれぞれどのようじ使えばいいのかを知るため、Deno側の実装を見てみます。
"fd_read": syscall((
fd: number,
iovsOffset: number,
iovsLength: number,
nreadOffset: number,
): number => {
Denoのモジュールの実装を見ていくと、次のことをやっているのがわかります。
-
dataOffset
を使って、dataOffset
とdataLength
を線形メモリから取得 - 線形メモリ
dataOffset
からoffset
までのバイト列を取得 - 取得したバイト列を指定したfdに書き込む
- これら
iovsLength
回繰り返し、書き込んだバイト列のサイズを線形メモリのnreadOffset
に書き込む
これと同じことをchibiwasm
でもやればいいので、次のように実装しました。
少し長いですが、やっていることは同じです。
fn fd_read(&self, store: Rc<RefCell<Store>>, args: Vec<Value>) -> Result<Value> {
let args: Vec<i32> = args.into_iter().map(Into::into).collect();
let (fd, mut iovs, iovs_len, nread_offset) = (
args[0] as usize,
args[1] as usize,
args[2] as usize,
args[3] as usize,
);
let store = store.borrow();
let memory = store.memory.get(0).with_context(|| "not found memory")?;
let mut memory = memory.borrow_mut();
let file = self
.file_table
.get(fd)
.with_context(|| format!("cannot get file with fd: {}", fd))?;
let file = Arc::clone(file);
let mut file = file.lock().expect("cannot lock file");
let file = file.capbable(FileCaps::Read)?;
let mut nread = 0;
for _ in 0..iovs_len {
let offset: i32 = memory_load!(memory, 0, 4, iovs);
iovs += 4;
let len: i32 = memory_load!(memory, 0, 4, iovs);
iovs += 4;
let offset = offset as usize;
let end = offset + len as usize;
nread += file.read(&mut memory.data[offset..end])?;
}
memory_write!(memory, 0, 4, nread_offset, nread);
Ok(0.into())
}
次にrandom_get
を実装していきますが、これもfd_read
と同様にDenoモジュールの実装を見ていきます。
どうやら、crypto.getRandomValuesを使って線形メモリのbufferOffset
からbufferLength
の部分にランダムな値を書き込むことをやっているようです。
これと同じことをchibiwasm
では次のように実装しました。
RustではgetRandomValues()
みたいな関数を見つからなかったので、愚直に実装しました。
leb128
でエンコードしてそのバイト列を線形メモリに書き込むの、もっとスマートにできそうではありますが…
fn random_get(&self, store: Rc<RefCell<Store>>, args: Vec<Value>) -> Result<Value> {
let args: Vec<i32> = args.into_iter().map(Into::into).collect();
let (mut offset, buf_len) = (args[0] as usize, args[1] as usize);
let store = store.borrow();
let memory = store.memory.get(0).with_context(|| "not found memory")?;
let mut memory = memory.borrow_mut();
let mut rng = thread_rng();
let distr = rand::distributions::Uniform::new_inclusive(1u32, 100);
for _ in 0..buf_len {
let x = rng.sample(distr);
let mut buf = std::io::Cursor::new(Vec::new());
leb128::write::unsigned(&mut buf, x as u64)?;
memory.write_bytes(offset, buf.into_inner().as_slice())?;
offset += 1;
}
Ok(0.into())
}
risp.wasm
を動かしてみる
ひととおり必要なwasi
関数を実装したので、実際に動かしてみると一発で動きました。
use anyhow::Result;
use chibiwasm::wasi::WasiSnapshotPreview1;
use chibiwasm::Runtime;
fn main() -> Result<()> {
let wasi = WasiSnapshotPreview1::default();
let mut runtime = Runtime::from_file("examples/risp.wasm", Some(vec![Box::new(wasi)]))?;
runtime.call("_start".into(), vec![])?;
Ok(())
}
$ cargo run -q --example risp
(+ 1 2)
3
(defun add (a b) (+ a b))
ADD
(add 10 4)
14
(defun half (x) (/ x 2))
HALF
(defun medium (x y) (half (+ x y))
MEDIUM
(medium 2 4)
3
意外とあっさり動いちゃったので、特にハマるポイントはなかったんですが、正直この実装でいいのか不安はあります。
というのもwasiのテストスイートを通していないので、おそらくバグだらけだと思われます。
なので、参考程度と思って読んでもらえたらと思います。
さいごに
wasi
を実装すると意外と遊べることがわかったのでよかったです。
今後も気が向いたらまた何かやろうかなと思います。
Discussion