私は他の人間と同じようにコードのベンチマークが嫌いです (この時点で、これを見ているほとんどの人はおそらくそうではないでしょう ¯\ (ツ) /¯)。値をキャッシュすることでパフォーマンスが 1000% 向上したと仮定する方が、それが何をもたらすかをテストするよりもずっと楽しいです。残念ながら、JavaScript でのベンチマークは依然として必要です。特に、JavaScript はパフォーマンスが重視されるアプリケーションで使用されるためです (使用すべきではない場合? )。残念ながら、そのコア アーキテクチャ上の決定の多くにより、JavaScript ではベンチマークが簡単になるわけではありません。
JavaScript のような最新のスクリプト言語の魔法に馴染みのない人にとっては、そのアーキテクチャはかなり複雑です。ほとんどの JavaScript エンジンは、命令を即座に吐き出すインタープリタを介してコードを実行するだけでなく、C のようなコンパイル言語に似たアーキテクチャを採用しており、複数の層の「コンパイラ」を統合しています。
これらのコンパイラはそれぞれ、コンパイル時間と実行時パフォーマンスのトレードオフが異なります。そのため、ユーザーはほとんど実行されないコードの最適化に計算を費やす必要がなく、最も頻繁に実行されるコード (「ホット パス」) に対しては、より高度なコンパイラのパフォーマンス上の利点を活用できます。最適化コンパイラを使用すると、「関数モノモーフィズム」などの複雑なプログラミング用語が関係するその他の複雑な問題も発生しますが、ここではそのことについては触れないことにします。
では、なぜこれがベンチマークにとって重要なのでしょうか。ご想像のとおり、ベンチマークはコードのパフォーマンスを測定するため、JIT コンパイラーがかなり大きな影響を与える可能性があります。コードの小さな部分をベンチマークすると、完全な最適化後にパフォーマンスが 10 倍以上向上することが多く、結果に多くのエラーが生じます。
たとえば、最も基本的なベンチマーク設定では、次のような設定は使用しないでください (複数の理由により、以下のような設定は使用しないでください)。
for (int i = 0; i<1000; i++) { console.time() // do some expensive work console.timeEnd() }
(心配しないでくださいconsole.time
についても説明します)
数回の試行後には多くのコードがキャッシュされ、操作ごとの時間が大幅に短縮されます。ベンチマーク プログラムでは、このキャッシュ/最適化を排除するために最善を尽くすことがよくあります。これは、ベンチマーク プロセスの後半でテストされるプログラムが比較的高速に見えるようにするためです。ただし、最終的には、最適化なしのベンチマークが実際のパフォーマンスと一致するかどうかを尋ねる必要があります。
確かに、あまりアクセスされない Web ページなど、特定のケースでは最適化は行われませんが、パフォーマンスが最も重要であるサーバーなどの環境では、最適化が期待できます。1 秒間に数千のリクエストに対してコードをミドルウェアとして実行している場合は、V8 がそれを最適化してくれることを期待したほうがよいでしょう。
つまり、基本的には、1 つのエンジン内でも、さまざまなレベルのパフォーマンスでコードを実行する方法が 2 ~ 4 通りあります。また、特定の最適化レベルを確実に有効にすることが、場合によっては非常に困難になることもあります。楽しんでください :)。
フィンガープリンティングって知ってますか? Do Not Track が追跡に利用されるのを可能にした技術ですか?ええ、JavaScript エンジンはそれを軽減するために最善を尽くしてきました。この取り組みとタイミング攻撃を防ぐ動きにより、JavaScript エンジンはタイミングを意図的に不正確にするようになり、ハッカーは現在のコンピューターのパフォーマンスや特定の操作のコストを正確に測定できなくなりました。
残念ながら、これは、調整を行わないとベンチマークに同じ問題が発生することを意味します。
前のセクションの例は、ミリ秒単位でしか測定しないため、不正確です。これをperformance.now()
に切り替えます。すばらしい。
これで、マイクロ秒単位のタイムスタンプが取得できました。
// Bad console.time(); // work console.timeEnd(); // Better? const t = performance.now(); // work console.log(performance.now() - t);
ただし、これらはすべて 100μs 単位です。では、タイミング攻撃のリスクを軽減するために、ヘッダーをいくつか追加しましょう。おっと、まだ 5μs 単位しか増分できません。5μs は多くのユースケースで十分な精度でしょうが、より細かい精度が必要な場合は、他の場所を探す必要があります。私の知る限り、より細かいタイマーを設定できるブラウザーはありません。Node.js では可能ですが、もちろん、Node.js にも独自の問題があります。
コードをブラウザで実行し、コンパイラに任せることにしたとしても、正確なタイミングが必要な場合は、明らかにさらに頭を悩ませることになります。そうそう、すべてのブラウザが同じように作られているわけではありません。
Bun はサーバーサイド JavaScript を前進させるのに役立ったので気に入っていますが、残念なことに、サーバーの JavaScript のベンチマークがかなり難しくなっています。数年前、人々が関心を寄せていたサーバーサイド JavaScript 環境は Node.js とDenoだけで、どちらも V8 JavaScript エンジン (Chrome と同じもの) を使用していました。Bun は Safari のエンジンである JavaScriptCore を使用しますが、これはパフォーマンス特性がまったく異なります。
複数の JavaScript 環境がそれぞれ独自のパフォーマンス特性を持つというこの問題は、サーバーサイド JavaScript では比較的新しいものですが、長い間クライアントを悩ませてきました。Chrome、Safari、Firefox でそれぞれ V8、JSC、SpiderMonkey という、一般的に使用されている 3 つの異なる JavaScript エンジンは、同等のコードでパフォーマンスが大幅に速くなったり遅くなったりすることがあります。
これらの違いの一例は、末尾呼び出し最適化 (TCO) です。TCO は、次のように、本体の最後で再帰する関数を最適化します。
function factorial(i, num = 1) { if (i == 1) return num; num *= i; i--; return factorial(i, num); }
Bun でfactorial(100000)
のベンチマークを実行してみてください。次に、同じことを Node.js または Deno で試してください。次のようなエラーが表示されるはずです。
function factorial(i, num = 1) { ^ RangeError: Maximum call stack size exceeded
V8 (および拡張機能の Node.js と Deno) では、 factorial()
最後に自分自身を呼び出すたびに、エンジンはネストされた関数を実行するためのまったく新しい関数コンテキストを作成します。これは、最終的にはコール スタックによって制限されます。しかし、Bun ではなぜこれが行われないのでしょうか。Bun が使用する JavaScriptCore は、これらのタイプの関数を次のように for ループに変換することで最適化する TCO を実装しています。
function factorial(i, num = 1) { while (i != 1) { num *= i; i--; } return i; }
上記の設計は、コールスタックの制限を回避するだけでなく、新しい関数コンテキストを必要としないため、はるかに高速です。つまり、上記のような関数は、異なるエンジンではベンチマーク結果が大きく異なります。
基本的に、これらの違いは、コードを実行すると予想されるすべてのエンジンでベンチマークを行い、あるエンジンで高速なコードが別のエンジンでは遅くならないことを確認する必要があることを意味します。また、多くのプラットフォームで使用されることが予想されるライブラリを開発している場合は、 Hermesなどのより難解なエンジンも必ず含めてください。これらのエンジンはパフォーマンス特性が大幅に異なります。
これらすべての問題を解決する npm パッケージを紹介できればよいのですが、実際にはそのようなパッケージは存在しません。
サーバーでは、少し楽になります。d8 を使用すると、最適化レベルを手動で制御し、ガベージ コレクターを制御し、正確なタイミングを取得できます。もちろん、このために適切に設計されたベンチマーク パイプラインを設定するには、ある程度の Bash-fu が必要です。残念ながら、d8 は Node.js と十分に統合されていない (またはまったく統合されていない) ためです。
Node.js で特定のフラグを有効にして同様の結果を得ることもできますが、特定の最適化層を有効にするなどの機能は利用できなくなります。
v8 --sparkplug --always-sparkplug --no-opt [file]
特定のコンパイル層 (sparkplug) が有効になっている D8 の例。D8 には、デフォルトで、GC のより詳細な制御と、一般的なデバッグ情報が含まれています。
JavaScriptCore でも同様の機能を利用できますか? 正直に言うと、JavaScriptCore の CLI はあまり使用していませんし、ドキュメントもほとんどありません。コマンドライン フラグを使用して特定の層を有効にすることはできますが、デバッグ情報をどの程度取得できるかはわかりません。Bun には便利なベンチマーク ユーティリティもいくつか含まれていますが、Node.js と同様に制限されています。
残念ながら、これらすべてにはエンジンのベース エンジン/テスト バージョンが必要ですが、これは入手がかなり難しい場合があります。エンジンを管理する最も簡単な方法は、 esvuとeshost-cliを組み合わせることだと私は考えています。これらを組み合わせると、エンジンの管理とエンジン間でのコード実行がかなり簡単になります。もちろん、これらのツールは異なるエンジン間での実行コードを管理するだけなので、依然として多くの手作業が必要です。ベンチマーク コードは自分で記述する必要があります。
サーバー上でできるだけ正確にデフォルト オプションを使用してエンジンのベンチマークを実行しようとしている場合は、タイミングの精度と GC 関連のエラーを改善するのに役立つ、 mitataなどの既製の Node.js ツールがあります。Mitata などのこれらのツールの多くは、多くのエンジンで使用できます。もちろん、上記のようなパイプラインを設定する必要があります。
ブラウザでは、すべてがはるかに困難です。より正確なタイミングを実現するソリューションは知りませんし、エンジンの制御もはるかに制限されています。ブラウザでのランタイム JavaScript パフォーマンスに関して取得できるほとんどの情報は、基本的なフレーム グラフと CPU スローダウン シミュレーション ユーティリティを提供するChrome 開発ツールから得られます。
JavaScript を (比較的) パフォーマンスが高く移植性の高いものにした設計上の決定の多くが、他の言語に比べてベンチマークを非常に困難にしています。ベンチマークするターゲットがはるかに多く、各ターゲットを制御することははるかに困難です。
うまくいけば、いつか解決策が生まれて、これらの問題の多くが簡素化されるでしょう。最終的には、エンジン間およびコンパイル層のベンチマークを簡素化するツールを作成するかもしれませんが、現時点では、これらすべての問題を解決するパイプラインを作成するにはかなりの作業が必要です。もちろん、これらの問題がすべての人に当てはまるわけではないことを覚えておくことが重要です。コードが 1 つの環境でのみ実行される場合は、他の環境のベンチマークに時間を無駄にしないでください。
どのようなベンチマーク方法を選択するにせよ、この記事で JavaScript ベンチマークに存在する問題のいくつかがわかったと思います。上記で説明したいくつかの事項を実装するチュートリアルが役立つかどうかお知らせください。