新しいV8およびNode.jsの速度:今日と明日の最適化手法

Node.jsは、当初からV8 JSエンジンに依存しており、V8 JSエンジンは誰もが知っている、愛する言語コマンドの実行を提供します。 V8は、GoogleがChromeブラウザー用に作成したJavaScript仮想マシンです。 当初から、少なくとも競合エンジンよりも高速なJavaScriptを実現するためにV8が作成されました。 強い型付けのない動的言語の場合、高いパフォーマンスを実現するのは簡単なことではありません。 V8およびその他のエンジンは、この問題をより良く、より良く解決しています。 ただし、新しいエンジンは「JS実行の速度を上げる」だけではありません。 これは、コード最適化への新しいアプローチの必要性でもあります。 今日最速だったすべてが将来最大のパフォーマンスで私たちを喜ばせるわけではありません。 遅いとみなされたすべてがそうであるとは限りません。



TurboFan V8の仕様はコードの最適化にどのように影響しますか? 近い将来に自分自身を示すために、今日の技術はどのように最適と考えられていますか? 最近のV8パフォーマンスキラーはどのように動作しますか?また、それらに何を期待できますか? この資料では、これらおよび他の多くの質問に対する答えを見つけようとしました。



デビッド・マーク・クレメンツマッテオ・コリーナの共同作業の成果がここにあります。 素材は、V8開発チームのFrancis HinkelmannBenedict Meirerによって確認されました。







JavaScriptを高速で実行できるV8エンジンの中心部分は、JIT(Just In Time)コンパイラーです。 実行時にコードを最適化できる動的コンパイラーです。 V8が最初に作成されたとき、JITコンパイラーはFullCodeGenと名付けられました。これは( Yang Guoが正しく述べているように)このプラットフォームの最初の最適化コンパイラーです。 次に、V8チームはCrankshaftコンパイラを作成しました。これには、FullCodeGenには実装されていない多くのパフォーマンス最適化が含まれていました。



90年代からJavaScriptを見て、ずっと使っていた人として、どのエンジンに関係なく、JSコードのどのセクションが動作が遅く、すぐに完全に非自明になることがしばしばあることに気付きました。使用されます。 プログラムが予想よりも遅く実行された理由は、しばしば理解するのが困難でした。



近年、Matteo Collinaと私は、Node.jsの高性能コードを記述する方法を見つけることに焦点を当ててきました。 当然、これは、V8 JSエンジンによってコードが実行されるときに、どのアプローチが高速で、どのアプローチが遅いかを知ることを意味します。



V8チームが新しいJITコンパイラーTurboFanを作成したので、今度はパフォーマンスに関するすべての前提条件を確認します。



最適化コンパイルの放棄につながる、よく知られたソフトウェア構成を検討します。 さらに、ここでは、さまざまなバージョンのV8のパフォーマンスを研究することを目的とした、より複雑な研究​​を扱います。 これはすべて、異なるバージョンのNodeおよびV8を使用して起動される一連のマイクロベンチマークを通じて行われます。



もちろん、V8用にコードを最適化する前に、最初にAPI、アルゴリズム、およびデータ構造の設計に焦点を合わせる必要があります。 これらのマイクロベンチマークは、NodeでのJavaScriptのパフォーマンスがどのように変化しているかを示す指標と見なすことができます。 これらのインジケーターを使用して、コードの全体的なスタイルと、通常の最適化を適用した後にパフォーマンスを改善する方法を変更できます。



バージョンV8 5.1、5.8、5.9、6.0、および6.1のマイクロベンチマークのパフォーマンスを検討します。



V8バージョンとNodeバージョンの関係を明確にするために、次の点に注意してください。V85.1エンジンはNode 6で使用され、Crankshaft JITコンパイラはここで使用され、V8 5.8エンジンはNodeバージョン8.0〜8.2で使用され、Crankshaftはここで使用されます。ターボファン。



現在、ノード8.3、またはおそらく8.4には、V8エンジンバージョン5.9または6.0が存在することが予想されます。 この記事の執筆時点でのV8の最新バージョンは6.1です。 node-v8実験リポジトリのNodeに統合されています。 言い換えれば、V8 6.1はNodeの将来のバージョンで使用されることになります。



この記事の準備で使用されたテストコードとその他の資料は、 ここにあります。

これは、特に未処理のテスト結果があるドキュメントです。



