📉

X68000向けのRust Wasmコードのメモリ使用量をダイエット

2024/04/16に公開

今回はX68000向けのRustコードのメモリ使用量を削減していきます。

現状のメモリ使用量に関する課題

Wasm経由でのRustコードをビルドするとHello Worldのような本来メモリをそれほど使わないはずの処理でも1MByte強のヒープ領域を確保してしまうという課題があります。
令和の感覚だとヒープ領域1MByteは控えめなサイズですが、X68000にとっては使いもしないのに確保だけするのを許容できるサイズではありません。
X68000で利用する上では解決すべき課題だといえます。

おさらい

課題の解決方法を探るために、RustコードをWasm経由でX68000向けにビルドするときのビルドの仕組みとその際のメモリの割り当てがどのように行われているのかおさらいしていきたいと思います。

ビルドの仕組み

現在、RustからX68000の実行形式をビルドするのに下記のダイアグラムで示すようにwasmを経由してビルドを行っています[1]

実行ファイルのメモリ使用量がどのように決まっていくのか知るには下記のポイントを押さえる必要があります。

  • Wasmのメモリの仕様
  • RustコードはWasmメモリをどのように利用しているか
  • WasmをC言語に変換するとWasmのメモリはC言語でどのように扱われるか

Wasmのメモリ仕様

まず、Wasmのリニアメモリとグローバル変数の仕様を見ていきます。

リニアメモリ

Wasmでは連続したアドレスを持つデータを格納するためにリニアメモリを利用します。
Wasmモジュールはモジュール内で利用するリニアメモリのサイズをMemories[2]で宣言しています。
リニアメモリは64kByteのページ単位で使用する領域を確保する仕様となっています。Memoriesではモジュールがこのページを最小で何ページ利用するかを表す値が示され、実行開始時にはここで示されたページ数のメモリが連続した領域として用意されます。
Wasmのコードで使われるメモリインストラクション[3]ではこの領域上のデータをアドレス(およびサイズ、オフセット、アラインメント)指定に従ってロード、ストア命令で読み書きする仕様となっています。
また、このリニアメモリの領域をページ単位で拡張するためのmomory.grow命令が用意されておりこの命令が実行されると指定されたページ数だけこのメモリ領域のサイズが拡張されます。
また、リニアメモリは基本的に実行開始時に0で初期化される仕様となっていますが、データセグメント[4]に初期化データを格納することで0以外の値で初期化できる仕様も備えています。

なお、仕様上このリニアメモリの領域を複数定義できるようになっていますが、現状はindex 0として定義されている1つの領域が利用される前提となっているようです。

グローバル

Wasmでは、リニアメモリとは別にグローバルにアクセスできる変数の領域を宣言できるようになっています。
この変数はGlobals[5]として宣言され、ここには各変数の型(可変不変の区別を含む)と初期値が定義されます。

RustからビルドしたWasmモジュールはどのようにメモリを利用しているのか

RustではWasmのリニアメモリとグローバルをどのように利用しているのか見てきます。

モジュール内の宣言

wasm-packなどでRustからビルドしたWasmをwasm2watでディスアセンブルしてみるとメモリに関する部分は下記のようになっています。

  (memory (;0;) 17)
  (global (;0;) (mut i32) (i32.const 1048576))
  (export "memory" (memory 0))
  (data (;0;) (i32.const 1048576) "called `Option::unwrap()` on a `None` value\00..."))
  • リニアメモリとして17ページを確保(1,114,112Byte)
  • 可変のi32(符号付32ビット)変数を1つグローバル変数として確保して初期値に1,048,576(65536*16)を設定
  • リニアメモリを外部から参照可能にするためにエクスポート
  • dataセグメントにリニアメモリの1,048,576番地からの値として"called ..."文字列などの初期値を設定

1,048,576番地はリニアメモリの17ページ目の先頭となります。17ページ目に初期値のあるデータが格納されていることがわかります。また、グローバル変数の値はこの17ページ目先頭を指すポインタとなっていそうです。

スタック

宣言されたグローバル変数がどのように使われているか見ていきます。
ここでは次のRustコードをコンパイルして生成されるWasmのインストラクションコードを見ていきます。
mainの処理としてはCRTMOD IOCSコールを使って画面を初期化した後に、IOCSコールのFILLを呼び出して16dotの正方形を描画する処理となっています。
FILLは引数としてFILLPTR構造体のポインタを受け取るのでローカル変数として構造体を定義して値を設定しているところがポイントです。

