最近のJavaパフォーマンスの記事は、パフォーマンス測定に関する議論を引き起こしました。 悲しいことに、多くの人が特定のコードの実行時間を正確に測定することがいかに難しいかをまだ理解していないことに気づかなければなりません。 さらに、人々は、異なる条件で同じコードを実行する時間が大幅に異なるという事実にまったく慣れていません。 たとえば、次のような意見があります。
「自分のタスクでどの言語が速いか」を知る必要がある場合、世界で最も原始的なベンチマークを実行します。 差が大きい場合(たとえば1桁)-ユーザーマシン上ですべてがほぼ同じになる可能性があります。
残念ながら、世界で最も原始的なベンチマークは、通常、不適切に作成されたベンチマークです。 そして、間違ったベンチマークが結果をオーダーまでの精度で測定することを期待しないでください。 完全に異なるものを測定できます。これは、同様のコードを持つプログラムの実際のパフォーマンスとは完全に異なります。 例を見てみましょう。
Java 8 Stream APIを見て、単純な数学でどれだけ速く動作するかを確認したいとしましょう。 たとえば、簡単にするために、0から99999の整数のストリームを取得し、それぞれがmap
操作を使用して2乗します。それだけで、他には何もしません。 パフォーマンスを測定したいだけですよね? ただし、APIをIntStream.range(0, 100_000).map(x -> x * x)
見ても、ストリームが遅延しており、 IntStream.range(0, 100_000).map(x -> x * x)
は実際には何もしないことがIntStream.range(0, 100_000).map(x -> x * x)
ます。 したがって、結果を使用するforEach
ターミナル操作を追加します。 たとえば、1ずつ増やします。 その結果、次のテストを取得します。
static void test() { IntStream.range(0, 100_000).map(x -> x * x).forEach(x -> x++); }
素晴らしい。 それがどのくらい機能するかを測定する方法は? 誰もが知っていることです。最初に時間を取り、最後に時間を取り、差を計算してください! 時間を測定し、結果をナノ秒単位で返すメソッドを追加します。
static long measure() { long start = System.nanoTime(); test(); long end = System.nanoTime(); return end - start; }
さて、結果を印刷するだけです。 それほど高速ではないCore i7とOpen JDK 8u91 64ビットでは、異なる起動時に約5,000〜6,500万ナノ秒の数値が得られます。 それは50〜65ミリ秒です。 50ミリ秒で10万平方? これはとんでもないです! これは1秒あたりわずか200万回です。 25年前、コンピューターはさらに高速になりました。 Javaは恥知らずに遅くなります! かどうか?
実際、アプリケーションでラムダとストリームAPIを初めて使用すると、最新のコンピューターでは常に50〜70ミリ秒の遅延が追加されます。 実際、この間に多くのことを行う必要があります。
- ラムダのランタイム表現(LambdaMetafactoryを参照)およびそれらに関連するすべてを生成するためのクラスをダウンロードします。
- Stream API自体のクラスをダウンロードします(多くあります)
- コード(この場合は2つ)で使用されるラムダについて、ランタイム表現を生成します。
- 少なくとも何らかの形で、これらすべてをJITコンパイルします。
これには多くの時間が必要であり、実際、50ミリ秒以内に維持できることは驚くべきことです。 しかし、これはすべて一度だけ必要です。
叙情的な余談
一般に、あらゆるものの動的なロードとキャッシングの存在により、測定したものを理解することは非常に困難になります。 これはJavaだけに当てはまりません。 単純なライブラリ呼び出しにより、ハードドライブからの読み込みと共有ライブラリの初期化を開始できます(ハードドライブもスリープモードになったと想像してください)。 その結果、呼び出しにはさらに時間がかかる可能性があります。 これについてスチームバスを浴びるかどうか? 時にはあなたがする必要があります。 たとえば、Windows 95では、共有OLE32.DLLライブラリのロードにかなりの時間がかかり、OLE32をロードしようとするブレーキの最初のプログラムを発表していました。 これにより、開発者は、可能であればOLE32をできるだけ長くダウンロードせず、他のプログラムが責任を負うようになりました。 一部の場所では、OLE32のロードを回避するためだけに、他のライブラリがOLE32関数の一部を複製する関数を実装します。 Raymond Chenでこのストーリーの詳細を読んでください。
そのため、このプロセスではロード後に一度だけ行う必要がある多くのことが行われているため、ベンチマークが非常に遅いことがわかりました。 プログラムを1秒以上実行することを計画している場合、ほとんどの場合これはあまり気にしません。 「JVMをウォームアップ」しましょう-この測定を10万回行い、最後の測定の結果を表示します。
for (int i = 100000; i >= 0; i--) { long res = measure(); if(i == 0) System.out.println(res); }
このプログラムは、1秒よりも速く完了し、マシン上で70〜90ナノ秒を印刷します。 これはすごい! それでは、1平方メートルあたり0.7-0.9ピコ秒ですか? Javaは1秒間に1兆回以上平方しますか? Javaは超高速です! かどうか?
すでに2回目の反復で、上記のリストの大部分が実行され、プロセスは100ごとに1回加速されます。次に、JITコンパイラーはさまざまなコードを徐々にコンパイルし(Stream APIに多くあります)、実行プロファイルを収集し、さらに最適化します。 最終的に、JITはラムダチェーン全体をインライン化し、乗算の結果がどこでも使用されないことを認識するのに十分スマートです。 JITコンパイラーの増分を通じてそれを使用しようとする素朴な試みは欺きませんでした。この操作にはまだ副作用がありません。 JITコンパイラーはストリーム全体を刈り取るほどの強さはありませんでしたが、内部ループを刈ることができたため、実際には反復の数に関係なくテストパフォーマンスが得られIntStream.range(0, 1_000_000)
IntStream.range(0, 100_000)
をIntStream.range(0, 1_000_000)
-結果は同じもの)。
ところで、このような場合、 nanoTime()
実行時間と粒度は重要です。 同じハードウェア上であっても異なるOS上であっても、大幅に異なる答えを得ることができます。 これについては、Alexey Shipilevで詳しく説明しています。
そこで、「最も原始的なベンチマーク」を作成しました。 最初は超低速でしたが、少し改良した後、超高速でほぼ100万倍高速であることが判明しました。 Stream APIを使用して、2乗がどれだけ速く実行されるかを測定したいと考えました。 しかし、最初のテストでは、この数学的操作は他の操作の海に沈み、2番目のテストでは実行されませんでした。 性急な結論には注意してください。
真実はどこですか? 真実は、このテストが現実とは何の関係もないということです。 プログラムで目に見える効果は生成されません。つまり、実際には何もしません。 実際には、何もしないコードを書くことはめったになく、確かに、それはあなたにお金をもたらすことはほとんどありません( 例外はありますが )。 Stream API内での二乗に実際にかかる時間の問題に答えようとするのは無意味です。これは非常に単純な操作であり、周囲のコードによっては、JITコンパイラーは乗算を使用してループを非常に異なる方法でコンパイルできます パフォーマンスは付加的なものではないことに注意してください。Aがx秒かかり、Bがy秒かかる場合、AとBがx + y秒かかるということはまったくありません。 それは完全に間違っているかもしれません。
簡単な答えが必要な場合、実際のプログラムでは真実はその中間にあります。2乗された100,000個の整数のストリームのオーバーヘッドは、超高速の結果の約1000倍であり、超遅い。 しかし、多くの要因によっては、さらに悪化する可能性があります。 またはより良い。
昨年のジョーカーで、Stream APIのパフォーマンスを測定し、そこで起こっていることをより深く掘り下げる、もう少し興味深い例を見ました 。 さて、 JMHへの必須の参照:JVM言語のパフォーマンスを測定するときに、単純な熊手を踏まないようにするのに役立ちます。 もちろん、JMHでさえすべての問題を魔法のように解決するわけではありませんが、まだ考えなければなりません。