Node.js でつくる WASMコンパイラー - Extra1:WASIを使ってWASMを動かす


はじめに

Node.jsで小さなプログラミング言語を作ってみるシリーズを、「ミニコンパイラー」「ミニインタープリター」とやってきました。そして三部作(?)の最後として、 ミニNode.jsからWASMを生成する小さなコンパイラーに取り組んでいます。

今回の目的

前回で目標としていたNode.js-WASMコンパイラーの最低限の実装が終わりました。今回は生成したWASMをいろいろな環境で動かすべく WASI(WebAssembly System Interface)に対応させたいと思います。

WASI とは

WASIはWebAssemblyをウェブ以外の場所(ブラウザやNode.js以外の環境)で動かせる様にする取り組みです。

WASMのコードを、いろいろなプラアットフォーム上で動かせる様にシステムコールに相当するAPIを標準化する試みです。様々なランタイムが実装されていて、CDNのエッジサーバーや組み込みデバイスで動かす試みもあります。

  • wasmtime ... Rustで作られた、リファレンス的なランタイム環境
  • lucet ... Fastlyが取り組んでいる、CDNエッジ上でWASMを実行することを目指したランタイム
  • WebAssembly Micro Runtime ... 組み込みでも使えることを目指した、軽量ランタイム(JITコンパイラーなし、インタープリターのみ)

WASIを使えば、将来的にCDN上や組み込みデバイス上でWASMを実行できるはずです。ワクワクしますね。

WASIで使える関数

WASIではOSを抽象化して、ファイルやネットワークなどの入出力にアクセスできるようになります。実際にサポートされるAPIはこちらにまとめられています。

これを見ると、「System Interface」と言うだけあってC言語のprintf()やputs()などは存在せず、よりプリミティブな関数がサポートされています。今回のミニWASMコンパイラーの組み込み関数putn()/puts()を実現するために、次の関数を利用することにします。

Hello, WASI

WASI の実行環境

今回はWASIの実行にwasmtimeを使います。ビルドにはRustとcargoが必要です。

wasmtimeのビルド
$ git clone --recurse-submodules https://github.com/bytecodealliance/wasmtime.git
$ cd wasmtime
$ cargo build --release
$ ./target/release/wasmtime --version
0.7.0

文字列の出力

さっそくWASIを使った文字列出力にチャンレンジしてみます。次のWATファイルを用意しました。

hello_wasi.wat
(module
    ;; -- WASIの fd_write()をインポイートするため宣言 -- 
    ;;    fd_write(File Descriptor, *iovs, iovs_len, nwritten)
    ;;      -> Returns number of bytes written
    (import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))

    (memory 1)
    (export "memory" (memory 0))
    (data (i32.const 16) "Hello WASI\n") ;; 'Hello WASI\n' をメモリ上に確保 (offset 16 bytes, length 11 bytes)

    ;; -- メイン関数は _start() としてエクスポート --
    (func $main (export "_start")
        ;; iov (バッファーのアドレスと、長さのセット)をメモリ上に用意
        (i32.store (i32.const 0) (i32.const 16))  ;; バアッファーの先頭アドレス(=offset)
        (i32.store (i32.const 4) (i32.const 11))  ;; バッファーの長さ

        (call $fd_write
            (i32.const 1) ;; ファイルでスクリプタ - 1:stdout
            (i32.const 0) ;; iovのセットへのアドレス
            (i32.const 1) ;; iovのセットの長さ - [buffer, length]のセットの数
            (i32.const 8) ;; *nwritten - 出力されたバイト数を受け取るポインター
        )
        drop ;; 戻り値として出力されたバイト数が帰ってきているので、それを破棄
    )
)
  • fd_write()関数をインポート
  • エントリーポイントとなるメイン関数を _start() という名前でエクスポート
  • 出力する文字列をメモリ上に確保
  • 「文字列バッファーの先頭アドレスと、その長さ」のセットをメモリ上に確保 ... iov
  • 先のiovのアドレスと、そのセット数を指定して、fd_write()を呼び出す

実行結果はこちら

$ wasmtime hello_wasi.wat
Hello WASI

無事出力されました。

WASI対応コンパイラー

これまで作った Node.js-WASMコンパイラー mininode_wasm_08.jsを、WASI向けに改造します。

WASI関数のインポート

いままでは呼び出し側(Node.js)でputn(), puts()の実体を用意したものをWASM内部でインポートしていました。その代わりにWASIのランタイムからfd_write()をインポートします。

// ---- compile simplified tree into WAT ---
function compile(tree, gctx, lctx) {
  // ... 省略 ...

  let block = '(module' + LF();
  // -- builtin func (imports) --
  block = block + TAB() + ';; ---- builtin func imports ---' + LF();

  // --- normal WASM ---
  //block = block + TAB() + '(func $putn (import "imports" "imported_putn") (param i32))' + LF();
  //block = block + TAB() + '(func $puts (import "imports" "imported_puts") (param i32))' + LF();

  // --- WASI ---
  block = block + TAB() + '(import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))' + LF();

  // ... 省略 ...
}

