AssemblyScript を使って WebAssembly で Hello, world.

AssemblyScript を使って WebAssembly で Hello, world.

AssemblyScript を使って WebAssembly で Hello, world.

WebAssembly をやってみた系のエントリーでは add(1, 2) //=> 3 という内容が多いのですが、やはりここは Hello, Wasm. を表示したいと思いました。そこで今回は "Hello, Wasm." が (結果的に) 得られる関数を書きました。

WebAssembly.Memory

WebAssembly の関数が直接やりとりできるのは数値のみなので、文字列のやりとりには JavaScript と共有した WebAssembly.Memory を読み書きしなければなりません。

(実際には内部の ArrayBuffer を読み書きします)

バイト配列への encode

WebAssembly.Memory は単純なバイト配列のみを扱います。そのため "Hello, Wasm." を関数に渡し受けとる際には以下が必要になります。

  • Uint8Array への decode
  • Uint8Array への encode
  • バイト配列に埋めこまれた任意の Uint8Array 部分を取り出すためのルール

Uint8Array 部分を取り出すためのルール

今回は AssemblyScript 内で文字列を定義した際に使用されるルールを借用し、以下のようにしました。

  • 先頭 4 バイトに uint32 で文字列の長さを埋めこみ
  • その後ろに文字列を埋めこむ。

処理の際にはバイト配列内の位置を指定し、文字列の長さを取得した後に必要な分だけバイト配列を読みこみます。

余談ですが Go 言語の wasm では起動時の引数として string + "\0" という形でバイト配列に埋め込んでいるようです。

AssemblyScript

JavaScript 側と被らない位置からバイト配列を使用するために 4096 ほどオフセットしています。

let memHead = 4096
export function hello (head: i32): i32 {
  const pointer  = memHead
  const stringHead = head + 4
  // 埋めこまれた文字列の長さを取得
  const length   = <i32>load<u32>(head)
  // JavaScript も文字列の長さを知る必要があるので
  // 長さを同フォーマットで埋めこみ
  i32.store(memHead, length)
  memHead += 4
  // 必要な分だけ文字データをバイト配列に埋めこみ
  for (let i = 0; i < length; i++) {
    i32.store8(memHead, <i32>load<u8>(stringHead + i))
    memHead++
  }
  // 埋め込んだ文字列の先頭位置を返す
  return pointer
}

WebAssembly を起動する JavaScript (Node 版)

複数の引数がある場合などを想定して、二つ投げてみました。

const fs = require('fs')
const util = require('util')
const readFile = util.promisify(fs.readFile)
const encoder = new util.TextEncoder("utf-8")
const decoder = new util.TextDecoder("utf-8")
async function main () {
  const wasm = await readFile('hello.wasm')
  const memory = new WebAssembly.Memory({ initial: 1 })
  const importObject = { js: { memory } }
  const wasmSet = await WebAssembly.instantiate(wasm, importObject)
  const {
    instance: {
      exports: {
        hello,
        memory: { buffer }
      },
    },
  } = wasmSet
  // 引数用の文字列を Memory に登録
  const registeredArguments = ['Hello, Wasm.', 'Yay!']
  const register = storeArgumentAndGetPointer(buffer, 0)
  const argumentPointers = registeredArguments.map((arg) => register(arg))
  argumentPointers.forEach(pointer => {
    console.log(
      readHelloResult(
        buffer,
        hello(pointer) // 登録した引数用の文字列のバイト配列内での位置を投げる
      )
    )
  })
}
function readHelloResult (buffer, offset) {
  const view = new DataView(buffer)
  // 文字列の長さをバイト配列から取りだし
  const stringLength = view.getUint32(offset, true)
  // 必要な分だけバイト配列から取り出して decode
  const array = new Uint8Array(view.buffer, offset + 4, stringLength)
  return decoder.decode(array)
}
function storeArgumentAndGetPointer (buffer, head = 0) {
  let offset = head
  return function (string) {
    const pointer = offset
    const { length } = string
    // 文字列の長さをセット
    new Uint32Array(buffer, offset, 1).set([length])
    offset += 4
    // 文字列を後ろにセット
    new Uint8Array(buffer, offset, length).set(encoder.encode(string))
    offset += length+ (8 - (string.length % 8))
    return pointer
  }
}
main()

実行

$ node exec.js
Hello, Wasm.
Yay!

感想

Memory 管理をちゃんとしないと確実に破滅することは間違いないでしょう。基本的にバイト配列を直接読み書きすることになるので、決まった量を決まった位置でやりとりする処理でないと扱いが難しそうです。

興味があって触ってみましたが、何かしら高速化が必要な処理があるとしても、わたしような普通の Web 系のレベルでは実戦投入するにはちょっと難しいという感想でした (単純な計算なら大丈夫な気もしますが、単純な計算で高速化が必要になる局面は無さそうです)。