ほとんどのマイクロベンチマークは、Macbook Pro 2016、3.3 GHz Intel Core i7、16 GB 2133 MHz LPDDR3メモリで実行されます。 それらの一部(数値の操作、オブジェクトのプロパティの削除)は、MacBook Pro 2014、3 GHz Intel Core i7、16 GB 1600 MHz DDR3メモリで実行されました。 Node.jsの異なるバージョンのパフォーマンス測定は、同じコンピューターで実行されました。 他のプログラムがテスト結果に影響しないことを確認しました。



テストを見て、結果が将来のノードにとって何を意味するかについて話しましょう。 すべてのテストは、 benchmark.jsパッケージを使用して実行されました。各図​​のデータは、1秒あたりの操作数を示しています。つまり、値が大きいほど優れています。



問題を試す/キャッチする



よく知られている最適化解除パターンの1つは、 try/catch



ブロックを使用することです。



以下、テストの説明のリストの括弧内に、英語の短いテスト名が記載されていることに注意してください。 これらの名前は、チャートで結果を示すために使用されます。 さらに、テスト中に使用されたコードをナビゲートするのに役立ちます。

このテストでは、4つのテストケースを比較します。





GitHubでコードをテストする







try/catch



パフォーマンスへの悪影響について既に知られていることがNode 6(V8 5.1)で確認されており、Node 8.0-8.2(V8 5.8) try/catch



パフォーマンスへの影響ははるかに小さいことがわかります。



また、 try



ブロックから関数を呼び出すことは、 try



外部で呼び出すよりもはるかに遅いことに注意する必要があります。これは、ノード6(V8 5.1)およびノー​​ド8.0-8.2(V8 5.8)の両方に当てはまります。



ただし、ノード8.3+では、 try



ブロックから関数を呼び出してもパフォーマンスにはほとんど影響しません。



それにもかかわらず、落ち着かないでください。 最適化セミナーのいくつかの資料に取り組んでいる間に、かなり特定の状況がTurboFanでの最適化解除/再最適化の無限のサイクルにつながる可能性がある場合にエラーを発見しました。 これは、次のパフォーマンスキラーパターンと考えられます。



オブジェクトからプロパティを削除する



長年にわたり、JSで高性能コードを記述したい人はだれでも(少なくとも、プログラムの最も負荷の高い部分に最適なコードを記述する必要がある場合)、 delete



コマンドは回避されていました。



delete



の問題は、V8がJavaScriptオブジェクトの動的な性質を処理する方法と、低レベルのエンジン実装でのプロパティの検索を複雑にするプロトタイプチェーン(潜在的に動的)に起因します。



プロパティを持つ高性能オブジェクトを作成するV8エンジンのアプローチは、オブジェクトの「フォーム」、つまりオブジェクトが持つキーと値(プロトタイプチェーンのキーと値を含む)に基づいて、C ++レベルでクラスを作成することです。 これらの構造は、「隠しクラス」として知られています。 ただし、このタイプの最適化はプログラムの実行中に実行されます。 オブジェクトの形状が不明な場合、V8には別のプロパティ検索モードがあります:ハッシュテーブル検索。 このようなプロパティ検索は非常に遅くなります。



従来、 delete



コマンドを使用してオブジェクトからキーを削除する場合、プロパティにアクセスする後続の操作は、ハッシュテーブルを検索して実行されます。 そのため、プログラマーはdelete



コマンドを使用せず、代わりにプロパティをundefined



に設定します。値を破壊するという点では、同じ結果になりますが、プロパティの存在を確認する際に複雑さが増します。 ただし、通常、このアプローチは、たとえばJSON.stringify



出力にundefined



値が含まれundefined



ため(JSON仕様によるとundefined



は有効な値に適用されないため)、シリアル化のためにオブジェクトを準備するときに十分です。



ここで、新しいTurboFan実装がオブジェクトからプロパティを削除する問題を解決するかどうかを調べましょう。



ここでは、3つのテストケースを比較します。





GitHubでコードをテストする







V8 6.0および6.1(ノードリリースではまだ使用されていません)では、オブジェクトに最後に追加されたプロパティを削除すると、プログラム実行の最適化されたTurboFanパスに対応するため、プロパティをundefined



設定するよりも高速です。 これは、V8開発チームがdelete