pub fn main() {
    unsafe {
        CRTMOD(14); // 256x256 16BITx1 RES_HIGH
        G_CLR_ON();
        let mut fillptr = FILLPTR {
            x1: 0,
            y1: 0,
            x2: 0,
            y2: 0,
            color:0xFFFF
        };
        set_native_i16(&mut fillptr.x2, 16);
        set_native_i16(&mut fillptr.y2, 16);

        FILL(fillptr.as_native_mut_ptr());
    }
}

このRustのコードをビルドして出力されるWasmは下記のようになります。
関数のインデックス0から4に外部関数がインポートされています。
インデックス5番の関数が上記のmainになっています。
スタックマシンのワークスタックにどのような値が積まれることになるのか、global変数の値に注目してmain関数内のインストラクションにコメントを追記してあります。
wasmのインストラクションはグローバル変数やローカル変数、関数などをインデックス値で指し示します。インストラクションglobal.get 0はインデックス0のグローバル変数から値を読み出すことを表しています。コメント内ではインデックス0のグローバル変数をglobal0、インデックス0のローカル変数をlocal0と呼んでいます。(両方とも1つしか宣言されていないので今回は0以外のインデックスは出てきません)

  (import "x68kutil" "convert_native_ptr_mut" (func (;0;) (type 0)))
  (import "x68iocs" "CRTMOD" (func (;1;) (type 0)))
  (import "x68iocs" "G_CLR_ON" (func (;2;) (type 1)))
  (import "x68kutil" "set_native_i16" (func (;3;) (type 2)))
  (import "x68iocs" "FILL" (func (;4;) (type 0)))
  (func (;5;) (type 1)
    (local i32)   ;; i32のローカル変数を確保`local0`
    global.get 0  ;; グローバル変数`global0`の値をスタック上に積む
    i32.const 16  ;; 定数値16をスタック上に積む
    i32.sub       ;; スタック上から2つの値を読みだして
                  ;; 減算 global0 - 16 の結果をスタック上に積む
    local.tee 0   ;; ローカル変数`local0`にスタック先頭の値(subの結果)を書き込んで
                  ;; 同じ値をスタックに積みなおす
    global.set 0  ;; グローバル変数global0にスタック先頭の値(subの結果)を書き込む
                  ;; (スタック上には値は残らない)
    i32.const 14
    call 1        ;; CRTMOD
    drop
    call 2        ;; G_CLR_ON
                  ;; 下記の3命令はFILLPTRのcolorに0xFFFFを書き込む処理に相当する
    local.get 0   ;; ローカル変数の値(global0と同値)をスタックに積む
    i32.const 65535  ;; 定数値0xFFFFをスタックに積む
    i32.store16 offset=8  ;; リニアメモリ上の local0 + 8 のアドレスに0xFFFFを
                          ;; 16bitのサイズで書き込み
                          ;; (スタック上の2つの値は消費されて残らない)

                  ;; 下記の3命令はFILLPTRのx1, y1, x2, y2に0を書き込む処理に相当する
    local.get 0   ;; ローカル変数`local0`の値(`global0`と同値)をスタックに積む
    i64.const 0   ;; 64bitの整数定数値 0をスタックに積む
    i64.store     ;; リニアメモリ上の `local0` の指すアドレスに定数値0を64bitのサイズで書き込み

    local.get 0
    i32.const 4
    i32.or
    i32.const 16
    call 3        ;; set_native_i16
    local.get 0
    i32.const 6
    i32.or
    i32.const 16
    call 3        ;; set_native_i16
    local.get 0
    call 0        ;; convert_native_ptr_mut
    call 4        ;; FILL
    drop
    local.get 0   ;; ローカル変数local0の値をスタック上に積む
    i32.const 16  ;; 定数値16をスタック上に積む
    i32.add       ;; 加算 local0 + 16 の結果をスタックに積む
    global.set 0) ;; addの結果をグローバル変数global0に書き込む
                  ;; 関数終わり

