SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

TypeScriptやGoやRustでWebAssemblyウォークスルー

みなさん、WebAssembly聞いたことありますよね?

アイキャッチ

スマートキャンプでエンジニアをしている瀧川です。

私が初めてWebAssemblyを目にしたのは確か2018年、VimをWebAssemblyに移植してブラウザで動くようにしたという記事だったかなと思います。 https://github.com/rhysd/vim.wasm

当時は「はー、なんだか未来を感じるけど、どう使われてくんだろう」くらいな認識で、最近までほとんど注目していませんでした。

しかし、少し前にffmpeg.wasmについての記事がバズっているのを見かけたときビビっときましたね。

ブラウザ上でffmpegが動かせるのはWebアプリケーションを作る上で可能性が広がりますし、何よりWebAssemblyのポテンシャルが活かされていると感じました。

そこで今回、WebAssemblyの世界観を味わうために、代表的なWebAssemblyで使われている言語をピックアップして試してみようと思います。

その中でWebAssembly自体のメリット、各言語でWebAssemblyをやることの是非を書いていければと思います!

(今後プロダクトでも利用していく可能性は大いにあるので、連載で継続して調べていきたいですね)

WebAssemblyとは

公式サイトより

WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.

Wasmとも略されますが、簡単に説明すると、WebAssemblyとはプログラミング言語をコンパイルすることで生成する ブラウザ上で実行可能なバイナリフォーマット になります。

2015年に公表され、2017年に主要なモダンブラウザで対応されることとなりました。

主なメリットは以下が挙げられるかと思います。

  • JavaScriptの実行に比べ、構文などの解析を伴わないため高速
  • コンパイル後のコードはネイティブに近い性能で高速
  • JavaScriptに比べ、WebAssemblyのフォーマットはコンパクトなので読み込みが高速
    • ※ 各言語の実装でも触れますが、JavaScriptとWebAssemblyのグルーコードなども存在するため一概には軽量とは言えないかもしれません
  • JavaScript以外の多くの言語で実装可能
  • JavaScript以外の言語で実装された既存ライブラリなどを移植可能
  • etc...

対応言語はかなり幅広く、なんとRubyでも実現はできるようです。 https://github.com/appcypher/awesome-wasm-langs

ただWebAssemblyの強みを活かす観点ではこちらにまとめられている通り、 Rust、AssemblyScript(TypeScript)、C++、Go あたりの採用が多くなっているようです。 https://blog.scottlogic.com/2021/06/21/state-of-wasm.html

国内での採用事例はそこまで多くみかけないですが、グローバルに目を向けると Google Meetの背景ぼかし なんかは身近に感じられるかと思います。 https://zenn.dev/kounoike/articles/google-meet-bg-features

実装してみる

さて今回は先程の利用事例数を参考に、上位の以下の言語でWebAssemblyを利用してみようと思います。

  • AssemblyScript
  • Rust
  • C++
  • Go

まずはJavaScriptでの参考実装を示し、それを各言語で実装&WebAssembly化して所感などまとめていきます!

それぞれの言語について、ほぼ触ったことがなかったので、細かい実装についてはご容赦いただき、よりよいやり方等あればご教示お願いします 🙏

参考実装(JavaScript)

実装するお題として以下のようなエラトステネスの篩のナイーブな実装を使っていこうと思います。

sieve.js

function sieve(maxCount) {
  const max = maxCount + 1;
  const primes = new Array(max);
  const result = new Array();

  for (let i = 1; i < max; ++i) {
    primes[i] = true;
  }

  for (let i = 2; i < max; ++i) {
    if (primes[i]) {
      result.push(i);

      for (let j = i; j * i <= max; ++j) {
        primes[j * i] = false;
      }
    }
  }

  return result;
}

index.html(抜粋)

<body>
  <script src="sieve.js"></script>
  <script>
    const n = 1_000;
    const result = sieve(n);
    console.log(result);
  </script>
</body>

AssemblyScript

まずは一番導入ハードルが低そうなイメージがあったAssemblyScriptから試していきましょう。

AssemblyScriptは、あまり聞き馴染みないかもしれませんが、WebAssemblyのために作られた TypeScriptのシンタックスを持つ言語 になります。

なにはともあれ、AssemblyScriptをWebAssemblyにコンパイルする環境を整備しましょう。

$ cd your_assemblyscript_wasm_dir
$ volta install assemblyscript # npm install -g assemblyscript
$ asinit . # 雛形の生成
$ npm install
$ ls
asconfig.json  assembly/      build/         index.js       package.json   tests/
$ npm run asbuild # wasmファイルの生成(Optimize版含め)
$ ls ./build # .wat はWebAssemblyTextでデバッグ時に人が見る用途
optimized.wasm      optimized.wat       untouched.wasm.map
optimized.wasm.map  untouched.wasm      untouched.wat

