Wasm入門(仕様編)

執筆者 : 小田 逸郎


はじめに

WebAssembly(以下、Wasm)というものが世間で話題となっているようです。 元々、Webブラウザで実行されるバイトコードであるという程度の認識で、Java みたいなものか(?)くらいの感想しかなく、あまり関心はなかったのでした。 しかしながら、最近、Kubernetesでコンテナの代わりにWasmアプリケーション を扱えるような記述を見かけるに至り、「は?(なんじゃそりゃ)」と、 にわかに関心が出てきたので調べてみることにしました。

本記事は、とりあえず、Wasmとは何ぞやということで、Wasmの仕様について 調べたものです(注意: 前記の「は?」には何も答えていません)。

参考資料

Wasmの仕様書は、以下にあります。本記事は、この仕様書によります(以下、文中で 仕様書と呼称)。

https://webassembly.github.io/spec/core/

また、以下のgithubに、補足用の資料やWasmの勉強の際に作成したプログラムがあるので、 合わせて参考にしてください(以下、文中で参考資料と呼称)。

https://github.com/oda-g/WASM

仕様書ですが、最初見た正直な感想は「これ読んで分かる人いるの?」という ものでした。分かっている人が仕様の確認に使用する分にはいいのでしょうが、最初に これを見て理解するのは、結構きついかと思います。自分は、一か月くらい取り組んで、 徐々に書いてあることが分かるようになってきました。仕様書を読む前に、本記事や 参考資料を読めば、若干理解までの時間が節約されると思います。

Wasmの特徴

Wasmは、ホストプラットフォーム上で直接実行されるのではなく、embedder によって 実行されることを想定されています。典型的には、Webブラウザですね。最近では、 wasmtimeとかwasmerとかのランタイムもそれに当たります。embedder の適当な訳が思い 浮かばないので、本記事では、ランタイムと呼ぶことにします。

感想: カーネルがWasmの実行をサポートすれば、プラットホームで直接実行されることに なりますね。やるかどうかは別として、実装は難しくないと思います。

仮想マシン実行環境

Wasmはランタイムにより実行される仮想的な命令セットとマシン環境を定義するものです。

仮想マシン実行環境(概略)

実行環境には以下の要素があります。

スタック

命令セットの特徴としては、スタックマシンであることが挙げられます(したがって、 レジスタというものはないです)。演算をするためにはまずデータをスタックに載せる 必要があります。例えば、a+bを計算したい場合は、aをスタックに載せ、bをスタックに載せ、 その状態で、「add」命令を実行すると、スタックからaとbが消え、代わりに演算結果(a+b)が スタックに残るといった具合です。

関数

命令の実行は関数という単位で行われます。関数は以下の要素から成り立っています。

  • 入出力パラメータの定義
  • ローカル変数の定義
  • 実行する命令列

通常の言語(ex. C)の関数実行と同じイメージで考えて良いです。ローカル変数を使用でき、 そのスコープはその関数内となります。関数内から別の関数を実行したり、同じ関数を実行 したりすることもできます。

補足: 入力パラメータは複数持つことができます(なくてもよい)。出力パラメータは、仕様上は 複数持つことができますが、現状サポートされているのはひとつだけです(なくてもよい)。

グローバル変数

関数をまたいでアクセス可能なグローバル変数を使用することができます。

メモリ

メモリを使用することができます。メモリは単なるデータの格納域であり、関数を またいでアクセス可能なデータを置くことができます(プログラムを置いて、実行すること はできません)。

テーブル

関数参照を要素とするテーブルを持つことができ、 テーブル内のインデックスを指定して関数を実行することができます。 処理によって、動的に実行する関数を変えたい場合などに使用することができます。

命令セット

Wasmの命令セットはどんなものか見ていきましょう。

データ型

データ型として以下のものが用意されています。

  • i32: 32bit整数型
  • i64: 64bit整数型
  • f32: 32bit浮動小数点型(IEEE 754)
  • f64: 64bit浮動小数点型(IEEE 754)
  • v128: 128bitベクトル型

以上お終いです。ローカル変数、グローバル変数、関数のパラメータ、スタック上の値として使用 出来るのは、これらのデータ型のみとなります。

