Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

WebAssemblyハンズオン: 実際に動かして基礎を学ぶ(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。画像はすべて元記事からの引用です。

WebAssemblyハンズオン: 実際に動かして基礎を学ぶ(翻訳)

Web開発の一般的な知識のみを前提とするシンプルなハンズオンチュートリアルで、WebAssembly(Wasm)を始めてみましょう。実行可能なコード例を用いてWasmを味わうのに必要なものは「コードエディタ」と「今どきのブラウザなら何でも」、そして本記事に付属する「CとRustのツールチェインが入ったDockerコンテナ」だけです。

WebAssemblyが誕生して3年が経過しました。WebAssemblyはあらゆるモダンなブラウザで動作し、一部の企業は本番でも用いています(Figmaには脱帽です)。WebAssemblyの背後には、Mozilla、Microsoft、Google、Apple、Intel、RedHatというそうそうたる頭脳パワーたちが控えていますし、そうした会社の選りすぐりのエンジニアもそうでないエンジニアもWebAssemblyに貢献し続けています。

WebAssemblyは、Web技術の「次のビッグウェーブ」と広く認識されていますが、多くのフロントエンドコミュニティは未だにWebAssemblyの採用を急ぐ様子がありません。私たちは既に「HTML」「CSS」「JavaScript」というWebを支える三大巨頭を身に付けていますし、世界観が変わるまでに3年以上はかかることでしょう。特に、WebAssemblyについてちょっとググっって以下のような面倒くさそうな説明文を目にしたらなおさらです。

WebAssemblyとは、スタックベースの仮想マシンを対象とする仮想インストラクションセットアーキテクチャであり、バイナリインストラクション形式です。

上の文面がすぐピンとこないからといって、諦めるのは早すぎます。

本記事の目的は、WebAssemblyをより手軽な方法で説明することと、WebページでWebAssemblyを用いた具体例をひととおりやれるようにすることです。WebAssemblyに興味津々だけど触ってみる機会に恵まれなかった開発者の皆さん、本記事は皆さん(特にドラゴンを愛する開発者)のためにあります。

「危険!ドラゴンがいます」

本題に進む前に申し上げておくと、私がかつてWebAssemblyに抱いていたメンタルモデルは、言ってみればドラゴンの一種のようなものでした。むやみに強くすばしっこく、危険な魅力を放ちながら神秘に包まれていて、そしておそらく命取りな存在。WebAssemblyは、Web技術に関する私のメンタルマップでただちに「危険!ドラゴンがいます」カテゴリに分類されました: "ここから先の竜の巣へは自己責任でお進みください"

しかしそんな恐れはまったくの杞憂でした。フロントエンド開発の主要なメタファーは今も通用します。WebAssemblyは今でも「クライアントサイドアプリケーション」の領域に属すものであり、ブラウザのサンドボックスの中で実行される点もやはり同じです。WebAssemblyはまだまだお馴染みのJavaScript APIに依存していますが、ブラウザにバイナリを直接提供できることによって「クライアント側でできること」の限界を劇的に広げるものでもあるのです。

WebAssemblyがいったいどのように動くのか、コードをWebAssemblyにコンパイルする方法は、自分たちのプロジェクトでWebAssemblyを使うことが合理的になるのはいつなのか、本記事をとくとご覧ください。

「人間が読むためのコード」と「機械のためのコード」

WebAssemblyが登場するまでは、ブラウザ環境で実行できる唯一のフル装備のプログラミング言語という地位をJavaScriptが思いのままに独占していました。Webのためのコードを書く人間は、そのコードをJavaScriptで表現する方法を理解し、そのコードを実行するクライアントコンピュータを信頼します。

プログラミング経験のほとんどない人でも、以下の数行のJavaScriptが何を意味するかぐらいは理解できます。このコードが解決する「タスク」は、ある乱数を2で割って、それを配列に11088回足すという、かなり無意味な代物ではありますが。

function div() {
  return Math.random() / 2;
}

const arr = [];

for (let i = 0; i < 11088; i++) {
  arr[i] = div();
}

人間ならこのコードを完璧に読めます。しかしこのスニペットをWeb経由で受け取ってただちに実行しなければならないクライアントコンピュータ上のCPUにとっては、このままでは何の意味もありません。CPUが最終的に理解するのは「マシンインストラクション」(いわゆる機械語)です。プロセッサが結果を出すために実行しなければならない(かなり退屈な)一連のステップをエンコードしたもの、それがマシンインストラクションです。

こんなわずかな上述のコードスニペットを実行するために、私のコンピュータにあるCPU(Intel x86-64)は516個ものインストクラクションを必要とします。そのインストラクションを、マシンコードをテキスト形式で表現する「アセンブリ言語」に置き換えて表すと以下のような感じになります。インストラクションの名前はどれも謎めいていて、理解するにはプロセッサに付属する分厚いマニュアルが必要です。

x86_64アセンブラのいくつかのインストラクション
プロセッサは、1回のクロックサイクル(なお2GHzは1秒間に20億サイクルの実行を表します)で1つ以上のインストラクションをメモリからフェッチして実行を試みます。多くのインストラクションは「同時に」実行されるのが普通です(これはインストラクションレベルのパラレリズムと呼ばれます)。

このコードをできるだけ高速に実行するために、プロセッサは「パイプライン」「ブランチ予測」「投機的実行」「プリフェッチ」といった技法を駆使します。インストラクションで使うデータ(およびインストラクション自身)をできるだけ高速にフェッチするために、プロセッサには複雑なキャッシュシステムが搭載されています。メインメモリからのデータ取得は、キャッシュからデータを取得する場合の何十倍も低速です。

CPUが違えばインストラクションセットアーキテクチャ(ISA)も違うので、PCに搭載されているCPU(たいていはIntel x86でしょうが)は、スマートフォン用CPU(Armアーキテクチャのいずれかでしょうが)のマシンコードを理解できません。

ここでうれしいお知らせです。Web用に書いたコードについては、プロセッサアーキテクチャ同士の違いを気にする必要はありません。ありがたいことにモダンなブラウザは、そうしたコードをクライアントコンピュータ上のCPUが理解できる「何か」に変換する、効率の良いコンパイラでもあるのです。

コンパイラの即席入門

WebAssemblyが登場したいきさつを理解するには、コンパイラについて少しばかり説明しておく必要があります。コンパイラの仕事は、人間が読めるソースコード(JavaScriptでもCでもRustでも何でも結構)を、対象となるプロセッサが理解できるインストラクションの集まりに変えることです。コンパイラは、マシンコードを生成する前にソースコードをIR(immediate representation)と呼ばれる形式に変換します。IRはソースコードの正確な「書き換え」であり、ソース言語とターゲット言語のどちらからも独立しています。

コンパイラは、このIRをチェックして最適化方法を調べ、おそらくまた別のIRを生成し、そしてまた別のIRを生成し、という具合に最適化がそれ以上できなくなるまで繰り返します。その結果、人間がエディタで書いたソースコードと、コンピュータが実際に実行するコードはまるで様子が変わることもあります。

今の話を理解するために、数字を足したりかけたりするC言語の小さなコードスニペットを以下に示します。

#include <stdio.h>

int main()
{
    int result = 0;
    for (int i = 0; i < 100; ++i) {
        if (i > 10) {
            result += i * 2;
        } else {
            result += i * 11;
        }
    }

    printf("%d\n", result);

    return 0;
}

そして以下は、上のコードスニペットを元にコンピュータが生成した、LLVM IR形式で広く用いられている内部表現です。

define hidden i32 @main() local_unnamed_addr #0 {
entry:
  %0 = tail call i32 (i8*, ...) @iprintf(...), i32 10395)
  ret i32 0
}

