概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Hands-on WebAssembly: Try the basics — Martian Chronicles, Evil Martians’ team blog
- 原文公開日: 2020/08/25
- 著者: Polina Gurtovaya、Andy Barnov
- サイト: Evil Martians -- ニューヨークやロシアを中心に拠点を構えるRuby on Rails開発会社です。良質のブログ記事を多数公開し、多くのgemのスポンサーでもあります。
日本語タイトルは内容に即したものにしました。画像はすべて元記事からの引用です。
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個ものインストクラクションを必要とします。そのインストラクションを、マシンコードをテキスト形式で表現する「アセンブリ言語」に置き換えて表すと以下のような感じになります。インストラクションの名前はどれも謎めいていて、理解するにはプロセッサに付属する分厚いマニュアルが必要です。
このコードをできるだけ高速に実行するために、プロセッサは「パイプライン」「ブランチ予測」「投機的実行」「プリフェッチ」といった技法を駆使します。インストラクションで使うデータ(およびインストラクション自身)をできるだけ高速にフェッチするために、プロセッサには複雑なキャッシュシステムが搭載されています。メインメモリからのデータ取得は、キャッシュからデータを取得する場合の何十倍も低速です。
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ソースコードが最終的に出力する正確な数値なのです。
コンパイラは魔法がたっぷり詰まったカバンを持っていて、人間が書いた「非効率な」コードを実行時にわざわざ走らせることを回避したり、より最適化されたマシンコードに置き換えたりします。
コンパイラのしくみ
モダンなコンパイラのほとんどは、コンパイラの「バックエンド」と「フロントエンド」の中間部分で最適化を行う「ミドルエンド」も持っています。
フロントエンドとバックエンド
どんなブラウザでも理解できる内部表現が使えたらどうなるだろう?
もしそうなったら、私たちはその内部表現をコンパイラの出力対象として使うことで、プログラムをコンパイルするときにクライアントシステムとの互換性のことを心配しないで済むようになるでしょう。私たちはプログラムをどんな言語で書いてもよくなり、いつまでも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をダンプ
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ファイルの内容
ツリーのルートはmodule
になっており、これは皆さんご存知のJavaScriptの「モジュール」ととても似ています。モジュールの中にはそのモジュール用の「import
」と「export
」もあります。
WebAssemblyを組み立てる基本的なブロックは、スタックを操作する「インストラクション(instruction)」です。
Wasmのインストラクション
コードのあちこちでif
やelse
やloop
といったステートメントが目につきますが、WebAssemblyで最も顕著な機能は、いわゆる「構造化制御フロー(structured control flow)」を用いて高水準言語のようにGOTOジャンプを回避する機能と、ソースコードを1パスでパース(parse: 解析)できるようにする機能です。
次は、エクスポートしたsign
関数を覗き込んで、スタックベースの仮想ISAの仕組みを見てみることにしましょう。
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)
- 整数値
2
をスタックにpushする(i32.const 2
) - 関数の最初のパラメーターをスタックにpush(
local.get 0
) - 整数値
4
をスタックにpush(i32.const 4
) - スタックから2つの値を削除して1番目の値を2番目の値で割った余りをスタックにpushする(
i32.rem_s
) - この時点でスタックのトップには余り
2
が置かれている - スタックから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つのステップの長さ、x0
とy0
は開始座標をそれぞれ設定します。
<!-- 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
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 new
やcreate-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.json
やGemfile
ととても似ています。エディタで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エンジンで動くしくみ
JavaScriptをバイトコードに変換中、Ignitionはいくつかの追加情報(フィードバック)を収集します。この追加情報は、最適化をこれ以上推し進めるかどうかの決定に役立てられます。最適化がオンになった関数は「hot」とみなされます。
生成されたバイトコードは、最終的にTurboFanと呼ばれるV8の別のコンポーネント内に送り込まれます。TurboFanの仕事は、上述の内部表現を対象アーキテクチャ向けに最適化されたマシンコードに変換することです。
TurboFanは、最適なパフォーマンスを達成するためにIgnitionからのフィードバック情報を元に「推測」を行わなければなりません。たとえば、TurboFanは関数の引数の型を推測できます。後続の新しいコードが引き続きやってきてこの推測が意味をなさなくなると、V8エンジンは最適化をすべて捨てて最初からやり直します。このメカニズムによって、コードの実行に要する時間は予測できなくなってしまいます。
JavaScriptの実行に要する時間
WasmがV8エンジンで動くしくみ
言うまでもありませんが、WebAssemblyは(原理的に)ブラウザの「外」でも実行できます。現在、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に感謝いたします。