コマンドのパフォーマンスの改善に取り組んでいることを示しているため、非常に優れています。



ただし、この演算子を使用すると、追加されたプロパティの最後ではないプロパティがオブジェクトから削除された場合、プロパティにアクセスするときに深刻なパフォーマンスの低下につながります。 この観察はJacob Kummerovによって助けられました。JacobKummerovは、最後に追加されたプロパティを削除するオプションのみが調査されたテストの特異性を指摘しました。 彼に感謝します。 その結果、 delete



コマンドは将来のNodeリリース用に記述されたコードでどれだけ使用でき、また使用すべきだと言っても、これを行わないことをお勧めします。 delete



コマンドは引き続きパフォーマンスに悪影響を及ぼします。



リークと引数の配列への変換



通常の関数で使用できる暗黙的に生成されたarguments



オブジェクトの典型的な問題( arguments



オブジェクトの矢印関数は使用できません)は、配列ではなく配列のように見えることです。



配列のメソッドまたはその動作の機能を使用するには、 arguments



のインデックス付きプロパティを配列にコピーする必要があります。 過去に、JS開発者はより短いコードとより速いコードを同一視する傾向がありました。 このアプローチは、クライアントコードの場合、ブラウザーがダウンロードするデータの量を削減することを可能にしますが、サーバーコードで問題を引き起こす可能性があります。サーバーコードでは、プログラムのサイズが実行速度よりもはるかに重要ではありません。 その結果、 arguments



オブジェクトを配列に変換する非常に短い方法が非常に一般的になりました:



Array.prototype.slice.call(arguments)



。 このようなコマンドは、 Array



オブジェクトのslice



メソッドを呼び出し、 arguments



オブジェクトをこのメソッドのthis



コンテキストとして渡します。 slice



メソッドは、配列のように見えるオブジェクトを検出し、その後にジョブを実行します。 その結果、 arguments



オブジェクトのコンテンツから配列に組み立てられた配列を取得します。



ただし、暗黙的に生成されたarguments



オブジェクトが関数のコンテキスト外に渡される場合(たとえば、 Array.prototype.slice.call(arguments)



呼び出すときなど、関数から返されるか別の関数に渡される場合)、これは通常パフォーマンスArray.prototype.slice.call(arguments)



引き起こします。 この声明を調べます。



次のマイクロベンチマークは、V8の4つのバージョンで相互に関連する2つの状況を調査することを目的としています。 つまり、これはarguments



リークのコストと配列にarguments



をコピーするコストであり、 arguments



オブジェクトの代わりに関数の外部に渡されます。



テストケースは次のとおりです。





GitHubでコードをテストする







次に、パフォーマンス特性の変化を強調するために、折れ線グラフの形式で表示される同じデータを見てみましょう。







これらすべてから導き出せる結論を以下に示します。 関数の入力データを配列の形で処理する生産的なコードを書く必要がある場合(経験から私は非常に頻繁に必要とすることを知っています)、ノード8.3以降では拡張演算子を使用する必要があります。 Node 8.2以下では、 for



ループを使用して、 arguments



からキーを新しい(以前に作成された)配列にコピーする必要があります(詳細については、テストコードを参照)。



さらに、ノード8.3+では、 arguments



オブジェクトを他の関数に渡すとパフォーマンスが低下するため、完全な配列を必要とせず、配列のように見えるが配列ではない構造を操作できる場合、他のパフォーマンス上の利点があります。



部分使用(カリー化)および関数コンテキストバインディング



関数を部分的に適用(またはカリー化)すると、囲まれた回路の可視領域に特定の状態を保存できます。



例:



 function add (a, b) { return a + b } const add10 = function (n) { return add(10, n) } console.log(add10(20))
      
      





この例では、 add



関数のパラメーターは、 add10



関数の数値10として部分的に適用されます。



bind



メソッドのおかげで、EcmaScript 5以降、関数のより短い形式の部分的な使用が可能になりました。



 function add (a, b) { return a + b } const add10 = add.bind(null, 10) console.log(add10(20))
      
      





ただし、通常、 bind



メソッドは上記のクロージャーメソッドよりも大幅に遅いため、使用されません。



このテストでは、V8の異なるバージョンでbind



とスナップの使用の違いを測定します。 比較のために、ここでは元の関数の直接呼び出しが使用されています。



以下に4つのテストケースを示します。





GitHubでコードをテストする







