WebAssembly のデバッグの高速化

Philip Pfaffe
Kim-Anh Tran
Kim-Anh Tran
Eric Leese
Eric Leese
Sam Clegg

Chrome Dev Summit 2020 では、ウェブ上で初めて Chrome の WebAssembly アプリケーションのデバッグ サポートのデモを行いました。それ以来、チームは、大規模なアプリケーションや巨大なアプリケーションでもデベロッパー エクスペリエンスをスケーリングできるように、多くの労力を費やしてきました。この投稿では、さまざまなツールに追加した(または機能するようにした)ノブと、その使用方法について説明します。

スケーラブルなデバッグ

2020 年の投稿の続きをご紹介します。当時の例を以下に示します。

#include <SDL2/SDL.h>
#include <complex>

int main() {
  // Init SDL.
  int width = 600, height = 600;
  SDL_Init(SDL_INIT_VIDEO);
  SDL_Window* window;
  SDL_Renderer* renderer;
  SDL_CreateWindowAndRenderer(width, height, SDL_WINDOW_OPENGL, &window,
                              &renderer);

  // Generate a palette with random colors.
  enum { MAX_ITER_COUNT = 256 };
  SDL_Color palette[MAX_ITER_COUNT];
  srand(time(0));
  for (int i = 0; i < MAX_ITER_COUNT; ++i) {
    palette[i] = {
        .r = (uint8_t)rand(),
        .g = (uint8_t)rand(),
        .b = (uint8_t)rand(),
        .a = 255,
    };
  }

  // Calculate and draw the Mandelbrot set.
  std::complex<double> center(0.5, 0.5);
  double scale = 4.0;
  for (int y = 0; y < height; y++) {
    for (int x = 0; x < width; x++) {
      std::complex<double> point((double)x / width, (double)y / height);
      std::complex<double> c = (point - center) * scale;
      std::complex<double> z(0, 0);
      int i = 0;
      for (; i < MAX_ITER_COUNT - 1; i++) {
        z = z * z + c;
        if (abs(z) > 2.0)
          break;
      }
      SDL_Color color = palette[i];
      SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a);
      SDL_RenderDrawPoint(renderer, x, y);
    }
  }

  // Render everything we've drawn to the canvas.
  SDL_RenderPresent(renderer);

  // SDL_Quit();
}

まだかなり小さな例であり、非常に大きなアプリケーションで発生するような実際の問題は発生しない可能性がありますが、新機能の概要を説明できます。設定も試用も簡単です。

前回の投稿では、この例をコンパイルしてデバッグする方法について説明しました。もう一度試してみましょう。その際、//performance// も確認します。

$ emcc -sUSE_SDL=2 -g -O0 -o mandelbrot.html mandelbrot.cc -sALLOW_MEMORY_GROWTH

このコマンドは 3 MB の wasm バイナリを生成します。その大部分は、ご想像のとおりデバッグ情報です。これは、llvm-objdump ツール [1] で確認できます。

$ llvm-objdump -h mandelbrot.wasm

mandelbrot.wasm:        file format wasm

Sections:
Idx Name          Size     VMA      Type
  0 TYPE          0000026f 00000000
  1 IMPORT        00001f03 00000000
  2 FUNCTION      0000043e 00000000
  3 TABLE         00000007 00000000
  4 MEMORY        00000007 00000000
  5 GLOBAL        00000021 00000000
  6 EXPORT        0000014a 00000000
  7 ELEM          00000457 00000000
  8 CODE          0009308a 00000000 TEXT
  9 DATA          0000e4cc 00000000 DATA
 10 name          00007e58 00000000
 11 .debug_info   000bb1c9 00000000
 12 .debug_loc    0009b407 00000000
 13 .debug_ranges 0000ad90 00000000
 14 .debug_abbrev 000136e8 00000000
 15 .debug_line   000bb3ab 00000000
 16 .debug_str    000209bd 00000000

この出力には、生成された wasm ファイル内のすべてのセクションが表示されます。ほとんどは標準の WebAssembly セクションですが、名前が .debug_ で始まるカスタム セクションもいくつかあります。バイナリにはデバッグ情報が含まれています。サイズをすべて合計すると、3 MB のファイルのうちデバッグ情報は約 2.3 MB を占めていることがわかります。emcc コマンドも time すると、マシンで実行に約 1.5 秒かかりました。これらの数値はベースラインとして適切ですが、あまりに小さいため、誰も気に留めないでしょう。ただし、実際のアプリケーションでは、デバッグ バイナリのサイズは簡単に GB に達し、ビルドに数分かかることがあります。

Binaryen のスキップ

