ぶていのログでぶログ

思い出したが吉日

mrubyを使ってWASMバイナリを作る

Zigで簡単クロスコンパイル 2022を読んでZigを使えばクロスコンパイルできることを知る -> Zigを使えばWASMバイナリが作れる ->つまりmrubyでWASMバイナリが作れるのでは?っと思いついたのでやってみたら、条件付きでWASMバイナリにクロスコンパイルできたのでメモしておく。

WASMバイナリのビルドの手順

まず冒頭に書いた条件付きとなっている理由に着いて説明する。 詳しくは後述するのだがWASMにクロスコンパイルするためにはWASMのexception handling機能を使う必要がある。 この機能は2023/02/20時点ではすべてのWASMランタイムで動くわけではないため、今回はこの機能に対応したNode.jsをランタイムとして使用する。

また、exception handling機能に絡んでもう1つ制約がある。 ここで紹介する手順ではRubyのraise〜catchが正しく動作しない。 これは私の調査不足なのだが、これ以上調べてもどう解決すればよいかわからなかったためそのままにしている。

そしてクリティカルではないが純粋なWASMではなくWASIも利用している。 これは単にそうしたほうがstdio.hのエラーなどの取り除くのが簡単だったのと、WASMだけでビルドする方法をイマイチ理解できてなかっただけである…。

これらの理由があるため条件付きとしている。

環境

  • zig: 0.10.1
    • 内蔵clang: 15.0.7
  • mruby: 3.1
  • Node.js: v19.6.1

ファイルの構成

今回は以下のファイル構成でビルドした。各ファイルやフォルダについては以降で説明する。

❯ ls --tree --icon=never
.
├── build_config.rb
├── build_config.rb.lock
├── mrbgem.rake
├── mrblib/
│   └── mruby-wasm.rb
├── mruby/
├── mruby-wasm.js
├── Rakefile
└── tools/
    └── mruby-wasm/
        └── mruby-wasm.c

mrubyディレクトリ

mrubyのビルドに必要なソースコードを配置している。ここでは簡単のために同じディレクトリに配置した。 https://github.com/mruby/mruby からgit cloneするかソースコードをDLして展開しておく。

Rakefile

本プロジェクトをビルドするのに用意したが、この程度の規模なら不要だったかも。

mruby_root = './mruby/mruby'
mruby_config = File.expand_path(ENV['MRUBY_CONFIG'] || 'build_config.rb')
ENV['MRUBY_ROOT'] = mruby_root
ENV['MRUBY_CONFIG'] = mruby_config

Rake::Task[:mruby].invoke unless Dir.exist?(mruby_root)

Dir.chdir(mruby_root)
load "#{mruby_root}/Rakefile"

build_config.rb

このファイルが今回のキモになっている。

MRuby::CrossBuild.new('wasm32-wasi') do |conf|
  conf.gem File.expand_path(File.dirname(__FILE__))
  conf.gem core: 'mruby-print'

  conf.enable_cxx_exception
  [conf.cc, conf.cxx, conf.linker].each do |cc|
    cc.command = 'zig'
    cc.flags += %w(
      c++ -target wasm32-wasi -O2 -fwasm-exceptions
      -Xclang -target-feature -Xclang +exception-handling -Xclang -exception-model=wasm
      -mllvm -wasm-enable-eh
    )
  end

  conf.archiver.command = 'zig ar'
end

重要なポイントを説明する。 まず conf.enable_cxx_exception を指定し、exception handling機能を利用するように変更する。 これについて詳細は後述する。

次にcc,cxx,linkerには zig c++を指定する。 そして、WASMにビルドするために必要なオプションを指定する。 各オプションの説明は以下の通り

  • -target wasm32-wasi
    • wasm32-wasi向けにビルドを行う
  • -O2
    • コードの最適化を行う。これを入れないとlocals exceed maximumが起こる(後述)
    • -O1 でもよい
  • -fwasm-exceptions -Xclang -target-feature -Xclang +exception-handling -Xclang -exception-model=wasm -mllvm -wasm-enable-eh
    • WASMのexception handling機能を有効にするためのオプション郡
    • -Xclangは次に指定したオプションをclangに渡すことを意味している
    • -mllvmは次に指定したオプションをllvmに渡すことを意味している

最後にarchiverにzig arを指定する。

ここで、追加のgemとしてconf.gembox 'default' を追加したくなるがWASMではビルドできないので(後述)追加してはいけない。 しかしそれだと動作確認がしづらいのでmruby-printを追加してputsを使えるようにしておく。

mrbgem.rake

