2014-04-19 9 views
40

Ich spiele mit den Streams von Java 8 und kann die Leistungsergebnisse, die ich bekomme, nicht verstehen.Java 8's Streams: Warum ist der parallele Stream langsamer?

Ich habe 2-Core-CPU (Intel i73520M), Windows 8 x64 und 64-Bit-Java 8 Update 5. Ich mache einfache Karte über Stream/parallelen Strom von Strings und festgestellt, dass die parallele Version ist etwas langsamer.

Wenn ich diesen Code ausführen:

String[] array = new String[1000000]; 
Arrays.fill(array, "AbabagalamagA"); 

Stream<String> stream = Arrays.stream(array); 

long time1 = System.nanoTime(); 

List<String> list = stream.map((x) -> x.toLowerCase()).collect(Collectors.toList()); 

long time2 = System.nanoTime(); 

System.out.println((time2 - time1)/1000000f); 

... Ich erhalte Ergebnis irgendwo um 600 Diese Version, die parallel Strom verwendet:

String[] array = new String[1000000]; 
Arrays.fill(array, "AbabagalamagA"); 

Stream<String> stream = Arrays.stream(array).parallel(); 

long time1 = System.nanoTime(); 

List<String> list = stream.map((x) -> x.toLowerCase()).collect(Collectors.toList()); 

long time2 = System.nanoTime(); 


System.out.println((time2 - time1)/1000000f); 

... gibt mir etwas etwa 900.

Sollte die parallele Version nicht schneller sein, wenn man bedenkt, dass ich 2 CPU-Kerne habe? Könnte mir jemand einen Hinweis geben, warum die parallele Version langsamer ist?

Antwort

94

Parallel dazu laufen mehrere Probleme parallel.

Die erste besteht darin, dass die parallele Lösung eines Problems immer mehr tatsächliche Arbeit erfordert als sequenzielle Ausführung. Overhead ist dabei involviert, die Arbeit auf mehrere Threads aufzuteilen und die Ergebnisse zusammenzuführen oder zusammenzuführen. Probleme wie das Konvertieren kurzer Strings in Kleinbuchstaben sind klein genug, dass sie Gefahr laufen, vom parallelen Splitting-Overhead überlagert zu werden.

Das zweite Problem ist, dass Benchmarking-Java-Programm sehr subtil ist, und es ist sehr einfach, verwirrende Ergebnisse zu erhalten. Zwei häufige Probleme sind die JIT-Kompilierung und die Eliminierung von toten Codes. Kurze Benchmarks enden häufig vor oder während der JIT-Kompilierung, daher messen sie nicht den Spitzendurchsatz, und sie messen möglicherweise sogar das JIT selbst. Wenn die Kompilierung etwas nicht-deterministisch ist, kann dies dazu führen, dass die Ergebnisse ebenfalls stark variieren.

Für kleine, synthetische Benchmarks berechnet die Arbeitslast häufig Ergebnisse, die weggeworfen werden. JIT-Compiler sind ziemlich gut darin, dies zu erkennen und Code zu eliminieren, der keine Ergebnisse liefert, die irgendwo verwendet werden. In diesem Fall passiert das wahrscheinlich nicht, aber wenn Sie mit anderen synthetischen Workloads experimentieren, kann es sicherlich passieren. Wenn das JIT den Benchmark-Workload eliminiert, macht es natürlich den Benchmark nutzlos.

Ich empfehle dringend, ein gut entwickeltes Benchmark-Framework wie JMH zu verwenden, anstatt eines von Ihnen selbst zu rollen. JMH verfügt über Einrichtungen, die helfen, häufige Probleme beim Benchmarking zu vermeiden, einschließlich dieser, und es ist ziemlich einfach einzurichten und zu betreiben. Hier ist Ihre Benchmark umgewandelt JMH zu verwenden:

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()); 
    } 
} 

ich diese mit dem Befehl lautete:

java -jar dist/microbenchmarks.jar ".*SO23170832.*" -wi 5 -i 5 -f 1 

