Usumerican Graffiti

プログラミングについて考えています

Zig 言語で文字列を受け渡す WebAssembly を作る方法

WebAssembly の関数で受け渡せるデータ型は i32, i64, f32, f64 なので、文字列の受け渡しはできません。

文字列を受け渡しは WebAssembly の線形メモリを利用することになります。

線形メモリのサイズの上限は 2GB でアドレス空間は 32bit なので、領域情報(オフセットと長さの組)は i64 で表せます。

WebAssembly の i64 は、JavaScript では bigint で扱え、Zig では 64bit の packed struct で扱えます。

線形メモリ上にバイト列の領域を確保して、領域情報(オフセットと長さの組)を関数の引数や戻り値に使うことで文字列を受け渡す関数を実現します。

文字列の受け渡しができるようになれば、複雑なデータ型も JSON 文字列で行えます。

文字列を受け渡す関数の流れ

入力文字列の領域の確保の要求は JavaScript 関数から行います。

出力文字列の領域の確保は WebAssembly 関数内で行います。

領域の解放の要求は JavaScript 関数から抜ける前に行います。

WebAssembly関数JavaScript関数WebAssembly関数JavaScript関数呼び出し元入力文字列を渡す入力文字列の領域の確保を要求する入力文字列の領域を確保する入力文字列の領域情報を返す入力文字列の領域に入力文字列を書き込む入力文字列の領域情報を渡す入力文字列の領域を読み込んで処理する出力文字列の領域を確保する出力文字列の領域に出力文字列を書き込む出力文字列の領域情報を返す出力文字列の領域から出力文字列を読み込む出力文字列の領域の解放を要求する出力文字列の領域を解放する入力文字列の領域の解放を要求する入力文字列の領域を解放する出力文字列を返す呼び出し元

Zig の実装

Zigの実装は次のとおりです。

領域情報の操作は JavaScript からも使うので、構造体のメソッドではなく関数として実装してエクスポートしています。

領域の確保と解放の管理には std.heap.wasm_allocator を使います。

// src/root.zig

const std = @import("std");
const testing = std.testing;
const builtin = @import("builtin");

/// 線形メモリの確保と解放を管理するアロケータ。
/// テスト時は、メモリリークを検出できる testing.allocator を使う。
const allocator = if (builtin.is_test) testing.allocator else std.heap.wasm_allocator;

/// 線形メモリに確保した領域の情報。
/// wasm32 では i64, js では bigint となる。
const Allocation = packed struct {
    /// 確保した領域のオフセット。
    /// wasm32 では u32 となる。
    offset: usize,

    /// 確保した領域の長さ。
    /// wasm32 では u32 となる。
    length: usize,

    // 確保した領域のスライスを取得する。
    fn getBytes(self: @This()) []u8 {
        return @as([*]u8, @ptrFromInt(self.offset))[0..self.length];
    }
};

/// 領域情報のオフセットを取得する。
export fn getAllocationOffset(allocation: Allocation) usize {
    return allocation.offset;
}

/// 領域情報の長さを取得する。
export fn getAllocationLength(allocation: Allocation) usize {
    return allocation.length;
}

/// 線形メモリに領域を確保する。
/// 確保した領域は必ず解放すること。
export fn allocBytes(length: usize) Allocation {
    const bytes = allocator.alloc(u8, length) catch return .{ .offset = 0, .length = 0 };
    return .{ .offset = @intFromPtr(bytes.ptr), .length = bytes.len };
}

/// 線形メモリの領域を解放する。
export fn freeBytes(allocation: Allocation) void {
    allocator.free(allocation.getBytes());
}

test "Allocation" {
    const allocation = allocBytes(10);
    defer freeBytes(allocation);
    try testing.expect(0 != getAllocationOffset(allocation));
    try testing.expectEqual(10, getAllocationLength(allocation));
}

