ludwig125のブログ

頑張りすぎずに頑張る父

github pages でWASMを使ったGoのWebツールを動かす 【その5】(UnixTime変換ツールのTinyGoへの置き換え)

ページの構成

TinyGo への置き換え

TinyGoのInstallと実行

Installなどはこちらのページにまとめました

ludwig125.hatenablog.com

TinyGo の実行方法

上で作った Unixtime ツールを TinyGo に置き換えてみます。

以下の方法で TinyGo として Buid できます。(通常の Go に比べて若干 Build に時間がかかるような気がします)

$tinygo build -o unixtime.wasm -target wasm unixtime.go
cp $(tinygo env TINYGOROOT)/targets/wasm_exec.js .

これだけで TinyGo として WASM で実行できます。

[~/go/src/github.com/ludwig125/githubpages/unixtime_tinygo] $goexec 'http.ListenAndServe(`:8080`, http.FileServer(http.Dir(`.`)))'

ただ、http://localhost:8080/ を見ると、一見問題ないように見えますが、 「変換対象の時刻」に Unixtime を入れると Console にエラーがでます。 (処理自体は問題なく行われます)

image

エラー:syscall/js.finalizeRef not implemented

このエラー原因について詳しくは以下を見ると良いのですが、

TinyGo のバグなので、TinyGo のwasm_exec.jsが修正されるまでは、以下のようにindex.html側に書いておくとこのエラーがなくなります。

const go = new Go();

// TinyGoのバグを無視するため
// https://github.com/tinygo-org/tinygo/issues1140#issuecomment-671261465
go.importObject.env["syscall/js.finalizeRef"] = ()=> {};