WASMバイナリを生成したいので、このプロジェクトをバイナリを生成するmrbgemとして定義する。 なお、ruby.wasmに合わせてmruby.wasmのようにドットを名前に含めるとビルド時にsyntax errorになってハマるので注意が必要…。

MRuby::Gem::Specification.new('mruby-wasm') do |spec|
  spec.license = 'MIT'
  spec.author = 'buty4649@gmail.com'
  spec.summary = 'mruby on wasm'
  spec.bins = ['mruby-wasm']
end

tools/mruby-wasm/mruby-wasm.c

WASMのエントリーポイント(main関数)を定義する。 今回はシンプルにmrb_openしてmrblib/mruby-wasm.rbで定義した__main__関数を呼び出している。

#include <stdlib.h>
#include <stdio.h>
#include <mruby.h>

int main()
{
    mrb_state *mrb = mrb_open();
    int i;
    int return_value;

    mrb_funcall(mrb, mrb_top_self(mrb), "__main__", 0);

    return_value = EXIT_SUCCESS;
    if (mrb->exc)
    {
        mrb_print_error(mrb);
        return_value = EXIT_FAILURE;
    }

    mrb_close(mrb);

    return return_value;
}

mrblib/mruby-wasm.rb

main関数から呼び出されるRubyのコードを書く。 ここではhello worldとmrubyのバージョンを出力する。

def __main__
  puts "hello world(RUBY_VERSION:#{RUBY_VERSION})"
end

mruby-wasm.js

生成したWASMバイナリを実行するためのスクリプト。 https://nodejs.org/api/wasi.html を見ながらコピペ。 exception handling機能の関係でimportObject周りを変更しているが詳しくは後述。

const fs = require('fs');
const { WASI } = require('wasi');
const { env } = require('process');

const wasi = new WASI();

const importObject = {
  env: {
    _Unwind_CallPersonality: function () { },
    __cxa_throw: function () { },
    __cxa_begin_catch: function () { },
    __cxa_allocate_exception: function () { },
    __cxa_end_catch: function () { },
  },
  wasi_snapshot_preview1: wasi.wasiImport,
};
(async () => {
  const wasm = await WebAssembly.compile(
    fs.readFileSync('./build/wasm32-wasi/bin/mruby-wasm'),
  );
  const instance = await WebAssembly.instantiate(wasm, importObject);

  wasi.start(instance);
})();

実行

用意ができたらmrubyをビルドしてwasmを生成する。

❯ rake
-- snip --

Build summary:

================================================
      Config Name: wasm32-wasi
 Output Directory: build/wasm32-wasi
    Included Gems:
             mruby-print - standard print/puts/p
             mruby-wasm - mruby on wasm
               - Binaries: mruby-wasm
================================================

================================================
      Config Name: host
 Output Directory: build/host
         Binaries: mrbc
    Included Gems:
             mruby-bin-mrbc - mruby compiler executable
             mruby-compiler - mruby compiler library
================================================

生成されたバイナリはWASMバイナリになっているはず。

❯ file build/wasm32-wasi/bin/mruby-wasm
build/wasm32-wasi/bin/mruby-wasm: WebAssembly (wasm) binary module version 0x1 (MVP)

WASMバイナリが生成されたらmruby-wasm.jsを実行する。 ただし、ここで注意が必要なのはNode.jsではWASIはexperimental扱いのため --experimental-wasi-unstable-preview1 をつける必要がある。 これをつけないとWASIモジュールが存在しないと言われてしまうので必ずつけるようにする。 すると以下のようにmrubyで生成したWASMバイナリが実行されるはず。

❯ node --experimental-wasi-unstable-preview1 mruby-wasm.js
(node:369204) ExperimentalWarning: WASI is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
hello world(RUBY_VERSION:3.1)

setjmp/longjmpをどうするか問題

CRuby/mrubyをWASM化するときに一番大変な部分はsetjmp/longjmpをどうするかだと思う。 先人たちもそこに苦労されていたのがうかがえる(参考1, 参考2)。 最初この罠にいきなりハマってどうしようかとなっていた。 ↓はbits/setjmp.hがwasm32-wasi環境で提供されていなくてコンパイルエラーになっている図。

CPP   src/error.c -> build/wasm32-wasi/src/error.pi
In file included from /home/buty4649/src/github.com/mruby/mruby/src/error.c:17:
In file included from /home/buty4649/src/github.com/mruby/mruby/include/mruby/throw.h:33:
In file included from /home/buty4649/.local/share/rtx/installs/zig/0.10.1/lib/libcxx/include/setjmp.h:34:
/home/buty4649/.local/share/rtx/installs/zig/0.10.1/lib/libc/include/generic-musl/setjmp.h:10:10: fatal error: 'bits/setjmp.h' file not found
#include <bits/setjmp.h>
         ^~~~~~~~~~~~~~~
