Emscripten を使用して WebAssembly でのメモリリークをデバッグする

JavaScript はクリーンアップが比較的寛大ですが、静的言語はそうではありません。

Squoosh.app は、さまざまな画像コーデックと設定で、画質に大きな影響を与えることなく画像ファイルのサイズをどれだけ改善できるかを示している PWA です。また、C++ または Rust で記述されたライブラリをウェブに移行する方法を紹介する技術的なデモでもあります。

既存のエコシステムからコードを移植できることは非常に有益ですが、これらの静的言語と JavaScript にはいくつかの重要な違いがあります。その 1 つは、メモリ管理に対するアプローチの違いです。

JavaScript はクリーンアップが比較的寛大ですが、このような静的言語はそうではありません。新しいメモリの割り当てを明示的にリクエストする必要があります。また、後で必ず返却し、再び使用しないようにする必要があります。これが行われないと、漏水が発生します。実際には、かなり頻繁に発生します。メモリリークをデバッグする方法と、次回メモリリークを回避するためにコードを設計する方法について説明します。

不審なパターン

最近、Squoosh の開発を開始したところ、C++ コーデック ラッパーに興味深いパターンがあることに気づきました。例として ImageQuant ラッパーを見てみましょう(オブジェクトの作成と割り当て解除の部分のみを表示するように省略しています)。

liq_attr* attr;
liq_image* image;
liq_result* res;
uint8_t* result;

RawImage quantize(std::string rawimage,
                  int image_width,
                  int image_height,
                  int num_colors,
                  float dithering) {
  const uint8_t* image_buffer = (uint8_t*)rawimage.c_str();
  int size = image_width * image_height;

  attr = liq_attr_create();
  image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_set_max_colors(attr, num_colors);
  liq_image_quantize(image, attr, &res);
  liq_set_dithering_level(res, dithering);
  uint8_t* image8bit = (uint8_t*)malloc(size);
  result = (uint8_t*)malloc(size * 4);

  // …

  free(image8bit);
  liq_result_destroy(res);
  liq_image_destroy(image);
  liq_attr_destroy(attr);

  return {
    val(typed_memory_view(image_width * image_height * 4, result)),
    image_width,
    image_height
  };
}

void free_result() {
  free(result);
}

JavaScript(TypeScript):

