RustとGoでWebAssemblyのファイルサイズを比較する

f:id:aptpod_tech-writer:20191128152059p:plain

この記事はAptpod Advent Calendar 2019の3日目の記事です。

先端技術調査グループの大久保です。

最近はWebAssemblyが注目されるようになり、弊社でもWebフロントエンド側での軽量化・高速化に応用できないか検討をしています。

そこで、今回はWebSocketのechoクライアントをRustとGoで作成し、wasmへコンパイルした時のファイルサイズを比較してみます。現状では、wasm内から直接WebSocketを取り扱う方法は無いらしいので、JavaScriptのAPIを呼び出してWebSocket通信を実現します。

Goでの実装

私はGoが書けないため、先輩からもらった以下のコードを使わせてもらいました。適当にwstest.goと名前を付けて保存します。

package main

import (
    "fmt"
    "syscall/js"
)

func print(i []js.Value) {
    fmt.Println(i)
}

func websocket_test(this js.Value, args []js.Value) interface{} {
    fmt.Println("start websocket_test")
    ws := js.Global().Get("WebSocket").New("wss://echo.websocket.org/")

    ws.Set("onopen", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        fmt.Println("open")
        ws.Call("send", "hello world")
        return nil
    }))

    ws.Set("onmessage", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        data := args[0].Get("data")
        fmt.Println("Received =", data)
        fmt.Println(args, args[0], data.Type(), data.String())
        ws.Call("close")
        return nil
    }))

    ws.Set("onerror", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        fmt.Println("Error")
        return nil
    }))

    ws.Set("onclose", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        fmt.Println("WebSocket connection closed.")
        return nil
    }))

    return nil
}

func registerCallbacks() {
    js.Global().Set("websocket_test", js.FuncOf(websocket_test))
    fmt.Println("After callback register")
}

func main() {
    c := make(chan struct{}, 0)
    registerCallbacks()
    <-c
}

index.htmlを次の内容で用意します。

<!doctype html>
<html>

<head>
    <meta charset="utf-8">
    <title>Go wasm</title>
</head>

<body>
    <script src="wasm_exec.js"></script>
    <script>
        const go = new Go();
        WebAssembly.instantiateStreaming(fetch("test.wasm"), go.importObject).then((result) => {
            go.run(result.instance);
        });
    </script>

    <button onClick="websocket_test();" id="runButton" >Run</button>
</body>
</html>

次のコマンドでコンパイルします。

GOOS=js GOARCH=wasm go build -o test.wasm

あと、wasm_exec.jsを用意します。

curl -sO https://raw.githubusercontent.com/golang/go/master/misc/wasm/wasm_exec.js

これらの用意したファイルがあるディレクトリでサーバを立ち上げます。

python3 -m http.server 8080

Webブラウザで http://localhost:8080/ にアクセスすると、ボタンを押すたびに次のようにコンソールへ出力されます。

open
Received = hello world
[<object>] <object> string hello world
WebSocket connection closed.

エコーサーバにアクセスし、メッセージが返ってきていることが確認できます。

Rustでの実装

RustからWasmのHelloWorldは以下のドキュメントの通り行えば問題ありません。もちろんRustの処理系はあらかじめインストールしておく必要があります。

https://rustwasm.github.io/docs/book/game-of-life/hello-world.html

WasmからWebSocketを利用する方法は、以下参考。

https://rustwasm.github.io/docs/wasm-bindgen/examples/websockets.html

Goの方の実装に合わせて少々変えたので、以下ソースコードを記載します。まずはlib.rsから。

#![no_std]

extern crate alloc;

use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{ErrorEvent, MessageEvent, WebSocket};
use alloc::boxed::Box;
use alloc::string::ToString;

#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

#[wasm_bindgen]
extern "C" {
    // console.log()をインポートする
    #[wasm_bindgen(js_namespace = console)]
    pub fn log(s: &str);
}

macro_rules! console_log {
    // println!()風にconsoleへ出力するためのマクロ
    ($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
}

#[wasm_bindgen]
pub fn websocket_test() -> Result<(), JsValue> {
    // echoサーバにつなげる
    let ws = WebSocket::new("wss://echo.websocket.org")?;

    // コールバックを登録していく
    let onmessage_callback = Closure::wrap(Box::new(move |e: MessageEvent| {
        let response = e
            .data()
            .as_string()
            .expect("Can't convert received data to a string");
        console_log!("message event, received data: {:?}", response);
    }) as Box<dyn FnMut(MessageEvent)>);
    ws.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref()));
    onmessage_callback.forget();

    let onerror_callback = Closure::wrap(Box::new(move |e: ErrorEvent| {
        console_log!("error event: {:?}", e);
    }) as Box<dyn FnMut(ErrorEvent)>);
    ws.set_onerror(Some(onerror_callback.as_ref().unchecked_ref()));
    onerror_callback.forget();

    let cloned_ws = ws.clone();
    let onopen_callback = Closure::wrap(Box::new(move |_| {
        console_log!("socket opened");
        match cloned_ws.send_with_str("ping") {
            Ok(_) => console_log!("message successfully sent"),
            Err(err) => console_log!("error sending message: {:?}", err),
        }
    }) as Box<dyn FnMut(JsValue)>);
    ws.set_onopen(Some(onopen_callback.as_ref().unchecked_ref()));
    onopen_callback.forget();

    Ok(())
}

