🦍

自作Wasm Runtimeで自作Lisp処理系を動かしてみた

2023/12/06に公開

この記事は WebAssembly Advent Calendar 2023 6日目の記事です。

はじめ

以前にRustで自作Wasm Runtimeであるchibiwasmを実装しました。
また、それをcontainerdで動かしたりもしました。

https://zenn.dev/skanehira/articles/2023-04-23-rust-wasm-runtime

https://zenn.dev/skanehira/articles/2023-09-18-rust-wasm-runtime-containerd

今回は以前に作った簡単なLisp処理系であるrispを動かしてみたので、どんな感じのことをやっていたのかについて軽く書いていきます。

https://zenn.dev/skanehira/articles/2022-07-22-rust-lisp

rispをwasmにコンパイル

まず最初にrispをwasmにコンパイルするところからですが、
rispはREPLにrustylineattyというクレートを使っていて、これらをwasmにコンパイルできないのでcfg!マクロを使ってwasmの場合はstdクレートのみを使うようにしました

実装
Cargo.toml
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustyline = "10.0.0"
atty = "0.2"
main.rs
#[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
───────────────┼───────────┼─────────────────────────────────────────────────────────────────────────
        74729532.83% ┊ custom section '.debug_str'
        50156022.04% ┊ custom section '.debug_info'
        32650814.35% ┊ custom section '.debug_line'
        30002813.18% ┊ custom section '.debug_pubnames'
        1914328.41% ┊ custom section '.debug_ranges'
         462292.03% ┊ data[0]
         274391.21% ┊ "function names" subsection
          65380.29% ┊ dlmalloc
          62730.28% ┊ core::num::flt2dec::strategy::dragon::format_shortest::h91322b6bec90f401
          53270.23% ┊ core::num::flt2dec::strategy::dragon::format_exact::h8d2742335d5486ee
        1172875.15% ┊ ... and 448 more.
       2275916100.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
───────────────┼───────────┼────────────────────
         4622926.14% ┊ data[0]
          65383.70% ┊ code[217]
          62733.55% ┊ code[290]
          53273.01% ┊ code[292]
          38082.15% ┊ code[9]
          26091.48% ┊ code[14]
          22661.28% ┊ code[23]
          22011.24% ┊ code[293]
          21681.23% ┊ code[11]
          19211.09% ┊ code[369]
         9752655.14% ┊ ... and 425 more.
        176866100.00% ┊ Σ [435 Total Rows]

ちなみに他にもサイズを小さくする方法があるので、詳細はこちらを参照してください。

必要なwasi関数を確認

wasmは作れたので、次にchibiwasmで動かせるようにしていきました。
動かそうとした時点では次の関数を実装しています。

関数 概要
fd_write 線形メモリのデータをfdに書き出す
proc_exit プロセスを終了
environ_get 環境変数を{key}={value}\0形式で線形メモリに書き込む
environ_sizes_get 環境変数のバイト列の長さを線形メモリに書き込む

fd_writeがあれば任意のfdにデータを書き出せるが、rispstdinから文字列を受け取るため、これだけでは足りないだろうなと思っていました。
なので、実際どんな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_raedrandom_getを実装すれば、risp.wasmを動かせそうということがわかりました。

ファイル管理について

実装について説明する前に、前提知識としてファイルの管理について少し説明します。

chibiwasmではfd_readfd_writeするfdFileTableという配列を用意して管理しています。
インデックスがfdの番号になるので、fd: 0が指定された場合は0番目のファイルを取得してそれに対して読み書きをします。

ちなみにwasiモジュール初期時に0: stdin, 1: stdout, 3:stderrFileTableに入っている状態になります。
それ以降はpath_openでファイルを開く度に配列に追加される想定です。

実装
file.rs
#[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>,
}
file_table.rs
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_readrandom_getを実装していきますが、正直Specを読んでもどう実装すればいいのかさっぱりわからないので、Denoのwasiモジュールwasmtimeの実装を参考にしつつ実装しました。

ただ、fd_writeは線形メモリのデータをfdに書き出すので、fd_readfdから取得したデータを線形メモリに書き込むことが容易に想像つきます。
なので同じ要領で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を使って、dataOffsetdataLengthを線形メモリから取得
  • 線形メモリdataOffsetからoffsetまでのバイト列を取得
  • 取得したバイト列を指定したfdに書き込む
  • これらiovsLength回繰り返し、書き込んだバイト列のサイズを線形メモリのnreadOffsetに書き込む

これと同じことをchibiwasmでもやればいいので、次のように実装しました。
少し長いですが、やっていることは同じです。

preview1.rs
    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でエンコードしてそのバイト列を線形メモリに書き込むの、もっとスマートにできそうではありますが…

preview1.rs
    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関数を実装したので、実際に動かしてみると一発で動きました。

examples/risp.rs
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