LLVM(以前はLow Level Virtual Machineの略でした)はもともとコンパイラ基盤プロジェクトの名称ですが、現在は単なるコンパイラ基盤にとどまらない存在になっています。

ここでのポイントは、(実行時にプロセッサが計算を行うときではなく)コンパイラが最適化を行っている間に計算の結果を出していることです。つまりi32 10395という部分は、元のCソースコードが最終的に出力する正確な数値なのです。

コンパイラは魔法がたっぷり詰まったカバンを持っていて、人間が書いた「非効率な」コードを実行時にわざわざ走らせることを回避したり、より最適化されたマシンコードに置き換えたりします。

コンパイラのしくみ

コンパイラのパイプラインは複雑ですが、フロントエンドとバックエンドの2つの部分に分けられます。コンパイラのフロントエンドはソースコードをパースして解析し、IRに変換します。そしてコンパイラのバックエンドがそのIRを対象となる環境に合わせて最適化し、ターゲットコードを生成します。

モダンなコンパイラのほとんどは、コンパイラの「バックエンド」と「フロントエンド」の中間部分で最適化を行う「ミドルエンド」も持っています。

フロントエンドとバックエンド

それではWebの話に戻るとしましょう。

どんなブラウザでも理解できる内部表現が使えたらどうなるだろう?

もしそうなったら、私たちはその内部表現をコンパイラの出力対象として使うことで、プログラムをコンパイルするときにクライアントシステムとの互換性のことを心配しないで済むようになるでしょう。私たちはプログラムをどんな言語で書いてもよくなり、いつまでもJavaScriptを押し付けられることもなくなります。ブラウザは私たちのコードの内部表現をフェッチして「バックエンドマジック」を施します。つまりIRをそのクライアントアーキテクチャに適したマシンコードに変換します。

それがまさにWebAssemblyなのです!

WebAssembly: WebのためのIR

「どんな言語で書かれてもコードを互いに交換できる単一のフォーマット」という人類の夢を実現するために、WebAssembly開発者はアーキテクチャをある程度戦略的に選択しなければなりませんでした。

ブラウザがコードをできるだけ短時間にフェッチできるよう、このフォーマットは「コンパクト」であることが求められます。WebAssemblyで最もコンパクトなのはバイナリフォーマットです。

コンパイルを効率よく行うために、マシンコードにできるだけ近い「何か」が必要です。それも移植性を犠牲にすることなく。あらゆるインストラクションセットアーキテクチャ(ISA)は必ずハードウェアに依存しますし、ブラウザが動作するあらゆる環境に合わせてカスタマイズするのは無理なので、WebAssemblyの作者は「仮想ISA」、すなわち抽象マシンを対象とするインストラクションセットを選びました。仮想ISAは現実世界のどのCPUとも対応関係がありませんが、ソフトウェア上で効率よく処理を実行できます。