export async function process(data: ImageData, opts: QuantizeOptions) {
  if (!emscriptenModule) {
    emscriptenModule = initEmscriptenModule(imagequant, wasmUrl);
  }
  const module = await emscriptenModule;

  const result = module.quantize(/* … */);

  module.free_result();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

問題は見つかりましたか?ヒント: これは 使用後の解放ですが、JavaScript で発生します。

Emscripten では、typed_memory_view は WebAssembly(Wasm)メモリバッファを基盤とする JavaScript Uint8Array を返します。byteOffsetbyteLength は、指定されたポインタと長さに設定されます。主なポイントは、これは JavaScript が所有するデータのコピーではなく、WebAssembly メモリ バッファへの TypedArray ビューであるということです。

JavaScript から free_result を呼び出すと、標準の C 関数 free が呼び出され、このメモリが今後の割り当てに使用可能としてマークされます。つまり、Uint8Array ビューが参照するデータは、今後の Wasm への呼び出しによって任意のデータで上書きされる可能性があります。

また、free の実装によっては、解放されたメモリをすぐにゼロで埋める場合もあります。Emscripten が使用する free はそうしませんが、ここでは保証できない実装の詳細に依存しています。

または、ポインタの背後にあるメモリが保持されていても、新しい割り当てで WebAssembly メモリを増やす必要がある場合があります。WebAssembly.Memory が JavaScript API または対応する memory.grow 命令によって拡張されると、既存の ArrayBuffer が無効になり、それに依存するビューも無効になります。

DevTools(または Node.js)コンソールを使用して、この動作を示します。

> memory = new WebAssembly.Memory({ initial: 1 })
Memory {}

> view = new Uint8Array(memory.buffer, 42, 10)
Uint8Array(10) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
// ^ all good, we got a 10 bytes long view at address 42

> view.buffer
ArrayBuffer(65536) {}
// ^ its buffer is the same as the one used for WebAssembly memory
//   (the size of the buffer is 1 WebAssembly "page" == 64KB)

> memory.grow(1)
1
// ^ let's say we grow Wasm memory by +1 page to fit some new data

> view
Uint8Array []
// ^ our original view is no longer valid and looks empty!

> view.buffer
ArrayBuffer(0) {}
// ^ its buffer got invalidated as well and turned into an empty one

最後に、free_resultnew Uint8ClampedArray の間で Wasm を明示的に呼び出さなくても、将来的にはコーデックにマルチスレッド処理のサポートが追加される可能性があります。その場合、クローンを作成する直前にデータを上書きするのは、まったく別のスレッドである可能性があります。

メモリバグの検索

念のため、このコードが実際に問題を引き起こしていないか確認することにしました。これは、昨年追加され、Chrome Dev Summit の WebAssembly に関する講演で紹介された新しい Emscripten サニタイザのサポートを試す絶好の機会です。

この場合、AddressSanitizer が重要です。これは、ポインタやメモリに関連するさまざまな問題を検出できます。これを使用するには、-fsanitize=address を使用してコーデックを再コンパイルする必要があります。

emcc \
  --bind \
  ${OPTIMIZE} \
  --closure 1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s MODULARIZE=1 \
  -s 'EXPORT_NAME="imagequant"' \
  -I node_modules/libimagequant \
  -o ./imagequant.js \
  --std=c++11 \
  imagequant.cpp \
  -fsanitize=address \
  node_modules/libimagequant/libimagequant.a

これにより、ポインタの安全性チェックが自動的に有効になりますが、潜在的なメモリリークも検出する必要があります。ImageQuant はプログラムではなくライブラリとして使用しているため、Emscripten がすべてのメモリが解放されたことを自動的に検証できる「終了ポイント」はありません。

代わりに、このようなケースでは、LeakSanitizer(AddressSanitizer に含まれている)が __lsan_do_leak_check__lsan_do_recoverable_leak_check 関数を提供します。これらの関数は、すべてのメモリが解放されることが予想され、その前提を確認する必要がある場合はいつでも手動で呼び出すことができます。__lsan_do_leak_check は、実行中のアプリの終了時に、リークが検出された場合にプロセスを中止する場合に使用することを目的としています。一方、__lsan_do_recoverable_leak_check は、リークをコンソールに出力し、アプリの実行を続行する場合など、ライブラリのユースケースに適しています。

2 つ目のヘルパーを Embind 経由で公開して、JavaScript からいつでも呼び出せるようにしましょう。

#include <sanitizer/lsan_interface.h>

// …

void free_result() {
  free(result);
}

EMSCRIPTEN_BINDINGS(my_module) {
  function("zx_quantize", &zx_quantize);
  function("version", &version);
  function("free_result", &free_result);
  function("doLeakCheck", &__lsan_do_recoverable_leak_check);
}

画像の処理が完了したら、JavaScript 側から呼び出します。C++ ではなく JavaScript 側から行うと、これらのチェックを実行するまでに、すべてのスコープが終了し、すべての一時 C++ オブジェクトが解放されます。

  // …

  const result = opts.zx
    ? module.zx_quantize(data.data, data.width, data.height, opts.dither)
    : module.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);

  module.free_result();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

コンソールに次のようなレポートが表示されます。

メッセージのスクリーンショット

小さなリークがあるようですが、関数名がすべてマングリングされているため、スタックトレースは役に立ちません。基本的なデバッグ情報を含めて再コンパイルし、情報を保持しましょう。

emcc \
  --bind \
  ${OPTIMIZE} \
  --closure 1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s MODULARIZE=1 \
  -s 'EXPORT_NAME="imagequant"' \
  -I node_modules/libimagequant \
  -o ./imagequant.js \
  --std=c++11 \
  imagequant.cpp \
  -fsanitize=address \
  -g2 \
  node_modules/libimagequant/libimagequant.a

これで見た目が大幅に改善されました。

GenericBindingType RawImage ::toWireType 関数から出力された「12 バイトの直接リーク」というメッセージのスクリーンショット

スタックトレースの一部は Emscripten 内部を指しているため不明瞭に見えますが、Embind による RawImage の「ワイヤータイプ」(JavaScript 値)への変換からリークが発生していることがわかります。実際、コードを見ると、RawImage C++ インスタンスを JavaScript に返していますが、どちらの側でも解放していません。

なお、現在のところ、JavaScript と WebAssembly の間にガベージ コレクションの統合はありませんが、開発中です。代わりに、オブジェクトの使用が終わったら、手動でメモリを解放し、JavaScript 側からデストラクタを呼び出す必要があります。Embind の場合、公式ドキュメントでは、公開された C++ クラスで .delete() メソッドを呼び出すことを推奨しています。

JavaScript コードは、受け取った C++ オブジェクト ハンドルを明示的に削除する必要があります。削除しないと、Emscripten ヒープが無限に増大します。

var x = new Module.MyClass;
x.method();
x.delete();

実際に、クラスで JavaScript でこれを実行すると、次のように表示されます。

  // …

  const result = opts.zx
    ? module.zx_quantize(data.data, data.width, data.height, opts.dither)
    : module.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);

  module.free_result();
  result.delete();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