数値については、32bitと64bitしかないという潔さです。整数型のsignedかunsignedかは、命令に よって区別されます。v128は、SIMD命令用のデータ型です。Wasmの命令セットは単純さが売りなのか と思っていたところに、SIMD用データ型とは、ちょっと唐突感がありますね(また、それにしても 128bit幅だけでよいのか、という疑問も湧きますね)。

補足: 正確には、funcref、externrefという2つの参照型と呼ばれるデータ型があって、データ型としては、 それですべてとなります。参照型はテーブルの要素として使用される特殊な型で、詳細は割愛します。

補足: endianについての規定はなく、実行するアーキテクチャによります。なお、バイトコード上の整数値は、 LEB128エンコーディングされており、浮動小数点型は、IEEE 754 little endian と規定されています。

数値演算

通常考えられる演算は用意されています。以下、ちょっとした補足事項のみ記述します。詳細は、仕様書か 参考資料を参照してください。

  • 定数命令: スタック上に定数を載せるための命令が用意されています。
  • 整数型のsignedかunsignedかは、命令によって区別されます。例えば、i32.div_s は、siginedと見なして除算、 i32.div_u は、unsignedと見なして除算といった具合です。
  • bool値: bool演算の結果は、i32の1(true)か0(false)で表現されます。

ベクトル演算

v128データ型に対する演算で、いわゆるSIMD命令となります。128bitをi8×16、i16×8、i32×4、i64×2 のデータであるとか、f32×4、f64×2のデータであるとかとみなし、一気に計算することができます。 詳細は、仕様書を参照してください。

補足: 大量(236命令)に用意されています。ベクトル演算命令だけで、他の命令すべて合わせたよりも多いです。

変数、メモリ、テーブルアクセス

演算するデータはスタックに載せないといけないので、ローカル・グローバル変数の値をスタックに 載せたり、スタックの値をローカル・グローバル変数に格納する命令が用意されています。メモリや テーブルに関しても同様です。

メモリアクセスに関しては、データのバイト幅を意識した命令が用意されています。例えば、i32.load8_s は、1byteのメモリをsigned integerと見なして、i32データ型としてスタックに載せる、といった具合です。

制御フロー

基本的には、命令列を順番に実行していくだけですが、ブロック命令という命令列をグループ化するもの が用意されています。ブロック命令には、blockloop の2種類があり、対応する end 命令までの 命令列をグループ化します。

  • block <命令列> end
  • loop <命令列> end

これらは入れ子にすることもできます。実はこれだけでは何も効果はないのですが、分岐命令と組み 合わせることにより、若干複雑な制御フローを実現することができます。

ブロック命令(例)

分岐命令はブロックから脱出する命令で、ブロック内だけで使用することができます。block の場合は、 end の次の命令に、loop の場合は、loop 命令に飛びます(それらが次に実行する命令になります)。 また、入れ子の場合に脱出するレベルを指定することができます。

上図を例に取ると、左は、「if {A} else {B}」のフロー、右は「loop {A}」のフローを実現したものとなります。 brは無条件分岐、br_ifは条件付き分岐命令となります。

補足: 他に「if <命令列1> (else <命令列2>) end」というブロック命令があり、図左のフローを実現 できますが、使用する必要がないですし、実際使用されてない(※)ようです。

(※) 例えば、RustプログラムをWasmにコンパイルした際、出現しない。

制御フローとしては、他に以下のものがあります。

  • 関数呼び出し
    関数を呼び出すことができます。関数の命令列を最初から実行し、復帰したら、呼び出した次の 命令から実行を継続します。基本的には、関数の命令列を最後まで実行したら復帰しますが、途中で復帰 させるreturn命令もあります。
  • trap: Wasmの実行を中止し、ランタイムに復帰します。

ランタイムとのインタフェース

ランタイムとのインタフェースとして、エクスポートとインポートというものを 定義できるようになっています。

  • エクスポート
    ランタイムからアクセス可能な関数(、メモリ、グローバル変数、テーブル)を指定することができます。
  • インポート
    Wasmの関数から呼び出し(アクセス)可能な、ランタイムが提供する関数(、メモリ、グローバル変数、 テーブル)を指定することができます。

Wasmのプログラムが実行されるためには、少なくとも一つの関数がエクスポートされている必要があり ます。ランタイムでは、Wasmのバイナリ(モジュール)をロード後、エクスポートされている関数を実行 することができます。また、ロード後、自動的に実行される関数を指定しておくこともできます。