/// 文字列を受け取り、指定した回数だけ繰り返した文字列を生成して返す。
export fn repeat(input_allocation: Allocation, count: usize) Allocation {
    var output_allocation = allocBytes(input_allocation.length * count);
    if (output_allocation.length > 0) {
        const input_bytes = input_allocation.getBytes();
        var output_bytes = output_allocation.getBytes();
        var i: usize = 0;
        for (0..count) |_| {
            for (input_bytes) |b| {
                output_bytes[i] = b;
                i += 1;
            }
        }
    }
    return output_allocation;
}

test "repeat" {
    const input_string = "abc";
    const input_allocation = allocBytes(input_string.len);
    defer freeBytes(input_allocation);
    @memcpy(input_allocation.getBytes(), input_string);
    const output_allocation = repeat(input_allocation, 2);
    defer freeBytes(output_allocation);
    try testing.expectEqualStrings("abcabc", output_allocation.getBytes());
}

Zig のビルド

線形メモリを使うので、zig build-exe のオプションに --import-memory を指定します。

% zig build-exe -target wasm32-freestanding -O ReleaseFast -fstrip -fno-entry -rdynamic --import-memory src/root.zig

JavaScript の実装

JavaScriptの実装は次のとおりです。

WebAssemblyの初期化時における線形メモリの設定は、JavaScript側で行っています。

領域の解放は finally 句に記述し、解放が確実に行われるようにしています。

// index.js

import fs from 'fs';

// 線形メモリの初期サイズを指定して、WebAssemblyのインスタンスを生成する。
const memory = new WebAssembly.Memory({ initial: 17 });
const { instance } = await WebAssembly.instantiate(fs.readFileSync('root.wasm'), { env: { memory } });

// 文字列の変換器を用意しておく。
const encoder = new TextEncoder();
const decoder = new TextDecoder();

/** 領域情報のオフセットを取得する。 */
function getAllocationOffset(allocation) {
  return instance.exports.getAllocationOffset(allocation) >>> 0;
}

/** 領域情報の長さを取得する。 */
function getAllocationLength(allocation) {
  return instance.exports.getAllocationLength(allocation) >>> 0;
}

/** 領域の確保を要求する。 */
function allocBytes(length) {
  const allocation = instance.exports.allocBytes(length);
  if (!allocation) {
    // 領域の確保に失敗した時は例外を投げる。
    throw new Error('failed to allocate in allocBytes');
  }
  return allocation;
}

/** 領域の解放を要求する。 */
function freeBytes(allocation) {
  instance.exports.freeBytes(allocation);
}

/** 文字列を書き込む。 */
function encode(string) {
  const bytes = encoder.encode(string);
  const allocation = allocBytes(bytes.length);
  const length = getAllocationLength(allocation);
  if (length) {
    new Uint8Array(memory.buffer, getAllocationOffset(allocation), length).set(bytes);
  }
  return allocation;
}

/** 文字列を読み出す。 */
function decode(allocation) {
  const length = getAllocationLength(allocation);
  return length ? decoder.decode(new Uint8Array(memory.buffer, getAllocationOffset(allocation), length)) : '';
}

/** 文字列を繰り返す。 */
function repeat(inputString, count) {
  const inputAllocation = encode(inputString);
  try {
    const outputAllocation = instance.exports.repeat(inputAllocation, count);
    if (!outputAllocation) {
      // 出力文字列の領域の確保に失敗した時は例外を投げる。
      throw new Error('failed to allocate in repeat');
    }
    try {
      return decode(outputAllocation);
    } finally {
      // 領域の解放は finnaly 句で確実に行う。
      freeBytes(outputAllocation);
    }
  } finally {
    // 領域の解放は finnaly 句で確実に行う。
    freeBytes(inputAllocation);
  }
}

// repeat を実行する。
console.log(repeat('abc', 3));

Node.js で実行

% node index.js
abcabcabc

参考

以上です。