🐱

DenoとZigの間でデータ受け渡し

2023/08/22に公開

前回の記事はこちら。

https://zenn.dev/itte/articles/57021ace128fff

前回JavaScriptとWasmでデータを受け渡す概念的なことを説明しました。今回は、実際にDenoとZigのコードを書いていきます。
Denoで書いていますが、これまでの記事で説明したようにブラウザのJavaScriptでも少し書き換えるだけで利用できます。

今回説明する方法はあくまで、私が考えた1つの方法にすぎないことをご了承ください。前記事を踏まえて自身で学んで書くか、ライブラリを探すのも一つの手だと思います。

なお、私はZigにあまり詳しくなく、データ受け渡しのための最低限のことしか勉強していません。そのため、Zigのコードについては稚拙なものになっているかもしれませんがご了承ください。

何を作るか

今回の目標は、Zigの標準ライブラリのBase64エンコードをWasm化することです。

これは実際に計測して確かめたことなのですが、Base64エンコード程度のものだと、Wasm化せずにDenoの標準ライブラリを使ったほうが速いです。

しかし、文字列から文字列への変換なので分かりやすいため採用しました。これができれば、他の標準ライブラリにも応用できます。

Base64エンコードを使ってみる

次のコードはBase64エンコード行うコードです。説明は後ほど。

hello.zig
const std = @import("std");
const print = std.debug.print;
const encoder = std.base64.standard.Encoder;
const allocator = std.heap.page_allocator;

pub fn main() !void {
  const memory = allocator.alloc(u8, 10) catch unreachable;
  defer allocator.free(memory);
  const result = encoder.encode(memory, "Hello");
  print("print: {s}\n", .{result});
}

実行すると次のように出力されます。

> zig run hello.zig
print: SGVsbG8=

Zigでメモリ確保

Zigでメモリ確保するにはpage_allocatorを使います。

次の場合、10バイトのメモリを返します。

const memory = allocator.alloc(u8, 10) catch unreachable;

戻り値の型は[]u8になっています。これはu8のスライスを意味しています。

文字列はUTF-8ですので、u8のスライスは文字列として扱うことができます。

ZigのBase64エンコード

次に、Base64エンコードを行うメソッドの型はこのようになっています。

encode(encoder: *const Base64Encoder, dest: []u8, source: []const u8) []const u8 

これはメソッドですので、第一引数は構造体になっています。encoder.encodeencoderのところです。そのため実際の第一引数はdestです。

destの型は[]u8になっていますのでスライスです。

sourceの型は[]const u8になっています。実はこれもスライスです。constが付いているのは明示的に引数に与えられたスライスを変更しないことを示しています。逆に言えばconstが付いていないdestのほうは変更されます。

戻り値は[]const u8になっています。これもスライスですね。引数に与えたdestと戻り値のスライスはどちらもポインタは同じところを指していますが、長さが異なります。destのうち、結果が格納された分だけのスライスが戻り値として返ります。

よって次のように、第一引数にメモリ、第二引数に元の文字列のスライスを渡すことでBase64エンコードができます。

const result = encoder.encode(memory, "Hello");

3点セットの格納領域

前回の記事で説明した、ポインタ、キャパシティ、サイズの3点セットを保存するために、構造体を作ります。これをバッファと名付けることとします。

const Buffer = packed struct {
    ptr: [*]u8,
    len: usize = 0,
    cap: usize,

    pub fn init(cap: usize) Buffer {
        var memory = allocator.alloc(u8, cap) catch unreachable;
        return Buffer {
            .ptr = memory.ptr,
            .cap = cap,
        };
    }

    pub fn destroy(self: Buffer) void {
        allocator.free(self.ptr[0..self.cap]);
    }
};

ptrの型[*]u8u8配列・スライスのポインタです。Wasmのポインタは32bitですので32bitです。lencapの型usizeもポインタと同じサイズの負号なし整数ですので32bitです。よってこの構造体は32bit×3のデータ型ということになります。

キャパシティからバッファを作成する関数と、確保したメモリを解放する関数も書いています。

3点セットは引数用と戻り値用など、複数個必要ですので、次のように、構造体をスライスで保存するグローバル変数を用意しておきます。

var buffers :[]Buffer = undefined;

バッファをJavaScriptから操作する

JavaScriptからバッファを作ったりアクセスしたりできるように関数を作っていきます。

