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

JavaScript はその後のクリーンアップにかなり寛容ですが、静的な言語は明らかにそうではありません...

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

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

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

問題を特定できましたか?ヒント: これは use-after-free ですが、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 Sanitizers のサポートを試すのに最適な機会といえます。

この場合、ポインタやメモリに関連するさまざまな問題を検出できる 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 によって(JavaScript 値への)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 バインディングで次のエラーが発生します。

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

ここではリークではありませんが、割り当てられた境界の外側にあるメモリに書き込みを行っています IZE

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 側で発生し、サニタイザーは可視化できないためです。これらの問題は、本番環境でのみ、または今後コードに一見無関係と思われる変更を加えた後にのみ現れます。

安全なラッパーの作成

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

まず、投稿の先頭から use-after-free(解放後の使用)問題を修正しましょう。そのためには、JavaScript 側で無料としてマークする前に、WebAssembly を使用するビューからデータを複製する必要があります。

  // …

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

ただし、JavaScript とのやり取りに Emscripten で Embind をすでに使用しているため、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 コードベースでは誤った管理が見落とされがちです。
  • 当然のことかもしれませんが、他のコードベースと同様に、可変状態をグローバル変数に保存しないでください。さまざまな呼び出しやスレッド間での再利用に関する問題をデバッグしたくないため、可能な限り自己完結型にすることをおすすめします。