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 は、リークをコンソールに出力しながらもアプリは実行したままにしたい場合に、Google のようなライブラリ ユースケースに適しています。

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);

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

共有状態に関する問題

……それとも、

Google のコーデック バインディングでは、一部の状態と結果がグローバル静的変数に格納されていますが、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 境界のバグは回避できませんが、少なくとも静的言語コードで自己完結するバグのサーフェスは減ります。
  • 使用する言語にかかわらず、開発時にサニタイザーを使用してコードを実行すると、静的言語コードの問題だけでなく、.delete() の呼び出しを忘れたり JavaScript 側から無効なポインタを渡すなど、JavaScript と WebAssembly の境界全体にわたる問題を検出できます。
  • 可能であれば、管理対象外のデータとオブジェクトを WebAssembly から JavaScript に公開しないでください。JavaScript はガベージ コレクションの対象言語であり、手動でのメモリ管理は一般的ではありません。これは、WebAssembly がビルドされた言語のメモリモデルの抽象化リークと見なすことができます。また、JavaScript コードベースでは、不適切な管理が簡単に見落とされます。
  • 当然のことかもしれませんが、他のコードベースと同様に、変更可能な状態をグローバル変数に保存することは避けてください。さまざまな呼び出しやスレッドで再利用して問題をデバッグしたくないため、可能な限り自己完結型にすることをおすすめします。