想定どおりにリークが解消されます。

サニタイザーに関するその他の問題の検出

サニタイザーを使用して他の Squoosh コーデックをビルドすると、同様の問題と新しい問題の両方が明らかになります。たとえば、MozJPEG バインディングで次のエラーが発生します。

メッセージのスクリーンショット

ここでは、リークではなく、割り当てられた境界外のメモリに書き込んでいる 😱?

MozJPEG のコードを確認すると、問題は jpeg_mem_dest(JPEG のメモリ デスティネーションを割り当てるために使用する関数)が、outbufferoutsize の既存の値がゼロでない場合、それらの値を再利用することです。

if (*outbuffer == NULL || *outsize == 0) {
  /* Allocate initial buffer */
  dest->newbuffer = *outbuffer = (unsigned char *) malloc(OUTPUT_BUF_SIZE);
  if (dest->newbuffer == NULL)
    ERREXIT1(cinfo, JERR_OUT_OF_MEMORY, 10);
  *outsize = OUTPUT_BUF_SIZE;
}

ただし、これらの変数のいずれも初期化せずに呼び出します。つまり、MozJPEG は、呼び出し時にこれらの変数に保存されていたランダムなメモリアドレスに結果を書き込みます。

uint8_t* output;
unsigned long size;
// …
jpeg_mem_dest(&cinfo, &output, &size);

呼び出しの前に両方の変数をゼロで初期化すると、この問題は解決します。コードはメモリリーク チェックに到達します。幸い、チェックは正常に完了しました。これは、このコーデックでリークが発生していないことを示しています。

共有状態に関する問題

……それとも、

コーデック バインディングは、一部の状態と結果をグローバルな静的変数に保存します。MozJPEG には特に複雑な構造があります。

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

val encode(std::string image_in, int image_width, int image_height, MozJpegOptions opts) {
  // …
}

これらの一部が初回実行時に遅延初期化され、その後の実行で不適切に再利用された場合はどうなりますか?その後、サニタイザーを使用した 1 回の呼び出しで、問題があると報告されることはありません。

UI でさまざまな品質レベルをランダムにクリックして、画像を数回処理してみましょう。実際に、次のようなレポートが生成されます。

メッセージのスクリーンショット

262,144 バイト - サンプル画像全体が jpeg_finish_compress から漏洩しているようです。

ドキュメントと公式の例を確認した結果、jpeg_finish_compress は、前の jpeg_mem_dest 呼び出しで割り当てられたメモリを解放せず、圧縮構造のみを解放することがわかりました。その圧縮構造はすでにメモリのデスティネーションを知っています。

この問題を解決するには、free_result 関数でデータを手動で解放します。

void free_result() {
  /* This is an important step since it will release a good deal of memory. */
  free(last_result);
  jpeg_destroy_compress(&cinfo);
}

これらのメモリバグを 1 つずつ探し続けることもできますが、メモリ管理に対する現在のアプローチが、厄介なシステムの問題につながることは明らかです。

一部はサニタイザによってすぐに検出されます。他のマルウェアは、複雑な手法で検出する必要があります。最後に、投稿の冒頭で説明したような問題があります。ログからわかるように、これらの問題はサニタイザでまったく検出されません。その理由は、実際の不正使用は JavaScript 側で発生し、サニタイザーには可視性がないためです。これらの問題は、本番環境でのみ、または将来的にコードに関係のない変更を加えた後にのみ発生します。

安全なラッパーを作成する

少し前に戻って、コードを安全な方法で再構築して、これらの問題をすべて修正しましょう。ここでも ImageQuant ラッパーを例にしますが、同様のリファクタリング ルールは、すべてのコーデックと同様のコードベースにも適用されます。

まず、この記事の冒頭で説明した、使用後の解放に関する問題を修正しましょう。そのためには、WebAssembly でサポートされているビューからデータをクローンし、JavaScript 側でフリーとしてマークする必要があります。

  // …

  const result = /* … */;

  const imgData = new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );

  module.free_result();
  result.delete();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
  return imgData;
}

次に、呼び出し間でグローバル変数の状態を共有しないようにします。これにより、これまでに発生した問題の一部が修正されるだけでなく、今後マルチスレッド環境でコーデックを使用する際にも便利になります。

そのために、C++ ラッパーをリファクタリングして、関数の呼び出しごとにローカル変数を使用して独自のデータを管理するようにします。次に、free_result 関数のシグネチャを変更して、ポインタを返すようにします。

liq_attr* attr;
liq_image* image;
liq_result* res;
uint8_t* result;

