blog

V8についての深い議論

ほとんどのフロントエンド開発者は、V8というバズワードについて話してきました。 その人気の多くは、パフォーマンスを新しいレベルに引き上げるという事実によるものです。 確かにV8は速いです。 しかし、V...

Jun 3, 2020 · 7 min. read
シェア

徹底討論V8

ほとんどのフロントエンド開発者は、V8というバズワードについて話してきました。 その人気の多くは、JavaScriptを新しいレベルのパフォーマンスに引き上げるという事実によるものです。

そう、V8はとても速い。 でも、どうやって魔法をかけているのでしょう?

公式ドキュメントには、「V8は、C ++で書かれたGoogleのオープンソースの高性能JavaScriptおよびWebAssemblyエンジンです。 ChromeやNode.jsなどで使用されています。"

言い換えれば、V8はJavaScriptを実行可能なマシンコードに変換するc++で開発されたソフトウェアです。

Google ChromeもNode.jsも、JavaScriptコードを最終目的地である特定のマシン上で動作するマシンコードに転送する単なるブリッジです。

V8 のパフォーマンスにおけるもうひとつの重要な役割は、世代分割と超精密なゴミコレクターです。 これは JavaScript がもう必要としないオブジェクトを収集するように最適化されており、その結果メモリフットプリントが小さくなっています。

それ以上に、V8は他のツールや機能に依存し、これまでJavaScriptの速度を遅くしてきたJavaScript固有の機能を改善しています。

この記事では、これらのツールとその機能について詳しく説明します。 これに加えて、V8内部の基本、コンパイルとゴミ収集のプロセス、シングルスレッドの性質についても説明します。

1.まずは基本から

マシンコードの仕組み 要するに、マシン・コードとは、マシンのメモリの特定の部分で実行される、非常に低レベルの命令の束のことです。

さらに議論を進める上で、これはコンパイルプロセスであり、JavaScriptの解釈プロセスとは異なることを指摘することが重要です。実際、コンパイラはプロセスの最後にプログラム全体を生成しますが、インタプリタはプログラムそのものとして動作し、命令を読み込んで実行可能なコマンドに変換することでこれを行います。

解釈プロセスは、動的または完全に解析することができます。

図に戻ると、コンパイルのプロセスは通常ソースコードから始まります。コードは実装され、保存され、実行されます。実行プロセスはコンパイラから順次始まります。コンパイラは他のプログラムと同じようにマシン上で動作するプログラムです。そして、すべてのコードを走査し、ターゲット・ファイルを生成します。これらのファイルはマシン・コードです。コンパイラは特定のマシンで動作するようにコードを最適化しますから、あるオペレーティング・システムから別のオペレーティング・システムに移行するときには、特定のコンパイラを使わなければなりません。

しかし、別々のターゲット・ファイルを実行することはできません。それらを1つのファイル、ことわざで言う.exeファイルにまとめる必要があります。それがリンカーの仕事です。

最後に、ローダーはexeファイル内のコードをオペレーティング・システムの仮想メモリに転送する役割を担うエージェントです。基本的にはトランスポーターです。ここでようやく、あなたのプログラムが稼働します。

長いプロセスのように聞こえますが?

ほとんどの場合、Java、C#、Ruby、JavaScriptなどの高級言語でのプログラミングに時間を費やすことになるでしょう。

高級言語であればあるほど遅くなります。CやC++がはるかに高速なのはそのためで、それらはマシンコード言語、つまりアセンブリ言語に非常に近いのです。

パフォーマンスだけでなく、V8の主な利点の1つは、ECMAScriptの標準を超えて、c++も理解できることです。

JavaScriptはECMAScriptに限定されており、V8が生き残るためには、ECMAScriptに限定されない互換性が必要です。

V8には、C++の機能を統合する優れた能力があります。 C++は、ファイル処理やメモリ/スレッド処理など、非常に優れたOS操作を開発しており、JavaScriptでこれらの機能をすべて利用できるのは非常に便利です。

考えてみれば、Node.js自体も同じような方法で生まれました。 V8にアップグレードし、さらにサーバーとネットワーク機能を追加するという、同じようなアプローチを取りました。

2、シングルスレッド

Node開発者であれば、V8がシングルスレッドであることをよく知っているはずです。 各JavaScript実行コンテキストはシングルスレッドに比例します。

もちろん、V8はバックグラウンドでOSのスレッド機構を管理しています。 V8は複雑なソフトウェアであり、一度に多くのジョブを実行できるため、複数のスレッドを使用することができます。

コードを実行するメイン・スレッド、コードをコンパイルする別のスレッド、ゴミ収集の処理をするスレッドなどがあります。

しかし、V8はJavaScriptの実行コンテキストごとにシングルスレッド環境を作ります。それ以外はV8の制御下にあります。

JavaScriptのコードを実行する関数呼び出しスタックを想像してみてください。 JavaScriptは、それぞれの関数が挿入/呼び出された順番に、関数を重ねていきます。 それぞれの関数の内容を紹介するので、他の関数を呼び出しているかどうかを知る方法はありません。 その場合、呼び出された関数はスタック内で呼び出し元の後に置かれます。

例えば、コールバックはスタックの最後に置かれます。

V8の主なタスクの1つは、そのスタックの整理と、その処理に必要なメモリを管理することです。

3.点火とターボファン

2017年5月にリリースされたバージョン5.9以降、V8にはV8のインタプリタであるIgnition上に構築された新しいJavaScript実行パイプラインが搭載されています。 また、より新しく優れた最適化コンパイラである TurboFan も含まれています。