web_sysというクレート以下にJavaScriptのオブジェクトにアクセスするための各種APIが用意されているので、それを利用します。また、#[wasm_bindgen]という属性をつけるだけで、RustとJavaScriptの間で関数やオブジェクトを相互にやりとりできるようにしてくれます。ここでは、console.log()をRustから利用できるようにしたり、websocket_test()という関数をJavaScript側から利用できるよう設定します。このwebsocket_test()が呼び出されると、WebSocketのインスタンスを作成し、on_openなどの各種コールバックを設定していきます。JavaScriptの関数やオブジェクトをシームレスに扱えるので、Goよりも洗練されている印象です。ただしクロージャまわりは若干トリッキーに書く必要があるようです。Rustのライフタイムを理解していないと意味不明かもしれません。

また、よりサイズを小さくするため#![no_std]を指定し、標準ライブラリが含まれないようにします。また、メモリアロケータにWeeAllocを指定します。これはwasm向けのよりサイズの小さいメモリアロケータです。

index.htmlではボタンを配置します。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Hello wasm-pack!</title>
  </head>
  <body>
    <noscript>This page contains webassembly and javascript content, please enable javascript in your browser.</noscript>
    <script src="./bootstrap.js"></script>
    <button id="runButton">Run</button>
  </body>
</html>

index.jsにJavaScriptを書いていきます。ここでは、ボタンがクリックされた場合に、lib.rsで定義したwebsocket_test()を呼び出すようにします。

import * as wasm from "wsecho-client";

document.getElementById("runButton").onclick = function() {
    wasm.websocket_test();
};

npm startでサーバを立ててアクセスすると、ボタンを押すたびにコンソールに次のような表示が出ます。

socket opened
message successfully sent
message event, received data: "ping"

エコーサーバにメッセージを投げて、返ってくることを確認できました。

ファイルサイズの比較

RustとGoそれぞれで生成されたwasmファイルを比較してみます。どちらも生成されるwasmファイルは1つのみですが、wasmをロードしたりするためのJavaScriptファイルがいくつかついてきます。JavaScriptファイルのサイズはどちらも10KB程度ですが、Rustの方はJavaScript向けにバインディングを生成する関係上、サイズが変化する可能性があります。ちなみにRustからはTypeScript用のバインディングも勝手に生成してくれるというオマケつき。

wasmファイルのサイズは以下の通りです。

Rust Go
wasmファイルのサイズ(バイト) 42875 2276691

Goはランタイムが重いせいか、Rustのほうが圧倒的にサイズが小さいです。

サイズを最適化

UNIXには実行ファイルを小さくする(シンボル情報を削除する)ためのstripコマンドがありますが、似たようなものがwasmにも用意されています。以下の2つのツールを使ってサイズを最適化してみます。

wasm-strip (https://github.com/WebAssembly/wabt) カスタムセクション(シンボル情報みたいなもの)を削除してくれるツール。

wasm-opt (https://github.com/WebAssembly/binaryen) 用途に応じてwasmを最適化してくれるツール。サイズ最適化には-Osオプションを使う。

これらを適用した結果が以下の表になります。 (単位はバイト)

Rust Go
オリジナル 42875 2276691
wasm-strip適用 30649 2226331
wasm-opt適用 26663 2161744
両方適用 26538 2161607

どちらもサイズの削減ができましたが、Goの方は2MBを割ることはできませんでした。GC等のランタイムが入っているとしたらこんなものなのでしょうか。tinygoというものを使えばずっと小さくできるらしく、試したところwasmファイルの生成まで行きましたが、Webブラウザでアクセスした時にエラーが出たので今回は割愛します。

ちなみに、wasm-optはデフォルトでカスタムセクションを削除するのでwasm-stripを重ねて適用する必要は無いはずですが、wasm-strip -> wasm-opt の順で適用すると、wasm-optだけ適用した時と少しサイズが異なってました。

まとめ

  • wasmのファイルサイズを見ると、RustはGoよりずっと軽量
  • RustとJavaScriptのやりとりはかなりシームレスに書けるようになっている。
  • wasmのバイナリをいじるツールもリリースされており、サイズ圧縮もできる。

WebAssemblyについては引き続き調査を行って参ります!