WebAssemblyをちょっといじってみて思ったところをまとめてみます。
WebAssembly/designに設計文書がまとまっています。特にHighLevelGoals.mdから読み取れるポイントは以下の4点です。
サンドボックスという観点からは、先行技術として以下のようなものが特筆に値します。
これらのサンドボックスとの比較では以下のようにWebAssemblyを特徴づけられるでしょう。 (FAQ.md も参照)
WebAssemblyコードを実行する処理系は、意味論の仕様を守っていれば名目上はどのように実装してもいいはずですが、設計上はJITまたはAOTコンパイラによって実CPUのネイティブコードに変換して実行することが想定されていると考えられます。また、期待される実行効率を考えると、そうすることが望ましいでしょう。
x86-64をはじめとした一般的な実CPUのISA/ABIとの比較です。
プログラムはELFなどの既存のフォーマットではなく、専用のフォーマットを持つコンテナに格納されます。これは「WebAssemblyモジュールのバイナリ形式」と呼ばれるものです。この仕様はWebAssemblyの命令セットのフォーマットと統合された形で定義されています。つまり、各CPUベンダが定義するISAの上にOS固有のフォーマット (ELFへのマッピングなど) を定義する伝統的な考え方とは異なることになります。
また、WebAssemblyのコードを直接読み書きするための「アセンブリ言語」相当のフォーマットとして、テキスト形式も定義されています。これもISAレベルの形式とコンテナレベルの形式が統合された形で定義されています。また構文的にはS式を利用している点が一般的なアセンブリと異なって見えるでしょう。
外界との相互作用を行う方法は、一般的な実CPUでは以下のようになっていることが多いと思います。
WebAssemblyにおける外界の相互作用にも同様にいくつかの方法があります。いずれについても、WebAssemblyが規定するのは汎用的な仕組みだけで、実際にどのような機能を提供するかは呼び出し元 (埋め込み側) に任されています。WASIのように、WebAssembly向けのシステムコールセットの標準化を試みるプロジェクトもあります。
WebAssemblyで扱う値には透明な値と不透明な値の2種類があります。
不透明な値は、一般的なISAにはあまり登場しないのではないかと思います。 (メモリにマップしてreinterpretすれば表現を読めてしまうので)
WebAssemblyには簡易的な型検査があるため、不透明な値を透明な値として読み直すような処理ははじめからできないようになっています。
一般的なISAには2つの明示的な記憶領域があります。
いっぽう、WebAssemblyの対応する記憶領域は目的別に以下のように分けらています。
このうち、WebAssemblyのスタックは一般的なISAにおけるコールスタックよりも能力が制限されています。具体的には動的なアドレッシングを行うことができません。そのためBasic C ABIではWebAssemblyのスタックと線形メモリ上に構築されたスタック構造を組み合わせてCのコールスタックを実現しています。
このように記憶領域が複雑に分割されている理由はいくつかあると考えられます。まず重要なのは、線形メモリには不透明な値を置けないという点です。線形メモリは高効率なバイト列ストレージであることが期待されるため、実行時型検査を入れるという選択肢はあまり考えられません。しかし、実行時型検査を入れない場合、不透明な値を透明な値として読み直す (reinterpret cast) 処理ができてしまうため、不透明な値を読み出せないようにしているWebAssemblyの抽象化が破壊されてしまいます。これが線形メモリとテーブルを分けている理由だと説明できます。[2]
いっぽう、スタックとローカル変数は動的なアドレッシングを禁止することで静的に型の一致を保証できるようになっています。そのため、透明な値と不透明な値の両方を同時に扱うことを許しても特に問題はないことになります。なお、いずれも単一のフレーム内では決まった大きさしか持たない一方、関数呼び出しを通じて無制限に領域を広げることができるため、単なるレジスタファイルというよりは無限に大きなレジスタウインドウのようなイメージが近いかもしれません。
スタック上に直接値を積むのと、同じフレーム内のローカル変数に値を格納することの役割上の区別はやや曖昧です。能力上は以下の違いがあります。
つまり、順序よく(逆ポーランド順でそのまま)値を計算しているときは、ローカル変数を経由してもスタックに先に積んでしまってもいいことになります。
WebAssemblyの制御フローはラベルベースの平坦なジャンプに基づくもの (非構造化制御フロー) ではなく、構造化されたものになっています。組み込みのスタックがある点も含めて、この部分はあまりアセンブリらしくありませんが、これについてはWhy a stack machine?で言及があります。
具体的には、WebAssemblyの基本的な制御フロー命令は以下の通りです。
理論的には block, loop と br_if の3つだけ考えるのがわかりやすいと思います。
br $foo はi32.const 1 br_if $foo と等価。br_table についても、br_if の繰り返しとおおむね等価。if $foo <...1> else <...2> end はblock $foo block $foo_else br_if $foo_else <...2> br $foo end <...1> end と等価。 (たぶん)状態機械の話なので、非構造化制御フローと構造化制御フローの関係はNFAと正規表現の関係のように考えればいいでしょう。NFAと正規表現が同じ強さの言語を表現できるのと同じように、非構造化制御フローと構造化制御フローも広い意味では等価です。
ただし、その変換には追加のコストが伴います。具体的には、構造化制御フローから非構造化制御フローへの変換はほとんど自明なのに対して、非構造化制御フローから構造化制御フローへの変換 (Relooping)は難しく、一般の変換には以下のいずれかの妥協が必要です。
実際にこれらの妥協を行わないと変換できないような例として以下があります。
start: ; ... block 1 ... jump_if (...) b2 jump b3b2: ; ... block 2 ... jump_if (...) b3 jump b4b3: ; ... block 3 ... jump_if (...) b2 jump b4b4: ; ... block 4 ... retここでは b2 ←→ b3 でループがありますが、b2からループに入るパターンとb3からループに入るパターンがあることで構造化が阻まれています。たとえばDuff's deviceはこのような制御構造を持つ例になっていますが、このようにループの途中にジャンプするパターンは実際には非構造的なパターンに位置づけられ、C/C++以外のswitch文ではできないほうが一般的だと思います。
さて、Reloopingが成功する条件はおそらく以下のように特徴づけられると思います。 (独自の考察なので間違ってたらすみません)
(証明の概略) [⇒] CFGの強連結な部分集合cを1つとる。c中で、構造化制御フロー上で並べたときに最初に来る(CFG上の)基本ブロックをb1とおき、b1を直接含む(構造化制御フロー上の)ブロックをBとおく。cのノードは全てb1に到達できるが、構造化制御フロー上でb1より後ろにあることから、これらはBのループ構造を通ってb1に復帰していることになる。したがってcの基本ブロックは全てBに含まれ、b1はBの最初の基本ブロックであることがわかる。構造化制御フローではブロックに入る方法は1通りしかないので、Bを通る全てのパスはb1を経由する。
[⇐] 構造化制御フローの組み立てアルゴリズムを記述する。このアルゴリズムは、入力CFGの最大の強連結成分の大きさに対する帰納法で定義される。入力CFGから到達不能な基本ブロックを取り除いたあと、強連結成分分解する。各連結成分cについて、仮定より支配ノードdを選択する。cの誘導部分グラフから、dに入る辺を取り除いた部分グラフを考えると、このグラフは元のグラフよりも小さい強連結成分しか持たない。このグラフに対して再帰的に構造化制御フローを組み立てたあと、このコードをloop ... end で囲い、dに入る辺に対応する分岐命令を追加する。これでcに対応するコードが得られた。各強連結成分に対応するコードが得られたため、これらを連結して1つのコードにする。強連結成分同士はDAGを形成するため、トポロジカルソートして先頭から順に処理することができる。空のコードから始め、各強連結成分ごとにblock <...直前のコード...> end <...現在の強連結成分に対応するコード...> というコードを作り、直前のコードから現在の強連結成分に出る辺に対応する分岐命令を追加する。これを繰り返すことで所望の構造化制御フローが生成される。
さて、この特徴付けを用いると、先ほどのグラフがReloopingできないことがわかります。{b2, b3}が強連結成分になっていますが、b1→b2, b1→b3の両方のパスがあるためいずれももう一方を支配していません。
コンパイラではLiveness解析など色々な目的で、「コード中の位置」をフラットに表現したいことが結構ある気がするので、結局のところ非構造化された形式のほうが便利そうな気がしますが、実際のところどうなんでしょうか? 識者の意見を聞きたいです。
WebAssemblyのバイナリフォーマットはいわゆるバイトコードと呼ばれる形式で、低レベルでの非構造化された解釈と高レベルでの構造化された解釈の両方に適するように設計されています。
たとえば、前述のように制御フロー命令は構造化されています。これは、block 命令が即値オペランドとして「命令列」という巨大なデータを含むという解釈です。しかし、これらの命令をバイナリ形式またはテキスト形式で記述する場合は、あたかもblock 命令とend 命令が独立した命令であるかのように記述されます。これは命令列をある程度フラットな状態で扱うことが想定されているのだと思われます。
ほとんどの部分ではエンコードが一意に決定できるようになっていますが、可変長整数フォーマットで冗長な表現が許されていることが特筆に値します。このユースケースは古典的なリンカのサポートです。古典的なリンカは命令列全てを解釈せず、アセンブラが別途出力するヒントに基づいて命令列を置換します。この方式ではバイナリの長さが変わらないことが重要になるため、整数を最小長ではなく固定長でエンコードする必要があります。
このようなユースケースがあることから、WebAssemblyモジュールのパーサーを実装するにあたっては、各セクションを未解釈の状態で残すオプションを用意しておくのがよいでしょう。
WebAssemblyはC/C++のコンパイルターゲットである必要があるため、全体が型で保護されているわけではありません。たとえば、線形メモリに対するアクセス違反などは実行時に検出されます。
いっぽう、WebAssemblyの機能のうち比較的高レベルな抽象化を提供している部分については、比較的単純な型システムを用いた型検査が行われます。これにはスタック・ローカル変数・グローバル変数の扱いが含まれます。ここで使われる値型は以下のようにごく基本的な型のみからなります。
これはTyped Function Referencesなどの提案によりさらに拡張される可能性があります。
現時点で型システムが単純である理由はBasic Types Onlyで説明されています。現在あるi32, i64, f64, v128は実CPUのISAにおけるレジスタにマッピングするのに適した型として必要で、f32については単精度演算 (丸めの関係で倍精度演算と等価でない) の結果として必要だと説明がつきます。不透明な値は透明な値との最低限の区別のために必要だと考えられます。それ以外の複雑な型はユーザー定義の方法で線形メモリやテーブルにマップすれば十分というのが現在の判断だと考えられます。ただ、今後GC Proposalなどで複雑な型システムを仕様レベルで取り込む必要があると判断される可能性は十分にあります。
バイナリ形式の内部構造は基本的にType-Value形式です。つまり最初の1~2バイト程度がノード種別をあらわし、ノード種別ごとに固有のデータがそれに後続します。この形式の特徴は以下の通りです。
いっぽう、ごく一部のノードはTLV形式になっています。この形式はTVとは逆の以下の特徴を持ちます:
具体的には各セクションとコードセクションのコード部分がTLVです。
また、式や制御命令中では、終了デリミタを持つ形式も使われています。この形式では、終了デリミタが発見されるまでデータを読み続けます。
テキスト形式はS式のような形でWebAssemblyモジュールを記述できる形式で、以下の特徴があります。
バイナリ形式では命令列のネスト構造 (block, loop, if) を終了デリミタで表現していますが、テキスト形式でも同じように命令列のネスト構造をend という専用のトークンで表現し、S式の構造である() を使いません。命令列中の() は折り畳み命令という構文糖衣のために利用されます。
WebAssemblyの仕様自体は特定のツールチェインに依存しないように定義されていますが、普及戦略としてはLLVM/Clang(とEmscripten)を名指ししてロードマップに組み込んでいます。
これらのツールはWebAssemblyの仕様の上に追加の規約を乗せて運用していますが、これらの規約はWebAssembly/tool-conventionsにまとめられています。新しいツールは、これに従うことで既存のツールとの相互運用性を高めることができますが、もちろんあえてこれらとは異なる規約を採用することも可能です。
GC Proposal自体はありますが、MVPにGCが含まれていないことでGCを前提にしないエコシステムがあるという特徴は変わらないでしょう。↩︎
ただし、元々のテーブルは関数の間接呼び出しのための静的なストレージという役割だったようです。↩︎
バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。
もう参照されたかもしれませんが、label/gotoを追加する要望についてはここで話し合われています。
https://github.com/WebAssembly/design/issues/796
Go teamでwasmアーキテクチャを担当するneelance氏も、I still haven't seen a good explanation for this design choice except that V8 supposedly would have a hard time supporting arbitrary control flow と評しています。
道のりは険しそうです(´・ω・`)