Denoでwasmを動かすだけの話
動機
小ネタです。もともとは「DenoのランタイムがRustで書かれているならサクッとinteropできんじゃね?」というモチベで調べ始めたんですが、見つかったのはRustコードをwasmにコンパイルしてDenoで使うための情報ばかりでした。まあじゃあそれでもいいやということでやってみます。
準備
必要なツールをインストールしてください。
- Deno
- Cargo(Rustのビルドツール)
- rustwasmc(旧ssvmup)
最初の2つはまあ明らかなので説明は省きます。最後のrustwasmcは、WasmEdgeというランタイムのためのツールという立て付けみたいですが、今回はDenoからwasmを呼び出すためのユーティリティとして使っています。Rustコードをwasmにコンパイルするとともに、Denoから使うためのグルーコードを生成してくれます。wasmコードさえあればグルーコードは手書きできるので必須ではありませんが、使うととても楽です。
その手のツールではwasm-packが有名だと思いますが、そちらはDenoに対応していないためこちらを使いました。内部的にはどちらもwasm-bindgenでコンパイルしてごにょごにょしているみたいです。
筆者の実行環境を貼っておきます。
macOs: 11.4
Rust: 1.49.0
Deno: 1.11.2
rustwasmc: 0.1.27
やる
まずRustプロジェクトを作ります。ライブラリクレートです。
cargo init --lib deno-wasm-example
Cargo.toml
をいじってwasm-bindgen
を依存に入れましょう。wasmにコンパイルするためにcreate-typeの指定も必要です。
# 前半は省略
[lib]
crate-type =["cdylib"]
[dependencies]
wasm-bindgen = "=0.2.61"
src/lib.rs
というファイルがあるはずなので、そこにDenoから呼び出したい関数を書いていきます。今回はフィボナッチ数列の第n項を返す関数にしました。
use wasm_bindgen::prelude::*;
fn fib_helper(n: i32, acc1: i64, acc2: i64) -> i64 {
if n < 1 {
acc1
} else {
fib_helper(n - 1, acc1 + acc2, acc1)
}
}
#[wasm_bindgen]
pub fn fib(n: i32) -> i64 {
fib_helper(n, 0, 1)
}
TCOを効かすために末尾再帰になるように定義しました。#[wasm_bindgen]
属性をつけたpub fn
がwasmでも公開されるようです。
それではコンパイルしてみましょう。rustwasmc build --target deno --release
を叩いてコンパイルすると、プロジェクトルートにpkg
というディレクトリが生成されます。
$ tree pkg
pkg
├── deno_wasm_example.js
├── deno_wasm_example_bg.wasm
└── package.json
deno_wasm_example.js
がグルーコードです。折角なので中身を覗いてみましょう。
let imports = {};
let wasm;
let cachegetInt32Memory0 = null;
function getInt32Memory0() {
if (cachegetInt32Memory0 === null || cachegetInt32Memory0.buffer !== wasm.memory.buffer) {
cachegetInt32Memory0 = new Int32Array(wasm.memory.buffer);
}
return cachegetInt32Memory0;
}
const u32CvtShim = new Uint32Array(2);
const int64CvtShim = new BigInt64Array(u32CvtShim.buffer);
/**
* @param {number} n
* @returns {BigInt}
*/
const fib = function(n) {
wasm.fib(8, n);
var r0 = getInt32Memory0()[8 / 4 + 0];
var r1 = getInt32Memory0()[8 / 4 + 1];
u32CvtShim[0] = r0;
u32CvtShim[1] = r1;
const n0 = int64CvtShim[0];
return n0;
};
export { fib };
import * as path from 'https://deno.land/std/path/mod.ts';
import WASI from 'https://deno.land/std/wasi/snapshot_preview1.ts';
const __dirname = path.dirname(new URL(import.meta.url).pathname);
const wasi = new WASI({
args: Deno.args,
env: Deno.env.toObject(),
preopens: {
'/': __dirname
}
});
imports = { wasi_snapshot_preview1: wasi.exports };
const p = path.join(__dirname, 'deno_wasm_example_bg.wasm');
const bytes = Deno.readFileSync(p);
const wasmModule = new WebAssembly.Module(bytes);
const wasmInstance = new WebAssembly.Instance(wasmModule, imports);
wasm = wasmInstance.exports;
wasi.memory = wasmInstance.exports.memory;
メモリアクセスのキャッシュ、64ビット整数の扱いやWASIなどでごちゃごちゃしていますが、基本的な流れは
- ファイルロード時にwasmファイル(
deno_wasm_example_bg.wasm
)をロードして初期化 - wasmモジュールの関数呼び出しを包んだJSの
fib
関数をexport(wasmファイルの読み込みは1回だけ)
というシンプルなものです。使ってみましょう。
import { fib } from "./pkg/deno_wasm_example.js";
for (let i = 0; i < 10; i++) {
console.log(fib(i).toString()) // BigIntをそのまま出力すると末尾にnが付くのでtoStringした
}
これをindex.ts
として保存してdeno run --allow-read --allow-env --unstable index.ts
と叩けば正しく動くはずです。--unstable
オプションはWASIの中でunstableなAPIを使っているため必要になります。その他のオプションはファイルや環境変数にアクセスするために必要です(Denoでは実行側が明示的に権限を与えなければならない)。
ベンチマーク
これだけではさすがにつまらないので、Native Denoな関数とパフォーマンスを比較してみます。こちらを参考にして、標準ライブラリのbench
を使いました。
import { fib } from "./pkg/deno_wasm_example.js";
import { bench, runBenchmarks } from "https://deno.land/std/testing/bench.ts";
function fibHelper(n: number, acc1: number, acc2: number): number {
return n < 1 ? acc1 : fibHelper(n - 1, acc1 + acc2, acc1);
}
function denoFib(n: number): number {
return fibHelper(n, 0, 1);
}
function denoFibLoop(n: number): number {
let acc1 = 0;
let acc2 = 1;
while (n >= 1) {
const tmp = acc1;
acc1 = acc1 + acc2;
acc2 = tmp;
n--;
}
return acc1;
}
Deno.test("benchmark fib", async () => {
bench({
func: function deno(b) {
b.start();
for (let i = 0; i < 1000; i++) {
denoFib(100);
}
b.stop();
},
runs: 10000,
name: "denoFib fib_100",
});
bench({
func: function wasm(b) {
b.start();
for (let i = 0; i < 1000; i++) {
fib(100);
}
b.stop();
},
runs: 10000,
name: "fib fib_100",
});
bench({
func: function denoLoop(b) {
b.start();
for (let i = 0; i < 1000; i++) {
denoFibLoop(100);
}
b.stop();
},
runs: 10000,
name: "denoFibLoop fib_100",
});
bench({
func: function wasmFib0(b) {
b.start();
for (let i = 0; i < 1000; i++) {
fib(0);
}
b.stop();
},
runs: 10000,
name: "fib fib_0",
});
bench({
func: function denoFibLoop0(b) {
b.start();
for (let i = 0; i < 1000; i++) {
denoFibLoop(0);
}
b.stop();
},
runs: 10000,
name: "denoFibLoop fib_0",
});
await runBenchmarks();
});
denoFib
はRustで書いたfib
と同じ実装です。ただ、DenoではTCOが効かないみたいなので、ループで実装したdenoFibLoop
も用意して比較しました。
対象の関数を1000回呼び出す試行を1万回繰り返して平均を取っています。最初の3つはフィボナッチの第100項を求めるものですが、後ろ2つは第0項を求めています。こうすることで、wasmモジュール呼び出しのオーバーヘッドを計測できると考えたためです(同僚のお兄さんの入れ知恵ですが)。なお、denoFib
が遅いのは分かっているので第0項では計測しません。
。deno test --allow-all --unstable index.ts
を実行しましょう1。
test benchmark fib ...running 5 benchmarks ...
benchmark denoFib fib_100 ...
10000 runs avg: 0.8100235556999976ms
benchmark fib fib_100 ...
10000 runs avg: 0.14344174250000197ms
benchmark denoFibLoop fib_100 ...
10000 runs avg: 0.08460032550000178ms
benchmark fib fib_0 ...
10000 runs avg: 0.08342863459999517ms
benchmark denoFibLoop fib_0 ...
10000 runs avg: 0.00250881599999866ms
第100項での計測だけ見るとdenoFibLoop
の方が速いですが、wasm呼び出しの時間をさっぴくと実処理自体はfib
が若干速そうです。
終わり
感想としては、Denoは初めて触りましたが処理系に諸々のツールが含まれているのでとても体験がいいですね。Node.js + TypeScriptに比べると書き始めのハードルが低いのも嬉しい。
以上。こいつが書きました。
なお、サムネはDimitrij AgalさんがMITライセンスで公開されているものです。
その他の記事
Other Articles
関連職種
Recruit