WebAssembly.instantiateStreaming(
    fetch("unixtime.wasm"),
    go.importObject
).then((result) => {

参考:

これでエラー文が出なくなります。

TinyGo のバイナリサイズ

2つのバイナリサイズを比べてみます

[~/go/src/github.com/ludwig125/githubpages/unixtime] $GOOS=js GOARCH=wasm go build -o unixtime.wasm
[~/go/src/github.com/ludwig125/githubpages/unixtime] $ls -l
合計 2096
-rw-r--r-- 1 ludwig125 ludwig125    1247  2月 14 06:58 index.html
-rw-r--r-- 1 ludwig125 ludwig125    2103  2月 18 06:08 unixtime.go
-rwxr-xr-x 1 ludwig125 ludwig125 2113909  2月 18 06:08 unixtime.wasm*
-rw-r--r-- 1 ludwig125 ludwig125   18346  2月 14 06:10 wasm_exec.js
[~/go/src/github.com/ludwig125/githubpages/unixtime_tinygo] $tinygo build -o unixtime.wasm -target wasm unixtime.go
[~/go/src/github.com/ludwig125/githubpages/unixtime_tinygo] $ls -l
合計 464
-rw-r--r-- 1 ludwig125 ludwig125   1437  2月 17 06:39 index.html
-rw-r--r-- 1 ludwig125 ludwig125   2103  2月 18 06:08 unixtime.go
-rwxr-xr-x 1 ludwig125 ludwig125 447857  2月 18 06:09 unixtime.wasm*
-rw-r--r-- 1 ludwig125 ludwig125  15929  2月 14 06:30 wasm_exec.js

私の環境では、ほぼ同じコードでも、TinyGo は Go と比べてunixtime.wasm*のバイナリサイズが 1/4 以下になっていました。

TinyGo の速度

バイナリサイズが小さいということは、当然 WASM として Fetch したり Load するのも速くなるはずです。

通常の Go と TinyGo の Load までの時間を計測するために、それぞれのindex.htmlに以下のコードを追加してみます。

<script>
    var start = performance.now(); // 追加部分

    const go = new Go();

    WebAssembly.instantiateStreaming(
        fetch("unixtime.wasm"),
        go.importObject
    ).then((result) => {
        go.run(result.instance);

        var end = performance.now(); // 追加部分
        console.log("latency of load and run wasm %f ms", end - start); // 追加部分
    });
</script>

https://developer.mozilla.org/ja/docs/Web/API/Performance/now

こちらのパフォーマンス計測用の関数を使います

  • WebAssembly.instantiateStreamingの前をstart
  • go.run(result.instance);の後をend

としてこの差分を測ってみます。

ついでに、Go の方の関数にも Latency を計測するために以下の部分を追記します。

func convTime(this js.Value, args []js.Value) interface{} {
    start := time.Now()
    defer func() {
        fmt.Println("convTime latency:", time.Since(start))
    }()

    略

これで、Go と TinyGoUnixtime の Web ページをそれぞれ順番に見てみます。

通常の Go image

TinyGo image

注意点

  • Go のあとに TinyGo のページを読み込みなおすときは、Chrome のキャッシュに残っていておかしなエラーが出る場合があります。この場合はキャッシュをクリアしてページを再読み込みするために、Ctrl+Shift+Rでページを更新するといいです

WASM の Fetch から実行までの時間は

  • Go: 52.10000002384186 ms
  • TinyGo: 16 ms

となりました。

やはり、起動までの時間は TinyGo の方が短くなっています。 今回は小さなプログラムなので、この程度の差ですが、大きなプログラムになると実行までの時間はさらに変わってくるかも知れません。

一方で、convTimeの実行速度はあまり変わりませんでした。 これは意外でした。

ひとたびバイナリとして読み込んでメモリに乗ってしまえばあとはそんなに変わらないものなのか、それとも実行している関数がそんなに違いが見られる類のものではなかったのかも知れませんが分かりません。

export を利用した TinyGo コードの書き換え

TinyGo は Go と同じコードをそのまま使えますが、TinyGo ならではのexportの機能を使うとコードをより直接に呼びだすことができます

https://tinygo.org/docs/guides/webassembly/

If you have used explicit exports, you can call them by invoking them under the wasm.exports namespace. See the export directory in the examples for an example of this.

とあるとおり、以下のように Go の関数に//export 関数名をつけるだけで、なんと Javascript 側から呼びだすことができます。

//export multiply
func multiply(x, y int) int {
    return x * y;
}
  • ここで、//export//exportの間に半角スペースを入れると認識されないので、くっつけて書くことを注意してください**
  • ちなみに//exportは以前は//go:exportでしたが、2020 年に変わったので少し古い資料を見ると//go:exportとなっていることがあります

javascript からの呼び出し方法

// Calling the multiply function:
console.log("multiplied two numbers:", wasm.exports.multiply(5, 3));

このmultiply関数はこれまでの WASM の Go の書き方の multiply(this js.Value, args []js.Value) interface{} のような形にしなくて済むというのが最大の利点です。

//exportを使った場合の大きな問題点もあるのですがそれは後述します

この機能を使うと、Unixtime ツールの例えばsetTimeZone関数は以下のようにシンプルになり、

//export setTimeZone
func setTimeZone() {
    t := time.Now()
    zone, _ := t.Zone()
    setJSValue("time_zone", fmt.Sprintf("(%s)", zone))
}

index.html 側では以下のように呼びだすことができます。

const go = new Go();
WebAssembly.instantiateStreaming(fetch("unixtime.wasm"), go.importObject).then(
    (result) => {
        go.run(result.instance);

        result.instance.exports.setTimeZone();
    }
);

この方式で、go.run(result.instance);のあとに必要な処理をつらつら書いても良いのですが、これだとindex.html<head><script>部分が肥大するので、以下の資料を参考にindex.jsファイルに切り出してみます。

package main

import (
    "errors"
    "fmt"
    "strconv"
    "syscall/js"
    "time"
)

func main() {}

//export setTimeZone
func setTimeZone() {
    t := time.Now()
    zone, _ := t.Zone()
    setJSValue("time_zone", fmt.Sprintf("(%s)", zone))
}

func setJSValue(elemID string, value interface{}) error {
    // 元と同じ
}

func getElementByID(targetID string) js.Value {
    // 元と同じ
}

//export clock
func clock() {
    nowStr, nowUnix := getNow(time.Now())

    getElementByID("clock").Set("textContent", nowStr)
    getElementByID("clock_unixtime").Set("textContent", nowUnix)
}

//export convTime
func convTime() {
    in := getElementByID("in").Get("value").String()
    date, err := unixtimeToDate(in)
    if err != nil {
        getElementByID("out").Set("value", js.ValueOf("不正な時刻です"))
        return
    }
    getElementByID("out").Set("value", js.ValueOf(date))
}

// 以降、元と同じ

//export」を使うことでかなりシンプルになりました。 TinyGo の export を使えば Javascript 側から Go の関数を直接呼びだすことができます。 コールバック関数が呼び出されたときのために Go のプログラムを永久に終わらせないようにするために、main関数内でチャネルを使っていましたがその必要もなくなりました。

Go の関数の呼び出し側である、HTML と Javascript も修正します。

前述の通り head 部分を見やすくするために、以下を参考に修正しました。

まず、WASM ファイルのインスタンス生成部分を別のファイルにします。

instantiateWasm.js

export const wasmBrowserInstantiate = async (wasmModuleUrl, importObject) => {
    let response = undefined;

    if (!importObject) {
        importObject = {
            env: {
                abort: () => console.log("Abort!"),
            },
        };
    }

    response = await WebAssembly.instantiateStreaming(
        fetch(wasmModuleUrl),
        importObject
    );

    return response;
};
// polyfillを定義した場合
if (WebAssembly.instantiateStreaming) {
    response = await WebAssembly.instantiateStreaming(
        fetch(wasmModuleUrl),
        importObject
    );
} else {
    const fetchAndInstantiateTask = async () => {
        const wasmArrayBuffer = await fetch(wasmModuleUrl).then((response) =>
            response.arrayBuffer()
        );
        return WebAssembly.instantiate(wasmArrayBuffer, importObject);
    };
    response = await fetchAndInstantiateTask();
}

一方、呼び出し側のindex.htmlから、WASM の呼び出し部分を切り出して別のファイルにすると以下のようになります。

index.js

import { wasmBrowserInstantiate } from "./instantiateWasm.js";

const go = new Go(); // Defined in wasm_exec.js. Don't forget to add this in your index.html.

// TinyGoのバグを無視するため
// https://github.com/tinygo-org/tinygo/issues/1140#issuecomment-671261465
go.importObject.env["syscall/js.finalizeRef"] = () => {};

const runWasm = async () => {
    // Get the importObject from the go instance.
    const importObject = go.importObject;

    // wasm moduleのインスタンスを作成
    const wasmModule = await wasmBrowserInstantiate(
        "./unixtime.wasm",
        importObject
    );

    go.run(wasmModule.instance);

    wasmModule.instance.exports.setTimeZone();
    setInterval(wasmModule.instance.exports.clock, 200);
    document
        .getElementById("in")
        .addEventListener("input", wasmModule.instance.exports.convTime);
};
runWasm();
  • インスタンス生成部分とメインの処理部分を分離して分かりやすくなりました
  • wasmModule.instance.exports.部分がやや鬱陶しいですが、)Go の関数を Javascript ネイティブの関数のように扱うことができるようになったため、実行方法も Javascript の書き方になっています

最後に、このindex.jsindex.htmlから呼びだせば終わりです。

<head>
   <meta charset="utf-8" />
   <title>unixtime</title>
   <link rel="shortcut icon" href="#" />
   <script src="./wasm_exec.js"></script>
   <script type="module" src="./index.js"></script>
</head>

かなり見やすくなったかと思います。

export を使った関数の限界

とても素敵な機能に思える TinyGo の//exportですが、これを書いている 2022 年 3 月の現時点ではとても大きな問題があります。

それは、WASM では直接文字列をやりとりできないことです

以下が WASM の扱える型の種類です。 https://github.com/WebAssembly/design/blob/main/Semantics.md#types

WebAssembly has the following value types:

i32: 32-bit integer
i64: 64-bit integer
f32: 32-bit floating point
f64: 64-bit floating point

そのため、例えば以下のような方法で直接文字列を関数に渡したり返してもらうことはできません

// 以下のようにTinyGoで関数を使うことはできない

//export printMessage
func printMessage(s string) { // stringを受け取ることができない
    fmt.Println("hello:", s)
}

//export returnString
func returnString() string {
    return "hello" // stringを返すこともできない
}

int 型は扱えるので、変数のアドレスと長さを計算してそれを関数に渡す方法があるにはありますが、とても分かりやすいとは言えません。

参考

また仮に文字列を1つ渡せても2つ以上はできないので、その場合は json などでデコードして渡す必要があります

TinyGo での json 参考

以上の理由から、TinyGo でも//exportを使いまくるわけにはいかず、文字列のやり取りをする際は素直に js パッケージを使って Javascript とやり取りしたほうが便利な場面が多そうです。