仮想ISAは、特定のマシンコードに変換しやすいよう、十分低レベルなつくりになっています。WebAssemblyの抽象マシンは現実世界のCPUと異なり、いわゆる「レジスタ」に依存しません(モダンなプロセッサはレジスタにデータを置いてから操作します)。その代わり、抽象マシンでは「スタック」データ構造を用います。たとえばaddというインストラクションは、スタックから直近の数値を2つ取り出して(pop)それらを足し、結果をスタックの上に戻します(push)。

これでやっと、冒頭の「スタックベースの仮想マシンを対象とする仮想インストラクションセットアーキテクチャであり、バイナリインストラクション形式である」の意味が理解できるようになりました。いよいよWebAssemblyの本当の力を解き放つときがやってまいりました!

ハンズオン:「ドラゴンを野に放て!」

それでは実際に手を動かして学ぶことにしましょう。ここではその名もドラゴン曲線と呼ばれるシンプルなフラクタル曲線を描画するシンプルなアルゴリズムを実装することにします。ここで最も重要なのはソースコードではありません。WebAssemblyモジュールをひとつ作成してブラウザ上で実行するために何をすればよいかを皆さんにご覧いただくことが重要なのです。

人生を楽にしてくれるemscriptenのような高度なツールを扱う前に、まずLLVM WebAssemblyバックエンドを持つClangコンパイラを直接利用する方法から学ぶことにします。

最終的にブラウザ上で描画するのは以下の画像です。

ドラゴン曲線とターン

キャンバス上の開始点から「左にターン」「右にターン」のシーケンスを実行してすべての線を描画すれば、欲しいフラクタル図形のできあがりです。

このプログラムの目的は、私たちがたどる座標の配列をひとつ生成することです。それを画像にするのはJavaScriptのお仕事で、配列を生成するコードは昔なつかしのC言語で書かれています。

ご心配なく。必要なツールはこちらのDockerイメージに全部盛り込んでありますので、開発環境のセットアップで時間を溶かさずに済みます。皆さんの環境で必要なのはDockerそのものだけなので、Dockerを使ったことのない方はここでインストールしておきましょう。好みのOS環境に応じて手順を実行すればインストール完了です。

