EmscriptenでALLOW_MEMORY_GROWTH=1した時ハマった話

WebAssemblyをターゲットとしてEmscriptenを用いてビルドする場合には-s ALLOW_MEMORY_GROWTH=1を指定して自動的なメモリ領域の拡張を利用することができるが、その時思いがけない問題に遭遇したのでメモ。

状況

EmscriptenコンパイルされるRustの関数に(C言語風の)数値の配列に対するポインタを渡したいと思って、次のようなコードを書いていた。

const f_ = Module.cwrap('_f', 'number', ['number']);

const f = (() => {
    const sizeofFloat32 = 4;
    const length = 3;
    const pointer = Module._malloc(sizeofFloat32 * length);
    const view = new Float32Array(Module.HEAPU8.buffer, pointer, length);

    return (a: number[]) => {
        view.set(a); // !!!

        f_(pointer);
    };
})();

このようにArrayBufferViewをクロージャを使ってガメておけば、オブジェクト生成のコストがなくってTypedArray.prototype.setを使うことができて便利だと思っていたが、このコードはまれにview.set(a);のところでエラーを吐いて死ぬ。

エラー

このときに出力されたエラーメッセージはこのようであった。

Uncaught TypeError: Cannot perform %TypedArray%.prototype.set on a detached ArrayBuffer

TypedArraydetachedなステートになってしまったらしい。

ArrayBuffer.transfer()

developer.mozilla.org

ArrayBufferにはtransferというメソッドが生えていて、C言語で言うところのreallocのように、引数として取るArrayBufferに結びついたViewをDetached状態にし、指定したサイズのArrayBufferを新しく返すというメソッドがある。

何が起きていたのか

コンパイルされたjsのコードからメモリ拡張部分のコードを抜き出すとこうなっている。

  var wasmReallocBuffer = (function (size) {
    var PAGE_MULTIPLE = Module["usingWasm"] ? WASM_PAGE_SIZE : ASMJS_PAGE_SIZE;
    size = alignUp(size, PAGE_MULTIPLE);
    var old = Module["buffer"];
    var oldSize = old.byteLength;
    if (Module["usingWasm"]) {
      try {
        var result = Module["wasmMemory"].grow((size - oldSize) / wasmPageSize); // WebAssembly.Memory.prototype.grow
        if (result !== (-1 | 0)) {
          return Module["buffer"] = Module["wasmMemory"].buffer // Updating Module['buffer']
        } else {
          return null
        }
      } catch (e) {
        return null
      }
    } else {
      exports["__growWasmMemory"]((size - oldSize) / wasmPageSize);
      return Module["buffer"] !== old ? Module["buffer"] : null
    }
  });
  Module["reallocBuffer"] = (function (size) {
    if (finalMethod === "asmjs") {
      return asmjsReallocBuffer(size)
    } else {
      return wasmReallocBuffer(size)
    }
  });
function enlargeMemory() {
  var PAGE_MULTIPLE = Module["usingWasm"] ? WASM_PAGE_SIZE : ASMJS_PAGE_SIZE;
  var LIMIT = 2147483648 - PAGE_MULTIPLE;
  if (HEAP32[DYNAMICTOP_PTR >> 2] > LIMIT) {
    return false
  }
  var OLD_TOTAL_MEMORY = TOTAL_MEMORY;
  TOTAL_MEMORY = Math.max(TOTAL_MEMORY, MIN_TOTAL_MEMORY);
  while (TOTAL_MEMORY < HEAP32[DYNAMICTOP_PTR >> 2]) {
    if (TOTAL_MEMORY <= 536870912) {
      TOTAL_MEMORY = alignUp(2 * TOTAL_MEMORY, PAGE_MULTIPLE)
    } else {
      TOTAL_MEMORY = Math.min(alignUp((3 * TOTAL_MEMORY + 2147483648) / 4, PAGE_MULTIPLE), LIMIT)
    }
  }
  var replacement = Module["reallocBuffer"](TOTAL_MEMORY);
  if (!replacement || replacement.byteLength != TOTAL_MEMORY) {
    TOTAL_MEMORY = OLD_TOTAL_MEMORY;
    return false
  }
  updateGlobalBuffer(replacement); // Update Module['buffer']
  updateGlobalBufferViews(); // Update HEAP***
  return true
}

コードを読んだ感じだとModule['buffer']を書き換えているし、WebAssembly.Memory.prototype.growが自身のbufferを別物にすり替えることがあるらしい*1。内部でArrayBuffer.transferを呼んでいると考えると今回のエラーの説明がつく。WebAssembly.Memoryの実装はまだ読んでいないので、確定的なことはなんとも言えないけど。

結論

どうやら、Emscriptenの管理しているヒープはそもそも別オブジェクトにすり替わったりするので、クロージャの内部に持ったりすることが無いようにしなければならないようだ。呼び出す側から持たせるデータはポインタのみにして、毎回毎回ArrayBufferViewを作り直すか、Module.setValue等で各要素書き込んでやるなどする必要がありそうだ。少しつらい気持ちになった。

*1:Cのrealloc()が別のポインタを返す事があることを考えれば自然ではある、同じ場所で連続なメモリを確保できるとは限らないし