これでAssemblyScriptを開発、コンパイルする準備は完了です。

続いてAssemblyScriptでお題のエラトステネスの篩を書くとこのようになります。

assembly/index.ts

export function sieve(maxCount: i32): Array<i32> {
  const max = maxCount + 1
  const primes = new Array<bool>(max)
  const result = new Array<i32>()

  for (let i = 1; i < max; ++i) {
      primes[i] = true
  }

  for (let i = 2; i < max; ++i) {
    if (primes[i]) {
      result.push(i)

      for (let j = i; j * i <= max; ++j) {
        primes[j*i] = false
      }
    }
  }

  return result
}

シンタックスについては完全にTypeScriptですね。

i32 については、こちらのドキュメントにあるように、より低レベルの制御をするためにJavaScriptとは違った型を使うこととなっています。

次にコンパイルされたWasmファイルを呼び出すコードを見てみましょう。

index.html(抜粋)

<body>
  <script src="https://cdn.jsdelivr.net/npm/@assemblyscript/loader/umd/index.js"></script>
  <script>
    (async () => {
      const imports = {
        env: {
          abort() {
            throw new Error("Abort called from wasm file");
          }
        }
      }
      let instance;
      if (!loader.instantiateStreaming) {
        const response = await fetch('build/optimized.wasm');
        const bytes = await response?.arrayBuffer();
        instance = await loader.instantiate(bytes, imports);
      } else {
        instance = await loader.instantiateStreaming(fetch('build/optimized.wasm'), imports);
      }
      const { sieve } = instance.exports;
      const { __getArray } = instance.exports;

      const n = 1_000;
      const arrPtr = sieve(n);
      const values = __getArray(arrPtr);
      console.log(values);
    })();
  </script>
</body>

少し複雑ですね。

ここでWebAssemblyで重要な問題の一つを説明します。 それは、 通常WebAssemblyとJavaScriptとのやり取りでは数値型しか使えない ということです。

仮に標準で実装されているWebAssemblyオブジェクトをそのまま使って今回の sieve 関数を読んだとしても、 配列のサイズのみしか取得できない といったことが起きてしまいます。

これはどの言語でコンパイルしても起こりうる問題で、それぞれ解決する手段があると覚えておいたほうがよさそうです。

AssemblyScriptだと公式で用意しているloaderを使います。

使い方としては loader を使い loader.instantiate または loader.instantiateStreaming を使いWasmファイルを読み込みます。

読み込みと instance.exports に自身が定義した関数がはえてきます。

それと同時に __getArray__newArray といったヘルパー関数も取得できるようになっているので、それらを使うことでWebAssemblyからArrayを返すことを実現しています。

loader のドキュメントを読んでいただければ分かる通り、stringなども同様のアプローチで解消することとなります。

少し癖はありますが、そこそこ簡単に実装することができました!

Rust

次に一番WebAssemblyで使われているというRustを試してみます。

まずはドキュメントを読みつつ環境を整えましょう。

# Rustをインストール(rustup, rustc, cargo)
$ cd your_rust_wasm_dir
$ cargo install wasm-pack # Wasmへのコンパイルをはじめ、グルーコードの生成なども担う
$ cargo init .

Cargo.toml

[package]
name = "sieve"
version = "0.1.0"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

コードはこのようになります。

src/lib.rs

extern crate wasm_bindgen;

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn sieve(max_count: usize) -> Vec<usize> {
  let max = max_count + 1;
  let mut primes: Vec<bool> = Vec::with_capacity(max);
  let mut result: Vec<usize> = vec!{};

  for _ in 1..=max {
    primes.push(true);
  }

  for i in 2..max {
    if primes[i] {
      result.push(i);

      let mut j = i;
      while j * i < max {
        primes[j * i] = false;
        j += 1;
      }
    }
  }
  return result;
}

注目すべきは wasm_bindgen を読み込んでいるのと、エクスポートしたい関数の前行に #[wasm_bindgen] と記述していることくらいかなと思います。

次にコンパイルしてみましょう。

$ wasm-pack build --target web
$ ls ./pkg
package.json        sieve.d.ts          sieve.js            sieve_bg.wasm       sieve_bg.wasm.d.ts

型定義ファイルなんかも生成されていますね。

呼び出すコードは以下になります。

index.html(抜粋)

<body>
  <script type="module">
    import init, { sieve } from '/pkg/sieve.js';
    (async () => {
      await init();
      const n = 1_000;
      const result = sieve(n);
      console.log(result);
    })();
  </script>
</body>

とても簡潔に書けました。

wasm-packwasm_bindgen がよしなにグルーコードを生成してくれているおかげだと思いますが、エコシステムの成熟度からしてもコミュニティの熱量を感じます。

