🕛

Webアプリのパフォーマンス改善にも使える!Deopt Explorer について解説

2023/06/27に公開
2

Deopt Explorer とは

Deopt Explorer は、V8 JavaScript エンジンのトレースログをもとに、インラインキャッシングに適していないプログラムを発見・可視化してくれる VSCode 拡張です。
本記事では Deopt Explorer 及びその周辺知識、Web アプリケーションのパフォーマンス改善への活用の仕方を解説します。

前提知識のインプットが不要な方は Deopt Explorer で Node.js の計測をしてみる の項目までスキップしてください。

Hidden Class とインラインキャッシング

インラインキャッシングに適していないプログラムを発見・可視化してくれる

はじめにこのような説明をしたと思いますが、日常の Web アプリケーションの開発でインラインキャッシングなんて言葉はあまり耳にする機会はあまりないと思います。

インラインキャッシングとは、JavaScript ランタイムで利用されているプログラムの解析・最適化手法の一つで、メソッドの呼び出しやオブジェクトプロパティへのアクセスを高速化するためのものです。

前提知識として、一般的にはオブジェクトプロパティのように名前を用いて格納されている値を探す動作は非常に低速です。
理由としては、単純にプロパティ名と格納されている値のアドレスを紐づけるハッシュテーブルのような手法を用いた場合、ハッシュの計算が必要となってしまうからです。
逆に、単純な配列では先頭からのオフセットを手間なく索引できるため、オブジェクトプロパティへのアクセスと比べると高速になります。

JavaScript ではオブジェクトを多用するため、プロパティアクセスが低速だと実行速度全体に致命的な影響を与えてしまいます。
これを解決するために、JavaScript エンジンでは実行時に Hidden Class と呼ばれるものを生成してオブジェクトプロパティへのアクセスを高速化しています。

Hidden Class は、メソッドの呼び出し等でオブジェクトが参照された際に生成される、そのオブジェクトプロパティの型やレイアウト情報を保存するための仕組みです。

JavaScript のオブジェクトにはプログラム上型の情報が存在しないため、どのようなプロパティが含まれているのかランタイムはわかりません。
しかし、プロパティ名からいちいち値のアドレスを参照してしまうと前述した通り処理速度が遅くなってしまいます。

この解決策として、JavaScript エンジンは実行中にメソッド等で利用されたオブジェクトの情報を記憶しておき、次回以降の実行ではその記憶をもとにオフセットを推測するという手法を採用しています。
この手法に用いられるオブジェクトの情報が Hidden Class と呼ばれており、Hidden Class の情報をもとにプロパティアクセスを高速化したりメソッド内の処理をキャッシングする最適化手法がインラインキャッシングと呼ばれています。

example_01.js
const f(x) => {
  let sum = x.a + x.b;
  return sum;
};

f({a: 1, b: 2}); // return 3
f({a: 2, b: 3}); // return 5
map_01_01
Map x {
  a: number;
  b: number;
}

example_01.js の例では、関数 f() の呼び出しに利用されている引数 x に対して、x.a x.b というプロパティが割り当てられており、この実行内ではオブジェクトプロパティが一貫しています。
この時、map_01_01 のような Hidden Class が生成されます。
この Hidden Class には xa:number b:number という型を持ったプロパティが定義順に並んでいるという情報が格納されます。
2 回目以降の実行ではこの情報をもとに、x.a は 0 番目のオフセット、x.b は 1 番目のオフセットに値が格納されているという前提で実行が行われるため、一回目と比較してプロパティへのアクセスが高速になります。

example_02.js
const f(x) => {
  let sum = x.a + x.b;
  if(x.c) sum + x.c;
  return sum;
};

f({a: 1, b: 2}); // return 3
f({a: 1, b: 2, c: 3}); // return 6
map_02_01
Map x {
  a: number;
  b: number;
}
map_02_02
Map x {
  a: number;
  b: number;
  c: number;
}

