私はJava 8のストリームで遊んでいて、得られるパフォーマンス結果を理解できません。2コアCPU(Intel i73520M)、Windows 8 x64、および64ビットJava 8 update 5. Stringsのストリーム/パラレルストリーム上で単純なマップを実行していますが、パラレルバージョンの方がやや遅いことがわかりました。
Function<Stream<String>, Long> timeOperation = (Stream<String> stream) -> {
long time1 = System.nanoTime();
final List<String> list =
stream
.map(String::toLowerCase)
.collect(Collectors.toList());
long time2 = System.nanoTime();
return time2 - time1;
};
Consumer<Stream<String>> printTime = stream ->
System.out.println(timeOperation.apply(stream) / 1000000f);
String[] array = new String[1000000];
Arrays.fill(array, "AbabagalamagA");
printTime.accept(Arrays.stream(array)); // prints around 600
printTime.accept(Arrays.stream(array).parallel()); // prints around 900
CPUコアが2つあるという事実を考慮して、パラレルバージョンを高速化すべきではありませんか?パラレルバージョンが遅い理由を教えてください。
ここで、並行していくつかの問題が発生しています。
1つ目は、問題を並行して解決するには、常に実際に作業を行うよりも実際の作業を多く行う必要があることです。オーバーヘッドは、作業を複数のスレッドに分割し、結果を結合またはマージすることに関与します。短い文字列を小文字に変換するなどの問題は十分に小さいため、並列分割のオーバーヘッドに圧倒される危険があります。
2番目の問題は、ベンチマークJavaプログラムは非常に微妙であり、混乱する結果を得るのは非常に簡単です。2つの一般的な問題はJITコンパイルとデッドコードの除去です。コンパイルはピークスループットを測定しておらず、実際にはJIT自体を測定している可能性があります。コンパイルが行われるときは、多少非決定的であるため、結果が大きく異なる場合があります。
小規模で総合的なベンチマークの場合、ワークロードはしばしば捨てられた結果を計算します。 JITコンパイラは、これを検出し、どこでも使用される結果を生成しないコードを排除するのに非常に優れています。これはおそらくこの場合は発生しませんが、他の合成ワークロードをいじくり回すと、確実に発生する可能性があります。もちろん、JITがベンチマークワークロードを排除すると、ベンチマークは役に立たなくなります。
独自のフレームワークを手動でローリングする代わりに、 [〜#〜] jmh [〜#〜] などのよく開発されたベンチマークフレームワークを使用することを強くお勧めします。 JMHには、これらを含む一般的なベンチマークの落とし穴を避けるための機能があり、セットアップと実行が非常に簡単です。 JMHを使用するために変換されたベンチマークは次のとおりです。
package com.stackoverflow.questions;
import Java.util.Arrays;
import Java.util.List;
import Java.util.stream.Collectors;
import Java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*;
public class SO23170832 {
@State(Scope.Benchmark)
public static class BenchmarkState {
static String[] array;
static {
array = new String[1000000];
Arrays.fill(array, "AbabagalamagA");
}
}
@GenerateMicroBenchmark
@OutputTimeUnit(TimeUnit.SECONDS)
public List<String> sequential(BenchmarkState state) {
return
Arrays.stream(state.array)
.map(x -> x.toLowerCase())
.collect(Collectors.toList());
}
@GenerateMicroBenchmark
@OutputTimeUnit(TimeUnit.SECONDS)
public List<String> parallel(BenchmarkState state) {
return
Arrays.stream(state.array)
.parallel()
.map(x -> x.toLowerCase())
.collect(Collectors.toList());
}
}
コマンドを使用してこれを実行しました:
Java -jar dist/microbenchmarks.jar ".*SO23170832.*" -wi 5 -i 5 -f 1
(オプションは、5つのウォームアップ反復、5つのベンチマーク反復、および1つの分岐JVMを示します。)実行中、JMHは多くの冗長メッセージを出力しますが、これは省略しました。要約結果は次のとおりです。
Benchmark Mode Samples Mean Mean error Units
c.s.q.SO23170832.parallel thrpt 5 4.600 5.995 ops/s
c.s.q.SO23170832.sequential thrpt 5 1.500 1.727 ops/s
結果は1秒あたりのopsであることに注意してください。したがって、並列実行は順次実行よりも約3倍高速であるように見えます。しかし、私のマシンには2つのコアしかありません。うーん。そして、実行ごとの平均エラーは実際には平均ランタイムよりも大きいです!ワット?ここで怪しいものが起こっています。
これにより、3番目の問題が発生します。ワークロードをより詳しく見ると、入力ごとに新しいStringオブジェクトが割り当てられていることがわかります。また、結果をリストに収集します。これには、多くの再割り当てとコピーが含まれます。これにより、かなりの量のガベージコレクションが行われると思います。 GCメッセージを有効にしてベンチマークを再実行すると、これを確認できます。
Java -verbose:gc -jar dist/microbenchmarks.jar ".*SO23170832.*" -wi 5 -i 5 -f 1
これにより、次のような結果が得られます。
[GC (Allocation Failure) 512K->432K(130560K), 0.0024130 secs]
[GC (Allocation Failure) 944K->520K(131072K), 0.0015740 secs]
[GC (Allocation Failure) 1544K->777K(131072K), 0.0032490 secs]
[GC (Allocation Failure) 1801K->1027K(132096K), 0.0023940 secs]
# Run progress: 0.00% complete, ETA 00:00:20
# VM invoker: /Users/src/jdk/jdk8-b132.jdk/Contents/Home/jre/bin/Java
# VM options: -verbose:gc
# Fork: 1 of 1
[GC (Allocation Failure) 512K->424K(130560K), 0.0015460 secs]
[GC (Allocation Failure) 933K->552K(131072K), 0.0014050 secs]
[GC (Allocation Failure) 1576K->850K(131072K), 0.0023050 secs]
[GC (Allocation Failure) 3075K->1561K(132096K), 0.0045140 secs]
[GC (Allocation Failure) 1874K->1059K(132096K), 0.0062330 secs]
# Warmup: 5 iterations, 1 s each
# Measurement: 5 iterations, 1 s each
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: com.stackoverflow.questions.SO23170832.parallel
# Warmup Iteration 1: [GC (Allocation Failure) 7014K->5445K(132096K), 0.0184680 secs]
[GC (Allocation Failure) 7493K->6346K(135168K), 0.0068380 secs]
[GC (Allocation Failure) 10442K->8663K(135168K), 0.0155600 secs]
[GC (Allocation Failure) 12759K->11051K(139776K), 0.0148190 secs]
[GC (Allocation Failure) 18219K->15067K(140800K), 0.0241780 secs]
[GC (Allocation Failure) 22167K->19214K(145920K), 0.0208510 secs]
[GC (Allocation Failure) 29454K->25065K(147456K), 0.0333080 secs]
[GC (Allocation Failure) 35305K->30729K(153600K), 0.0376610 secs]
[GC (Allocation Failure) 46089K->39406K(154624K), 0.0406060 secs]
[GC (Allocation Failure) 54766K->48299K(164352K), 0.0550140 secs]
[GC (Allocation Failure) 71851K->62725K(165376K), 0.0612780 secs]
[GC (Allocation Failure) 86277K->74864K(184320K), 0.0649210 secs]
[GC (Allocation Failure) 111216K->94203K(185856K), 0.0875710 secs]
[GC (Allocation Failure) 130555K->114932K(199680K), 0.1030540 secs]
[GC (Allocation Failure) 162548K->141952K(203264K), 0.1315720 secs]
[Full GC (Ergonomics) 141952K->59696K(159232K), 0.5150890 secs]
[GC (Allocation Failure) 105613K->85547K(184832K), 0.0738530 secs]
1.183 ops/s
注:#
で始まる行は、通常のJMH出力行です。残りはすべてGCメッセージです。これは、5回のベンチマーク反復に先行する5回のウォームアップ反復の最初のものにすぎません。 GCメッセージは、残りの反復中も同じように続きました。測定されたパフォーマンスはGCのオーバーヘッドに左右され、報告された結果を信じるべきではないと言うのは安全だと思います。
この時点では、何をすべきかは不明です。これは純粋に合成ワークロードです。割り当てとコピーに比べて、実際の作業を行うCPU時間は非常に少ないことは明らかです。ここで本当に測定しようとしていることを言うのは難しいです。 1つのアプローチは、ある意味でより「現実的」な別のワークロードを考え出すことです。別のアプローチは、ベンチマーク実行中にGCを回避するために、ヒープとGCパラメーターを変更することです。
ベンチマークを行うときは、JITコンパイルに注意を払う必要があり、そのタイミング動作は、JITコンパイルされたコードパスの量に基づいて変化する可能性があります。テストプログラムにウォームアップフェーズを追加すると、パラレルバージョンはシーケンシャルバージョンよりも少し高速になります。結果は次のとおりです。
Warmup...
Benchmark...
Run 0: sequential 0.12s - parallel 0.11s
Run 1: sequential 0.13s - parallel 0.08s
Run 2: sequential 0.15s - parallel 0.08s
Run 3: sequential 0.12s - parallel 0.11s
Run 4: sequential 0.13s - parallel 0.08s
次のコードフラグメントには、このテストに使用した完全なソースコードが含まれています。
public static void main(String... args) {
String[] array = new String[1000000];
Arrays.fill(array, "AbabagalamagA");
System.out.println("Warmup...");
for (int i = 0; i < 100; ++i) {
sequential(array);
parallel(array);
}
System.out.println("Benchmark...");
for (int i = 0; i < 5; ++i) {
System.out.printf("Run %d: sequential %s - parallel %s\n",
i,
test(() -> sequential(array)),
test(() -> parallel(array)));
}
}
private static void sequential(String[] array) {
Arrays.stream(array).map(String::toLowerCase).collect(Collectors.toList());
}
private static void parallel(String[] array) {
Arrays.stream(array).parallel().map(String::toLowerCase).collect(Collectors.toList());
}
private static String test(Runnable runnable) {
long start = System.currentTimeMillis();
runnable.run();
long elapsed = System.currentTimeMillis() - start;
return String.format("%4.2fs", elapsed / 1000.0);
}
複数のスレッドを使用してデータを処理するには、初期設定コストがかかります。スレッドプールの初期化。これらのスレッドは、特にランタイムがすでに非常に低い場合に、これらのスレッドを使用することによる利益を上回る場合があります。さらに、競合がある場合、たとえば実行中の他のスレッド、バックグラウンドプロセスなど、並列処理のパフォーマンスはさらに低下する可能性があります。
この問題は、並列処理にとって新しいものではありません。この記事では、Java 8 parallel()
)およびその他の考慮すべき事項に照らして詳細を説明します。 http://Java.dzone.com/articles/ think-twice-using-Java-8
Javaのストリーム実装は、明示的に並行して言及されない限り、デフォルトでシーケンシャルです。ストリームが並行して実行されると、Javaランタイムはストリームを複数のサブストリームに分割します。集計操作は、これらのサブストリームを繰り返し処理して並列処理し、結果を結合します。そのため、開発者がシーケンシャルストリームのパフォーマンスに影響を与える場合は、パラレルストリームを使用できます。 パフォーマンスの比較を確認してください:https://github.com/prathamket/Java-8/blob/master/Performance_Implications.Java パフォーマンスに関する全体的なアイデアが得られます。