WebAssembly は多くのプログラム言語からライブラリの様に呼び出す事が出来る。
WebAssembly のコードを書く方法は多くの記事で書かれているが、その使い方はフレームワーク等のツールに頼っている事が多い様だ。
本記事では、そのような便利ツールに頼らず JavaScript から WebAssembly を実行する方法を記載する。
なお、本記事の外部プログラムは 第4回 とほとんど同じだ。
本シリーズの他記事では JavaScript の他に Rust, Python, Ruby から実行する方法も紹介しているが、第4回 と内容が重複するので本記事では JavaScript のみを紹介する。
WebAssembly は新しい技術である。
目先の最先端ツールに飛びつくのもよいが、その基礎を学んで長く使える知識を身に着けないか?
本記事はシリーズの番外編第1回である。シリーズ記事の一覧は 第1回 の #シリーズ記事一覧 に載せている。シリーズの記事を追加する時は 第1回 の一覧を更新して通知を行うので興味の有る人は 第1回 をストックして欲しい。
本記事の概要と過去記事の復習
WebAssembly 自体は OS から直接確保する事は出来ない。
しかし、外部プログラムから最大 2 GB のメモリー塊を渡してもらう事が可能だ。
その中でやりくりすれば実質的に「メモリーの確保、解放」の様な事が出来る。
このようなメモリー塊を「線形メモリー」と呼ぶ。
第4回 では WebAssembly のインスタンスに String 相当の状態(プロパティ、インスタンス変数)を 1 個持たせた。
今回は、任意の数の String を保存する WebAssembly のインスタンスを考えてみる。
イメージとしては、以下の JavaScript の様な Class を WebAssembly で作ってみる。
(保存するデータは String のみを想定する。)
class Names {
constructor() {
this.names = [];
}
push(name) {
this.names.push(name);
}
get(index) {
return this.names[index];
}
set(index, name) {
this.names[index] = name;
}
}
上記の様なコードを本シリーズ記事で紹介してきた方法で実装するには少し問題が有る。
第4回 では保存する String は必ず 1 個だったので、WebAssembly は線形メモリの最初の部分を使用する事ができた。
今回は配列と複数の String を保存しなくてはいけないので、メモリ管理をちゃんとする必要が有る。
では、WebAssembly 内でメモリ管理を行うにはどのようにすれば良いか?
普通は既存のライブラリを使用するのが良いだろう。
本シリーズでは WebAssembly のコードは WebAssembly Text Format で書いてきた。
多くの読者の方にコードを何となく理解してもらうには、これが一番良いと思ったからだ。
しかし、実際に筆者が業務で WebAssembly のコードを作る場合は Rust 等の別の言語で書くだろう。
Rust にはメモリー管理する既存のライブラリーが存在するので、筆者自身がメモリー管理を意識する必要は無い。
Rust と同様に(WebAssembly Text Format 以外の)多くのプログラム言語ではメモリーを適切に管理する仕組み(ライブラリ、GC 等)が存在するだろう。
今回はあえて「WebAssembly Text Format」でメモリー管理の様な事をやってみる。
なお、本記事では WebAssembly に関する新しい情報は出てこない。
そのため、連番では無く「番外編」とした。
メモリ管理の戦略
本記事の String は線形メモリー上にメモリーを確保し、実際の文字列はそのメモリー上に保存する。
String 自身は、文字列を保存したメモリーを指すポインター(32 bit 符号付き整数)と、文字列のバイト数(32 bit 符号付き整数)を保存する。
(WebAssembly のアドレス空間は 2023 年 10 月現在では 32 bit である。)
この String を保存する可変長配列は、同様に線形メモリー上にメモリーを確保し、そこに String (32 bit 符号付き整数 2 個)を保存する。
つまり、本プログラムでは下記の 2 種類のメモリーの使い方をする。
- 可変長配列に保存する各 String の保持するバイト列
- インスタンスが保持する可変長配列
1 については、線形メモリーの最初から順に使っていく。
(解放したメモリーを再利用する事は無く、順次新しいメモリーを使用する。)
2 については、は線形メモリーの後ろから使用する。
前述の様に、私達の String は 8 byte (32 bit x 2) だ。
線形メモリーの最後の 8 byte には最初の string を、その前の 8 byte には 2 番目の string を保存する。
このメモリー管理方法だと、保存する String を何度も更新した時に(実際に使用中のメモリー量が少ないとしても)やがてメモリーが枯渇してしまう。
ただ、今回は「このような事態は発生しない」という想定でコードを書いていく。
WebAssembly コードの準備
過去記事同様、WebAssembly Text Format で動作確認をする WebAssembly のコードを作成し、wat2wasm でコンパイルする。
wabt のインストール
github の README.md を読んでコンパイルする。
C++ のコンパイル環境が必要なので注意。
wat2wasm というコマンドがコンパイルされるので、必要に応じて Path を通しておくと良いだろう。
wasm ファイルの準備
wasm ファイルとは、WebAssembly の規格に沿ったバイナリファイルの事である。
最初に各種言語から動かして動作確認するための wasm ファイルを用意しよう。
最初にテキストエディタで以下の様な内容のファイルを作成し /tmp/str_array.wat
という名前で保存する。
(module
(import "host" "mem" (memory 32768))
(export "memory" (memory 0))
(global $next_ptr (mut i32) (i32.const 8))
(global $length (mut i32) i32.const 0)
;; Return a pointer sized of $bytes
(func (export "malloc") (param $bytes i32) (result i32)
;; Stack the return value
global.get $next_ptr
;; Update $next_ptr
global.get $next_ptr
local.get $bytes
i32.add
global.set $next_ptr)
;; Do nothing.
(func (export "free") (param $ptr i32) (param $bytes i32))
;; Returns String stored at $index.
(func $string_at(param $index i32) (result i32)
i32.const 2147483640 ;; The pointer to the first element.
local.get $index
i32.const 8 ;; Size of String. (String is composed of the ptr and the length)
i32.mul
i32.sub)
(func $set (export "set") (param $index i32) (param $ptr i32) (param $length i32)
(local $string i32)
;; Acquire a pointer where the string is.
local.get $index
call $string_at
local.set $string
;; Update the pointer where the contents are.
local.get $string
local.get $ptr
i32.store
;; Update the length
local.get $string
i32.const 4
i32.add
local.get $length
i32.store)
(func (export "get") (param $index i32) (result i32 i32)
(local $string i32)
;; Acquire a pointer where the string is.
local.get $index
call $string_at
local.set $string
;; Stack a pointer where the contents are.
local.get $string
i32.load
;; Stack the length
local.get $string
i32.const 4
i32.add
i32.load)
(func (export "push") (param $ptr i32) (param $length i32)
global.get $length
local.get $ptr
local.get $length
call $set
global.get $length
i32.const 1
i32.add
global.set $length))
本シリーズの他の記事と比較して、この wasm のコードは長いので、少しずつ上から読んでみる。
なお、以下で i32
とは「符号付き 32 bit 整数」を意味する。
最初に以下の行を読んでみる。
(module
(import "host" "mem" (memory 32768))
(export "memory" (memory 0))
...
)
まず、最初はおまじないの module
から始まる。
次に、外部プログラムの "host.mem" という物をインポートする。
これは 32768 page (= 2 GB) の線形メモリである。
(WebAssembly において、1 page = 64 KB, 32768 page はサポートされるメモリの最大値)
また、外部プログラムから "memory" という名前でこの線形メモリーにアクセス可能にしている。
次に、以下の行を読んでみる。
(global $next_ptr (mut i32) (i32.const 8))
(global $length (mut i32) i32.const 0)
ここではグローバル変数(プロパティ、インスタンス変数に相当)の $next_ptr
と $length
を宣言している。
$next_ptr
は「文字列保存の為に使用可能なポインターの最初の値」を保存し、8
で初期化する。
8
で初期化したことに大きな意味はない。
ただ、 0
は多くのプログラムで NULL ポインター
と呼ばれる物だ。
使用するのは、何となく気持ちが悪い。(WebAssembly では使用可能なのだが。)
また、大きい値にすると「これより前の部分」が使用不可能になる。
なので「 0
ではなく大きすぎない数値」という事で適当に 8
という値を選んだ。
$length
は「保存している string の数」を保存し、 0
で初期化する。
次に以下の行を読んでみる。
;; Return a pointer sized of $bytes
(func (export "malloc") (param $bytes i32) (result i32)
;; Stack the return value
global.get $next_ptr
;; Update $next_ptr
global.get $next_ptr
local.get $bytes
i32.add
global.set $next_ptr)
ここでは引数に i32
を取り、 i32
を返す関数を定義している。また、外部プログラムから malloc
という名前でアクセス可能にしている。
この関数は WebAssembly のメモリを $bytes
分だけ確保し、そのポインターを返す。
具体的には、グローバル変数 $next_ptr
の値を引数 $bytes
だけ増加させ、増加前の $next_ptr
の値を返す。
次に以下の行を読んでみる。
;; Do nothing.
(func (export "free") (param $ptr i32) (param $bytes i32))
ここでは引数に i32
を 2 個とり、値を返さない関数を定義している。また、外部プログラムから free
という名前でアクセス可能にしている。
この関数は「使用しなくなったメモリーを WebAssembly に返す」事を意図して宣言しているが、コメントに有るように実際には何もしない。
つまり、この WebAssembly は解放したメモリーを再利用しないのだ。
何もしないならば関数を作る必要も無いのだが、後から「やっぱりメモリーを再利用しよう」と思って改修する時に外部プログラムを変更しなくて済むように、宣言だけ行った。
次に以下の行を読んでみる。
;; Returns String stored at $index.
(func $string_at(param $index i32) (result i32)
i32.const 2147483640 ;; The pointer to the first element.
local.get $index
i32.const 8 ;; Size of String. (String is composed of the ptr and the length)
i32.mul
i32.sub)
ここでは、引数に i32
を 1 個とり i32
を 1 個返す関数を定義している。
この関数は同じ WebAssembly インスタンスからは $string_at
という名前でアクセス可能だが、外部プログラムからはアクセスできない。
(プライベート method の様な物だ。)
やっている事は、引数 $index
番目の String が保存されているポインターを返す。
次に以下の行を読んでみる。
(func $set (export "set") (param $index i32) (param $ptr i32) (param $length i32)
(local $string i32)
;; Acquire a pointer where the string is.
local.get $index
call $string_at
local.set $string
;; Update the pointer where the contents are.
local.get $string
local.get $ptr
i32.store
;; Update the length
local.get $string
i32.const 4
i32.add
local.get $length
i32.store)
ここでは引数に i32
を 3 個とる関数を定義している。
また、この関数には同じ WebAssembly インスタンス内からは $set
という名前で、外部プログラムからは set
という名前でアクセス可能にしている。
やる事は $index
番目に保存している string を引数 $ptr
と $length
から構成される string で上書きする事だ。
この関数は $index
のサニタイズは行っていない。
また、 $ptr
に実際の文字列を保存するのは外部プログラムの仕事だ。
次に以下の行を読んでみる。
(func (export "get") (param $index i32) (result i32 i32)
(local $string i32)
;; Acquire a pointer where the string is.
local.get $index
call $string_at
local.set $string
;; Stack a pointer where the contents are.
local.get $string
i32.load
;; Stack the length
local.get $string
i32.const 4
i32.add
i32.load)
ここでは引数に i32
を 1 個とり、 i32
の値を 2 個返す関数を定義している。
また、この関数には外部プログラムから get
という名前でアクセス可能にしている。
やっている事は、引数 $index
番目に保存している string を返す。
(戻り値の「 i32
2 個」というのは、string の事だ。)
set
同様に、この関数は $index
のサニタイズは行っていない。
次に以下の行を読んでみる。
(func (export "push") (param $ptr i32) (param $length i32)
global.get $length
local.get $ptr
local.get $length
call $set
global.get $length
i32.const 1
i32.add
global.set $length)
ここでは引数に i32
の値を 2 個とり、値を返さない関数を定義している。
また、この関数には外部プログラムから push
という名前でアクセス可能にしている。
引数の「 i32
2 個」というのは、string の事だ。
この WebAssembly に保存してある string の数はグローバル変数 $length
に保存してある。
やっている事は、配列の最後に引数として渡された string を保存し、グローバル変数 $length
を 1 増加させている。
この wat ファイルを以下の様に wat2wasm コマンドでコンパイルすると /tmp
以下に str_array.wasm
というバイナリファイルが出来る。
$ wat2wasm -o /tmp/str_array.wasm /tmp/str_array.wat
Vanilla JS から実行
筆者の JavaScript 環境
- nodejs v18.13.0
Vanilla JS のコード
nodejs から WebAssembly を実行するコードは、例えば下記の様になる。
const fs = require('fs');
const wasm = fs.readFileSync('/tmp/str_array.wasm');
const mod = new WebAssembly.Module(wasm);
const importObject = {
host: {
mem: new WebAssembly.Memory({initial: 32768}),
}
};
const instance = new WebAssembly.Instance(mod, importObject);
const buffer = new Uint8Array(instance.exports.memory.buffer);
const push = (name) => {
const encoded = new TextEncoder().encode(name);
const ptr = instance.exports.malloc(encoded.length);
const subarray = buffer.subarray(ptr, ptr + encoded.length);
subarray.set(encoded);
instance.exports.push(ptr, encoded.length);
};
const set = (name, index) => {
const current = instance.exports.get(index);
instance.exports.free(...current);
const encoded = new TextEncoder().encode(name);
const ptr = instance.exports.malloc(encoded.length);
const subarray = buffer.subarray(ptr, ptr + encoded.length);
subarray.set(encoded);
instance.exports.set(index, ptr, encoded.length);
};
const get = (index) => {
const str = instance.exports.get(index);
const subarray = buffer.subarray(str[0], str[0] + str[1]);
return new TextDecoder().decode(subarray);
};
// Store "Foo" and "Bar" to the instance.
push("Foo");
push("Bar");
// Check the stored values
console.log(`0: ${get(0)}`);
console.log(`1: ${get(1)}`);
// Update the 1st element to "Baz"
console.log("Update the 1st element to \"Baz\"");
set("Baz", 0);
// Check the stored values
console.log(`0: ${get(0)}`);
console.log(`1: ${get(1)}`);
上記のファイルを str_array.js
という名前で保存し実行すると、下記の様な結果を得る。
$ node str_array.js
0: Foo
1: Bar
Update the 1st element to "Baz"
0: Baz
1: Bar
JavaScript のコードを少しずつ読んでみる。
(以下の 太字リンク は JavaScript のクラス名(コンストラクター名)である事を示す。リンク先は MDN Web Docs 。)
最初に以下を読んでみる。
const fs = require('fs');
const wasm = fs.readFileSync('/tmp/str_array.wasm');
const mod = new WebAssembly.Module(wasm);
const importObject = {
host: {
mem: new WebAssembly.Memory({initial: 32768}),
}
};
const instance = new WebAssembly.Instance(mod, importObject);
const buffer = new Uint8Array(instance.exports.memory.buffer);
ここでは 第4回 と同じような方法で WebAssembly の Instance を作成している。
次に、以下の行を読んでみる。
const push = (name) => {
const encoded = new TextEncoder().encode(name);
const ptr = instance.exports.malloc(encoded.length);
const subarray = buffer.subarray(ptr, ptr + encoded.length);
subarray.set(encoded);
instance.exports.push(ptr, encoded.length);
};
ここでは WebAssembly の関数 push
を実行するためのラッパー関数 push
を定義している。
ラッパー関数の行っている事は以下だ。
最初に引数 name
をエンコードしてバイト列にする。
(JavaScript の内部的な文字コードは UTF-16 だが、WebAssembly には UTF-16 を解釈できないのでバイト列に変換する。)
次に WebAssembly の関数 malloc
を実行してエンコードしたバイト数だけメモリーを確保し、その確保したメモリーにエンコードしたバイト列をコピーする。
最後に WebAssembly の push
関数を実行する。
次に、以下の行を読んでみる。
const set = (name, index) => {
const current = instance.exports.get(index);
instance.exports.free(...current);
const encoded = new TextEncoder().encode(name);
const ptr = instance.exports.malloc(encoded.length);
const subarray = buffer.subarray(ptr, ptr + encoded.length);
subarray.set(encoded);
instance.exports.set(index, ptr, encoded.length);
};
ここでは WebAssembly の関数 set
を実行するためのラッパー関数 set
を定義している。
ラッパー関数の行っている事は以下だ。
最初に WebAssembly に保存されている index
番目の既存の文字列の使用しているメモリーを解放する。
(ただし前述の様に、実は何もやっていない。)
次に、 push
の時と同様に引数の name
をバイト列にエンコードする。
次に WebAssembly の関数 malloc
を実行してエンコードしたバイト数だけメモリーを確保し、その確保したメモリーにエンコードしたバイト列をコピーする。
最後に、WebAssembly の set
関数を実行する。
次に、以下の行を読んでみる。
const get = (index) => {
const str = instance.exports.get(index);
const subarray = buffer.subarray(str[0], str[0] + str[1]);
return new TextDecoder().decode(subarray);
};
ここでは WebAssembly の関数 get
を実行するためのラッパー関数 get
を定義している。
ラッパー関数の行っている事は以下だ。
最初に WebAssembly の関数 get を実行し、該当の文字列がメモリー上のどの場所に、何 byte で保存されているか確認する。
次に WebAssembly の線形メモリーから該当の部分を読み、string に変換して返す。
最後に、以下の行を読んでみる。
// Store "Foo" and "Bar" to the instance.
push("Foo");
push("Bar");
// Check the stored values
console.log(`0: ${get(0)}`);
console.log(`1: ${get(1)}`);
// Update the 1st element to "Baz"
console.log("Update the 1st element to \"Baz\"");
set("Baz", 0);
// Check the stored values
console.log(`0: ${get(0)}`);
console.log(`1: ${get(1)}`);
ここでは、先ほど定義したラッパー関数 push
, get
, set
を実行している。
最初に "Foo"
, "Bar"
の 2 個の文字列を WebAssembly に保存する。
次に WebAssembly に保存した最初の文字列と、その次の文字列を表示する。
次に WebAssembly に保存した最初の文字列を "Baz"
に変更する。
最後に、改めて WebAssembly に保存した最初の文字列と、その次の文字列を表示する。
まとめ
20 年程前の C 言語による開発では「メモリー解放禁止」というルールが存在する場合もあった事をご存知だろうか?
一般的に、メモリー管理というのは重い処理だ。
小さいメモリーの確保、解放を頻繁に行うと、CPU の使用時間が増える。
そこで「メモリーはプロセスを終了する時に一括して OS に返されるのだから、いちいちメモリーを解放するな」という考え方をする人達もいたのだ。
今回の WebAssembly のコードも、少し似ているかもしれない。
WebAssembly の使用するメモリー管理は、 Instance の作成時に 2 GB のメモリーを 1 回確保し、破棄する時に 2 GB のメモリーを 1 回解放するだけですむ。
近年ではこの知識が役に立つ事は少ないと思うが、軽く紹介してみた。