次に、example_02.js の例です。
1 度目の実行では先ほどと同様に x.a x.b のプロパティをもとに map_02_01 のような Hidden Class を生成します。
しかし、2 度目の実装では x.c というプロパティが末尾に追加されています。
この場合、JavaScript エンジンは既存の map_02_01 をもとに x.c を追加した map_02_02 を生成します。

これ以降で f() が呼び出された際は x.c が含まれていた場合は map_02_02 を利用するという分岐が付け足されることになります。

example_03.js
const f(x) => {
  let sum = x.a + x.b;
  return sum;
};

f({a: 1, b: 2}); // return 3
f({b: 2, a: 1}); // return 3
map_03_01
Map x {
  a: number;
  b: number;
}
map_03_02
Map x {
  b: number;
  a: number;
}

最後にもう一例だけ解説します。

example_03.js では、最初の f() 呼び出しに {a, b} を、2 番目の呼び出しに {b, a} を渡しています。
この場合、一見同様のオブジェクトを渡しているように見えますが、実際には map_03_01map_03_02 の 2 つの Hidden Class を生成してしまうことになります。

Hidden Class はオブジェクトのレイアウトも保存しており、プロパティの順序によってオフセットが決められています。
そのため、プロパティの順序が違うオブジェクトが渡された場合、別のオブジェクトとしてそれぞれの Hidden Class が生成されてしまうわけです。

このように、受け取ったオブジェクトのプロパティが変わるたびに Hidden Class が生成されていくわけですが、呼び出される値に対して複数の Hidden Class が生成されるような状態はポリモーフィックであると表現され、インラインキャッシングによる高速化の妨げになってしまいます。

インラインキャッシュに悪影響を与えるコードとは

インラインキャッシュでは Hidden Class から受け取った情報を利用して、メソッドの計算にキャッシュされた以前の計算リソースを流用するといった手法を用いて処理の高速化をします。
しかし、引数がポリモーフィックである場合、以前の計算処理と同様には処理できない場合が発生します。

example_04.js
const f(x) => {
  let sum = x.a + x.b;
  return sum;
};

f({a: 1, b: 2}); // return 3
f({a: 1, b: "2"}); // return 12
map_04_01
Map x {
  a: number;
  b: number;
}
map_04_02
Map x {
  a: number;
  b: string;
}

example_04.js の例を見ると、一度目の実行では x.ax.b を算術的に加算する処理が行われています。
インラインキャッシングが有効化されると、 x.ax,b が共に number である場合、次回の実行では 「x.ax.b を加算する命令」を 1 度目の実行からキャッシュして実行できるため、2 度目の実行はその分高速に実行ができます。
しかし、2 度目の実行では x.b に文字列が指定されているため、JavaScript エンジンは Hidden Class を作り直し、新たに x.ax.b を文字列結合するという命令を実行します。

このように、オブジェクトがポリモーフィックになるということはインラインキャッシングによる最適化から外れることを意味するため、ポリモーフィックなコードはインラインキャッシュに悪影響を与えやすいと言えます。

Deopt Explorer で Node.js の計測をしてみる

冒頭でも触れましたが、Deopt Explorer はインラインキャッシングに悪影響を与えやすいプログラム、つまりポリモーフィックなオブジェクトを発見することができます。
ポリモーフィックという単語ですが、ここではいくつかの段階に分かれて呼称されるため整理しておきます。

  • モノモーフィック(monomorphic): 単一の Hidden Class を持つ
  • ポリモーフィック(polymorphic): 2~4 種類の Hidden Class を持つ
  • メガモーフィック(megamorphic): 5 種類以上の Hidden Class を持つ

この中では特にメガモーフィックと呼ばれる状態のオブジェクトがパフォーマンスに大きな影響を与えると考えられるため、覚えておきましょう。

polymorphicExample.js
function f(x, y) {
  if (x <= y) {
    return { x, y };
  } else {
    return { y, x };
  }
}

function g(p) {
  const x = p.x; // polymorphic
}

for (let i = 0; i < 1000; i++) g(f(0, 1));
g(f(1, 0));