インポートは、ランタイムが提供するものであり、例えば、インポートされた関数を呼び出すと、 ランタイムに制御が移り、なんらかの処理がされた上で、出力パラメータがスタック上に残された状態 で、Wasmに処理が戻るといった具合です。

何がインポートできるかは、ランタイム依存となります。ランタイムによって、できることが異なる のでは、アプリ(Wasm)作成側としては不便なので、このあたりは(、本ブログで参照している仕様書では 既定されておらず)、別の仕様書により、規定されています。

Wasm単独では、何らかの計算をしてその結果を返すということくらいしかできません。外部とのやりとり、 例えば、ファイルやネットワークのアクセスをしたい場合は、WASIを使用することになります。通常の プログラムに対するシステムコールのような位置づけですね。

一応、ホストランタイム向けとしては、WASIというものが標準のようですが、まだpreviewのよう(2023年8月現在)ですし、 wasmerがWASIXを出したりして混沌としているようです。これはランタイムの差別化要素でもあるため、 また更なる拡張だとか別の仕様が出てきそうな気もします。まだまだ成熟には遠そうです。

バイナリフォーマット

Wasmバイナリは、モジュールと呼ばれています。モジュールの構成要素は、以下の項で示しますが、それぞれを 定義するセクションがバイナリ中にバイトコードとしてエンコードされています。各セクションは高々 ひとつです(なくてもよい)。

タイプ

関数の入出力パラメータの型を定義します。

インポート

ブログラムからアクセス可能な、ランタイムが提供する関数、テーブル、メモリ、グローバル変数の 識別名と属性を定義します。実体は、ランタイムにあるので、属性(ex. 関数であれば入出力パラメータの定義) のみです。識別名は、{モジュール名}.{オブジェクト名}で表されます。例えば、WASIで規定されている関数例 として、wasi_snapshot_preview1.fd_write といった具合です。

関数

入出力パラメータの型、ローカル変数の型、関数本体(命令列)を定義します。入出力パラメータの型については、 タイプセクションを参照し、そのインデックスを指定します。

テーブル

テーブルの属性(大きさ、要素の種類)を定義します。テーブルは典型的には関数参照(funcref)を要素とし、 間接関数呼び出し(call_indirect)命令で使用されます。

補足: 外部参照(externref)も要素として持つことができますが、詳細は割愛します。

メモリ

メモリの属性(大きさの最小、最大)を定義します。大きさの単位はページ(64KB)です。メモリは単なる バイト列です。

仕様書上は、複数のメモリを扱えるようになっていますが、現状サポートしているのは一つのみです。

グローバル

グローバル変数の属性(型、初期値、変更可能かどうか)を定義します。

エクスポート

ランタイムからアクセス可能な、関数、テーブル、メモリ、グローバル変数を定義します。各セクション で定義したオブジェクトのインデックスとオブジェクトの識別名を指定します。

スタート

ランタイムがモジュールをロードした時に自動的に実行する関数を指定します。

エレメント

テーブルに格納する要素(エレメントセグメント)を定義します。基本的にテーブルの要素の元ネタは、 エレメントセクションに置いておく必要があります。テーブルの要素は、モジュールロード時に (エレメントセグメントから)格納することができます。また、プログラム中で(エレメントセグメント から)格納することもできます。

データ

メモリ中に格納するデータ(データセグメント)を定義することができます。データセグメントは、 モジュールロード時にメモリに格納することも、プログラム中で格納することもできます。 通常のELFバイナリのデータセクションに当たるものです。

終わりに

Wasmの仕様書を紐解いて、どんなことができるのか把握してみました。結構単純であることが分かりました。 これなら、ランタイム(単純なインタープリタ)を作るのは難しくなさそうです(弊社の守備範囲としては、 使う側よりも作る側なので、どうしてもそちらの方向に関心が行きます)。

売りのひとつに JavaScriptよりも実行が速いというのがありましたが、バイトコードである分、構文解析等不要なので、 インタープリタでも速そうには思いますが、nativeコードに比べれば随分遅くなってしまうのでは ないでしょうか。実際のランタイムでは、nativeコードに変換しているのでしょうか。ベクトル命令が 実際のランタイムでどう実行されているのか(nativeのSIMD命令を使うようになっているのか)も気になる ところです。そのあたりも調べてみたいところです。もしかしたら、ブログ記事になるかもしれません。