バッファの確保

バッファを確保するための関数を作っていきます

バッファのスライスを確保

次の関数は引数で渡された長さのバッファのスライスを作ります。

Zig
export fn alloc_buffers(size: usize) void {
    buffers = allocator.alloc(Buffer, size) catch unreachable;
}

JavaScriptで使うときはこのようになります。

JavaScript
const allocBuffers = instance.exports.alloc_buffers as (size: number) => void
allocBuffers(2)

バッファを作成

次に、先ほど作ったバッファのスライスの各要素にバッファを作る関数です。インデックスとキャパシティを指定します。

Zig
export fn create_buffer(index: usize, cap: usize) *Buffer {
    buffers[index] = Buffer.init(cap);
    return &buffers[index];
}

戻り値はバッファのポインタになっています。

JavaScriptで使うときはこのようになります。

JavaScript
const createBuffer = instance.exports.create_buffer as (index: number, size: number) => number
const pointer = createBuffer(0, 100)

バッファの解放

作成したバッファを解放する関数も用意しておきます。

Zig
export fn free_buffers() void {
    for (buffers) |buffer| {
        buffer.destroy();
    }
    allocator.free(buffers);
}

JavaScriptではこのように呼び出します。

JavaScript
const freeBuffers = instance.exports.free_buffers as () => void
freeBuffers()

JavaScriptからバッファの読み書き

バッファは32bit×3の構造体ですのでUint32Arrayにすることで、0番目にポインタ、1番目にサイズ、2番目にキャパシティが入っています。これを使って読み書きします。

JavaScriptからバッファに書き込み

JavaScriptからバッファに書き込むには次の手順になります。毎回同じ手順なので関数化しておきます。

JavaScript
function setBuffer(index: number, data: Uint8Array): number {
  // インデックスの位置に必要なサイズのバッファを作る
  const pointer = createBuffer(index, data.length)

  // バッファをUint32Arrayでアクセスできるようにする
  const buffer = new Uint32Array(memory.buffer, pointer, 3)

  // buffer[0]にポインタが入っているのでその位置にデータを書き込む
  const binary = new Uint8Array(memory.buffer, buffer[0], data.length)
  binary.set(data)

  // サイズを書き込む
  buffer[1] = data.length

  return pointer
}

最後にポインタを返しているのは、後で関数の実行や、データ読み込みに利用するためです

JavaScriptからバッファのデータを読み込み

JavaScriptからデータを読み込む関数も作っておきます。

JavaScript
function getBuffer(pointer: number): Uint8Array {
  const buffer = new Uint32Array(memory.buffer, pointer, 3)
  return new Uint8Array(memory.buffer, buffer[0], buffer[1])
}

Uint8Arrayにしていますが、データそのものはまだメモリを参照していますので、書き換えられる恐れがあります。メモリを解放する前にUint8Arrayから別のオブジェクトに変換しておきましょう。

Base64エンコードする

先ほど準備したバッファを使って、Base64エンコードを実行する関数を作ります。今回はBase64エンコードですが、他の文字列やバイナリでも同じく、バッファを介して実データにアクセスします。

Zig
export fn encode_buffer(dest: *Buffer, source: *Buffer) void {
    // バッファからそれぞれのスライスを作ってエンコードを実行
    const result = encoder.encode(dest.ptr[0..dest.cap], source.ptr[0..source.len]);

    // 結果のサイズを書き込む
    dest.len = result.len;
}

JavaScriptからはバッファのポインタを使って関数を呼び出します。

JavaScript
const encodeBuffer = instance.exports.encode_buffer as (index: number, source: number) => number
encodeBuffer(destPointer, sourcePointer)

作った関数を実行

作ったBase64エンコードをJavaScriptから実行するには事前準備が必要で、結果の取得も必要になります。次の手順になります。

  1. 必要なバッファを作成・データ書き込み
  2. 関数を実行
  3. 結果をバッファから取得

実際に受け取った文字列をBase64エンコードした文字列を返す関数のコードを見てみます。