テスト結果の線形図は、最新バージョンのV8で機能を操作するための考慮された方法の間にほとんど完全な違いがないことを明確に示しています。 興味深いことに、矢印関数を使用する部分的なアプリケーションは、通常の関数を使用するよりもはるかに高速です(少なくともテストでは)。 実際、直接関数呼び出しとほぼ一致します。 V8 5.1(ノード6)および5.8(ノード8.0-8.2)では、 bind



非常に遅く、これらの目的で矢印関数を使用すると最高速度を達成できることが明らかです。 ただし、V8バージョン5.9(ノード8.3+)以降、 bind



パフォーマンスは大幅に向上しています。 このアプローチは、V8 6.1(将来のバージョンのノード)で最速です(ただし、ここでのパフォーマンスの違いはほとんど区別できません)。



Nodeのすべてのバージョンで最速のカレー方法は、矢印関数を使用することです。 最近のバージョンでは、この方法とbind



の使用の違いbind



重要でbind



ません。現在の状態では、通常の関数を使用するよりも高速です。 ただし、より完全な全体像を得るには、さまざまなサイズのデータ​​構造を持つ関数のより多くのタイプの部分適用を調査する必要があるため、得られた結果がどのような状況でも有効であるとは言えません。



機能コードサイズ



署名、スペース、さらにはコメントを含む関数のサイズは、V8が関数をインライン化できるかどうかに影響を与える可能性があります。 はい。関数にコメントを追加すると、パフォーマンスが約10%低下する可能性があります。 これは将来変更されますか?



このテストでは、3つのシナリオを検討します。





GitHubでコードをテストする







V8 5.1(ノード6)では、small関数とlongの合計テストの合計は同じ結果を示します。 これは、埋め込みの仕組みを完全に示しています。 小さな関数を呼び出すと、V8がこの関数の内容を呼び出される場所に書き込むことに似ています。 したがって、関数のテキストを記述するとき(コメントを追加しても)、呼び出しの場所に手動で埋め込みますが、パフォーマンスは同じです。 繰り返しになりますが、V8 5.1(ノード6)では、関数が特定のサイズに達した後、コメントが追加された関数を呼び出すと、コードの実行が大幅に遅くなることがわかります。



Node 8.0-8.2(V8 5.8)では、小さな関数を呼び出すコストが著しく増加したことを除いて、全体として状況は同じままです。 これはおそらく、CrankshaftとTurboFanの要素の混合によるものです。1つの関数がCrankshaftにあり、もう1つの関数がTurboFanにある場合、埋め込みのメカニズムの故障につながります(つまり、連続して組み込まれた関数のクラスター間の移行が発生するはずです)。



V8 5.9以降(ノード8.3以降)では、スペースやコメントなどの無関係な文字を追加しても、関数のパフォーマンスには影響しません。 これは、Curboshaftなどの文字をカウントする代わりに、TurboFanが抽象構文ツリー (AST、 抽象構文ツリー )を使用して関数のサイズを計算するためです。 TurboFanは、関数のバイト数を考慮する代わりに、関数の実際の命令を分析します。そのため、V8 5.9(ノード8.3+) スペースから始まり、変数名を構成する文字、関数シグネチャ、およびコメントは、関数が埋め込まれる 。 さらに、関数の全体的なパフォーマンスが低下していることに注意する必要があります。



ここでの主な結論は、機能はできる限り小さくする価値があるということです。 現時点では、関数内の不要なコメント(さらにはスペース)を避ける必要があります。 さらに、最高のパフォーマンスを目指している場合、関数を手動で埋め込む(つまり、関数コードを呼び出しの場所に転送し、関数を呼び出す必要がなくなる)ことは、最速のアプローチであり続けます。 もちろん、ここでバランスを取る必要があります。実際の実行可能コードが特定のサイズに達すると、関数はとにかく組み込まれないため、他の関数のコードを思いがけずコピーすると、パフォーマンスの問題が発生する可能性があるためです。 言い換えると、手動で関数を埋め込むことは、脚を撃つ可能性があります。 ほとんどの場合、コンパイラに関数の埋め込みを委任することをお勧めします。



32ビットおよび64ビット整数



JavaScriptの数値型はNumber



のみであることがよく知られています。



ただし、V8はC ++で実装されているため、JavaScriptの数値の基本的なタイプは選択の問題です。



