7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

はじめに

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にソースを上げておきます。

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?