これらの変更は、全体的なパフォーマンスと、Googleの開発者がJavaScriptの領域によってもたらされる急速かつ重大な変化すべてにエンジンを適応させる際に直面する困難に完全に焦点を当てています。

プロジェクト開始当初から、V8のメンテナンス担当者は、JavaScriptの開発ペースに追いつくためにV8のパフォーマンスを向上させる良い方法を見つけることに頭を悩ませてきました。

最大のベンチマークと比較して、新しいエンジンを実行する際に大きな改善を確認することが可能になりました。

イグニッションとターボファンについては、こちらとこちらをご覧ください。

  • https://v8.dev/docs/ignition
  • https://v8.dev/docs/turbofan

4.隠しカテゴリー

JavaScriptは動的言語です。つまり、実行中に新しいプロパティを追加したり、置き換えたり、削除したりすることができます。これは、例えばJavaのような、プログラム実行時にすべてを定義しなければならず、アプリケーション起動後に動的に変更することができない言語では不可能です。

その特別な性質のため、JavaScriptインタプリタは通常、変数やオブジェクトがメモリ上のどこに割り当てられているかを正確に知るために、ハッシュ関数に基づいて辞書検索を行います。

これは、最終的なプロセスにおいて大きなコストとなります。 他の言語では、オブジェクトが作成されると、暗黙のプロパティの1つとしてアドレスを受け取ります。 こうすることで、オブジェクトがメモリ上のどこに配置されるか、割り当てられる領域が正確にわかります。

JavaScriptでは、存在しないものをマッピングすることはできないので、これは不可能です。そこで隠しクラスが活躍します。

隠しクラスはJavaとほとんど同じです。静的で固定されたクラスには、それらを見つけるための一意のアドレスがあります。しかし、V8ではプログラムの実行時に実行するのではなく、オブジェクトの構造が動的に変化するたびに実行時に実行します。

この問題を理解するために例を見てみましょう。 次のコードを見てください:

function bar(b, obj, foo) {
    this.name = b;
    this.phone = phone;
    this.address = foo;
}

JavaScriptのプロトタイプベースの性質上、新しいUserオブジェクトは毎回インスタンス化されます:

var foo = new User('John\x20May', '+ \x20(555)\x20555-"1"234', '"1"23\x203rd\x20Ave');

そして、V8は新しい隠しクラスを作成します。 これを _User0 と呼びます。

すべてのオブジェクトは、メモリ上にそのクラス表現への参照を持っています。これがクラス・ポインターです。この時点では、新しいオブジェクトがインスタンス化されただけなので、メモリ上には隠しクラスが1つだけ作成されています。今は空です。

この関数の最初の行を実行すると、前の隠しクラスをもとに新しい隠しクラスが作成されます。

name属性はオフセット0でメモリ・バッファに追加され、最終的な順序では最初の属性とみなされます。

V8は_User0隠しクラスにも遷移値を追加します。nameプロパティがUserオブジェクトに追加されるたびに、_User0から_User1への遷移に対応しなければならないことをインタープリタに理解させます。

また、V8は_User0隠しクラスに遷移値を追加します。 これにより、name属性がUserオブジェクトに追加されるたびに、_User0から_User1への遷移が吹かれなければならないことをインタープリタが理解できるようになります。

関数の2行目が呼ばれると、同じ処理が再び行われ、新しい隠しクラスが作成されます:

隠しクラスがスタックを追跡していることがわかります。変換値によって維持される連鎖の中で、ある隠しクラスは別の隠しクラスにつながります。

属性を追加する順番によって、V8が作成する隠しクラスの数が決まります。作成されたコードスニペット内の行の順序を変更すると、異なる隠しクラスも作成されます。このため、開発者の中には隠しクラスが再利用される順番を維持し、オーバーヘッドを減らそうとする人もいます。

5.キャッシュ

これはJITコンパイラの世界では非常に一般的な用語です。 これは、隠しクラスの概念に直接関係しています。

前回の例を思い出してください:

function a(obj, c, bar) {
    this.name = obj;
    this.phone = phone;
    this.address = bar;
}

インスタンス化されたユーザオブジェクトを引数として関数に2回送ると、V8は非表示のクラス検索をスキップしてオフセットのプロパティに直接移動します。この方がはるかに高速です。

ただし、関数の中で属性の割り当ての順番を変更すると、異なる隠しクラスが作成されるため、V8はキャッシュ機能を使用できなくなることに注意してください。

これは、開発者がエンジンをより深く理解することを怠るべきではないという好例です。むしろ、このような知識を持つことが、コードのパフォーマンスを向上させるのです。

6.ガベージコレクション

V8が異なるスレッドでメモリのゴミを集めるという話を覚えていますか?プログラムの実行に影響を与えないので助かりますね。

V8は、メモリから死んだオブジェクトや古いオブジェクトを収集するために、よく知られた「マーク・アンド・クリーン戦略」を使用します。この戦略では、GCがオブジェクトのためにメモリをスキャンし、収集のためにそれらをマークするフェーズは、収集を達成するために実行を一時停止するので、少し遅いです。

しかし、V8はインクリメンタルに実行されます。GCが停止するたびに、V8はできるだけ多くのオブジェクトをマークしようとします。コレクション完了時に実行全体を停止する必要がないため、すべてが高速になります。大規模なアプリケーションでは、パフォーマンスの向上は大きな違いになります。

Read next