🌟

異種Wasmランタイム間のライブマイグレーション

2023/12/17に公開

この記事は WebAssembly Advent Calendar 2023 17日目の記事である。

はじめに

WasmEdgeとWebAssembly Micro Runtime(WAMR)の間でライブマイグレーションする機能を実装した。
https://github.com/funera1/WasmEdge
https://github.com/funera1/wasm-micro-runtime

ライブマイグレーションとは、実行中の仮想マシンまたはアプリケーションを、異なる物理マシン間で移動することである。ライブマイグレーションは、メンテナンスなどの用途で利用され、現在のクラウドコンピューティングでも使われている。

モチベーション

モチベーションは主に次の2つである

  • エッジコンピューティングでライブマイグレーションしたいモチベーションがあるが[1][2]、エッジコンピューティングは多種多様なOSやCPUアーキテクチャのマシンが混在しているので、それらの抽象レイヤとしてWasm上でライブマイグレーションをしたい
  • Wasmはいろんなランタイムがあって、それぞれに特性差があるので、用途や環境によって最適なランタイムを使い分けたい

方針

ライブマイグレーションは、実行中の内部状態を保存する処理と、復元する処理の実装によって実現できる。
WasmはCore仕様から、次の内部状態を持つ

また、モジュールインスタンスの内、実行中に状態が変化するのは、メモリインスタンスとグローバルインスタンスのみであるため、実際に保存復元する対象は、以下である。

  • モジュールインスタンス
    • メモリインスタンス
    • グローバルインスタンス
  • プログラムカウンタ
  • スタック
    • 値スタック
    • ラベルスタック
    • フレームスタック

これらの内部状態を保存ないし復元可能な表現形式へ変換し、ファイルへ読み書きすることで保存復元を行う。

技術的課題

WAMRもWasmEdgeもCore仕様に従っているので、これらの内部状態はは持つ。しかし、ランタイム毎に実装は異なるので、各内部状態を対応付ける必要がある。

WAMRとWasmEdgeの実装差と変換手法

モジュールインスタンス

メモリインスタンス

メモリインスタンスは、Wasmランタイムにおけるリニアメモリである。メモリインスタンスは、65536bytesを1ページとして、ページ単位で動的に確保する。
したがって、メモリインスタンスを復元するには、次の値を保存すれば良い。

  • 現在のページ数
  • メモリインスタンスの中身(バイト列)

グローバルインスタンス

グローバルインスタンスは、グローバル変数の実行時表現で、個々の値を保持する。したがって、グローバルインスタンスを復元するには、次の値を保存すれば良い。

メモリインスタンスとグローバルインスタンスは、WAMRとWasmEdgeの間に実装差がなかったので、ランタイム間で変換させる必要はなかった。

プログラムカウンタ

プログラムカウンタは、次に実行するWasm命令のアドレスである。アドレスはそのまま保存しても、別マシン・プロセスで使えない。したがって、関数インデックスとその関数の先頭アドレスからのオフセットの組を保存することで、復元させる。

関数インデックスは、Wasmモジュールで定義されているものなので、WAMRとWasmEdgeで共通している。

一方、オフセットは、両ランタイムで差があった。
WAMRの命令シーケンスは、WasmバイナリのCode Sectionに記述されている命令と引数が混在した列を、そのままのレイアウトで扱っている。
WasmEdgeの命令シーケンスは、Code Sectionを整理して、Instructionというクラスの列として実装していた。Instructionクラスは、命令やその命令の引数、ジャンプ命令の場合そのジャンプアドレスなどさまざまな値を持つ。つまり、WasmEdgeの命令シーケンスは、命令のみから構成されているといえる。
そのために、同じ命令を指す場合でも、そのオフセットはWasmEdgeとWAMRで異なる。

その差を対応させる方法として、本手法では、WAMRの命令と引数が混在した仕様のオフセットを保存復元表現として設計した。この設計から、WAMRの保存処理は、プログラムカウンタのアドレスからその関数の先頭アドレスを引いたものを保存した。WasmEdgeは、Instructionクラスの中に、Offsetという命令と引数が混在したオフセットを持っていたのでそれを利用した。

値スタック

Core仕様より、Wasmの値の型サイズは32bit, 64bit, 128bitのみである。
WAMRの値スタックは、uint32の配列として実装されている。そのため、レイアウトは、1要素が32bitで、例えば64bitの値を格納する場合、2つ分の要素を使って格納する。したがって、値スタックのメモリ中に値が敷き詰まっている。
WasmEdgeの値スタックは、ValVariantという128bitの共用体のvectorで実装されている。そのため、レイアウトは、1要素が128bitとして、1要素内にすべての型サイズの値が格納できる。しかし、空いている部分は0埋めされている。