RawImage quantize(std::string rawimage,
                  int image_width,
                  int image_height,
                  int num_colors,
                  float dithering) {
  const uint8_t* image_buffer = (uint8_t*)rawimage.c_str();
  int size = image_width * image_height;

  attr = liq_attr_create();
  image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_attr* attr = liq_attr_create();
  liq_image* image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_set_max_colors(attr, num_colors);
  liq_result* res = nullptr;
  liq_image_quantize(image, attr, &res);
  liq_set_dithering_level(res, dithering);
  uint8_t* image8bit = (uint8_t*)malloc(size);
  result = (uint8_t*)malloc(size * 4);
  uint8_t* result = (uint8_t*)malloc(size * 4);

  // …
}

void free_result() {
void free_result(uint8_t *result) {
  free(result);
}

ただし、Emscripten で Embind を使用して JavaScript とやり取りしているため、C++ メモリ管理の詳細を完全に隠すことで、API をさらに安全にすることもできます。

そのためには、Embind を使用して new Uint8ClampedArray(…) 部分を JavaScript から C++ 側に移動しましょう。次に、この関数を使用して、関数から返されるに、データを JavaScript メモリにクローンを作成できます。

class RawImage {
 public:
  val buffer;
  int width;
  int height;

  RawImage(val b, int w, int h) : buffer(b), width(w), height(h) {}
};
thread_local const val Uint8ClampedArray = val::global("Uint8ClampedArray");

RawImage quantize(/* … */) {
val quantize(/* … */) {
  // …
  return {
    val(typed_memory_view(image_width * image_height * 4, result)),
    image_width,
    image_height
  };
  val js_result = Uint8ClampedArray.new_(typed_memory_view(
    image_width * image_height * 4,
    result
  ));
  free(result);
  return js_result;
}

1 つの変更で、結果のバイト配列が JavaScript によって所有され、WebAssembly メモリによってバッキングされないようにし、以前にリークした RawImage ラッパーも削除しています。

これにより、JavaScript はデータを解放する必要がなくなり、他のガベージ コレクション オブジェクトと同様に結果を使用できるようになりました。

  // …

  const result = /* … */;

  const imgData = new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );

  module.free_result();
  result.delete();
  // module.doLeakCheck();

  return imgData;
  return new ImageData(result, result.width, result.height);
}

また、C++ 側でカスタム free_result バインディングが必要なくなることも意味します。

void free_result(uint8_t* result) {
  free(result);
}

EMSCRIPTEN_BINDINGS(my_module) {
  class_<RawImage>("RawImage")
      .property("buffer", &RawImage::buffer)
      .property("width", &RawImage::width)
      .property("height", &RawImage::height);

  function("quantize", &quantize);
  function("zx_quantize", &zx_quantize);
  function("version", &version);
  function("free_result", &free_result, allow_raw_pointers());
}

全体として、ラッパー コードはよりクリーンになり、同時に安全性も向上しました。

その後、ImageQuant ラッパーのコードにいくつかの軽微な改善を加えて、他のコーデックでも同様のメモリ管理の修正を再現しました。詳細については、C++ コーデックのメモリ修正で、結果の PR をご覧ください。

要点

このリファクタリングから学び、他のコードベースに適用できる教訓は何ですか?

  • WebAssembly を基盤とするメモリビューは、どの言語でビルドされているかにかかわらず、1 回の呼び出しを超えて使用しないでください。それ以上存続することを期待することはできず、従来の方法でこれらのバグを検出することはできません。後でデータを保存する必要がある場合は、JavaScript 側にコピーして保存してください。
  • 可能であれば、未加工のポインタを直接操作するのではなく、安全なメモリ管理言語を使用するか、少なくとも安全な型ラッパーを使用します。これにより、JavaScript ↔ WebAssembly 境界のバグは回避できませんが、少なくとも静的言語コードで自己完結するバグのサーフェスは減ります。
  • どの言語を使用していても、開発中にサニタイザーを使用してコードを実行します。サニタイザーは、静的言語コードの問題だけでなく、JavaScript ↔ WebAssembly 境界をまたぐ問題(.delete() の呼び出し忘れや、JavaScript 側から無効なポインタを渡すなど)を検出できます。
  • 可能であれば、管理対象外のデータとオブジェクトを WebAssembly から JavaScript に公開しないでください。JavaScript はガベージ コレクション言語であり、手動のメモリ管理は一般的ではありません。これは、WebAssembly がビルドされた言語のメモリモデルの抽象化リークと見なすことができます。また、JavaScript コードベースでは、不適切な管理が簡単に見落とされます。
  • 他のコードベースと同様に、変更可能な状態をグローバル変数に保存しないでください。さまざまな呼び出しやスレッド間で再利用される問題をデバッグしたくないため、できるだけ自己完結型に保つことをおすすめします。