Rust で WASI 対応の WebAssembly を作成して実行
Rust で WASI 対応の WebAssembly を作って、スタンドアロン実行や Web ブラウザ上での実行を試してみました。
WASI(WebAssembly System Interface) は WebAssembly のコードを様々なプラットフォームで実行するためのインターフェースで、これに対応した WebAssembly であれば Web ブラウザ外で実行できます。
Rust で WASI 対応の WebAssembly を作るのは簡単で、ビルドターゲットに wasm32-wasi
を追加しておいて、rustc
や cargo build
によるビルド時に --target wasm32-wasi
を指定するだけでした。
wasm32-wasi の追加
> rustup target add wasm32-wasi
標準出力へ文字列を出力するだけの下記サンプルコードを --target wasm32-wasi
でビルドすると sample1.wasm
ファイルが作られました。
sample1.rs
fn main() { for i in 1..=3 { println!("count-{}", i); } print!("aaa"); print!("bbb"); }
WASI 対応の WebAssembly として sample1.rs をビルド
> rustc --target wasm32-wasi sample1.rs
なお、今回のビルドに使用した Rust のバージョンは以下の通りです。
- Rust 1.43.0
また、使用したソースコードは http://github.com/fits/try_samples/tree/master/blog/20200429/ に置いてあります。
(1) スタンドアロン用ランタイムで実行
sample1.wasm を WebAssembly のランタイム wasmtime と wasmer でそれぞれ実行してみます。
(1-a) wasmtime で実行
wasmtime v0.15.0 による sample1.wasm 実行結果
> wasmtime sample1.wasm count-1 count-2 count-3 aaabbb
(1-b) wasmer で実行
wasmer v0.16.2 による sample1.wasm 実行結果
> wasmer sample1.wasm count-1 count-2 count-3 aaabbb
どちらのランタイムでも問題なく実行できました。
(2) Web ブラウザ上で実行
次は、sample1.wasm を外部ライブラリ等を使わずに Web ブラウザ上で実行してみます。
主要な Web ブラウザや Node.js は JavaScript 用の WebAssembly API に対応済みのため、WebAssembly を実行可能です。
WASI 対応 WebAssembly の場合、実行対象の WebAssembly がインポートしている WASI の関数(の実装)を WebAssembly インスタンス化関数(WebAssembly.instantiate()
や WebAssembly.instantiateStreaming()
)の第二引数(引数名 importObject)として渡す必要があるようです。
(2-a) WebAssembly のインポート内容を確認
WebAssembly.compile()
関数で取得した WebAssembly.Module
オブジェクトを WebAssembly.Module.imports()
関数へ渡す事で、その WebAssembly がインポートしている内容を取得できます。
ここでは、以下の Node.js スクリプトを使って WebAssembly のインポート内容を確認してみました。
wasm_listup_imports.js (WebAssembly のインポート内容を出力)
const fs = require('fs') const wasmFile = process.argv[2] const run = async () => { const module = await WebAssembly.compile(fs.readFileSync(wasmFile)) const imports = WebAssembly.Module.imports(module) console.log(imports) } run().catch(err => console.error(err))
sample1.wasm へ適用してみると以下のような結果となりました。
インポート内容の出力結果(Node.js v12.16.2 で実行)
> node wasm_listup_imports.js sample1.wasm [ { module: 'wasi_snapshot_preview1', name: 'proc_exit', kind: 'function' }, { module: 'wasi_snapshot_preview1', name: 'fd_write', kind: 'function' }, { module: 'wasi_snapshot_preview1', name: 'fd_prestat_get', kind: 'function' }, { module: 'wasi_snapshot_preview1', name: 'fd_prestat_dir_name', kind: 'function' }, { module: 'wasi_snapshot_preview1', name: 'environ_sizes_get', kind: 'function' }, { module: 'wasi_snapshot_preview1', name: 'environ_get', kind: 'function' } ]
この結果から、sample1.wasm は以下のようにしてインスタンス化できる事になります。
WebAssembly インスタンス化の例
const importObject = { wasi_snapshot_preview1: { proc_exit: () => {・・・}, fd_write: () => {・・・}, fd_prestat_get: () => {・・・}, fd_prestat_dir_name: () => {・・・}, environ_sizes_get: () => {・・・}, environ_get: () => {・・・} } } WebAssembly.instantiate(・・・, importObject) ・・・
(2-b) fd_write 関数の実装
Rust の println!
で呼び出される WASI の関数は fd_write
なので、これを実装してみます。
fd_write の引数は 4つで、第一引数 fd
は出力先のファイルディスクリプタで標準出力の場合は 1、第二引数 iovs
は出力内容へのポインタ、第三引数 iovsLen
は出力内容の数、第四引数 nwritten
は出力済みのバイト数を設定するポインタとなっています。
なお、ポインタの対象は WebAssembly.instantiate()
で取得した WebAssembly のインスタンスに含まれている WebAssembly.Memory です。
出力内容は iovs ポインタの位置から 4バイト毎に以下のような並びで情報が格納されているようなので、これを基に出力対象の文字列を取得して出力する事になります。
- 1個目の出力内容の格納先ポインタ(4バイト)
- 1個目の出力内容のバイトサイズ(4バイト)
- ・・・
- iovsLen 個目の出力内容の格納先ポインタ(4バイト)
- iovsLen 個目の出力内容のバイトサイズ(4バイト)
何処まで処理を行ったか(出力したか)を返すために、nwritten ポインタの位置へ出力の完了したバイトサイズを設定します。
fd_write の実装例(wasmInstance には WebAssembly のインスタンスを設定)
・・・ fd_write: (fd, iovs, iovsLen, nwritten) => { const memory = wasmInstance.exports.memory.buffer const view = new DataView(memory) const sizeList = Array.from(Array(iovsLen), (v, i) => { const ptr = iovs + i * 8 // 出力内容の格納先のポインタ取得 const bufStart = view.getUint32(ptr, true) // 出力内容のバイトサイズを取得 const bufLen = view.getUint32(ptr + 4, true) const buf = new Uint8Array(memory, bufStart, bufLen) // 出力内容の String 化 const msg = String.fromCharCode(...buf) // 出力 console.log(msg) return buf.byteLength }) // 出力済みのバイトサイズ合計 const totalSize = sizeList.reduce((acc, v) => acc + v) // 出力済みのバイトサイズを設定 view.setUint32(nwritten, totalSize, true) return 0 }, ・・・
最終的な HTML は下記のようになりました。
fd_write 以外の WASI 関数を空実装にして main
関数を呼び出して実行するようにしていますが、WASI の仕様としては _start
関数を呼び出すのが正しいようです ※。(WASI Application ABI 参照)
※ _start 関数を使う場合、fd_prestat_get 等の実装も必要となります
WebAssembly がインポートしている WASI 関数の実装をインスタンス化時(WebAssembly.instantiateStreaming
)に渡す事になりますが、WASI の関数(fd_write 等)はインスタンス化の結果を使って処理する点に注意が必要です。
index.html(main 関数版)
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> </head> <body> <h1>WASI WebAssembly Sample</h1> <div id="res"></div> <script> const WASM_URL = './sample1.wasm' const wasiObj = { wasmInstance: null, importObject: { wasi_snapshot_preview1: { fd_write: (fd, iovs, iovsLen, nwritten) => { console.log(`*** call fd_write: fd=${fd}, iovs=${iovs}, iovsLen=${iovsLen}, nwritten=${nwritten}`) const memory = wasiObj.wasmInstance.exports.memory.buffer const view = new DataView(memory) const sizeList = Array.from(Array(iovsLen), (v, i) => { const ptr = iovs + i * 8 const bufStart = view.getUint32(ptr, true) const bufLen = view.getUint32(ptr + 4, true) const buf = new Uint8Array(memory, bufStart, bufLen) const msg = String.fromCharCode(...buf) // 出力 console.log(msg) document.getElementById('res').innerHTML += `<p>${msg}</p>` return buf.byteLength }) const totalSize = sizeList.reduce((acc, v) => acc + v) view.setUint32(nwritten, totalSize, true) return 0 }, proc_exit: () => {}, fd_prestat_get: () => {}, fd_prestat_dir_name: () => {}, environ_sizes_get: () => {}, environ_get: () => {} } } } WebAssembly.instantiateStreaming(fetch(WASM_URL), wasiObj.importObject) .then(res => { console.log(res) // fd_write で参照できるようにインスタンスを wasmInstance へ設定 wasiObj.wasmInstance = res.instance // main 関数の実行 wasiObj.wasmInstance.exports.main() }) .catch(err => console.error(err)) </script> </body> </html>
main 関数の代わりに _start 関数を呼び出す場合は下記のようになりました。
_start 関数版の場合、fd_prestat_get
の実装が重要となります ※。
※ fd_prestat_get を正しく実装していないと、 fd_prestat_get の呼び出しが延々と繰り返されてしまいました
今回はファイル等を使っていないので(file descriptor 3
以降を開いていない)、fd_prestat_get は単に 8
(WASI_EBADF, Bad file descriptor)を返すだけで良さそうです。
index2.html(_start 関数版)
・・・ <script> const WASM_URL = './sample1.wasm' const wasiObj = { wasmInstance: null, importObject: { wasi_snapshot_preview1: { ・・・ fd_prestat_get: () => 8, ・・・ } } } WebAssembly.instantiateStreaming(fetch(WASM_URL), wasiObj.importObject) .then(res => { console.log(res) wasiObj.wasmInstance = res.instance // _start 関数の実行 wasiObj.wasmInstance.exports._start() }) .catch(err => console.error(err)) </script> ・・・
(2-c) 実行
上記の .html ファイルを Web ブラウザで直接開いても WebAssembly を実行できないため、HTTP サーバーを使う事になります。
更に、Web ブラウザ上で WebAssembly を実行するには、.wasm ファイルを MIME Type application/wasm
で取得する必要があるようです。
Python の http.server は application/wasm
に対応していたため(Python 3.8.2 と 3.7.6 で確認)、以下のスクリプトで HTTP サーバーを立ち上げる事にしました。
web_server.py
import http.server import socketserver PORT = 8080 Handler = http.server.SimpleHTTPRequestHandler with socketserver.TCPServer(("", PORT), Handler) as httpd: print(f"start server port:{PORT}") httpd.serve_forever()
HTTP サーバー起動(Python 3.8.2 で実行)
> python web_server.py start server port:8080
Web ブラウザ(Chrome)で http://localhost:8080/index.html
へアクセスしたところ(index2.html でも同様)、sample1.wasm の実行を確認できました。
Chrome の実行結果
(3) Node.js で組み込み実行
次は、Node.js で WebAssembly を組み込み実行してみます。
(3-a) fd_write 実装
上記 index2.html の処理をベースにローカルの .wasm ファイルを読み込んで実行するようにしました。
sample1.wasm のインポート内容に合わせたものなので、インポート内容の異なる WebAssembly の実行には使えません。
wasm_run_sample.js
const fs = require('fs') const WASI_ESUCCESS = 0; const WASI_EBADF = 8; // Bad file descriptor const wasmFile = process.argv[2] const wasiObj = { wasmInstance: null, importObject: { wasi_snapshot_preview1: { fd_write: (fd, iovs, iovsLen, nwritten) => { ・・・ const sizeList = Array.from(Array(iovsLen), (v, i) => { ・・・ process.stdout.write(msg) return buf.byteLength }) ・・・ return WASI_ESUCCESS }, ・・・ fd_prestat_get: (fd, bufPtr) => { console.log(`*** call fd_prestat_get: fd=${fd}, bufPtr=${bufPtr}`) return WASI_EBADF }, ・・・ } } } const buf = fs.readFileSync(wasmFile) WebAssembly.instantiate(buf, wasiObj.importObject) .then(res => { wasiObj.wasmInstance = res.instance wasiObj.wasmInstance.exports._start() }) .catch(err => console.error(err))
実行結果(Node.js v12.16.2 で実行)
> node wasm_run_sample.js sample1.wasm *** call fd_prestat_get : fd=3, bufPtr=1048568 *** call fd_write: fd=1, iovs=1047968, iovsLen=1, nwritten=1047948 count-1 *** call fd_write: fd=1, iovs=1047968, iovsLen=1, nwritten=1047948 count-2 *** call fd_write: fd=1, iovs=1047968, iovsLen=1, nwritten=1047948 count-3 *** call fd_write: fd=1, iovs=1048432, iovsLen=1, nwritten=1048412 aaabbb
(3-b) Wasmer-JS 使用
Wasmer-JS の @wasmer/wasi
モジュールを使って、もっと汎用的に組み込み実行できるようにしてみます。
@wasmer/wasi インストール例
> npm install @wasmer/wasi
@wasmer/wasi の WASI
を使う事で、インポート内容に合わせた WASI 関数の取得や _start 関数の呼び出しを任せる事ができます。
run_wasmer_js/index.js
const fs = require('fs') const { WASI } = require('@wasmer/wasi') const wasmFile = process.argv[2] const wasi = new WASI() const run = async () => { const module = await WebAssembly.compile(fs.readFileSync(wasmFile)) // インポート内容に合わせた WASI 関数の実装を取得 const importObject = wasi.getImports(module) const instance = await WebAssembly.instantiate(module, importObject) // 実行 wasi.start(instance) } run().catch(err => console.error(err))
実行結果(Node.js v12.16.2 で実行)
> node index.js ../sample1.wasm count-1 count-2 count-3 aaabbb
(4) 標準出力以外の機能
最後に、現時点でどんな機能を使えるのか気になったので、いくつか試してみました。
まず、TcpStream
を使ったコードの wasm32-wasi ビルドは一応成功しました。
sample2.rs
use std::net::TcpStream; fn main() { let res = TcpStream::connect("127.0.0.1:8080"); println!("{:?}", res); }
sample2.rs ビルド
> rustc --target wasm32-wasi sample2.rs
ただし、実行してみると以下のような結果となりました。(wasmtime 以外で実行しても同じ)
wasmtime による sample2.wasm 実行結果
> wasmtime sample2.wasm Err(Custom { kind: Other, error: "operation not supported on wasm yet" })
Rust のソースコードで該当(すると思われる)箇所を確認してみると、unsupported()
を返すようになっていました。
https://github.com/rust-lang/rust/blob/master/src/libstd/sys/wasi/net.rs(2020/4/26 時点)
・・・ impl TcpStream { pub fn connect(_: io::Result<&SocketAddr>) -> io::Result<TcpStream> { unsupported() } ・・・ } ・・・
https://github.com/rust-lang/rust/blob/master/src/libstd/sys/wasi/ のソースを確認してみると、(スレッド系の処理等)他にも未対応の機能がいくつもありました。
一方で、環境変数・システム時間・スリープ処理は使えそうだったので、以下のコードで確認してみました。
sample3.rs
use std::env; use std::thread::sleep; use std::time::{ Duration, SystemTime }; fn main() { // 環境変数 SLEEP_TIME からスリープする秒数を取得(デフォルトは 1) let sleep_sec = env::var("SLEEP_TIME").ok() .and_then(|v| v.parse::<u64>().ok()) .unwrap_or(1); // システム時間の取得 let time = SystemTime::now(); println!("start: sleep {}s", sleep_sec); // スリープの実施 sleep(Duration::from_secs(sleep_sec)); // 経過時間の出力 match time.elapsed() { Ok(s) => println!("end: elapsed {}s", s.as_secs()), Err(e) => println!("error: {:?}", e), } }
sample3.rs のビルド
> rustc --target wasm32-wasi sample3.rs
wasmtime では正常に実行できましたが、wasmer は今のところスリープ処理に対応していないようでエラーとなりました。
ちなみに、環境変数はどちらのコマンドも --env
で指定できました。
wasmtime v0.15.0 による sample3.wasm 実行結果
> wasmtime --env SLEEP_TIME=5 sample3.wasm start: sleep 5s end: elapsed 5s
wasmer v0.16.2 による sample3.wasm 実行結果
> wasmer sample3.wasm --env SLEEP_TIME=5 start: sleep 5s thread 'main' panicked at 'not yet implemented: Polling not implemented for clocks yet', lib\wasi\src\syscalls\mod.rs:2373:21 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace. Error: error: "unhandled trap at 7fffd474a799 - code #e06d7363: unknown exception code"