ポイントとしては、下記の4点に注目してください。

  • 関数の先頭でグローバル変数の値を16減じている
  • 関数の最終でグローバル変数の値を16足して元の値に戻している
  • グローバル変数の初期値から16減じた値をローカル変数に保持している
  • ローカル変数をアドレスポインタとして構造体の各要素にアクセスしている

これらからグローバル変数は、リニアメモリ上に関数内で利用する構造体を保持するためのスタックのスタックポインタとして使われいるのがわかります。また、ローカル変数local0はRustコード上のローカル変数fillptrを指すポインタになっています。

17ページ確保されているリニアメモリの先頭16ページはこの構造体用スタックの領域として確保されているということのようです。

ヒープ

続いてRustのヒープ領域をみていきます。
wasm-packでビルドしたWasmモジュールは既定でメモリアロケータとしてdlmalloc[6]を利用しています。
dlmallocのコードを見ていくとヒープ領域が必要になったタイミングでmemory.growを使ってリニアメモリを拡張してこの拡張したページをヒープ領域として利用していることがわかります。[7]
これまで見てきたWasmモジュールだと18ページ目以降が確保されここがヒープとして使われることになります。

wasm.rs
unsafe impl Allocator for System {
    fn alloc(&self, size: usize) -> (*mut u8, usize, u32) {
        let pages = size / self.page_size();
        let prev = wasm::memory_grow(0, pages);
        if prev == usize::max_value() {
            return (ptr::null_mut(), 0, 0);
        }
        (
            (prev * self.page_size()) as *mut u8,
            pages * self.page_size(),
            0,
        )
    }

Wasmリニアメモリ、グローバル変数、データセクションはどのようにC言語のコードに変換されるか

先ほどwat形式のディスアセンブルで参照したメモリ関連のWasmコンポーネントはwasm2cによって下記のようなC言語のコードに変換されます。
リニアメモリ等の設定がどのようにコードに対応しているのか表すためコメントを追記しています。

static uint8_t *m0;  // リニアメモリの空間を指すポインタ
static uint32_t p0;
static uint32_t c0;

uint32_t g0 = UINT32_C(0x100000); // グローバル変数0、初期値1,048,576

uint8_t **const wasm_memory = &m0; // 外部にリニアメモリを公開するためのm0へのポインタ

/// リニアメモリとデータセグメントを初期化する処理
static void init_data(void) {
    p0 = UINT32_C(17); // リニアメモリとして確保するページ数
    c0 = p0;
    m0 = calloc(c0, UINT32_C(1) << 16);  // リニアメモリの空間を0で初期化して確保する

    // データセグメントの初期値
    static const uint8_t s0[UINT32_C(856)] = {
        0x63, 0x6C, 0x6F, 0x73, 0x75, 0x72, 0x65, 0x20, 0x69, 0x6E, 0x76, 0x6F, 0x6B, 0x65, 0x64, 0x20, 0x72, 0x65, 0x63, 0x75, 0x72, 0x73, 0x69, 0x76, 0x65, 0x6C, 0x79, 0x20, 0x6F, 0x72, 0x20, 0x61,
// ...(略)
    };
    memcpy(&m0[UINT32_C(0x100000)], s0, UINT32_C(856)); // リニアメモリ17ページ目先頭に初期値をコピー
}

リニアメモリの領域を指すm0ポインタのほかに確保済みのページ数を管理するためにp0, c0の2つの変数を利用しているのがわかります。

これらの変数はmemory.growが実行されたときに利用されます。
Wasmのmemory.growインストラクションはC言語のコードでは下記のようにmemory_grow関数の呼び出しに変換されます。