1 error generated.
rake aborted!
Command failed with status (1): [zig c++ -target wasm32-wasi -O1 -fwasm-exc...]

しかし、色々調べていくうちにWASMにexception handlingが搭載され始めているということを知った。 exception handlingはC++のthrow〜catchに対応していてsetjmp/longjmpではないのだが、幸運なことにmrubyではconf.enable_cxx_exceptionを設定することでsetjmp/longjmpからC++のthrow〜catchに変更することができたために解決にいたった。

今回はNode.jsを使用したが主要なブラウザはすでに対応していて、残りはwasmerやwasmtimeくらいが残っているような状況のようだ。そのため近いうちに標準機能としてサポートされるのではないかなっと思っている。 https://webassembly.org/roadmap/ ↑2023/02/20の状況。

__cxa_throwなどの関数をimportしないといけない問題

mruby-wasm.jsでは詳しく説明しなかったのだが、以下のexceptionに関する関数をNode.js側でマッピングしないとWASMバイナリの実行時にエラーになってしまう。

  • _Unwind_CallPersonality
  • __cxa_allocate_exception
  • __cxa_begin_catch
  • __cxa_end_catch
  • __cxa_throw

本来であればビルド時にWASMバイナリ内に定義されると思われるのだがどうもそうなっていないようだ。 これらの関数はC++のABIに定義されているっぽく、libc++abiをリンクするようにlinkerのオプションに追加したのだがうまくいかなかった。 色々調べたがどうするのがいいのかわからなくて今回は空の関数にしてしまった。 そのため、mruby内でraiseするとそのままWASMの実行エラーになってしまい意図しない動作になっている。

❯ node --experimental-wasi-unstable-preview1 mruby-wasm.js
(node:371569) ExperimentalWarning: WASI is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
wasm://wasm/004892f6:1