でははじめに、このコードを Deopt Explorer で解析するために Node.js から V8 のトレースログを取得しましょう。
手元で確認をしたい場合は、node の環境を用意した上で上記コードをコピーし、後述のコマンドを入力しましょう。

npx dexnode --out v8.log ./polymorphicExample.js ./

カレントディレクトリ直下に v8.log が出力されていれば成功です。
VS Code の Deopt Explorer のタブを開いて読み込みましょう。

このような画面がひらけていれば成功です。

ここで、左側のタブにある ICS という項目に注目すると、polymorphicExample.js が Polymorphic であることが示唆されています。
polymorphicExample.js > g > Polymorphic > LoadIC の順でインタラクトすると、10 行目の左辺にある p.x に対してハイライトが表示されます。

このポップオーバーから分かる情報として、1 種類目の呼び出しで Uninitialized から Monomorphic x へ、2 種類目の呼び出しで Monomorphic x から Polymorphic x へ Hidden Class が書き換わっているということです。

それぞれの Map (V8 実装上での Hidden Class の呼称)は上のようになっています。
この情報から、g() を呼び出す際の引数 p{x, y}{y, x} の 2 パターンのオブジェクトレイアウトになっていることがわかりますね。

今回のコードでは、関数 g() の引数 p がポリモーフィックになっている要因である関数 f() の返り値のレイアウトを {x, y} もしくは {y, x} へ統一することによってインラインキャッシングフレンドリーなプログラムに修正することができます。

Web アプリケーションを計測してみる

それでは、実際の Web アプリケーションを Deopt Explorer で計測してみましょう。

Chrome を CLI で利用できるように alias を設定し、下の html ファイルを作成してからコマンドを実行します。
(<script> で読み込む js ファイルは Node.js の際に利用したファイルです。)

index.html
<!DOCTYPE html>
<html>
  <body>
    <script src="./polymorphicExample.js"></script>
  </body>
</html>

chrome --no-sandbox --js-flags=--log-deopt,--log-ic,--log-maps,--log-maps-details,--log-internal-timer-events,--prof,... file://path/to/index.html

成功すると、カレントディレクトリ直下に isolate-0xXXX-XXX-v8.log が生成されているので、開きましょう。
読み込んでいる js ファイルが同一なので当たり前ですが、Node.js で計測した結果と同様のものが見えているはずです。

index.html へのパスを任意の URL へ置換することで、あらゆる Web アプリケーションに対して Deopt Explorer での計測を実施することができます。
すでにビルド済みの Web アプリケーションではコードの把握が難しいため、dev サーバ等で起動しているものか、意図的に development ビルドされた環境で試すことで元コードとの参照がしやすいと思います。

トレードオフについて

インラインキャッシングフレンドリーなプログラムは JavaScript エンジンの最適化処理の恩恵を受けやすくなりますが、場合によってはデメリットも存在します。
Hidden Class が更新されないようにするためには、事前にオブジェクトに含まれる可能性があるプロパティを列挙する必要がありますが、その時点で不要なプロパティを確保することは結果的にメモリ使用量の増加につながる場合があります。

ですから、指摘された項目全てを改善対象とするのではなく、改善できる可能性のある指標の一つとして Deopt Explorer を利用してみることをお勧めします。

最後に

本記事では、Deopt Explorer を利用して Web アプリケーションの計測を実施する方法について解説していきました。
近年では Web アプリケーションのパフォーマンスが重要視されつつあり、少しでも高速化できる要因を増やしたいという場面が増えてきている印象があります。
興味が湧いた方は是非一度 Deopt Explorer を利用してみてはいかがでしょうか。

サイボウズ フロントエンド

Discussion

negibouzenegibouze

ありがとうございます。
細かいですが、2つほどタイプミスっぽいものを見つけたのでコメントさせていただきます。

  1. サンプルコードの a.bx.b かなと思いました。
const f(x) => {
  let sum = x.a + a.b;
  return sum;
};
  1. 管渠環境 かなと思いました。
BaHoBaHo

コメントありがとうございます!
修正させていただきました🙏