|詳説|線形メモリとArrayBuffer |〜 wasm-bindgenではどのように文字列を扱っているのか?〜
概要
先日、Emscripten & WebAssembly night !! #6 でwasm-bindgenについて発表させて頂きました。
振り返りをする中で、
- 線形メモリとArrayBufferについてもっと詳しくお話すればよかったな😫
と思いましたので、補足内容をブログに書き起こすことにしました。
(以下の内容はスライドの補足なので、スライドを見ながら読み進めて頂けば幸いです🙏)
線形メモリはArrayBufferってどういうこと?
Rustで書いた"Hello World"をWebAssemblyに変換し、JSで単純な呼び出し方をすると、1048576という数列が表示されてしまいます。
この理由を図で表したのがこちらです。
発表の際に大雑把な説明で終わってしまったので、どういうものか詳しく書きます。
線形メモリについて
まずは線形メモリがどういうものか見ていきましょう。
rustwasmの公式ドキュメントの説明が一番わかりやすかったのでご紹介します。
WebAssembly has a very simple memory model. A wasm module has access to a single "linear memory", which is essentially a flat array of a bytes. This memory can be grown by a multiple of the page size (64K). It cannot be shrunk.
WebAssemblyは非常に単純なメモリモデルを持っています。 wasmモジュールは単一の「線形メモリ」にアクセスできます。これは基本的にはフラットなバイト配列です。このメモリは、ページサイズの倍数(64K)で増大する可能性があります。縮小することはできません。
WebAssemblyの世界では、文字列は線形メモリにただのバイト列として保管されます。どんな型であるのかは解釈されず、「ここからここまで意味のある塊です!」というオフセットと長さの情報のみを持ちます。
実際に.wasmのテキスト表現である.watを見てみましょう。発表スライドの30枚目で紹介した、wasm-pack buildコマンドで自動生成される.wasmを.watにしたものがこちらになります。
(module (type (;0;) (func (param i32 i32))) (type (;1;) (func)) (import "./js_hello_world" "__wbg_appendStringToBody_ad7a4c61e9d583b5" (func $__wbg_appendStringToBody_ad7a4c61e9d583b5 (type 0))) (func $run (type 1) i32.const 1048576 i32.const 11 call $__wbg_appendStringToBody_ad7a4c61e9d583b5) (memory (;0;) 17) (export "memory" (memory 0)) (export "run" (func $run)) (data (;0;) (i32.const 1048576) "Hello World") (data (;1;) (i32.const 1048592) "\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00\00"))
「ここからここまで意味のある塊です!」を表すのはこの部分です。
(func $run (type 1) i32.const 1048576 i32.const 11 call $__wbg_appendStringToBody_ad7a4c61e9d583b5)
一行ずつ説明していくと、
- type1の関数シグネチャ(=返り値なしの関数)を持つ$runという名前の関数を宣言
- i32型の整数1048576 = メモリのオフセット
- i32型の整数11 = メモリの長さ
- type0の関数シグネチャ(=i32型の引数を2つ受け取る関数)を持つ$__wbg_...(長いので略)という名前の関数をJSから呼び出し、2.および3.で宣言した整数を引数に入れる
となります。S式で書かれているため一見とっつきにくいですが、やっていることは至ってシンプルです。
JSの世界では線形メモリをArrayBufferとして扱う
JSの世界では、WebAssemblyの世界から「ここからここまで意味のある塊です!」と伝えられた情報をどのように処理するのでしょうか?
wasm-pack buildコマンドで自動生成された.jsファイルを見ていきましょう。
(.watと違って.jsはシンタックスハイライトが付けられるので、説明をコードに直接書いていきます)
/* tslint:disable */ import * as wasm from './js_hello_world_bg'; import { appendStringToBody } from '../domUtils'; let cachedTextDecoder = new TextDecoder('utf-8'); let cachegetUint8Memory = null; function getUint8Memory() { // ★ wasm.memory.bufferは線形メモリのことで、JSの世界ではArrayBufferとして扱っています! if (cachegetUint8Memory === null || cachegetUint8Memory.buffer !== wasm.memory.buffer) { // ArrayBufferをUint8Arrayというビューで解釈します。 cachegetUint8Memory = new Uint8Array(wasm.memory.buffer); } return cachegetUint8Memory; } // Uint8Array型にした線形メモリの内容をコピー(subarray)し、utf-8でデコードして文字列にします。 function getStringFromWasm(ptr, len) { return cachedTextDecoder.decode(getUint8Memory().subarray(ptr, ptr + len)); } // この関数が.watファイルで呼ばれていた関数です。arg0 = 1048576, arg1 = 11が入ります。 export function __wbg_appendStringToBody_ad7a4c61e9d583b5(arg0, arg1) { let varg0 = getStringFromWasm(arg0, arg1); appendStringToBody(varg0); } /** * @returns {void} */ export function run() { return wasm.run(); }
いかがでしょうか。
JSの世界では「これは文字列です」を表現するために、ArrayBufferとUint8ArrayとTextDecoderの3セットが必要なのです。
ArrayBufferとUint8Arrayの違いについて
私はArrayBufferとUint8Arrayの概念に出会ったとき、「この2つは一緒のものじゃないの?関係性がよくわからないぞ😫」となりました。
MDNには次のような説明が書かれています。
バッファ (ArrayBuffer オブジェクトで実装) は、データの塊を表すオブジェクトです。これは特に形式がなく、またその中身にアクセスする手段を提供しません。バッファに格納されている情報にアクセスするには、ビューを使用することが必要です。ビューはコンテキスト (データの種類、開始位置のオフセット、要素の数) を提供し、データを実際の型付き配列に返します。
文章だけではいまいちピンと来ず...
試しに同一のバッファに対する複数のビューを作ってみたところ、一気に氷解したのでそのコードを共有します。
// 16バイトのバッファ var buffer = new ArrayBuffer(16); if (buffer.byteLength === 16) { console.log("bufferは16バイトのArrayBufferです。"); } console.log(" ✂ ------------"); // 同一のバッファに対する複数のビュー /* - int32Array - -2147483648 から 2147483647まで扱える - 32ビット = 4バイト - 4バイト単位でバッファを使っていくので配列の長さは4 */ var int32View = new Int32Array(buffer); console.log("int32View.length: " + int32View.length); for (var i = 0; i < int32View.length; i++) { console.log("int32View[" + i + "]: " + int32View[i]); } /* - Int16Array - -32768から32767まで扱える - 16ビット = 2バイト - 2バイト単位でバッファを使っていくので配列の長さは8 */ var int16View = new Int16Array(buffer); console.log("int16View.length: " + int16View.length); for (var i = 0; i < int16View.length; i++) { console.log("int16View[" + i + "]: " + int16View[i]); } console.log(" ✂ ------------"); // ダウンキャストを起こしてみる console.log("int32View is now " + int32View[0]); console.log("int16View is now " + int16View[0]); console.log(" ✂ ------------"); console.log("int32View[0]に40000を代入します。"); int32View[0] = 40000; console.log(" ✂ ------------"); console.log("int32View is now " + int32View[0]); // result = 40000 console.log("int16View is now " + int16View[0]); // result = -25536 ← オーバーフローする
おわりに
今回の記事では、登壇時にお話し足りなかった線形メモリとArrayBufferについて詳しく説明させて頂きました。
文字列を渡す仕組みはwasm-bindgenが綺麗に隠蔽してくれるため、正直WebAssemblyをJSで使う分には余分な知識だと思います。
でも仕組みを理解してみて、改めてwasm-bindgenのツールのすごさ・面白さが一層深く感じられるようになりました😊
皆さんもぜひ、好きなツールの仕組みを深掘りして学んでみて下さい✨