また、Wasmは値スタック自身が内部の値の型情報を持つようなデータ構造は設計されていない。
これらの理由から、WAMRとWasmEdgeは値スタック内の値の対応付けができない。
本手法では、型スタックという、値スタックの各値の型サイズを管理するデータ構造を用意した。これにより、レイアウトの異なるWAMRとWasmEdge間の値スタックを相互変換できるようにした。

ラベルスタック

Wasmはジャンプ命令で任意のアドレスに対してジャンプできない。代わりに、ラベルが貼られたアドレスにのみジャンプできる。そのラベルを管理するのがラベルスタックである。ラベルは、br命令、loop命令、if命令などによって貼ることができる。

WAMRはCore仕様通り、ラベルスタックを持ち、ラベルスタックを介してジャンプアドレスへジャンプする。
一方、WasmEdgeは、バリデーションフェーズに、各ジャンプ命令がどのアドレスにジャンプするかを前計算して、そのアドレスをInstructionクラスに持たせる。これによって、ラベルスタックの実装を省略している。

これらを対応付けるには、WasmEdgeがラベルスタックを復元して保存する必要がある。ただし、実行中には必要ないので、保存するときに、ラベルスタックを復元させる。

フレームスタック

フレームスタックは、呼び出し関数から抜けて、呼び出し元の関数に戻る際に必要な情報(リターンアドレスや戻ったときの値スタック)を管理するスタック。いわゆるコールスタックである。

WAMRのフレームは次の構造体である。

typedef struct WASMInterpFrame {
    /* The frame of the caller that are calling the current function. */
    struct WASMInterpFrame *prev_frame;

    /* The current WASM function. */
    struct WASMFunctionInstance *function;

    /* Instruction pointer of the bytecode array.  */
    uint8 *ip;

    /* Operand stack top pointer of the current frame. The bottom of
       the stack is the next cell after the last local variable. */
    uint32 *sp_bottom;
    uint32 *sp_boundary;
    uint32 *sp;

    WASMBranchBlock *csp_bottom;
    WASMBranchBlock *csp_boundary;
    WASMBranchBlock *csp;

    /**
     * Frame data, the layout is:
     *  lp: parameters and local variables
     *  sp_bottom to sp_boundary: wasm operand stack
     *  csp_bottom to csp_boundary: wasm label stack
     *  jit spill cache: only available for fast jit
     */
    uint32 lp[1];
} WASMInterpFrame;

一方WasmEdgeのフレームは次の構造体である。

struct Frame {
    Frame() = delete;
    Frame(const Instance::ModuleInstance *Mod, AST::InstrView::iterator FromIt,
          uint32_t L, uint32_t A, uint32_t V) noexcept
        : Module(Mod), From(FromIt), Locals(L), Arity(A), VPos(V) {}
    // モジュールインスタンス
    const Instance::ModuleInstance *Module;
    // リターンアドレス
    AST::InstrView::iterator From;
    / /
    uint32_t Locals;
    uint32_t Arity;
    uint32_t VPos;
  };

整理すると、各ランタイムのフレームは次の値を持つ

WAMRは、リターンアドレス、値スタックのポインタ、ラベルスタックのポインタを持つ。
WasmEdgeは、リターンアドレス、値スタックのポインタ、ローカルの個数、返り値の個数を持つ。

また、値スタックやラベルスタックのポインタは、WAMRとWasmEdgeで、呼び出し元関数のものか、呼び出し先関数のものかも異なる。

これらの差を対応させるために、リターンアドレス、値スタックポインタ、ラベルスタックポインタは、フレームをずらしつつ対応させた。WasmEdgeのフレームにあるローカルの個数と返り値の個数は、WAMRは関数インスタンスが持っているので、それを参照して、解決した。

デモ

WAMR -> WasmEdge

WasmEdge -> WAMR

おわりに

今回扱ったのは、内部がスタックマシンのインタプリタのみで、Wasm3やWAMRの高速インタプリタなどの内部がレジスタマシンに置き換わっていて(かつ高速な)インタプリタは扱わなかった。
今後の方針では、内部がレジスタマシンになっているようなインタプリタも含め、すべてのWasmランタイムで、ランタイム中立にライブマイグレーションができる設計・実装を考えていきたいと考えている。

脚注
  1. https://ieeexplore.ieee.org/abstract/document/9610311 ↩︎

  2. https://dl.acm.org/doi/10.1145/3357223.3362735 ↩︎

Discussion