ローファイ日記

出てくるコード片、ぼくが書いたものは断りがない場合 MIT License としています http://udzura.mit-license.org/

mruby/edge でimport/exportする関数における、文字列の仕様の話

はじめに

  • この文字列の扱いの仕様はアルファ版であり、もっといいアイデアが出たら大胆に変更します
  • 何もわかっていないんですが、 WASM Component Model では string の扱いも言及されており 、それに合わせたいい感じのやつにきっと将来なるでしょう
  • ご意見ご感想を歓迎します。特に、私はRubyKaigiに出現するので、そこでリアタイでコミュニケーションとれると飛び跳ねて喜びます。

mec v0.3.0 より、mruby/edgeでimportするJavaScript(など)の関数、あるいはexportするRuby側のトップレベルメソッドの引数や戻り値で、Stringを扱えるようにした。

ここで、WebAssembly(WASM)においては現状文字列を扱うのはトリックが必要なので、その辺踏まえた仕様と使い方をまとめておきたい。

English version will show up on RubyKaigi...

全体の仕様 0.1.0-alpha1

im/ex RBS def defined sig in Rust 備考
import def foo(bar: String) fn foo(barptr: const *u8, barlen: usize) -
import def foo() -> String fn foo() -> const *u8 JS側で __set__foo_size(u32) も呼んで更新しないといけない。セットしていない場合、mruby/edgeの内部で \0 終端まで取得する
export def foo(bar: String) fn foo(barptr: const *u8, barlen: usize) -
export def foo() => String fn foo() -> const *u8 __get__foo_size() -> u32 という関数も同時に生成され、戻り値のサイズがわかる。また、mruby/edgeの内部で強制的に \0 終端される

また、実験的に __mrbe_grow(num_pages: usize) -> *const u8 をexportするようにしている。

(JS側で Memory.prototype.grow() を呼んでもOK、同等のことをしています)

使う場合

exportから説明すると、 foo(str) は例えばこうやって呼び出す:

// def foo: (String) -> void
WebAssembly.instantiateStreaming(fetch("./sample.wasm"), importObject).then(
    (obj) => {
        window.mywasm = obj.instance;
        let message = "The String is from Javascript!";
        let length = message.length;
        let offset = window.mywasm.exports.__mrbe_grow(Math.ceil(length+1/65536));
        offset = offset >>> 0; // 符号を剥がしているらしい

        let buffer = new Uint8Array(window.mywasm.exports.memory.buffer, offset, length);
        for( var i = 0; i < length; i++ ) {
            buffer[i] = message.charCodeAt(i);
        }

        window.mywasm.exports.foo(offset, length);
    },
);

exportで文字列を戻す場合は、メモリ上の位置だけが返ってくる。このとき __get__foo_size() もexportされているので、こうすることができる:

// def foo: () -> String
WebAssembly.instantiateStreaming(fetch("./sample.wasm"), importObject).then(
    (obj) => {
        window.mywasm = obj.instance;
        let offset2 = window.mywasm.exports.foo();
        // foo() を呼び出したら文字列のサイズが更新される
        let length2 = window.mywasm.exports.__get__foo_size();
        let buffer2 = new Uint8Array(window.mywasm.exports.memory.buffer, offset2, length2);
        console.log(String.fromCharCode.apply(null, buffer2));
    },
);

また、 __get__foo_size() を使わなくとも、必ず \0 で終端されているので、 \0 に突き当たるまでメモリを読み出すでもOK。

importの場合は、Rubyの内部では以下のように普通の文字列のように呼び出せばOKで、

# def bar: (String) -> void
message = "Hello mruby/edge"
bar(message)

ただしJavaScriptの側では (offset, length) に変換されて渡るため、あらためてWASMインスタンスから文字列を取り出すことで取り扱える。

function bar(offset, length) {
    // wasmのインスタンスを参照できる必要がある
    let instance = window.mywasm;
    let buffer = new Uint8Array(instance.exports.memory.buffer, offset, length);
    console.log(String.fromCharCode.apply(null, buffer));
}

importする関数の戻り値として文字列を指定することもできるが、その場合、あらかじめWASMのメモリに文字列を書き込む必要がある。このとき、文字列は \0 で終端するか、明示的に __set__bar_size(usize) を呼び出して文字列のサイズをWASMに教える必要がある。

その上で offset だけを返却する。

// def bar: () -> String
function bar() {
    let message = "Javascript Message";
    let instance = window.mywasm;
    let length = message.length;
    let offset = instance.exports.__mrbe_grow(Math.ceil(length+1/65536));
    offset = offset >>> 0;
    let buffer = new Uint8Array(instance.exports.memory.buffer, offset, length+1);

    for( var i = 0; i < length; i++ ) {
        buffer[i] = message.charCodeAt(i);
    }
    instance.exports.__set__putstring_size(length);
    // buffer[length] = 0; でもOK
    return offset;
}

なお、どの場合であっても、Rubyスクリプトから見た場合は単にStringを受け取るあるいはStringを返すメソッドとして扱えば良い。

つまりどういうことだってばよ

ポイントは:

  • 大前提、文字列はメモリ上の u8 の配列である(ユニコード等の話は一旦おいておくよ)
  • JavaScript上のメモリとWASMのメモリは直接共有できない
  • したがって、JavaScript側の文字列の先頭ポインタをWASMに渡しても認識できないし、反対にWASMでの文字列の先頭のポインタをJavaScriptに渡してもそのままでは取り扱えない
  • 一方、WASMのメモリは、JavaScript側から専用(あるいは汎用)のAPIで操作が可能である

したがって具体的には:

  • JavaScript側→WASMに文字列を渡すときは、
    • 1) WASM側でメモリを拡張し、文字列の領域を確保する
    • 2) JavaScript側で、WASMで確保したメモリ上に外からバイトを書き込む
    • 3) その確保したメモリの先頭と、文字列の長さを渡す
  • WASM側→JavaScriptに文字列を渡すときは、
    • 1) WASMメモリ上の文字列の先頭と、文字列の長さをJavaScriptに渡す
    • 2) JavaScript側で、WASMメモリを参照し、先頭位置と長さからバイト列(Uint8Array)を取り出す
    • 3) Uint8Arrayを文字列に変換する

上記のようなことをしている。

当たり前だが、こんな操作はめんどくさくてアプリケーションエンジニアはしたくないに決まっている。ということで、emscriptenにせよwasm-bindgenにせよ、malloc()等のヘルパーを用意したり、この辺の操作をうまくラップしたコードを自動生成してくれるようになっている。で、使う場合それを経由するような設計になっている。

ruby.wasmにも同等の仕組みがあるのだろうと思っている(今度読みますね)。

だが、mruby/edgeはもう少し低レイヤに位置付けされるべきランタイムであろうとしており、具体的にはmruby/edgeはJavaScriptはのラッパーやヘルパーをなるべく用意しない、それらは別のフレームワークの仕事、という設計思想なので、このような仕様にしている。もちろん私自身でもう一段階抽象化した何かを作る気持ちはなくはないが、だいぶ先になりそう。

一方で、ラッパーやフレームワークを作っていただくにせよ、もうちょっといい仕様がある可能性もある。ということで、この辺の仕様はあくまでアルファ版という扱いである。


ちなみに、筆者は正直な話そもそもJavaScriptが苦手であるのと、WebAssemblyに関しても未だ素人に近い状態のため、識者による各種提案やツッコミを色々と聞いてみたいという気持ちが強い。Xでもどこでもご意見ご感想ください。特に、5月15日からのRubyKaigiで捕まえいていただけるとめちゃくちゃ嬉しいです。コミュニティに入りたい!