C++

次はC++です。

個人的にOpenCV×WebAssemblyでなにか作りたいなと考えていたので、C++は注目していました。

C/C++のWebAssemblyへのコンパイルでは、Emscriptenを用いることが多いようです。

Emscriptenは、C/C++を始めとするLLVMを使用する言語をWebAssemblyにコンパイルするツールとなっています。

さてコンパイル環境の整備ですが、便利なDocker Imageが公式で用意されていたのでそちらを使っていこうと思います。 https://hub.docker.com/r/emscripten/emsdk

それではコードです。

sieve.cpp

#include <emscripten/emscripten.h>
#include <emscripten/bind.h>
#include <cstdlib>
#include <vector>

EMSCRIPTEN_KEEPALIVE std::vector<int> sieve(int maxCount)
{
    auto max = maxCount + 1;
    auto primes = std::vector<bool>(max);
    auto result = std::vector<int>{};

    for(auto i = 1; i < max; i++) {
        primes[i] = true;
    }

    for(auto i = 2; i < max; i++) {
        if(primes[i]) {
            result.push_back(i);

            for(auto j = i; j*i <= max; j++) {
                primes[j*i] = false;
            }
        }
    }
    return result;
}

EMSCRIPTEN_BINDINGS(module) {
  emscripten::function("sieve", &sieve);
  emscripten::register_vector<int>("vector<int>");
}

いくつかポイントがあります。

1つ目は EMSCRIPTEN_KEEPALIVE についてです。

Emscriptenでコンパイルをする際にデフォルトの挙動だと、 main関数以外の呼び出されていない関数は消されてしまう ようです(dead code elimination)。

それを回避する方法の一つがこれになります。

その他の方法もあり、以下の記事がとても参考になりました。 https://qiita.com/chikoski/items/462b34db61daf13a7897

2つ目は EMSCRIPTEN_BINDINGS についてです。

AssemblyScriptの項目でも説明しましたが、通常WebAssemblyとJavaScriptとのやり取りは数値型しかできません。

それを解消するための仕組みがEmbindになります。

この機能を使い、C++のvectorをEmscriptenが用意しているJavaScript互換のregister_vectorにBindする設定をしています。

それではコンパイルし、呼び出してみましょう。

$ docker run --rm -v $(pwd):/src -u $(id -u):$(id -g) emscripten/emsdk emcc sieve.cpp --bind -o sieve.js # Embindを使う場合、--bindが必要

index.html(抜粋)

<body>
  <script src="sieve.js"></script>
  <script>
    Module.onRuntimeInitialized = () => {
      const n = 1_000;
      const result = Module.sieve(n); // JavascriptのArrayではなくC++のvector互換
      for (let i = 0; i < result.size(); i++) {
        console.log(result.get(i))
      }
    }
  </script>
</body>

呼び出すコードはRust同様簡潔で良いですね。

一つ特徴的なのはEmbindしたregister_vectorで、JavaScriptのArrayになるわけではなく、C++のvectorと同じAPIになっています。

完成形としては簡潔ですが、C++でのWebAssembly実現は結構苦戦しました。

その要因は、dead code eliminationの回避方法がいくつかあるという話とも繋がりますが、様々なやり方が散見していてどの組み合わせが正しく動作するのかわかりにくいことです。

例えばコンパイル時のコマンドのオプションについて以下のように呼び出す方法が紹介されているものも多かったですが、うまく動かすことができませんでした。 https://developer.mozilla.org/ja/docs/WebAssembly/C_to_wasm

$ emcc sieve.cpp --bind -o sieve.js -s WASM=1 -s NO_EXIT_RUNTIME=1 -s "EXPORTED_RUNTIME_METHODS=['ccall']"

Go

最後にGo言語を試していきます。

Go1.11から正式にWebAssemblyサポートが導入されているようです。

そのためWebAssemblyへのコンパイルは非常に簡単で、以下のようなコマンドによりコンパイルすることができます。

$ GOOS=js GOARCH=wasm go build -o main.wasm

しかし、GoはRuntimeが大きくRustやC++と比べると、WebAssemblyに向いていないという話をよく目にします。

そこでよく使われるのがTinyGoです。

TinyGo

https://tinygo.org/

あまり詳しく把握できてはいませんが、GoのRuntimeからGCやgoroutineなど不要なものを除き、LLVMベースで新しく作られたコンパイラのようです。

Goの言語としての差はほぼありませんが、Wasmのグルーコードの生成などはこちらのほうが進んでいるみたいです(Export周り)。

こちらもEmscripten同様、公式でDocker Imageを用意してくれているので、そちらを使いコンパイルしていきます。

まずはコードです。