RuntimeError: unreachable
    at exc_throw(mrb_state*, mrb_value) (wasm://wasm/004892f6:wasm-function[157]:0xeb58)
    at mrb_exc_raise (wasm://wasm/004892f6:wasm-function[156]:0xeaf3)
    at mrb_f_raise (wasm://wasm/004892f6:wasm-function[184]:0xfaca)
    at mrb_vm_exec (wasm://wasm/004892f6:wasm-function[679]:0x3b751)
    at mrb_vm_run (wasm://wasm/004892f6:wasm-function[670]:0x2de16)
    at mrb_funcall_with_block (wasm://wasm/004892f6:wasm-function[664]:0x2d74a)
    at mrb_funcall_with_block (wasm://wasm/004892f6:wasm-function[664]:0x2d1ae)
    at mrb_funcall (wasm://wasm/004892f6:wasm-function[663]:0x2d100)
    at __original_main (wasm://wasm/004892f6:wasm-function[47]:0x4b89)
    at _start (wasm://wasm/004892f6:wasm-function[10]:0x74f)

この問題もそのうち時間が解決してくれるのだろうと期待している。

その他:ハマったところ

Validation error: locals exceed maximum

初めてビルドが通ってこれに遭遇して心が折れかかった。。 このときはまだwasmerで実行していたのだが、ほとんど情報を出さないためにデバッグのしようがなくて辛かった。。。

❯ wasmer build/wasm32-wasi/bin/mruby-wasm
error: failed to run `build/wasm32-wasi/bin/mruby-wasm`
│   1: module instantiation failed (compiler: cranelift)
╰─▶ 2: Validation error: locals exceed maximum (at offset 1472023)

何気なしにwasmtimeで実行したところmrb_vm_exec関数に問題があることがわかった(ログ消失)。 そこでwasm2wat(WASMバイナリをWASMテキストに変化するツール)を使ってmrb_vm_exec関数の定義をみてみると、エラーの内容が一瞬で理解できた。

❯ wasm2wat build/wasm32-wasi/bin/mruby-wasm > mruby-wasm.wat
❯ cat mruby-wasm.wat | grep -A1 "func \$mrb_vm_exec"
  (func $mrb_vm_exec (type 0) (param i32 i32 i32) (result i32)
    (local i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 ...(長すぎるため省略)
❯ cat mruby-wasm.wat | grep -A1 "func \$mrb_vm_exec" | tail -1 | sed 's/ /\n/g' | wc -l
170584

なんと、ローカル変数が1.7万個ほど定義されていたのであった。それはエラーになりますね…。 LLVMの中間コードを見たりしてバグっていないかを確認していたが、結局のところ最適化オプション(-O2)をつけて解決した。 よかったよかった。

どういうエラーなのか全く理解できなくてこれも心が折れそうになったシリーズ。 どうもlibmruby.aをリンクしようとしてエラーになっているようだ。 実際にlibmruby.aを見てみるが、正常なように見える。

❯ file libmruby.a
libmruby.a: current ar archive

何もわからんとなっていたのでobjdumpしたところ原因がわかった

❯ objdump -a libmruby.a
-- snip --
objdump: dump.o: file format not recognized
objdump: enum.o: file format not recognized

error-cxx.o:     file format elf64-x86-64
rw-r--r-- 0/0  28168 Jan  1 09:00 1970 error-cxx.o

objdump: etc.o: file format not recognized
objdump: fmt_fp.o: file format not recognized

gc-cxx.o:     file format elf64-x86-64
rw-r--r-- 0/0  29872 Jan  1 09:00 1970 gc-cxx.o

objdump: hash.o: file format not recognized
objdump: init.o: file format not recognized
-- snip --

いくつかのファイルだけWASMではなくelf64-x86-64でビルドされていたのであった。 このファイル群はC++なファイルになっていて、zig c++ではなくホストのgccでビルドされていたためにWASMになっていないのが原因であった。 C++なファイルもzig c++でビルドするようにすればいいのでbuild_config.rbにconf.cxx.commandの定義を追加して解決した。

mrubyをビルドするのにZigが必要なのか問題

実はZigでC/C++のソースをビルドするときにはLLVMが使われている。 そのため、今回行った作業はそのままLLVMに置き換えることが可能だと思われる。 しかしながら、ZigはLLVMのみならずmusl-libcを同梱していて別途用意する必要がない点がメリットだと思う。 また、冒頭の記事にもあるようにZigをフロントエンドとしてクロスコンパイルができるのもメリットだと思う。

おまけ: デフォルトmrbgemのビルド可能状況

WASMへのクロスコンパイルが可能なデフォルトmrbgemを調べた。 なおバイナリ関連(mruby-bin-**)は除いてある。

mrbgem gembox build?
mruby-array-ext stdlib OK
mruby-class-ext stdlib OK
mruby-compar-ext stdlib OK
mruby-enum-ext stdlib OK
mruby-enum-lazy stdlib OK
mruby-enumerator stdlib OK
mruby-fiber stdlib OK
mruby-hash-ext stdlib OK
mruby-kernel-ext stdlib OK
mruby-numeric-ext stdlib OK
mruby-object-ext stdlib OK
mruby-objectspace stdlib OK
mruby-proc-ext stdlib OK
mruby-range-ext stdlib OK
mruby-string-ext stdlib OK
mruby-symbol-ext stdlib OK
mruby-toplevel-ext stdlib OK
mruby-pack stdlib-ext OK
mruby-random stdlib-ext OK
mruby-sprintf stdlib-ext OK
mruby-struct stdlib-ext OK
mruby-time stdlib-ext OK
mruby-io stdlib-io NG
mruby-socket stdlib-io NG
mruby-print stdlib-io OK
mruby-complex math OK
mruby-math math OK
mruby-rational math OK
mruby-compiler metaprog OK
mruby-eval metaprog OK
mruby-metaprog metaprog OK
mruby-method metaprog OK
mruby-proc-ext metaprog OK

すべての動作を確認したわけではないが、少なくともIOとSocket関連以外はWASMへのクロスコンパイルが可能だった。

ruby.wasmとmrubyのWASMバイナリ化の違い

WASMでRubyを使う方法としてruby.wasmが思いつくだろう。 当ブログでも以前検証して記事にしている。 それを使えばmrubyをWASMバイナリにする必要はないのでは?っと思うかもしれないがこの2つは明確に違う。 ruby.wasmはCRubyをWASMの上に構築しているので、これ単体で動作させるというよりかはRubyのコードを入力させて動作させることが主な目的だろう。 一方mrubyをWSAMバイナリ化した場合、それ単体で動かすことを目的としているので欲しい機能に特化したWASMバイナリを用意することができる。

おわりに

条件付きとはいえmrubyからWASMバイナリが作れることがわかったのは大きな収穫だと思う。 本格的にWASMのexception handlingがサポートされれば実用性も上がるかなっと思うので期待。

参考: * https://k0kubun.hatenablog.com/entry/zig * https://techracho.bpsinc.jp/hachi8833/2018_08_22/60810 - ここで紹介されているmruby-wasmではsetjmp/localjmp問題をどうやって解決したんだろうか・・・気になる * https://eng-blog.iij.ad.jp/archives/10875 * https://emscripten.org/docs/porting/exceptions.html