メイン関数の宣言

メイン関数のエクスポート宣言部分も_start()に変更します。

// ---- compile simplified tree into WAT ---
function compile(tree, gctx, lctx) {
  // ... 省略 ...

  block = block + TAB() + ';; ---- export main function  ---' + LF();

  // --- normal WASM ---
  //block = block + TAB() + '(export "exported_main" (func $main))' + LF();

  // --- WASI ---
  block = block + TAB() + '(export "_start" (func $main))' + LF();

  // ... 省略 ...


}

整数の出力 putn()

インポートしたfd_write()を内部で呼び出して、符号付32ビット整数を表示するputn()関数を作ります。あらかじめWATで記述した別ファイルを用意した関数を用意しておき、コンパイラでWATを生成する際に連結する方式ににします。

putn() は内部で次の処理を行います。

  • (1) 整数値を文字列で表現した時の桁数を算出する
    • この時、マイナス値の場合はマイナス記号分もカウントする
  • (2) 整数値がマイナスの場合は絶対値をとる
  • (3) 一桁ずつ取り出し、1文字のASCIIキャラクターに変換、メモリー上に格納する
    • 例) 1 --> 49 (0x31)
  • (4) 最後に改行文字(`\n')を入れる、マイナス値だったら先頭にマイナス記号を格納する
  • (5) fd_write()を呼び出すためのパラメーターをメモリー領域に準備する
    • パラメーターは、あらかじめメモリー上の決まった位置にダミーの値で確保
    • 毎回値を書き換えて使う
  • (6) fd_write()を呼びだす

これを全てWATで手書きするのはなかなか骨が折れます。そこで、一部をJSで記述して、前回までのコンパイラーを使ってWATを生成することにしました。直接メモリーをいじったり関数を呼び出すところはコンパイラーでサポートしていないので、手書きすることになります。

整数を文字列に変換する部分を _convI32ToString() として抜き出してJSで実装します。担当する処理は上記(1)~(3)の範囲です。

_convI32ToString()
function _convI32ToString(n) {
  let restValue = n;
  let isMinus = 0;
  let dummy;
  if (_isMinus(n)) {
    restValue = -n;
    isMinus = 1;
    dummy = _storeChar(0, 45); // minus mark '-'
  }

  let len = _calcLength(restValue);
  let idx = len - 1;
  let digitChar = 0;
  while (idx >= 0) {
    digitChar = _getOneDigit(restValue);
    _storeChar(idx + isMinus, digitChar);

    restValue = _div10(restValue);
    idx = idx - 1;
  }

  return len + isMinus;
}

実際にはさらに次の内部関数を呼び処理を行っています。
- _calcLength() ... 整数が文字列にした場合に何桁になるかを算出
- _getOneDigit() ... 整数の一の位をASCIIコードに変換
- _div10() ... 整数を1/10にする
- _isMinus() ... 整数がマイナス値かどうかを判定
- _storeChar() ... 1文字分をメモリーに格納するダミー
- 実際にはWASMのメモリーに値を格納する処理に後で置き換える

// calc char length of int32
//  NOT support minus
function _calcLength(n) {
  let restValue = n;
  let len = 1;
  while (restValue >= 10) {
    restValue = restValue / 10;
    len = len + 1;
  }

  return len;
}

// get 1 digit char code
function _getOneDigit(n) {
  const r = n % 10;
  const c = 48 + r;  // '0' + r
  return c;
}

// div 10
function _div10(n) {
  const d = n / 10; // calc as int
  return d;
}

// --- for node direct ---
//let _strBuf = '....................';

function _storeChar(idx, charCode) {
  puts(' _storeChar() called. idx, charCode bellow');
  putn(idx);
  putn(charCode);

  /* --- for Node.js direct --- */
  //let ch = String.fromCharCode(charCode);
  //_strBuf = _strBuf.slice(0, idx) + ch + _strBuf.slice(idx + 1);

  return 0;
}

function _isMinus(n) {
  if (n < 0) {
    return 1;
  }

  return 0;
}

前回のWASMコンパイラー mininode_wasm_08.jsでコンパイルした結果の抜粋はこちらです。実際にはこれを手で修正して利用しています。

watの抜粋
 (func $_calcLength (param $n i32) (result i32)
    (local $restValue i32)
    (local $len i32)
    get_local $n
    set_local $restValue

    i32.const 1
    set_local $len

    loop ;; --begin of while loop--
      get_local $restValue
      i32.const 10
      i32.ge_s
      if
        get_local $restValue
        i32.const 10
        i32.div_s

        set_local $restValue

        get_local $len
        i32.const 1
        i32.add

        set_local $len

        br 1 ;; --jump to head of while loop--
      end ;; end of if-then
    end ;; --end of while loop--
    get_local $len
    return

    i32.const 88
    return
  )

  (func $_div10 (param $n i32) (result i32)
    (local $d i32)
    get_local $n
    i32.const 10
    i32.div_s

    set_local $d

    get_local $d
    return

    i32.const 88
    return
  )

  (func $_convI32ToString (param $n i32) (result i32)
    (local $restValue i32)
    (local $isMinus i32)
    (local $dummy i32)
    (local $len i32)
    (local $idx i32)
    (local $digitChar i32)
    get_local $n
    set_local $restValue

    i32.const 0
    set_local $isMinus

    get_local $n
    call $_isMinus

    if
      i32.const 0
      get_local $n
      i32.sub

      set_local $restValue

      i32.const 1
      set_local $isMinus

      i32.const 0
      i32.const 45
      call $_storeChar

      set_local $dummy

    end

    get_local $restValue
    call $_calcLength

    set_local $len

    get_local $len
    i32.const 1
    i32.sub

    set_local $idx

    i32.const 0
    set_local $digitChar

    loop ;; --begin of while loop--
      get_local $idx
      i32.const 0
      i32.ge_s
      if
        get_local $restValue
        call $_getOneDigit

        set_local $digitChar

        get_local $idx
        get_local $isMinus
        i32.add

        get_local $digitChar
        call $_storeChar

        get_local $restValue
        call $_div10

        set_local $restValue

        get_local $idx
        i32.const 1
        i32.sub

        set_local $idx

        br 1 ;; --jump to head of while loop--
      end ;; end of if-then
    end ;; --end of while loop--
    get_local $len
    get_local $isMinus
    i32.add

    return

  )

この生成した関数を使って、putn()を実現します。

putn()
  (func $putn(param $n i32)
    (local $strLen i32)
    get_local $n
    call $_convI32ToString ;; ret=Lenght
    set_local $strLen

    ;; write tail LF
    i32.const 12 ;; head of string buffer
    get_local $strLen
    i32.add
    i32.const 10 ;; LF
    i32.store8 

    ;; +1 length for tail LF
    get_local $strLen
    i32.const 1
    i32.add
    set_local $strLen

    ;; iov.iov_base 
    i32.const 4
    i32.const 12
    i32.store

    ;; iov.iov_len
    i32.const 8
    get_local $strLen
    i32.store

    ;; $fd_write
    i32.const 1 ;; file_descriptor - 1 for stdout
    i32.const 4 ;; *iovs - The pointer to the iov array, which is stored at memory location 0
    i32.const 1 ;; iovs_len - We're printing 1 string stored in an iov - so one.
    i32.const 0 ;; nwritten - A place in memory to store the number of bytes writen
    call $fd_write

    drop ;; Discard the number of bytes written from the top the stack
  )

文字列の出力 puts()

同様に、固定文字列を表示するputs()関数も作ります。puts() は内部で次の処理を行います。

  • (1) 出力する文字列のアドレスを受け取る
  • (2) 別のメモリー領域に文字列をコピーする
  • (3) 最後に改行文字(`\n')を入れる
  • (4) fd_write()を呼び出すためのパラメーターをメモリー領域に準備する
    • パラメーターは、あらかじめメモリー上の決まった位置にダミーの値で確保
    • 毎回値を書き換えて使う
  • (5) fd_write()を呼びだす

これを全てWATで手書きするのはなかなか骨が折れます。そこで、一部をJSで記述して、前回までのコンパイラーを使ってWATを生成することにしました。直接メモリーをいじったり関数を呼び出すところはコンパイラーでサポートしていないので、手書きすることになります。

今回のputs()の例では「(2)別のメモリー領域に文字列をコピーする」部分をJSファイルで書いてからコンパイラーで生成したものを参考にし、残りは手書きで作りました。

puts()
  (func $puts (param $n i32)
    (local $srcIdx i32)
    (local $destIdx i32)
    (local $len i32)
    (local $c i32)
    get_local $n
    set_local $srcIdx

    i32.const 0
    set_local $destIdx

    i32.const 0
    set_local $len

    get_local $srcIdx
    call $_loadChar

    set_local $c

    loop ;; --begin of while loop--
      get_local $c      
      if
        get_local $destIdx
        get_local $c
        call $_storeChar

        get_local $len
        i32.const 1
        i32.add

        set_local $len

        get_local $srcIdx
        i32.const 1
        i32.add

        set_local $srcIdx

        get_local $destIdx
        i32.const 1
        i32.add

        set_local $destIdx

        get_local $srcIdx
        call $_loadChar
        set_local $c

        ;; check lenght 255
        get_local $destIdx
        i32.const 255
        i32.lt_s
        br_if 1

        ;; br 1 ;; --jump to head of while loop--
      end ;; end of if-then
    end ;; --end of while loop--

    ;;get_local $len
    ;;call $putn

    ;; tail LF
    get_local $destIdx
    i32.const 10 ;; LF
    call $_storeChar

    get_local $len
    i32.const 1
    i32.add
    set_local $len

    ;; iov.iov_base 
    i32.const 4
    i32.const 12
    i32.store

    ;; iov.iov_len
    i32.const 8
    get_local $len
    i32.store


    ;; $fd_write
    i32.const 1 ;; file_descriptor - 1 for stdout
    i32.const 4 ;; *iovs - The pointer to the iov array, which is stored at memory location 0
    i32.const 1 ;; iovs_len - We're printing 1 string stored in an iov - so one.
    i32.const 0 ;; nwritten - A place in memory to store the number of bytes writen
    call $fd_write

    drop ;; Discard the number of bytes written from the top the stack 
  )

WASI対応コンパイラーの拡張

テンプレートの用意

用意したビルトイン関数putn(), puts()はこちらの別ファイルに保存しておき、コンパイラーで読み込んで使います。

テンプレート読み込みモジュール

今回のミニNode.js-WASMコンパイラーでは、最初に作っていた「ミニインタープリター」で動かす、という縛りを設けています。ミニインタープリターではファイルの読み書きを直接はサポートしておらず、外部モジュールとして準備しています。なので今回のテンプレートファイルも外部モジュールを用意してそちらで読み込みます。


// -------------------------
// module_wasibuiltin.js - WASM builtin for WASI
// - puts()
// - putn()
// -------------------------

'use strict'

const fs = require('fs');
const println = require('./module_println.js');
const abort = require('./module_abort.js');
const printWarn = require('./module_printwarn.js');

const builtinTamplateFile = 'wasi_builtin_template.watx';

// === exports ===

// --- parser ----
module.exports = wasiBuiltin;

function wasiBuiltin() {
  const builtinFuncs = fs.readFileSync(builtinTamplateFile, 'utf-8');
  //println(builtinFuncs);
  return builtinFuncs;
}

fd_write()呼び出し用のパラメータ領域

fd_write()の呼び出しで使うパラメータをメモリ上に確保しておきます。

  • オフセット位置 0バイト目から、4バイト分 ... 実際に出力したバイト数を受け取るための領域
  • オフセット位置 4バイト目から、4バイト分 ... 出力するバイト列の組の最初のアドレスを格納する領域
  • オフセット位置 8バイト目から、4バイト分 ... 出力するバイト列の組の数
  • オフセット位置 12バイト目から、255バイト分 ... 出力するバイト列を格納する領域
function generateMemoryBlock() {
  let block = '';
  block = block + TAB() + '(memory 1)' + LF();
  block = block + TAB() + '(export "memory" (memory 0))' + LF();
  block = block + TAB() + '(data (i32.const 0) "\\00\\00\\00\\00") ;; placeholder for nwritten - A place in memory to store the number of bytes written' + LF();
  block = block + TAB() + '(data (i32.const 4) "\\00\\00\\00\\00") ;; placeholder for iov.iov_base (pointer to start of string)' + LF();
  block = block + TAB() + '(data (i32.const 8) "\\00\\00\\00\\00") ;; placeholder for iovs_len (length of string)' + LF();
  block = block + TAB() + '(data (i32.const 12) "hello world\\n")  ;; 4--> iov.iov_base = 12, 4--> iov_len = 8, 12-->"hello ...":len=13' + LF();

  return block;
}

この領域をputn(), puts()で利用しています。

テンプレートの連結

コンパイラーでWATファイルを生成する際に、ユーザ定義関数に引き続きテンプレートとして用意しておいたputn(), puts()のWATコードを連結して出力します。

function compile(tree, gctx, lctx) {
  // ... 省略 ...

  // ---- global user_defined functions ---
  block = block + generateGlobalFunctions(gctx);

  // ---- builtin function for wasi ---
  block = block + wasiBuiltin();

  // --- close all ---
  block = block + ')';

  return block;
}

WASI向けのコンパイル&実行

今回作ったコンパイラーはこちらです。

これを使って、これまでのサンプルをコンパイル、wasmtimeを使って実行してみましょう。(wasmtimeはテキスト形式の.wat、バイナリ形式の.wasmの両方を実行することができます)

FizzBuffの例

$ node mininode_wasm_wasi.js sample/fizzbuzz_func.js
$ wasmtime generated.wat
1
2
Fizz
4
Buzz
Fizz
7
... 省略 ...
94
Buzz
Fizz
97
98
Fizz
Buzz
$

WASIランタイム上で、無事FizzBuzzを実行できました!

ここまでのソース

GitHubにソースを上げておきます。