Emscripten で wasm アプリケーションをビルドする場合、最後のビルドステップの 1 つとして Binaryen オプティマイザーが実行されます。Binaryen は、WebAssembly(のような)バイナリを最適化と合法化の両方を行うコンパイラ ツールキットです。ビルドの一部として Binaryen を実行するのはかなりコストがかかりますが、特定の条件下でのみ必要です。デバッグビルドでは、Binaryen パスの必要性を回避することで、ビルド時間を大幅に短縮できます。最も一般的な Binaryen パスは、64 ビット整数値を含む関数シグネチャを合法化するためのものです。-sWASM_BIGINT を使用して WebAssembly BigInt 統合を有効にすることで、これを回避できます。

$ emcc -sUSE_SDL=2 -g -O0 -o mandelbrot.html mandelbrot.cc -sALLOW_MEMORY_GROWTH -sWASM_BIGINT -sERROR_ON_WASM_CHANGES_AFTER_LINK

念のため -sERROR_ON_WASM_CHANGES_AFTER_LINK フラグも追加しました。Binaryen が実行されてバイナリが予期せず書き換えられていることを検出できます。これにより、迅速な対応を維持できます。

サンプルは比較的小さなものです。それでも、Binaryen をスキップした場合の影響を確認できます。time によると、このコマンドは 1 秒未満で実行されるため、以前よりも 0.5 秒速くなっています。

高度な調整

入力ファイルのスキャンをスキップする

通常、Emscripten プロジェクトをリンクするときに、emcc はすべての入力オブジェクト ファイルとライブラリをスキャンします。これは、プログラム内の JavaScript ライブラリ関数とネイティブ シンボル間の正確な依存関係を実装するために行われます。大規模なプロジェクトでは、この入力ファイルの追加スキャン(llvm-nm を使用)により、リンク時間が大幅に長くなる可能性があります。

代わりに -sREVERSE_DEPS=all で実行すると、JavaScript 関数のすべてのネイティブ依存関係を含めるよう emcc に指示できます。コードサイズのオーバーヘッドは小さいですが、リンク時間を短縮でき、デバッグビルドに役立ちます。

この例のような小さなプロジェクトでは、実際には違いはありませんが、プロジェクトに数百、数千ものオブジェクト ファイルがある場合は、リンク時間が大幅に短縮される可能性があります。

「name」セクションの削除

大規模なプロジェクト、特に C++ テンプレートを大量に使用するプロジェクトでは、WebAssembly の「name」セクションが非常に大きくなる可能性があります。この例では、ファイル全体のサイズのごく一部にすぎませんが(上記の llvm-objdump の出力を参照)、場合によっては非常に大きくなることがあります。アプリケーションの「name」セクションが非常に大きく、デバッグに必要な情報として十分な情報量が含まれている場合は、「name」セクションを削除するとメリットがあります。

$ emstrip --no-strip-all --remove-section=name mandelbrot.wasm

これにより、DWARF デバッグ セクションを保持しながら、WebAssembly の「name」セクションが削除されます。

分裂をデバッグする

デバッグデータが多いバイナリは、ビルド時間だけでなくデバッグ時間にも負荷をかけます。デバッガは、データの読み込みとインデックスの作成を行う必要があります。これにより、「ローカル変数 x の型は何ですか?」などのクエリに迅速に対応できます。

デバッグ分割を使用すると、バイナリのデバッグ情報を 2 つの部分に分割できます。1 つはバイナリに残り、もう 1 つは DWARF オブジェクト(.dwo)ファイルに格納されます。これを有効にするには、Emscripten に -gsplit-dwarf フラグを渡します。

$ emcc -sUSE_SDL=2 -g -gsplit-dwarf -gdwarf-5 -O0 -o mandelbrot.html mandelbrot.cc  -sALLOW_MEMORY_GROWTH -sWASM_BIGINT -sERROR_ON_WASM_CHANGES_AFTER_LINK

以下に、デバッグデータなしでコンパイルした場合、デバッグデータありでコンパイルした場合、デバッグデータとデバッグ分割の両方ありでコンパイルした場合に生成されるファイルと、さまざまなコマンドを示します。

さまざまなコマンドと生成されるファイル

DWARF データを分割すると、デバッグデータの一部はバイナリとともに存在しますが、大部分は mandelbrot.dwo ファイルに格納されます(上記の図を参照)。

mandelbrot にはソースファイルが 1 つしかありませんが、通常、プロジェクトはもっと大きく、複数のファイルが含まれています。デバッグ分割では、それらのファイルごとに .dwo ファイルが生成されます。デバッガの現在のベータ版(0.1.6.1615)でこの分割されたデバッグ情報を読み込むには、次のように、それらをすべて DWARF パッケージ(.dwp)にバンドルする必要があります。