    l206 = memory_grow(&m0, &p0, &c0, l205);

呼び出されるmemory_grow関数は下記のような関数になっています。
コード内にはp0, c0の値に注目してコメントを追記してあります。
p0は実際にWasmリニアメモリとして利用されているページ数、c0は実際に確保しているページ数を表しているのがわかります。reallocの呼び出し回数を減らすために実際に要求されたサイズよりもやや大きめの領域を確保する戦略をとっていることがわかります。

static uint32_t memory_grow(uint8_t **m, uint32_t *p, uint32_t *c, uint32_t n) {
    uint8_t *new_m = *m;
    uint32_t r = *p; // 既にWasmリニアメモリとして確保済みのページ数(p0)
    uint32_t new_p = r + n; // 拡張要求されたページ数を足して新たに必要になる領域のページ数を求める(p0 + n)
    if (new_p > UINT32_C(0x10000)) return UINT32_C(0xFFFFFFFF);
    uint32_t new_c = *c; // realloc(calloc)で実際にリザーブしてあるページ数(c0)
    if (new_c < new_p) { // リザーブしてあるページ数で賄えない時には
        do new_c += new_c / 2 + 8; while (new_c < new_p); // 新たにリザーブするページ数を決めて
        if (new_c > UINT32_C(0x10000)) new_c = UINT32_C(0x10000);
        new_m = realloc(new_m, new_c << 16); // reallocでメモリ領域を広げる
        if (new_m == NULL) return UINT32_C(0xFFFFFFFF); // reallocに失敗したら-1を返す
        *m = new_m;
        *c = new_c;  // c0を更新
    }
    *p = new_p; // p0を更新
    memset(&new_m[r << 16], 0, n << 16); // 新たにWasmリニアメモリに加わった領域を0クリア
    return r;
}

ダイエット

C言語のヒープ領域(calloc, reallocで確保されるメモリ)がどのように利用されているのか分かったのでここからは必要なメモリを削減してきたいと思います。
主に下記の2つが削減の対象となります。

  • ローカルの構造体を割り当てるためのスタック領域
  • Rustのメモリアロケータ(dlmalloc)が利用する領域

構造体スタック領域のダイエット

このスタック領域のサイズはRustをビルドするときのリンカオプションで指定できます。
具体的には.cargo/config.tomlwasm32-unknown-unknownターゲットのオプションとして下記の形式でスタックサイズを指定してあげます。
下記の例ではスタックサイズを32kByteに指定しています。

.cargo/config.toml
[target.wasm32-unknown-unknown]
rustflags = [
    "-C", "link-arg=-zstack-size=32768"
]

このオプションを利用して作成したWasmバイナリをディスアセンブルするとmemoryは下記のようになります。

  (memory (;0;) 1)
  (global (;0;) (mut i32) (i32.const 32768))

リニアメモリの初期サイズは1ページ、スタックポインタは32kByte領域の後ろを指していることがわかります。

メモリアロケータが利用する領域のダイエット

現状、メモリアロケータがmemory.growを使って領域を確保する際に、要求されたページ数がたとえ1ページだけであっても余分な領域が確保される動作となっています。
memory_grow関数の該当箇所を下記に再掲します。
do-whileで確保済みのページ数を元に次のサイズを決めているのがわかります。

    if (new_c < new_p) {
        do new_c += new_c / 2 + 8; while (new_c < new_p); // 既に確保済みのページサイズを元に余裕をもって次に確保するページ数を決める
        if (new_c > UINT32_C(0x10000)) new_c = UINT32_C(0x10000);
        new_m = realloc(new_m, new_c << 16);

X68000で実行する際には余裕をもってメモリ確保するメリットは少ないと判断してこの部分を改修して要求されたページだけを確保するようしました。[8]

    if (new_c < new_p) {
        new_c = new_p; // 余分な領域をリザーブしない
        if (new_c > UINT32_C(0x10000)) new_c = UINT32_C(0x10000);
        new_m = realloc(new_m, new_c << 16);

初回のRustヒープの確保でいきなり10ページ以上のメモリが確保されてしまう動作は、これによって抑えることができます。

まとめ

下記の2点を変更することによって実行に必要なメモリを大幅に減らすことができました。

  • スタック領域を削減するためにリンカのオプション設定を変更
  • ヒープ領域を削減するためにwasm2cの出力するmemory.grow処理のアロケーション戦略を変更

Rustのヒープを使わないコードであれば64KByteのWasmリニアメモリ、Rustのヒープを使うコードであれば128KByteのリニアメモリを使うのが最小構成となります。メインメモリを拡張していない1MByteのX68000でも実行可能なサイズになりました。

脚注
  1. RustやZigのコードをWebAssembly経由でレトロPC(X68000)で実行する ↩︎

  2. Memories ↩︎

  3. Memory Instructions ↩︎

  4. Data Segments ↩︎

  5. Grobals ↩︎

  6. dlmalloc-rs ↩︎

  7. dlmalloc-rs/blob/main/src/wasm.rs ↩︎

  8. icontrader/wasmx68.wb ↩︎

Discussion