(. Die Optionen zeigen fünf Warmup Iterationen, fünf Benchmark-Iterationen und einem gegabelten JVM) Während seines Laufs , JMH sendet viele ausführliche Nachrichten aus, die ich weggelassen habe. Die zusammenfassenden Ergebnisse sind wie folgt.

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 

Beachten Sie, dass Ergebnisse in Operationen pro Sekunde sind, so sieht es aus wie die parallel laufen etwa dreimal schneller als der sequentielle Lauf war. Aber meine Maschine hat nur zwei Kerne. Hmmm. Und der mittlere Fehler pro Lauf ist tatsächlich größer als die mittlere Laufzeit! WAT? Etwas Fischiges geht hier vor.

Dies bringt uns zu einem dritten Problem. Bei näherer Betrachtung der Arbeitslast können wir sehen, dass es für jede Eingabe ein neues String-Objekt zuweist, und es sammelt auch die Ergebnisse in einer Liste, die viel Neuzuweisung und Kopieren beinhaltet. Ich würde vermuten, dass dies zu einer beträchtlichen Menge an Garbage Collection führen wird. Wir können sehen, durch die Benchmark mit GC-Nachrichten erneut ausführen aktiviert:

java -verbose:gc -jar dist/microbenchmarks.jar ".*SO23170832.*" -wi 5 -i 5 -f 1 

Dies gibt Ergebnisse wie:

[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 

Hinweis: die Anfangszeilen mit # normalen JMH Ausgangsleitungen sind. Der ganze Rest sind GC-Nachrichten. Dies ist nur die erste der fünf Warmup-Iterationen, die fünf Benchmark-Iterationen vorausgehen. Die GC-Nachrichten wurden während der restlichen Iterationen in der gleichen Weise fortgesetzt. Ich denke, es ist sicher zu sagen, dass die gemessene Leistung von GC Overhead dominiert wird und dass die gemeldeten Ergebnisse nicht geglaubt werden sollten.

An diesem Punkt ist es unklar, was zu tun ist. Dies ist eine rein synthetische Arbeitslast. Im Vergleich zur Zuweisung und zum Kopieren benötigt die CPU tatsächlich sehr wenig Rechenzeit. Es ist schwer zu sagen, was Sie wirklich hier messen wollen. Ein Ansatz wäre eine andere Arbeitslast, die in gewissem Sinne "realer" ist. Ein anderer Ansatz wäre, die Heap- und GC-Parameter zu ändern, um GC während des Benchmark-Laufs zu vermeiden.

+12

+1 sehr gründliche Antwort und ein gutes Tutorial, wie man * einen Mikrobenchmark richtig laufen und interpretieren kann! – assylias

8

Die Verwendung mehrerer Threads zur Verarbeitung Ihrer Daten erfordert einige anfängliche Installationskosten, z. Initialisieren des Thread-Pools. Diese Kosten können den Gewinn aus der Verwendung dieser Threads übersteigen, insbesondere wenn die Laufzeit bereits ziemlich niedrig ist. Wenn es Streit gibt, z.B. andere laufende Threads, Hintergrundprozesse etc. können die Performance der Parallelverarbeitung weiter verringern.

Dieses Problem ist nicht neu für die parallele Verarbeitung. Dieser Artikel gibt einige Details im Lichte der Java 8 parallel() und einige weitere Dinge zu beachten: http://java.dzone.com/articles/think-twice-using-java-8

14

Wenn Benchmarks tun, Sie die Aufmerksamkeit auf den JIT-Compiler zahlen sollte, und dass Timing Verhalten ändern können, wenn die JIT-Kicks. Wenn ich Ihrem Testprogramm eine Aufwärmphase hinzufüge, ist die parallele Version etwas schneller als die sequentielle Version. Hier sind die Ergebnisse:

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 

Nachstehend ist der vollständige Quellcode, den ich für diesen Test verwendet habe.

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); 
} 
Verwandte Themen