ここで一言ご注意申し上げます。本記事のコマンドライン例はLinuxまたはMac環境を前提としています。Windowsで動かすには、WSL(WSL2にアップグレードすることをおすすめします)を使うか、Power Shellをサポートする構文に書き換えてください( \ の代わりにバッククォート`を、$(pwd):$(pwd)の代わりに${pwd}:/tempを使います)。

ターミナルを立ち上げてフォルダをひとつ作成しましょう。このフォルダにコード例を置くことにします。

mkdir dragon-curve-llvm && cd dragon-curve-llvm
touch dragon-curve.c

touchコマンドで作成したファイルを好みのテキストエディタで開き、以下のコードを貼り付けます。

// dragon-curve-llvm/dragon-curve.c
#ifndef DRAGON_CURVE
#define DRAGON_CURVE

// Helper function for generating x,y coordinates from "turns"
int sign(int x) { return (x % 2) * (2 - (x % 4)); }

// Helper function to generate "turns"
// Adapted from https://en.wikipedia.org/wiki/Dragon_curve#[Un]folding_the_dragon
int getTurn(int n)
{
  int turnFlag = (((n + 1) & -(n + 1)) << 1) & (n + 1);
  return turnFlag != 0 ? -1 : 1; // -1 for left turn, 1 for right
}

// fills source with x and y points [x0, y0, x1, y1,...]
// first argument is a pointer to the first element of the array
// that will be provided at runtime.
void dragonCurve(double source[], int size, int len, double x0, double y0)
{
  int angle = 0;
  double x = x0, y = y0;
  for (int i = 0; i < size; i++)
  {
    int turn = getTurn(i);
    angle = angle + turn;
    x = x - len * sign(angle);
    y = y - len * sign(angle + 1);
    source[2 * i] = x;
    source[2 * i + 1] = y;
  }
}
#endif

続いて、LLVMのClangとWebAssemblyのバックエンドリンカーを用いて上のコードをWebAssemblyにコンパイルする必要があります。以下のコマンドを実行してDockerコンテナを動かしましょう。このコマンドは、いくつかのフラグセットを引数に与えてclangバイナリを呼び出しているだけです。

docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit \
clang --target=wasm32 -O3 -nostdlib -Wl,--no-entry -Wl,--export-all -o dragon-curve.wasm dragon-curve.c
--target=wasm32
コンパイルのターゲットとしてWebAssemblyを指定する
-O3
最適化の適用を最大にする
-nostdlib
システムライブラリを使わない(ブラウザのコンテキストでは無意味なので)
-Wl,--no-entry -Wl,--export-all
main()の不在を無視する」「WebAssemblyモジュールで定義したC関数をすべてエクスポートする」ようリンカに通知するフラグ

これで、dragon-curve.wasmというファイルがフォルダ内にできます。想像どおり、これはプログラム全体を含む530バイトのバイナリです。以下のようにして内容を出力できます。

docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit \
wasm-objdump dragon-curve.wasm -s

wasm-objdumpでdragon-curve.wasmをダンプ

WebAssemblyツールチェインに含まれているBynarienという素晴らしいツールを使えばバイナリサイズを縮小できます。

docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit \
wasm-opt -Os dragon-curve.wasm -o dragon-curve-opt.wasm

生成されるファイルを数百バイト程度削減できます。

ドラゴンのはらわた

バイナリは人間が読むようにできていないのが面倒です。ありがたいことに、WebAssemblyにはバイナリ形式とテキスト形式の2つの形式があり、WebAssembly Binary toolkit(wabt)を用いれば両者を互いに変換できます。以下を実行してみてください。

docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit \
wasm2wat dragon-curve-opt.wasm > dragon-curve-opt.wat

生成されたdragon-curve-opt.watファイルをテキストエディタで見てみましょう。

.watファイルの内容

おびただしい数のかっこは「S式(s-expression)」と呼ばれます(昔なつかしのLISPと似てますね)。S式はツリー状の構造を表現するのに用いられるものなので、Wasmファイルの内容もツリーになっています。

ツリーのルートはmoduleになっており、これは皆さんご存知のJavaScriptの「モジュール」ととても似ています。モジュールの中にはそのモジュール用の「import」と「export」もあります。

WebAssemblyを組み立てる基本的なブロックは、スタックを操作する「インストラクション(instruction)」です。

Wasmのインストラクション

インストラクションは組み合わされて、モジュールからインポート/エクスポートできる「関数(function)」になります。

エクスポートされたsign関数とgetTurn関数

コードのあちこちでifelseloopといったステートメントが目につきますが、WebAssemblyで最も顕著な機能は、いわゆる「構造化制御フロー(structured control flow)」を用いて高水準言語のようにGOTOジャンプを回避する機能と、ソースコードを1パスでパース(parse: 解析)できるようにする機能です。

構造化制御フロー

次は、エクスポートしたsign関数を覗き込んで、スタックベースの仮想ISAの仕組みを見てみることにしましょう。

sign function

sign関数

この関数は整数パラメータ(param i32)をひとつ受け取り、整数の結果(result i32)をひとつ返します。すべてはスタック上で行われます。

  (func (;1;) (type 0) (param i32) (result i32)
    i32.const 2
    local.get 0
    i32.const 4
    i32.rem_s
    i32.sub
    local.get 0
    i32.const 2
    i32.rem_s
    i32.mul)
  1. 整数値2をスタックにpushする(i32.const 2
  2. 関数の最初のパラメーターをスタックにpush(local.get 0
  3. 整数値4をスタックにpush(i32.const 4
  4. スタックから2つの値を削除して1番目の値を2番目の値で割った余りをスタックにpushする(i32.rem_s
  5. この時点でスタックのトップには余り2が置かれている
  6. スタックから2つの値をpopして一方から他方を引いた結果をpushする(i32.sub

最初の5つのインストラクションは、(2 - (x % 4))と同等です。

ここにはもうひとつの重要なエンティティである「テーブル(table)」があります。テーブルはメモリーと同様の線形な配列ですが、関数参照だけを保存する点がメモリーと異なります。これらの関数参照は、関数を間接的に呼び出すのに使われます(関数がWebAssemblyの一部であるかどうかを問わず)。

Wasmはシンプルな線形メモリーモデルを用いています。WebAssemblyのメモリーはひとつのシンプルなバイト配列として扱えます。

上の.watファイルでは、このモジュールを(export memory (memory 0))でエクスポートしています。これは、WebAssemblyプログラムのメモリーを外部から操作できるということであり、それをこれから皆さんと一緒に行います。

「照明よし」「カメラよし」「アクション!」

ドラゴン曲線をブラウザで表示するために、HTMLファイルをおひとつ用意する必要があります

touch index.html

空のcanvasタグにいくつかテンプレ記述を書き込んで初期化します。sizeは曲線を描画するときの総ステップ数、lenは1つのステップの長さ、x0y0は開始座標をそれぞれ設定します。

<!-- dragon-curve-llvm/index.html -->

<!DOCTYPE html>
<html>
  <head>
    <title>Dragon Curve from WebAssembly</title>
  </head>
  <body>
    <canvas id="canvas" width="1920" height="1080"></canvas>
    <script>
      const size = 2000;
      const len = 10;
      const x0 = 500;
      const y0 = 500;
    </script>
  </body>
</html>

今度は.wasmファイルを読み込んでWebAssemblyモジュールをインスタンス化する必要があります。JavaScriptの場合と異なり、モジュールを使うために全モジュールの読み込みを待つ必要などありません。WebAssemblyのコンパイルと実行は、バイトストリームが入って来るとオンザフライで始まります。

このモジュールの読み込みには標準のフェッチAPIを使い、WebAssembly組み込みのJavaScript APIでモジュールをインスタンス化します。WebAssembly.instantiateStreamingプロミスをひとつ返します。このプロミスはモジュールオブジェクトを解決し、モジュールオブジェクトはこのモジュールのインスタンスを含みます。これでめでたく、C言語で書いた関数はインスタンスのexportsで有効となり、JavaScriptから存分に使えるようになります。

<!-- dragon-curve-llvm/index.html -->

<!DOCTYPE html>
<html>
  <head>
    <title>Dragon Curve from WebAssembly</title>
  </head>
  <body>
    <canvas id="canvas" width="1920" height="1080"></canvas>
    <script>
      const size = 2000;
      const len = 10;
      const x0 = 500;
      const y0 = 500;
      WebAssembly.instantiateStreaming(fetch("/dragon-curve.wasm"), {
        // この例では何もインポートしない
        imports: {},
      }).then((obj) => {
        const { memory, __heap_base, dragonCurve } = obj.instance.exports;
        dragonCurve(__heap_base, size, len, x0, y0);
        const coords = new Float64Array(memory.buffer, __heap_base, size);
        const canvas = document.querySelector("canvas");
        const ctx = canvas.getContext("2d");
        ctx.beginPath();
        ctx.moveTo(x0, y0);
        [...Array(size)].forEach((_, i) => {
          ctx.lineTo(coords[2 * i], coords[2 * i + 1]);
        });
        ctx.stroke();

        // 曲線をアニメーションしたい場合は末尾の4行を以下に変更する
        // [...Array(size)].forEach((_, i) => {
        //   setTimeout(() => {
        //     requestAnimationFrame(() => {
        //       ctx.lineTo(coords[2 * i], coords[2 * i + 1]);
        //       ctx.stroke();
        //     });
        //   }, 100 * i);
        // });
      });
    </script>
  </body>
</html>

それではinstance.exportsをもっとよく見てみましょう。座標を生成するC関数dragonCurveのほかに、WebAssemblyモジュールの線形メモリーを表現するmemoryオブジェクトも受け取ります。ここには仮想マシン用インストラクションのスタックなどの重要なものも含まれるので、注意して扱う必要があります。

テクニカルには、正常に動かすためのメモリーアロケーターが必要です。しかしこのシンプルなコード例では内部の__heap_baseプロパティを読み出すことにします。これは私たちが安全に利用できるメモリー領域(ヒープ)へのオフセットを提供します。

dragonCurve関数へのこのオフセットを「まともな」メモリーに与えて関数を呼び出し、Float64Array型の座標が生成されたこのヒープの内容を抽出します。

残る作業は、Wasmモジュールから抽出した座標に基づいてcanvas上に線を描画するだけです。実際に動かすにはHTMLをローカルで提供する、つまり基本的なWebサーバーがひとつ必要です。さもないと、クライアント(ブラウザ)でWasmモジュールをfetchできません。運のよいことに、私たちのDockerイメージには必要なものがすべて仕込まれています。

本節はSurma氏の『"Compiling C to WebAssembly without Emscripten"』という素晴らしい記事に刺激を受けました。

docker run --rm -v $(pwd):$(pwd) -w $(pwd) -p 8000:8000 zloymult/wasm-build-kit \
python -m http.server

ブラウザでhttp://localhost:8000を開き、ドラゴン曲線をとくとご覧あれ!

「補助車輪」を外す

ここまでご説明した「純粋なLLVM」アプローチは、「システムライブラリを一切用いずにコンパイルする」という目的を達するための最小限の手法です。しかもメモリーの管理は「ヒープへのオフセットを算出する」という、考えられる限り最もダサい方法ですが、そのおかげでWebAssemblyのメモリーモデルを白日の下に晒すことができました。しかし現実のアプリケーションではまともなメモリーアロケーションやシステムライブラリも使いたいものです(ここで言う「システム」はブラウザということになりますが)。WebAssemblyの実行は未だにサンドボックスの中に閉じ込められており、OSに直接アクセスする方法はありません。

WebAssemblyコンパイル用のツールチェインであるemscriptenの助けを借りれば、これらはすべて可能になります。Emscriptenはブラウザ内でさまざまなシステム機能(STDIN/STDOUT/ファイルシステム、WebGLに自動変換されるOpenGLグラフィックスなど)のシミュレーションの面倒を見てくれます。Emscriptenは、バイナリをスリム化するBynarienも統合しているので、最適化についてこれ以上頭を痛める心配はありません。

EmscriptenはWebAssemblyより前に誕生しました。Emscriptenは元々C/C++コードをJavaScriptやasm.jsにコンパイルするためのものでしたが、今も同じことができるのです!

Emscripten

いよいよWebAssemblyを「正しく」動かすときが来ました!なおCのコードは変更しません。別のフォルダを作成してソースコードをコピーし、後で比較できるようにしておきます。

cd .. && mkdir dragon-curve-emscripten && cd dragon-curve-emscripten
cp ../dragon-curve-llvm/dragon-curve.c .

私たちが頑張ってDockerイメージにEmscriptenも入れてあるので、何も考えずに以下のコマンドを実行するだけでやれます。

docker run --rm -v $(pwd):$(pwd) -w $(pwd) zloymult/wasm-build-kit \
emcc dragon-curve.c -Os -o dragon-curve.js \
-s EXPORTED_FUNCTIONS='["_dragonCurve", "_malloc", "_free"]' \
-s EXPORTED_RUNTIME_METHODS='["ccall"]' \
-s MODULARIZE=1

コマンドの実行に成功すると、dragon-curve-em.wasmというちっぽけなファイルとdragon-curve-em.jsという15Kbもある恐ろしげなJSファイルがひとつずつ新しく作成されます。この最小化されたJavaScriptファイルには、WebAssemblyモジュールのインスタンス化ロジックやさまざまなブラウザポリフィルが含まれています。これが、WebAssemblyをブラウザ内で実行するための現時点での代償です。現在も、これらをつなぎ合わせるためのJavaScriptコードがたくさん必要になります。

上で実行しているのは以下のとおりです。

  • -Os: WasmとJSの両方についてサイズを最適化するようEmscriptenに通知する
    • .wasmファイルは自動生成されるので、指定が必要なのは.jsファイル名だけ
  • 生成されるWebAssemblyモジュールからエクスポートしたい関数も指定できる
    • エクスポートする関数名の前にはアンダースコアを付けなければならない
    • したがって-s EXPORTED_FUNCTIONS='["_dragonCurve", "_malloc", "_free"]'となる
    • 末尾の2つの関数はメモリー操作を支援する
  • ソースコードがC言語で書かれているので、Emscriptenが生成するccall関数もエクスポートしなければならない
  • MODULARIZE=1は、このWasmモジュールのインスタンスをひとつ持つPromiseを返すグローバルなModule関数を使えるようにする

これで、以下のHTMLファイルを作成してその下の新しいコンテンツを貼り付けられます。

touch index.html
<!DOCTYPE html>
<html>
  <head>
    <title>Dragon Curve from WebAssembly</title>
  </head>
  <script type="text/javascript" src="./dragon-curve.js"></script>
  <body>
    <canvas id="canvas" width="1920" height="1080">
      Your browser does not support the canvas element.
    </canvas>

    <script>
      Module().then((instance) => {
        const size = 2000;
        const len = 10;
        const x0 = 500;
        const y0 = 500;
        const canvas = document.querySelector("canvas");
        const ctx = canvas.getContext("2d");

        const memoryBuffer = instance._malloc(2 * size * 8);
        instance.ccall(
          "dragonCurve",
          null,
          ["number", "number", "number", "number"],
          [memoryBuffer, size, len, x0, y0]
        );

        const coords = instance.HEAPF64.subarray(
          memoryBuffer / 8,
          2 * size + memoryBuffer / 8
        );

        ctx.beginPath();
        ctx.moveTo(x0, y0);
        [...Array(size)].forEach((_, i) => {
          ctx.lineTo(coords[2 * i], coords[2 * i + 1]);
        });
        ctx.stroke();

        instance._free(memoryBuffer);
      });
    </script>
  </body>
</html>

上述のWebAssembly.instantiateStreamingの例で行ったように、Emscriptenを用いると、WebAssemblyをインスタンス化するのに必ずしもブラウザAPIを使わなくてもよくなります。

その代わりに、Emscriptenが提供するModule関数を用います。このModuleは、プログラムのコンパイル時に定義されていたすべてのエクスポートを持つpromiseをひとつ返します。このpromiseが解決されると、_malloc関数を用いてメモリーのある領域を予約し、座標の保存に使えるようになります。_malloc関数が返すのはオフセット付きの整数値であり、それを memoryBuffer変数に保存します。これは、前述した(安全でない)heap_baseアプローチと比べてずっとセキュアな方法です。

2 * size * 8という引数は、1ステップごとに2個の座標「(x, y)」を保存できる長さの配列をひとつアロケーションすることを意味します。それぞれの座標は8バイト(float64)を占めます。

EmscriptenにはC関数を呼び出すためのccallという特殊なメソッドが用意されています。このメソッドを用いてdragonCurveを呼び出すと、memoryBufferで指定されたメモリオフセット位置に座標が書き込まれます。canvasのコードは前述のコード例と同じです。ここではメモリーの利用終了時にメモリーをクリーンアップするためにEmscriptenのinstance._freeメソッドを用いています。

Rustの実行と、他の人が書いたコードの実行

CコードをWebAssemblyに変換する理由として「シンプルなメモリーモデルを使える」「ガベージコレクタに依存しない」というのがあります。もしそうでなければ、言語の巨大なランタイムを丸ごとWasmモジュールに焼き込まなければならなくなるでしょう。それはそれで技術的には不可能ではありませんが、バイナリサイズが著しく巨大化して読み込みも実行も遅くなるのがオチです。

もちろん、WebAssemblyにコンパイル可能な言語はC言語やC++だけではありません。LLVMフロントエンドを備えた言語が候補として最適ですが、その中でも目立つのがRustです。

Rustのクールな点は、Cargoという素晴らしいパッケージマネージャが最初から組み込まれていることで、古き良きC言語に比べて既存ライブラリの発見や再利用がきわめて容易です。

ここでは既存のRustライブラリをWebAssemblyモジュールに変換するのがどれほど簡単にやれるかをお見せしたいと思います。Rustのwasm-packという素晴らしいツールチェインの助けを借りれば、Wasmプロジェクトを秒で動かせるようになります。

早速wasm-packを組み込み済みのDockerイメージを用いて新しいプロジェクトをひとつ作ってみましょう。カレントディレクトリがdragon-curve-ecmscripten/のままの方は必ず1つ上のディレクトリに移動しておいてください。wasm-packのプロジェクト作成はrails newcreate-react-appと同じ要領でできます。

docker run --rm -v $(pwd):$(pwd) -w $(pwd) -e "USER=$(whoami)" zloymult/wasm-build-kit wasm-pack new rust-example

作成されたrust-example/ディレクトリにcdしてエディタでディレクトリを開きます。ドラゴン曲線用のCコードは、既に変換済みのものをCargoクレートとしてパッケージ化してあります。

Rustプロジェクトの依存関係はすべてCargo.tomlファイルで管理します。このファイルの振る舞いはpackage.jsonGemfileととても似ています。エディタでCargo.tomlを開いて[dependencies]セクションを参照し、現在wasm-bindgenだけが含まれていることを確認したら、以下のdragon_curveクレートを追加します。

# Cargo.toml
[dependencies]
# ...
dragon_curve = {git = "https://github.com/HellSquirrel/dragon-curve"}

このプロジェクトのソースコードはsrc/rib.rsの中にあるので、他に必要なのは、インポートしたクレートからdragon_curveを呼び出すための関数をひとつ定義することだけです。

// src/lib.rs
#[wasm_bindgen]
pub fn dragon_curve(size: u32, len: f64, x0: f64, y0: f64) -> Vec<f64>
{
  dragon_curve::dragon_curve(size, len, x0, y0)
}

それではコンパイルしましょう。以下のコマンドのフラグがこれまでよりずっと人間にとって読みやすいことにご注目ください。wasm-packにはJavaScriptビルド用にWebpackサポートが組み込まれており、その気になればHTMLも生成できますが、ここでは最小限のアプローチで--target webを指定します。これでWasmモジュールとJSラッパーがコンパイルされてネイティブのESモジュールになります。

以下のコマンドは、ネット接続の速度次第では少々時間がかかる可能性があります。

docker run --rm -v $(pwd):$(pwd) -w $(pwd)/rust-example -e "USER=$(whoami)" zloymult/wasm-build-kit wasm-pack build --release --target web

コンパイルの結果はプロジェクトのpkg/フォルダ内に置かれます。今度はプロジェクトのルートディレクトリにHTMLファイルを作成しましょう。今回のコードはこれまでのコード例の中で最もシンプルになりました。ここでやっていることは、ネイティブのdragon_curve関数をJavaScriptのimportとして利用しているだけで、その背後では私たちのWasmがえっちらおっちら頑張ってくれています。上述の例のようにメモリーを手動で管理する必要もありません。

もうひとつ説明しておきたいのが非同期のinit関数です。これを用いることで、Wasmモジュールの初期化が完了するまで待てるようになります。

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <canvas id="canvas" width="1920" height="1080"></canvas>
    <script type="module">
      import init, { dragon_curve } from "/pkg/rust_example.js";
      (async function run() {
        await init();
        const size = 2000;
        const len = 10;
        const x0 = 500;
        const y0 = 500;
        const coords = dragon_curve(size, len, x0, y0);
        const canvas = document.querySelector("canvas");
        const ctx = canvas.getContext("2d");
        ctx.beginPath();
        ctx.moveTo(x0, y0);
        [...Array(size)].forEach((_, i) => {
          ctx.lineTo(coords[2 * i], coords[2 * i + 1]);
        });
        ctx.stroke();
      })();
    </script>
  </body>
</html>

いよいよ結果をWebサーバーで見てみましょう!

docker run --rm -v $(pwd):$(pwd) -w $(pwd) -p 8000:8000 zloymult/wasm-build-kit \
python -m http.server

開発者のエクスペリエンス目線で見れば、明らかにRustとwasm-packタッグの圧勝です。言うまでもありませんが、私たちは基本部分をスクラッチでここまでやれるようになったのです。今や皆さんは、DOMを直接操作するのと同じように、Emscriptenやwasm-packでさらに多くのことをやれるようになりました。

以下の記事もご覧ください。

その頃、遥か彼方のブラウザでは...

WebAssemblyの強みは「移植性」「ソース非依存」「コードの再利用」だけではありません。WebAssemblyは、ブラウザがWasmコードをどのように実行しなければならないかに関連するパフォーマンス上のメリットも約束します。WebアプリケーションのロジックをWebAssemblyに書き換えるメリット(およびデメリット)を理解するには、「クライアントブラウザの背後で何が起きているか」および「通常のJavaScript実行とどこが違うか」について理解しておかなければなりません。

ここ10数年の間に、ブラウザ上でのJavaScript実行速度はきわめて優秀になってきましたが、JavaScriptコードを高効率なマシンコードに変換するのは必ずしもそこまで簡単ではありません。ブラウザの内部にはあらゆる先端技術が集約されており、Web界隈で最も冴えた頭脳がそこに結集してコンパイル技術の切磋琢磨を繰り返しています。

本記事であらゆるブラウザエンジンの内部動作をすべて扱うのは無理なので、ここではV8の話にとどめます。V8はChromiumやNode.jsで用いられているJSランタイムであり、現時点ではブラウザ方面のJavaScriptとバックエンド環境のJavaScriptの両方を支配しています。

V8はJavaScriptとWasmのどちらもコンパイルおよび実行できますが、アプローチは微妙に異なります。「ソースのフェッチ」「解析」「コンパイル」「実行」というパイプラインの流れについてはどちらもよく似ています。ユーザーに結果が表示されるまでは、すべてのステップが実行完了するまで待たなければなりません。

JavaScriptにおける主なトレードオフは「コンパイルに要する時間」と「実行に要する時間」の間にあります。最適化しないマシンコードなら極めて高速に生成できる代わりに実行時間が長くなり、コンパイルにもっと時間をかけてよいのであれば生成されるマシンインストラクションの効率は最大になる、という具合です。

V8がこの問題をどのように解決しようとしているかを以下の図で説明します。

JavaScriptがV8エンジンで動くしくみ

V8は最初にJavaScriptをパースし、得られた抽象構文木(AST)をIgnitionと呼ばれるインタープリターに渡します。Ignitionは、ASTをレジスタベースの仮想マシン上の内部表現に変換します。Wasmソースは既に仮想インストラクションセットになっているので、WebAssemblyを扱う場合はこのステップを省略できます。

JavaScriptをバイトコードに変換中、Ignitionはいくつかの追加情報(フィードバック)を収集します。この追加情報は、最適化をこれ以上推し進めるかどうかの決定に役立てられます。最適化がオンになった関数は「hot」とみなされます。

生成されたバイトコードは、最終的にTurboFanと呼ばれるV8の別のコンポーネント内に送り込まれます。TurboFanの仕事は、上述の内部表現を対象アーキテクチャ向けに最適化されたマシンコードに変換することです。

TurboFanは、最適なパフォーマンスを達成するためにIgnitionからのフィードバック情報を元に「推測」を行わなければなりません。たとえば、TurboFanは関数の引数の型を推測できます。後続の新しいコードが引き続きやってきてこの推測が意味をなさなくなると、V8エンジンは最適化をすべて捨てて最初からやり直します。このメカニズムによって、コードの実行に要する時間は予測できなくなってしまいます。

JavaScriptの実行に要する時間

Wasmでは、ブラウザエンジンの推測作業がうんと楽になります。.wasmフォーマットのおかげで、マルチスレッド解析しやすく設計された内部表現形式が最初から使えますし、開発者のコンピュータ上でコンパイルする時点でWebAssemblyに対してある程度の最適化も施されます。つまりV8エンジンは、JavaScriptでやっているような「最適化」「最適化のキャンセル」を行ったり来たりせずに、コードを即座にコンパイルおよび実行できるということです。

WasmがV8エンジンで動くしくみ

Liftoffというベースラインコンパイラは、V8エンジンに「fast start」機能を提供します。TurboFanの大げさな最適化も引き続き使われますが、必要な型情報がすべてソースコードに備わっているので今度ばかりは推測機能も出る幕がありません。「hotな」関数という概念はもはや適用されなくなるので、実行に要する時間が決定可能になります。つまり、実行時間をプログラム実行前に当たりをつけられるようになるわけです。

言うまでもありませんが、WebAssemblyは(原理的に)ブラウザの「外」でも実行できます。現在、Wasmを用いて任意のクライアント上で任意のコードを実行できるようにする以下のようなプロジェクトが多数存在します。

Wasmの実行に要する時間

既におわかりのとおり、WebAssemblyの野望は最終的にブラウザをも越え、ありとあらゆるシステムおよびアプライアンスを目指しています。

WebAssemblyをどんなときに使うか

WebAssemblyが作り出された目的は、既存のWebエコシステムを補完するためであり、いかなる意味でもJavaScriptを置き換えるものではありません。モダンなブラウザ上のJavaScriptは、DOM操作などの主なWebタスクにおいて素のままでも十分高速なので、WebAssemblyを使ったところでパフォーマンス上のメリットを得られるわけではありません。

WebAssemblyで約束されることのひとつに「Webアプリケーションとそれ以外のアプリケーションの垣根を取り払う」というものがあります。つまり「さまざまな言語で開発されてきた成熟したコードベースを最小限の手間でブラウザに持ち込める」というものです。既に、ゲーム画像コーデック、機械学習ライブラリ、そして言語ランタイムすらWasmに移植されています。

以下の記事にもあるように、現代のWebデザイナーにとってなくてはならないFigmaもごく初期段階からWebAssemblyをproductionで使い続けています。

現時点のWebAssemblyの状況についてですが、純粋なWasmをJavaScript「抜き」で利用するシンプルな方法はありません。依然として何らかの「接着剤」となるコードが必要です(自分で書くかツール生成に頼るかにかかわらず)。

パフォーマンス上のボトルネック解消をWasmに期待している方がもしいらっしゃれば、もう一度よく考えてみることをおすすめします。おそらく、コードを完全に書き直さなくても同じボトルネックを解消できる可能性もあるからです。少なくともWebAssemblyとJavaScriptパフォーマンスをシングルタスク実行だけで比較するベンチマークを当てにしないことです。現実のアプリケーションでは、WasmとJavaScriptは常に互いに繋がり合っているのですから。

Webのバイナリの未来に待ち構えている機能を知りたい方は、プロポーザルを詳しくチェックしてください。

WebAssemblyは現時点でも公式にはMVP(実用最小限の製品)フェーズの状態にありますが、WebAssemblyを始めるには今が絶好のチャンスです。私たちが本記事での解説で試してみた正しいツールを用いれば、すぐにでも準備が整います。


WebAssemblyについてもっと詳しく調べたい場合は、本記事執筆中に私たちが自分たち用に編集した参考記事リストをどうぞ。本記事で用いたすべてのコード例を置くための GitHubリポジトリも作成いたしました。

アプリケーションのボトルネックを発見および修正したい方は、ぜひEvil Martiansのフォームまでお気軽にお問い合わせください。私たちは、WebAssemblyであるなしに関わらずバックエンドやフロントエンドのボトルネックを解消する作業を喜んでお引き受けいたします。

特に、本記事をレビューいただだいた@whitequarkに感謝いたします。

本記事の翻訳や転載についてのご相談は、まずメールにてお願いします。

関連記事

mrubyをWebAssemblyで動かす(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。