$ emdwp -e mandelbrot.wasm -o mandelbrot.dwp

dwo ファイルを DWARF パッケージにバンドルする

個々のオブジェクトから DWARF パッケージをビルドすると、追加で提供するファイルが 1 つだけという利点があります。現在、今後のリリースですべての個別オブジェクトを読み込むよう取り組んでいます。

DWARF 5 とは

上記の emcc コマンドに、別のフラグ -gdwarf-5 が追加されていることにお気づきでしょうか。DWARF シンボルのバージョン 5 を有効にすることも、デバッグ作業を迅速に開始するためのもう 1 つの方法です(現在はデフォルトではありません)。これにより、デフォルトのバージョン 4 では除外されていた特定の情報がメイン バイナリに保存されます。具体的には、メイン バイナリからソースファイルの完全なセットを特定できます。これにより、デバッガは、完全なシンボルデータを読み込んで解析しなくても、完全なソースツリーを表示したり、ブレークポイントを設定したりなどの基本的なアクションを実行できます。これにより、分割シンボルを使用したデバッグが大幅に高速化されるため、-gsplit-dwarf コマンドライン フラグと -gdwarf-5 コマンドライン フラグを常に併用しています。

DWARF5 デバッグ形式では、別の便利な機能も利用できます。これにより、-gpubnames フラグを渡すときに生成されるデバッグデータに名前インデックスが導入されます。

$ emcc -sUSE_SDL=2 -g -gdwarf-5 -gsplit-dwarf -gpubnames -O0 -o mandelbrot.html mandelbrot.cc -sALLOW_MEMORY_GROWTH -sWASM_BIGINT -sERROR_ON_WASM_CHANGES_AFTER_LINK

デバッグ セッション中、シンボル検索は、変数や型を検索する場合など、エンティティを名前で検索することで行われることが多い。名前インデックスは、その名前を定義するコンパイル単位を直接参照することで、この検索を高速化します。名前インデックスがないと、検索対象の名前付きエンティティを定義する正しいコンパイル単位を見つけるために、デバッグデータ全体を網羅的に検索する必要があります。

デバッグデータの確認

llvm-dwarfdump を使用して、DWARF データを調べることができます。試してみましょう。

llvm-dwarfdump mandelbrot.wasm

これにより、デバッグ情報が存在する「コンパイル単位」(大まかに言えばソースファイル)の概要を確認できます。この例では、mandelbrot.cc のデバッグ情報のみが取得されます。一般情報には、スケルトン ユニットがあることが示されます。これは、このファイルのデータが不完全であり、残りのデバッグ情報を含む別の .dwo ファイルがあることを意味します。

mandelbrot.wasm とデバッグ情報

このファイル内の他のテーブル(wasm バイトコードと C++ 行のマッピングを示す行テーブルなど)も確認できます(llvm-dwarfdump -debug-line を使用してみます)。

別の .dwo ファイルに含まれるデバッグ情報も確認できます。

llvm-dwarfdump mandelbrot.dwo

mandelbrot.wasm とデバッグ情報

要約: デバッグ分割を使用するメリットは何ですか?

大規模なアプリケーションを扱う場合、デバッグ情報を分割することにはいくつかの利点があります。

  1. リンクが高速化: デバッグ情報全体を解析する必要がなくなった。通常、リンカーはバイナリ内の DWARF データ全体を解析する必要があります。デバッグ情報の大部分を個別のファイルに分離することで、リンカーは小さいバイナリを処理できるため、リンク時間が短縮されます(特に大規模なアプリケーションで顕著です)。

  2. デバッグの高速化: デバッガは、一部のシンボル検索で .dwo/.dwp ファイル内の追加シンボルの解析をスキップできます。一部の検索(wasm から C++ ファイルへの行マッピングのリクエストなど)では、追加のデバッグデータを調べる必要はありません。これにより、追加のデバッグデータを読み込んで解析する必要がなくなり、時間を節約できます。

1: システムに最新バージョンの llvm-objdump がなく、emsdk を使用している場合は、emsdk/upstream/bin ディレクトリにあります。

プレビュー チャネルをダウンロードする

デフォルトの開発用ブラウザとして Chrome の CanaryDevBeta を使用することを検討してください。これらのプレビュー チャンネルでは、最新の DevTools 機能にアクセスしたり、最先端のウェブ プラットフォーム API をテストしたりできます。また、ユーザーよりも早くサイトの問題を見つけることもできます。

Chrome DevTools チームに問い合わせる

次のオプションを使用して、DevTools の新機能、アップデート、その他のトピックについて話し合います。