整数の場合(つまり、小数点なしでJSで数値を指定する場合)、V8はすべての数値が32ビットであると見なします-それらがそうでなくなる限り。 多くの場合、数値は2147483648〜2147483647の範囲にあるため、これは公平な選択のようです。 JS番号(全体)が2147483647を超える場合、JITコンパイラーは、数値の基本型を倍精度型(浮動小数点)に動的に変更する必要があります-これは、潜在的に、他の最適化に特定の影響を与える可能性があります。



このテストでは、3つのシナリオを検討します。





GitHubでコードをテストする







この図から、ノード6(V8 5.1)、ノード8(V8 5.8)、またはノードの将来のバージョンについても、上記の観察結果は有効であると言えます。 つまり、2147483647より大きい整数を使用した計算は、関数が最大値の半分または3分の2の領域にある速度で実行されるという事実につながります。 したがって、デジタルIDが長い場合は、文字列に入れてください。



さらに、ノード6(V8 5.1)およびノー​​ド8.1および8.2(V8 5.8)では、ノード8.3+(V8 5.9+)よりも32ビットの範囲内の数値での操作がはるかに高速に実行されることが非常に顕著です。 ) ただし、Node 8.3+(V8 5.9+)の倍精度数の操作は高速です。 これはおそらく、32ビット数の処理速度が遅いためであり、テストコードで使用される関数またはfor



ループの呼び出し速度には適用されません。



Jakob KummerovYang Guo 、およびV8チームは、このテストの結果をより正確に、より正確にするのに役立ちました。 これについて彼らに感謝しています。



オブジェクトプロパティの列挙



オブジェクトのすべてのプロパティの値を取得し、それらのアクションを実行することは一般的なタスクです。 それを解決する方法はたくさんあります。 V8とNodeの調査済みのバージョンの中で、どのメソッドが最速かを調べます。



V8のすべてのテスト済みバージョンが受けた4つのテストは次のとおりです。





さらに、V8バージョン5.8、5.9、6.0、および6.1の3つの追加テストを実施しました。





このバージョンはEcmaScript 2017 Object.values



組み込みメソッドをサポートしていないため、V8 5.1(ノード6)ではこれらのテストを実行しませんでした。



GitHubでコードをテストする







Node 6(V8 5.1)およびNode 8.0-8.2(V8 5.8)では、 for-in



ループを使用することが、間違いなくオブジェクトのキーを反復処理し、そのプロパティ値にアクセスする最速の方法です。 40 , 5 , , Object.keys



, 8 .



V8 6.0 (Node 8.3) for-in



- , . , .



V8 6.1 ( , Node), , Object.keys



, , for-in



, , , for-in



V8 5.1 5.8 (Node 6, Node 8.0-8.2).



, TurboFan — , . , , .



Object.values



, Object.keys



. , , . , , .



, , for-in



- , . , .





JS — , , .



:





GitHub







Node 6 (V8 5.1) .



Node 8.0-8.2 (V8 5.8), EcmaScript 2015, , -. , , Node.



V8 5.9 .



, V8 6.0 (, Node 8.3 8.4) 6.1 ( V8 Node), . 500 ! .









, . , , . , , , ( ).



, , TurboFan . .





(, ), , . . , . , , , , - . , , , . .



:





GitHub







, V8.



V8 6.1 ( , Node) , . , , node-v8, « » V8, V8 6.1.



, , , , , . , , , , API .



, V8 , , , d8



. , Node. , , Node ( , Node V8). . , .



debugger



, , debugger



.



-. .



:





GitHub







. V8 debugger



.



, without debugger V8.



:



, , V8 . Node.js, , Pino .



, 10 ( — ) Node.js 6.11 (Crankshaft).









— , V8 6.1 (TurboFan).









, , Winston JIT- TurboFan. , , , , . Crankshaft TurboFan, , Crankshaft, TurboFan . Winston, , , , Crankshaft, TurboFan. , Pino Crankshaft. .



まとめ



, , V8 5.1, 5.8 5.9, TurboFan V8 6.0 6.1. , , , , , .



TurboFan (V8 6.0 ). TurboFan , , , « V8» . (Chrome) (Node) . , , , . , . , TurboFan (, Winston Pino).



- JavaScript, , , , - , - . JS-, , V8, .



親愛なる読者! JavaScript ?



All Articles