JavaScript
export function encode(text: string): string {
  // 文字列をUint8Arrayにする
  const source = new TextEncoder().encode(text)

  // エンコード前とエンコード後の2つ分のバッファを準備する
  allocBuffers(2)

  // エンコードする文字列をバッファにセット
  const sourcePointer = setBuffer(0, source)

  // 結果用のバッファを作る
  //(BASE64エンコードはデータを6bitずつにするのでbit数を6で割って繰り上げれば足りる)
  const destPointer = createBuffer(1, Math.ceil(source.length * 8 / 6))

  // 関数を実行する
  encodeBuffer(destPointer, sourcePointer)

  // 結果を取得する
  const dest = getBuffer(destPointer)

  // メモリ解放前に結果を文字列にする
  const result = new TextDecoder().decode(dest)

  // メモリを解放する
  freeBuffers()

  return result
}

使ってみる

最終的なコードはこちらです。折りたたんでいます。

base64.zig
base64.zig
const std = @import("std");
const allocator = std.heap.page_allocator;
const encoder = std.base64.standard.Encoder;

const Buffer = packed struct {
    ptr: [*]u8,
    len: usize = 0,
    cap: usize,

    pub fn init(cap: usize) Buffer {
        var memory = allocator.alloc(u8, cap) catch unreachable;
        return Buffer {
            .ptr = memory.ptr,
            .cap = cap,
        };
    }

    pub fn destroy(self: Buffer) void {
        allocator.free(self.ptr[0..self.cap]);
    }
};

var buffers :[]Buffer = undefined;

export fn alloc_buffers(size: usize) void {
    buffers = allocator.alloc(Buffer, size) catch unreachable;
}

export fn create_buffer(index: usize, cap: usize) *Buffer {
    buffers[index] = Buffer.init(cap);
    return &buffers[index];
}

export fn free_buffers() void {
    for (buffers) |buffer| {
        buffer.destroy();
    }
    allocator.free(buffers);
}

export fn encode_buffer(dest: *Buffer, source: *Buffer) void {
    const result = encoder.encode(dest.ptr[0..dest.cap], source.ptr[0..source.len]);
    dest.len = result.len;
}
コンパイル
zig build-lib base64.zig -target wasm32-freestanding -dynamic -rdynamic --import-memory -O ReleaseSmall
base64.ts
base64.ts
const memory = new WebAssembly.Memory({initial:20, maximum:100})
const module = new WebAssembly.Module(Deno.readFileSync('base64.wasm'))

const instance = new WebAssembly.Instance(module, { env: { memory } })

const allocBuffers = instance.exports.alloc_buffers as (size: number) => void
const freeBuffers = instance.exports.free_buffers as () => void
const createBuffer = instance.exports.create_buffer as (index: number, size: number) => number
const encodeBuffer = instance.exports.encode_buffer as (index: number, source: number) => number

function setBuffer(index: number, data: Uint8Array): number {
  const pointer = createBuffer(index, data.length)
  const buffer = new Uint32Array(memory.buffer, pointer, 3)
  const binary = new Uint8Array(memory.buffer, buffer[0], data.length)
  binary.set(data)
  buffer[1] = data.length
  return pointer
}

function getBuffer(pointer: number): Uint8Array {
  const buffer = new Uint32Array(memory.buffer, pointer, 3)
  return new Uint8Array(memory.buffer, buffer[0], buffer[1])
}

export function encode(text: string): string {
  const source = new TextEncoder().encode(text)

  allocBuffers(2)
  const sourcePointer = setBuffer(0, source)
  const destPointer = createBuffer(1, Math.ceil(source.length * 8 / 6))

  encodeBuffer(destPointer, sourcePointer)

  const dest = getBuffer(destPointer)
  const result = new TextDecoder().decode(dest)
  freeBuffers()

  return result
}

作った関数を呼び出してみます。

base64bin.ts
import { encode } from './base64.ts'

console.log(encode('hello'))
> deno run -A base64bin.ts
aGVsbG8=

最後に

以上、4記事に渡ってZigで作ったWasmをDenoから使えるようにする方法を書いてきました。

最後に、Wasmにする処理というのは、今回はBase64を使いましたが、そんな単純なものではなくJavaScriptで実行すると重くなってしまう処理が多いと思います。

そういった処理をシングルスレッドで実行するのは現実的ではありませんので、WasmのプログラムはWorkerにした方が良いです。Workerの使い方はここで説明を省きますが、その使い方にクセがありますのでWorkerをPromiseにする記事も書いてみました。よければ併せてお読みください。

https://zenn.dev/itte/articles/5f18c19c735fd1

Discussion