sieve.go

package main

//export sieve
func sieve(maxCount int) []int {
    max := maxCount + 1
    primes := make([]bool, max)
    result := []int{}

    for i := 1; i < max; i++ {
        primes[i] = true
    }

    for i := 2; i < max; i++ {
        if primes[i] {
            result = append(result, i)

            for j := i; j*i <= max; j++ {
                primes[j*i] = false
            }
        }
    }
    return result
}

func main() {
    fmt.Println("loaded!")
}

大切なことは2つです。

1つ目は //export 関数名 です。

これはTinyGoの機能で、このコメントが書いてある関数がJavaScriptで呼び出せるようになります( // export 関数名 にしていてしばらくハマりました...)

2つ目はmain関数についてで、main関数はコンパイル時に必須となっているため、特にWasmロード時に必要なくとも書いておくようにしましょう。

それではコンパイルと呼び出しです。

docker run --rm -v $(pwd):/src tinygo/tinygo:0.19.0 tinygo build -o /src/sieve.wasm -target=wasm ./src/sieve.go
<body>
    <script src="wasm_exec.js"></script>
    <script>
       (async () => {
           const go = new Go();
           const result = await WebAssembly.instantiateStreaming(fetch("./sieve.wasm"), go.importObject);
           const instance = result.instance
           await go.run(instance);
           const n = 1_000;
           const result = instance.exports.sieve(100);
           console.log(result); // undefinedになってしまう...
       })();
   </script>
</body>

呼び出し時に使うグルーコードとして、 wasm_exec.js を読み込んでいます。

これはGoやTinyGoのインストールされたディレクトリから取得、またはリポジトリから取得する方法があります(少し不安になりますね)。

コンパイルをしたバージョンと同一の wasm_exec.js を取得するほうがいいので、リポジトリから取るよりインストールディレクトリから取得するほうがいいような気はします。

# リポジトリから取得する場合
$ wget https://raw.githubusercontent.com/tinygo-org/tinygo/master/targets/wasm_exec.js
# Dockerでコンパイルした場合
$ docker run -v $(pwd):/src tinygo/tinygo:0.19.0 /bin/bash -c "cp /usr/local/tinygo/targets/wasm_exec.js /src"

これでTinyGoでの実装も完了...と言いたいところですが、まだこの実装ではArrayの取得がundefinedになってしまいます。

実はこの問題、今回調べた限り回避することはできませんでした...

追々分かれば追記していこうと思います。 (情報をお持ちの方はご一報ください)

各言語ファイルサイズ比較

月並みではありますが、それぞれの実装言語で読み込まれるファイルサイズを比較してみます。

今回Optimizedなバージョンも取り上げていますが、OptimizeLevelなどはデフォルト値を使っているのと、コンパイラによってOptimizeの内容は変わると思うので、この比較だけでは判断できないことはご認識ください。

またOptimizeについては、以下のリンクにあるように、ファイルサイズと実行スピードはトレードオフの場合もあるようなので、気をつけて設定する必要がありそうです。 https://rustwasm.github.io/book/reference/code-size.html#tell-llvm-to-optimize-for-size-instead-of-speed

JavaScript AssemblyScript AssemblyScript(Optimized) Rust C++ C++(Optimized) TinyGo TinyGo(Optimized)
Wasm File 0 12.3kB 7.3kB 14.8kB 46.5kB 20.1kB 2.0MB 223kB
Other Files(Glue,etc) 559B 5.5kB(loader) 5.5kB(loader) 3.0kB(Glue) 206kB(Glue) 57.6kB 15.9kB(wasm_exec.js) 15.9kB(wasm_exec.js)

Goはランタイムが大きく、WebAssemblyには向いていないとはよく聞きますが、TinyGoでもまだ比較するとサイズは大きいみたいですね。

まとめ

以上、各言語での実装はいかがでしたでしょうか?

AssemblyScriptは今回調べるまで知らなかったのですが、やはりTypeScriptで書けるというのはフロントエンドエンジニアとしてハードルが低いので導入は進めやすいと感じました。

また、流石よく使われているだけあって、Rustがエコシステムの成熟度が高く、一つ頭抜けている印象を受けました。 個人的にはGoが好きなので、期待はしているのですが...。

全体通しての感想ですが、やはりまだまだWebAssemblyは枯れていないため、正しい情報を得て実装していく難易度の高さを感じました。

ただ、このペースでアップデートが進んでいくと、早いタイミングでWebの必須技術になる可能性は大いにあるなとも感じましたね。

今回は初めて触ることが多く、かなり軽いプログラムで試してみましたが、次回は例えば外部のライブラリを含めたビルドや、JSとのやり取りなどにフォーカスして調べ、よりメリットを感じられるようなことをしていきたいですね!