|
|||||||||||||||||||||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | |||||||||||||||||||||||||||||||||||||||
|
Effective Java
|
||||||||||||||||||||||||||||||||||||||
Wir wollen uns in diesem Artikel dem Thema Stream-Performance zuwenden. Es geht zum einen darum, ob sequentielle Streams schneller oder langsamer sind als for -Schleifen. Dann schauen wir uns weiter an, um wie viel parallele Streams schneller sind als sequentielle und von welchen Kriterien der Geschwindigkeitsunterschied abhängt. Performance von sequentiellen Streams
Nachdem wir uns jetzt schon in einigen
Artikeln mit den Streams beschäftigt haben, stellen wir nun die Frage:
wie sieht es eigentlich mit der Performance von Streams aus? Anfangen
wollen wir mit dem Performance-Vergleich von Stream und
for-
Schleife.
Einfache Funktionalität
Beginnen wir mit einem einfachen Beispiel.
Wir wollen in einem
int
-Array mit
500
.
000
ganzzahligen Zufallszahlen die größte Zahl suchen. Dies ist der Code
für die Lösung mit einer
for
-Schleife:
int
[]
a = ints;
und dies der Code für die Lösung mit einem sequentiellen
Int
Stream
:
int m = Arrays.stream(ints)
.reduce(Integer.MIN_VALUE,
Math::max);
Wir haben hier bewusst die
reduce()
Methode anstelle der
max()
Methode benutzt,
weil
max()
ein
OptionalInt
zurückliefert. Das erfordert die Konstruktion eines
OptionalInt
-Objekts,
die bei Verwendung von
reduce()
entfallen
kann. Die Lösung mit
reduce()
ist deshalb eher vergleichbar mit dem Code der
for
-Schleife
als es eine Lösung mit
max()
wäre.
Das Ergebnis unserer Performance-Messung ist:
for- Schleife: 0. 36 ms
seq. Stream
:
5.
35
ms
Das Ergebnis sieht ziemlich eindeutig aus.
Die
for
-Schleife ist knapp 15-mal schneller
als der sequentielle Stream. Bevor wir dieses Ergebnis zu einem
Streams-sind-total-langsam
verallgemeinern wollen, schauen wir uns an, wie das Ganze aussieht, wenn
wir als unterliegende Sequenz anstelle eines
int
-Arrays
eine
ArrayList
<Integer>
verwenden.
Der Code für die
for
-Schleife ist
nun:
int m = Integer.MIN_VALUE; for (int i : myList)
if (i > m) m = i;
und der Code für die sequentielle Stream Lösung ist:
int m = myList.stream()
.reduce(Integer.MIN_VALUE,
Math::max);
Das Ergebnis ist:
ArrayList, for-Schleife: 6. 55 ms
ArrayList
,
seq
. Stream
:
8
.
33
ms
Auch hier ist wieder die
for
-Schleife
schneller, aber die Unterschiede sind nicht mehr so signifikant. Der
Geschwindigkeitsvorteil beträgt nur noch das 1,27-fache. Woran liegt
es? Der Speicherzugriff bei der Iteration über die
ArrayList
<Integer>
ist deutlich aufwändiger als bei einem
int
-Array.
Dies verursacht hohe Grundkosten. Deshalb fällt der Performance-Vorteil
der
for
-Schleife gegenüber dem sequentiellen
Stream geringer aus, denn die teuren Speicherzugriffe überdecken den Unterschied
zwischen
for
-Schleife und Stream.
Neben den Kosten der Iteration spielen aber auch die Kosten der auf die Sequenzelemente anzuwendenden Funktionalität eine Rolle. In unseren vorhergehenden Beispielen haben wir für alle Elemente der unterliegenden Sequenz die Methode Math::max für int aufgerufen. Das ist eine relativ preiswerte Funktionalität. Nach dem JIT-Compilieren bleibt davon kaum mehr als ein Compare-Befehl im Assembler übrig. Aufwändige Funktionalität
Wie sieht es aber aus, wenn wir stattdessen
eine aufwändige Funktionalität auf jedes Element der unterliegenden Datenstruktur
anwenden?
Als Beispiel für eine CPU-aufwändige Funktionalität
werden wir im Folgenden die Methode
slowSin()
verwenden. Sie stammt aus der Apache Commons Mathematics Library (/
ACML
/).
Die Methode berechnet den Sinuswert zu ihrem jeweiligen Inputparameter.
Das macht sie mit einer Taylor-Approximation, die in diesem Spezialfall
auch als Kleinwinkelnäherung (/
WKKW
/) bekannt ist.
Die Methode steht nicht im public API der Commons Math Lib zur Verfügung;
sie wird nur intern benutzt, um eine Tabelle mit Sinuswerten zu füllen.
Diese Tabelle wird dann von der
public
Methode
sin()
für die Interpolation
von Sinus-Werten benutzt (siehe Klasse:
FastMathCalc
).
Da die
slowSin()
Methode eine CPU-aufwändige
Approximation durchführt, ist sie für unseren Benchmark interessant.
Wir haben den Source Code der Library deshalb so geändert, dass wir die
slowSin()
Methode
public
gemacht haben, damit wir
sie direkt nutzten können.
Unser Beispiel sieht nun so aus, dass wir
für jedes Element des
int
-Arrays den
Sinus-Wert mit Hilfe von
slowSin()
bestimmen
und dann das Maximum der Sinuswerte suchen. Die Länge des Arrays haben
wir auf
10
.
000
verkürzt, damit der Test nicht so lange läuft. Der Code für die
for
-Schleife
sieht nun so aus:
int[] a = ints; int e = a.length;
double m = Double.MIN_VALUE;
for (int i = 0; i < e; i++) { double d = Sine.slowSin(a[i]); if (d > m) m = d;
}
Und der Code für die sequentielle Stream-Lösung ist:
Arrays.stream(ints) .mapToDouble(Sine::slowSin)
.reduce(Double.MIN_VALUE, (i, j) -> Math.max(i,
j));
Der Code für den Test mit einer
ArrayList<Integer>
wurde auch entsprechend angepasst. Die Ergebnisse sind nun:
for-Schleife: 11.82 ms
seq. Stream:
12.15
ms
ArrayList , for- Schleife : 11.84 ms
ArrayList
,
seq
. Stream
:
11.85
ms
Zwar ist die
for
-Schleife
immer noch schneller als die Stream-Operationen, aber ihr Performance-Vorteil
ist nur noch minimal. Der Grund dafür ist, dass nun die Performance
wesentlich durch die Taylor-Approximation bestimmt wird. Der Overhead
des sequentiellen Streams im Vergleich zur
for
-Schleife
ist im Verhältnis dazu sehr gering.
Zusammenfassend lässt sich sagen, sequentielle Streams sind langsamer bzw. bestenfalls genauso schnell wie for -Schleifen. Der Performance-Vorteil der for -Schleife hängt vom Verarbeitungsaufwand pro Element ab. Ist der Aufwand pro Element gering, so ist der Performance-Vorteil der for -Schleife groß (in unserem Beispiel knapp 15-mal schneller). Ist der Aufwand pro Element groß, so wird der Performance-Vorteil der for -Schleife insignifikant klein (Beispiel Arr a yList mit slowSin() : nur noch 1,0008446-mal schneller, d.h. praktisch genauso schnell). Zum Verarbeitungsaufwand pro Element zählen sowohl die Iterationskosten als auch die Kosten der angewandten Funktionalität, die pro Element anfallen. Performance von parallelen Streams
Das Ergebnis des vorhergehenden Abschnitts
mag erst einmal ernüchternd sein. Eventuell kommt sogar das Gefühl
auf, dass man Streams aus Performancegründen besser gar nicht benutzten
sollte. Nun waren aber sequentielle Streams, wie wir sie in den oben
gezeigten Benchmarks verwendet haben, auch gar nicht der alleinige Grund
dafür, dass man überhaupt einen Stream-Framework für den JDK gebaut
hat. Das Stream API wurde auch entwickelt, weil man damit Funktionalität
parallel auf die unterliegende Stream-Source anwenden kann. Interessant
für die Performance von Streams sind also in erster Linie die parallelen
Streams. Die Benutzung paralleler Streams erfordert im Vergleich zu sequentiellen
Streams nur einen minimalen Programmier-Mehraufwand, nämlich ein
parallel
an der richtigen Stelle. Es besteht also die berechtigte Hoffnung, dass
die parallelen Streams eine performante Alternative zu
for
-Schleifen
bieten und damit helfen können, Java Programme zu beschleunigen.
Deshalb wollen wir uns nun anschauen, wie sich die Performance von parallelen Streams und sequentiellen Streams unterscheidet. Algorithmischer Overhead
Ehe wir zu den Benchmarks kommen, wollen
wir erst einmal ein paar theoretische Vorüberlegungen darüber anstellen,
wie sich die parallele und sequentielle Verarbeitung von Streams unterscheidet.
Wir haben uns bereits im vorhergehenden Artikel /
KLPA
/
angesehen, wie die Verarbeitung von parallelen Streams grundsätzlich funktioniert.
Dort haben wir gesehen, dass die Verarbeitung eines parallelen Streams immer einen gewissen algorithmischen Overhead gegenüber der eines sequentiellen Streams hat. Dieser Overhead besteht im wesentlich aus den folgenden zusätzlichen Aktivitäten: • Die ForkJoinTask Objekte müssen konstruiert werden. • Dabei muss die unterliegende Stream-Source sukzessive geteilt werden ( Splitting ). • Die ForkJoinTask s müssen auf die Threads des CommonPool s verteilt werden ( Scheduling ).
•
Wenn
die
ForkJoinTask
Objekte nach der Verarbeitung
nicht mehr referenziert werden, muss ihr Speicherplatz vom Garbage Collector
wieder freigegeben werden.
Die Idee bei der Benutzung eines parallelen
Streams ist nun, dass der algorithmische Overhead durch die Benutzung paralleler
Threads auf weiteren CPU-Cores der unterliegende Rechnerplattform so stark
überkompensiert wird, dass die Performance des parallelen Streams deutlich
besser ist als die des sequentiellen Streams. Dies impliziert, dass die
Anzahl der CPU-Cores eine entscheidende Rolle für die Performance von
parallelen Streams spielt. Wir verwenden für den Benchmarks dieses Artikels
eine Dual-Core-CPU (siehe auch Text-Box an Ende des Artikels: Details zu
den Benchmarks). Bei Plattformen mit mehr CPU-Cores wird die Performancesteigerung
beim Vergleich sequentieller Stream / paralleler Stream in der Regel größer
ausfallen, als in unseren Benchmarks.
Eine Sonderrolle nimmt die Benutzung paralleler Streams auf Single-Core-Plattformen ein. Auf Grund des algorithmischen Overheads dürfte die Performance von parallelen Streams hier im Allgemeinen schlechter sein als die Performance von sequentiellen Streams. Wir haben es aber nicht getestet, da uns keine Single-Core-Plattform zur Verfügung stand. Einfache Funktionalität
Schauen wir uns an, welche Benchmark-Ergebnisse
wir für unser Ausgangsproblem, die Suche nach der größten Zahl aus
500
.
000
ganzzahligen Zufallszahlen, erhalten. Für ein
int
-Array
ints
sieht der Code für den sequentiellen Stream so aus:
int m = Arrays.stream(ints)
.reduce(Integer.MIN_VALUE,
Math::max);
und für den parallelen Stream so:
int m = Arrays.stream(ints).parallel()
.reduce(Integer.MIN_VALUE,
Math::max);
Und bei einer Collection sieht der Code so aus:
int m = myCollection.stream()
.reduce(Integer.MIN_VALUE,
Math::max);
int m = myCollection.parallelStream()
.reduce(Integer.MIN_VALUE,
Math::max);
Wir haben diesmal nicht nur die
Array
List
sondern auch weitere Collection-Typen mit in den Test aufgenommen.
Die Ergebnisse sind:
sequentiell parallel seq./par.
int-Array 5.35 ms 3.35 ms 1.60
ArrayList 8.33 ms 6. 33 ms 1.32
LinkedList 12.74 ms 19.57 ms 0.65
HashSet 20.76 ms 16.01 ms 1.30
T
reeSet
19.79
ms
15.49
ms
1.28
Die Ergebnisse lassen sich, was die Performancesteigerung angeht, grob in drei Kategorien einteilen: • ganz okay ( int -Array), • katastrophal ( LinkedList ),
•
nicht
wirklich katastrophal, aber auch nicht unbedingt das, was wir uns erhofft
haben (alle anderen Collections).
Fangen wir mit dem Punkt "katastrophal" an.
Warum ist das Ergebnis für die
LinkedList
so schlecht? Statt einer Steigerung haben wir beim Wechsel vom sequentiellen
Stream auf einen parallelen Stream eine Verschlechterung bekommen. Das
heißt, da arbeiten jetzt zwei Cores an dem Problem und es dauert 1,46-mal
länger. Das klingt wie ein schlechter Witz aus dem Handbuch für Software-Tuning.
Schauen wir noch mal auf den weiter oben aufgelisteten algorithmischen
Overhead bei parallelen Streams gegenüber sequentiellen Streams. Ein
Punkt ist das
Splitting
, also das sukzessive Teilen der unterliegenden
Stream-Source in halbwegs gleichgroße Teile. Für die meisten Datenstrukturen
ist das nicht besonders aufwändig, aber bei der
LinkedList
kostet es ziemlich viel. Um die Mitte der
LinkedList
zu finden und den ersten Split machen zu können, muss in unserem Fall
(bei einer Sequenz mit 500.000 Elementen) über 250.000
Node
s
der
LinkedList
iteriert werden. Das
dauert eine Weile. Im Vergleich dazu lässt sich für die
ArrayList
ohne großen Aufwand ausrechnen, dass bei Index 250.000 die zweite Hälfte
beginnt. Das heißt, im Vergleich zu anderen Collections ist die
LinkedList
schecht teilbar (
splittable
). Das führt zu ihrer schlechten Performance
in parallelen Streams.
Kommen wir nun zu Punkt "nicht-unbedingt-das-was-wir-uns-erhofft-haben".
Steigerungen von rund 130% beim Wechsel vom sequentiellen Stream zum parallelen
Stream (auf einer Dual-Core-Plattform) sind ein bisschen wenig und lassen
sich auch nicht allein durch den algorithmischen Overhead des parallelen
Streams erklären. Der Grund ist vielmehr, dass die Funktionalität,
die auf jedes Element angewendet wird, in unserem Benchmark nicht besonders
CPU-intensiv ist. Wir haben es oben schon einmal erwähnt: nach der JIT-Compilation
bleibt von
Math::max
nicht viel mehr
als ein Compare-Befehl im Assembler übrig. Nun ist der Vorteil eines
parallelen Streams im Vergleich zum sequentiellen Stream, dass er mehr
CPU-Power nutzen kann. Dies hilft aber nicht viel, wenn die CPU nicht
der alleinige oder wesentliche Engpass ist. Vielmehr wird beim parallelen
Stream nun der Speicherzugriff auf die Collection-Elemente zum Bottleneck,
denn es müssen (auf einer Dual-Core-Plattform) doppelt soviel Daten aus
dem Speicher gelesen werden, um beide Cores zu versorgen. Das heißt,
wenn die Verarbeitung der Elemente wenig CPU-intensiv ist, dominieren die
Speicherzugriffszeiten die Performance.
Warum ist die Performance-Steigerung dann
beim
int
-Array besser? Das liegt einfach
daran, dass die Datenorganisation besser passt. Ein Teil des
int
-Arrays
passt in den CPU-Cache und die Daten können der Reihe nach vom Cache direkt
zum CPU-Core wandern. Das ist bei den Collections anders, da sie nur
boxed-Typen, also in unserem Fall
Integer
s,
speichern können. Es hilft wenig, wenn zum Beispiel bei einer
ArrayList
ein Teil des unterliegenden Arrays im CPU-Cache liegt. Das Array enthält
ja nur die Referenzen auf die
Integer
Objekte. Es ist deshalb stets ein weiterer Speicherzugriff nötig, um
den
int
-Wert aus dem
Integer
Objekt zu lesen. Dieser aufwändige Speicherzugriff führt dazu, dass
die Performance-Steigerung bei den Collections nicht so hoch ausfällt
wie bei dem
int
-Array.
Wie gut Speicherzugriffe bei parallelen Streams skalieren, ist übrigens stark plattform-abhängig. Belastbare Ergebnisse kann man nur durch Nachmessen mit einem Benchmark bekommen. Grundsätzlich kann man aber sagen, dass Speicherzugriffe bei parallelen Streams schnell zum Bottleneck werden können, wenn die Funktionalität, die auf jedes Element angewendet wird, nicht besonders CPU-intensiv ist. Was uns zu unserem nächsten Thema bringt. Aufwändige Funktionalität
Doug Lea hat bei einer Diskussion im OpenJDK-Lambda-Libs-Form
erläutert, wann man eine Performance-Steigerungen durch die Benutzung
paralleler Stream erreichen kann (/
CAPS
/): „…,
the
easy guidance is: If you have a lot of data, or very costly per-element
computations, the best practice is to use
parallel()
.
Otherwise, feel free to experiment with it, but don't expect any miracles.
”
"A lot of data" lässt sich mit hinreichend großer Stream-Source übersetzten. Diese Anforderung ergibt sich einfach daraus, dass der algorithmische Overhead der parallelen Verarbeitung bei einer zu kleinen Stream-Source gar nicht kompensiert werden kann.
Wenn man sich unseren vorhergehenden Benchmark
ansieht, so hatten wird dort „a lot of data”; immerhin war die unterliegende
Stream-Source 500.000 Elemente groß. Was wir nicht hatten, waren „very
costly per-element computations“; der Vergleich von zwei
int
-Werten
kostet halt nicht viel. Deshalb wollen wir unseren Test jetzt noch mal
mit
slowSin()
und 10.000 Elementen in
der Stream-Source durchführen.
slowSin()
ist eine solche „costly per-element computation“, denn der zugrunde
liegende Algorithmus führt CPU-intensive Berechnungen aus, ohne weitere
Daten aus dem Speicher zu benötigen. Während der Berechungen sind die
Daten idealerweise in Registern des CPU-Cores, so dass der Core im Wesentlichen
ohne Zugriffe auf Speicher oder Caches arbeiten kann.
Der Code für den Benchmark sieht im Falle des
int
-Array
so aus:
Arrays.stream(ints) .mapToDouble(Sine::slowSin)
.reduce(Double.MIN_VALUE, (i, j) -> Math.max(i,
j);
Arrays.stream(ints). parallel() .mapToDouble(Sine::slowSin)
.reduce(Double.MIN_VALUE, (i, j) -> Math.max(i,
j);
und für die Collections so:
myCollection. stream() .mapToDouble(Sine::slowSin)
.reduce(Double.MIN_VALUE, (i,
j) -> Math.max(i, j);
myCollection. parallelStream() .mapToDouble(Sine::slowSin)
.reduce(Double.MIN_VALUE, (i,
j) -> Math.max(i, j);
Die Ergebnisse auf unserer Plattform sind:
sequentiell parallel seq./par.
int-Array 1 0 . 8 1 ms 6. 0 3 ms 1. 79
ArrayList 10.97 ms 6. 1 0 ms 1. 80
LinkedList 11.15 ms 6 . 2 5 ms 1 . 78
HashSet 11.15 ms 6. 15 ms 1. 81
TreeSet
11.14
ms
6.3
0
ms 1.
7
7
Nach dem oben Gesagten sollten die Zahlen
nun nicht überraschen. Die Steigerung beim Übergang vom sequentiellen
Stream zum parallelen Stream beträgt unabhängig vom Typ der unterliegenden
Stream-Source rund 180% (auf unserer Dual-Core-Plattform). Die in
slowSin()
durchzuführende CPU-intensive Berechung ist jetzt der Aspekt, der das
Benchmark-Ergebnis dominiert.
Die 20%, die zu den maximal möglichen 200% fehlen (also zur Verdopplung der Performance bei Dual-Core), sind dem algorithmischen Overhead der parallelen Stream-Verarbeitung und möglichen plattform-spezifischen Ressourcen-Engpässen geschuldet. Mit anderen Worten, auf unserer Plattform haben wir die Performance-Steigerung mit diesem Testfall ausgereizt. Funktionalität mit veränderlichem Zustand
Bekanntermaßen ist veränderlicher Zustand
(
mutable state
) bei paralleler Verarbeitung eine Herausforderung,
die sich meist nur mit gewissen Performance-Einschränkungen meistern lässt.
Dies gilt auch für parallele Streams.
Bevor wir die konkreten Performance-Einschränkungen diskutieren wollen, schauen wir uns an, welche Stream-Operationen überhaupt veränderlichen Zustand zulassen. Diese Operationen lassen sich grob folgendermaßen kategorisieren: • zustandsbehaftete, intermediäre Operationen ( stateful intermediate operations ), z.B.: distinct() , • terminale Operationen ( terminal operations ), die zustandsbehaftete Operationen als Parameter akzeptieren, z.B.: forEach() ,
•
und
je nachdem, ob man sie als einen Spezialfall der vorhergehenden Kategorie
oder als eigene Kategorie für sich betrachtet, die Methode
collect()
.
Für unsere Performance-Betrachtungen wollen wir uns im Detail nur mit der zustandsbehaftete, intermediäre Operation distinct() befassen. Alles Weitere würde den Rahmen des Artikels sprengen. Aber vieles von dem, was wir uns bei distinct() ansehen werden, lässt sich auf die anderen Operationen übertragen. Implementierung von distinct()
Rekapitulieren wir kurz, was
distinct()
überhaupt macht. Es eliminiert alle Element aus dem Input-Stream, die
(im Sinne von
equals()
) mehrfach vorkommen.
Der veränderliche Zustand der Operation sind all diejenigen Elemente,
die bereits im Output-Stream gelandet sind. Mit diesen Elementen muss
das aktuelle Element auf Gleichheit geprüft werden.
Die Implementierung von
distinct()
für den sequentiellen Stream erledigt diese Prüfung auf Gleichheit mit
den vorhergehenden Elementen, indem sie einen
HashSet
anlegt,
in den sie jedes Element des Input-Streams per
add()
-Methode
einfügt. Der Return-Wert der
add()
-Methode
spiegelt das Ergebnis der Prüfung wider, denn sie fügt das Element nur
ein, wenn es noch nicht im
HashSet
enthalten ist; sonst gibt sie
false
zurück.
Man kann sich die Implementierung von
distinct()
im sequentiellen Fall in etwa so vorstellen:
// Vorsicht! Pseudocode! Nicht zur Nachahmung empfohlen! Set<Integer> dSet = new HashSet<>(); myStream.filter(dSet::add)
. ... // weitere Stream-Operationen
Aber Vorsicht, es wäre falsch, solchen Code
tatsächlich hinzuschreiben. Wir haben den Code gezeigt, um das Prinzip
der internen Implementierung von
distinct()
zu illustrieren. Die
filter()
Operation
verlangt in ihrer Javadoc, dass die Funktionalität, die ihr als Parameter
übergeben wird, nicht zustandsbehaftet sein darf (siehe /
FILT
/).
Gegen diese Anforderung wird hier verstoßen.
Bevor wir uns die distinct() Implementierung für den parallelen Fall ansehen, werfen wir einen kurzen Blick in die Javadoc (/ DIST /):
Preserving stability for distinct() in parallel pipelines is relatively
expensive (requires that the operation act as a full barrier, with substantial
buffering overhead), and stability is often not needed. (…) removing
the ordering constraint with unordered() may result in significantly more
efficient execution for distinct() in parallel pipelines, if the semantics
of your situation permit.
Was bedeutet das? Für den parallelen Fall
hat
distinct()
zwei Implementierungen.
Die eine funktioniert so, dass sie einen geordneten (
ordered
) Stream
erzeugt; bei der anderen Implementierung ist der Output-Stream ungeordnet.
"Geordneter Stream" bedeutet im Fall von
distinct()
,
dass die Element im Output-Stream die gleiche Reihenfolge wie im Input-Stream
haben; von den mehrfach vorkommenden Elementen ist aber nur das erste in
den Output-Stream gewandert, weil
distinct()
die
Duplikate wegfiltert. Auf diese Weise sind die Elemente im Output-Stream
genauso angeordnet wie bei der sequentiellen Ausführung, obwohl das ganze
parallel abgearbeitet worden ist.
Man kann sich vorstellen, dass ein Algorithmus,
der die Reihenfolgen erhält, weniger performant ist, als ein Algorithmus,
der dies nicht tut. Deshalb gibt es für
distinct()
beide Möglichkeiten. Wenn es auf die Reihenfolge nicht ankommt, kann
man auf dem Stream
unordered()
aufrufen;
dann wird der performantere Algorithmus verwendet.
Den Algorithmus für den geordneten, parallelen Stream kann man sich
vom Prinzip her so vorstellen:
// Vorsicht! Pseudocode! Nicht zur Nachahmung empfohlen! myParallelStream.collect(Collectors.toCollection(LinkedHashSet::new)) .parallelStream()
. ... // weitere Stream-Operationen
Auch diesen Code sollte man nicht hinschreiben.
Er dient hier nur zur Illustration, um den Overhead der parallelen Ausführung
von
distinct()
im Vergleich zur sequentiellen
Ausführung zu diskutieren.
Die Elemente des Input-Streams werden parallel in einem LinkedHashSet gesammelt. Dabei werden mehrfache vorkommende Elemente eliminiert (der HashSet -Aspekt von LinkedHashSet ) und die Reihenfolge bleibt erhalten (der Linked -Aspekt von LinkedHashSet ). Danach wird aus dem resultierenden LinkedHashSet wieder ein paralleler Stream gemacht (Aufruf von parallelStream() ). Auf diesem können dann weitere Stream-Operationen aufgerufen werden. Der Overhead der parallelen Ausführung von distinct() hat mehrere Gründe: • Die add() Methode des LinkedHashSet ist etwas teuerer als die des einfachen HashSets . • Der parallele collect() mit dem toCollection() -Collector hat einen erheblichen algorithmischen Overhead (sehen wir in einem zukünftigen Artikel).
•
Die
Verarbeitung wird dadurch unterbrochen, dass
collect()
eine terminale Operation ist, und muss danach in einem neuen Stream auf
dem Ergebnis des
collect()
s (Aufruf:
parallelStream()
)
wieder neu gestartet werden. In der Javadoc wird dies als „full barrier
with substantial buffering overhead“ beschrieben.
Sehen wir uns nun noch den Algorithmus für den ungeordneten, parallelen
Stream an:
// Vorsicht! Pseudocode! Nicht zur Nachahmung empfohlen! myParallelStream.collect(Collectors.toConcurrentMap(i->i, i->Boolean.TRUE, (oldV,newV)->oldV)) .keySet() .parallelStream()
. ... // weitere Stream-Operationen
Hier fungiert eine
ConcurrentHashMap
als Full Barrier. Dabei wird nur ihr
keySet()
benutzt
wird; die assoziierten Werte sind nicht relevant; sie sind alle
Boolean.TRUE
.
Das haben wir so gemacht, weil man hier eigentlich einen
ConcurrentHashSet
bräuchte, aber den gibt es im JDK nicht.
Im Unterschied zum vorhergehenden Algorithmus
hat der
collect()
mit dem
toConcurrentMap()
-Collector
keinen deterministischen algorithmischen Overhead. Der Overhead entsteht
hier vielmehr durch die Kollisionen der konkurrierend auf die
ConcurrentHashMap
zugreifenden Threads des parallelen Streams. Je mehr Threads konkurrierend
zugreifen, desto öfter scheitern die Compare-and-Swap(CAS)-Operationen
in den Methoden der
ConcurrentHashMap
.
Das wiederum erhöht die Zahl der Retry-Versuche und führt zu noch mehr
Compare-and-Swap(CAS)-Operationen.
Auch hier dient der oben gezeigt Code nur zur Illustration. Der echte Code in der Stream-Implementierung ist nämlich noch etwas komplizierter. Da die ConcurrentHashMap nicht mit null als Key umgehen kann, Streams aber im Allgemeinen null enthalten können, muss dieser Sonderfall auch noch behandelt werden. Wir haben diesen Aspekt hier nicht betrachtet, weil er nicht performance-relevant ist. Benchmark Tests
Fangen wir mit einem Benchmark-Test mit
einfacher Funktionalität an. Auf ein
Integer
-Array
mit 100.000 Elementen, in dem jede Zahl von 0 bis 49.000 zweimal vorkommt,
wenden wir die folgende Funktionalität an:
// sequentiell
Arrays.stream(integers).distinct().count();
// parallel geordnet
Arrays.stream(integers).
parallel()
.distinct().count();
// parallel ungeordnet
Arrays.stream(integers).
parallel().unordered()
.distinct().count();
Die Ergebnisse sind:
sequentiell parallel geordnet parallel ungeordnet
6.39 ms
34.09 ms
9.1
ms
Nach dem bisher Gesagten sind die Ergebnisse
vielleicht nicht so überraschend;
distinct()
ist zustandsbehaftet und das ist schlecht für die Performance im parallelen
Fall. In der Tat sind dann auch beide parallelen Lösungen auf unserem
Test-System langsamer als die sequentielle Lösung. Die Lösung für
parallel und geordnet ist sogar 5,33-mal langsamer als die sequentielle.
Jetzt sollte man aber die Kombination "zustandsbehafte
Operationen + paralleler Stream" nicht gleich abschreiben, was die Performance
angeht. Auch hier gilt wieder: aufwändige Verarbeitungsfunktionalität,
wie unser CPU-intensives
slowSin()
, liefert
ganz andere Ergebnisse. Als Test wollen wir auf ein
Integer
-Array
mit 10.000 Elementen, das die Zahlen 0 bis 9.999 enthält, die folgende
Funktionalität anwenden:
Arrays.stream(newIntegers) //.parallel().unordered() .map(i -> new Double(2200* Sine.slowSin(i * 0.001)).intValue()) .distinct()
.count();
Die Ergebnisse sind nun:
sequentiell parallel geordnet parallel ungeordnet
11
.
5
9
ms
6
.
8
3
ms
6
.
8
1
ms
Jetzt haben wir wieder eine deutliche Performance-Steigerung (~170%) bei der parallelen Verarbeitung, unabhängig davon, ob der parallele Stream geordnet oder ungeordnet ist. Die Performance-Steigerung ist aber nicht ganz so groß wie weiter oben im Test der aufwändigen Verarbeitungsfunktionalität bei einer zustandslosen Stream-Operation. Dort war die Steigerung rund 180%. Die zustandsbehafteten Operationen haben halt bei der parallelen Verarbeitung spürbare Performance-Einschränkungen. distinct() von primitiven Streams
Bei einem Vortrag auf der JAX zu diesem
Thema kam die Frage auf, ob der Code oben nicht folgendermaßen geändert
werden sollte, um eine bessere Performance zu erzielen:
Arrays.stream(newIntegers) //.parallel().unordered() . mapToInt (i -> new Double(2200* Sine.slowSin(i * 0.001)).intValue()) .distinct()
.count();
Die Frage ist: wäre es nicht besser,
mapToInt()
anstelle von
map()
zu verwenden? Die
allgemeine Regel ist ja, dass im Fall von
int
/
Integer
die Performance des
IntStream
s besser
ist als die des
Stream<Integer>
.
Aber dieses Beispiel ist eine Ausnahme von der Regel, wegen der inperformanten
Implementierung der
distinct()
Operation
des
IntStream
s. Die Implementierung
findet sich in der (package protected) Klasse
java.util.stream.IntPipeline
.
IntStream
ist nur das Interface. Die Implementierung (einschließlich Kommentar)
sieht so aus:
public final IntStream distinct() { // While functional and quick to implement, this approach is not very efficient. // An efficient version requires an int-specific map/set implementation. return boxed().distinct().mapToInt(i -> i);
}
Das heißt, es wird die
distinct()
Operation des
Stream<Integer>
benutzt.
Vorher findet ein
mapToObj()
statt
(das ist das
boxed()
) und danach ein
mapToInt()
.
Wenn wir die Implementierung selbst inlinen, steht da nun also:
Arrays.stream(newIntegers) //.parallel().unordered() .mapToInt(i -> new Double(2200* Sine.slowSin(i * 0.001)).intValue()) .mapToObj(Integer::valueOf) .distinct() .mapToInt(i -> i)
.count();
Das erste
mapToInt()
und das nachfolgende
mapToObj()
(also
unboxing
mit anschließendem
boxing
) heben sich funktional auf. Trotzdem
werden sie ausgeführt und kosten CPU-Zeit.
Der Performanceverlust gegenüber der Version
mit einem einfachen
map()
hält sich
aber in unserem Beispiel in Grenzen (2-5% je nach Testfall). Das liegt
einfach daran, dass
Sine.slowSin()
den
Performancetest dominiert.
Der zweite Satz im Kommentar der distinct() Implementierung der IntPipeline ( An efficient version requires an int- specific map/set implementation. ) legt die Hoffnung nahe, dass die distinct() Operation für primitive Streams mit der Einführung von Value Typen performanter wird. Die Vorstellung ist, dass sich dann generische Typen auch mit primitiven Typen bzw. Value Typen parametrisieren lassen und damit effiziente Versionen von HashSet<int> , LinkedHashSet<int> und ConcurrentHashSet<int> zur Verfügung stehen (siehe / PRVA /). Fazit
Wir haben uns angesehen, ob Streams schneller
sind als
for
-Schleifen. Die Erkenntnis
ist, dass sequentielle Streams langsamer sind als
for
-Schleifen
und dass parallele Streams durchaus schneller sein können als
for
-Schleifen.
Ob sie tatsächlich schneller sind, hängt von den Umständen ab. Als
Faustregel gilt: parallele Streams sind schneller als sequenzielle Stream
und sogar schneller als
for
-Schleifen,
wenn a) die Sequenz groß ist, b) die Verarbeitung auf den Elementen CPU-intensiv
ist und c) die Stream-Operation zustandslos ist. Ob die Faustregel in
einem bestimmten Kontext zutrifft oder nicht, bekommt man nur durch Benchmarking
raus. Measure, don't guess!
Literaturverweise
Die gesamte Serie über Java 8:
|
|||||||||||||||||||||||||||||||||||||||
© Copyright 1995-2018 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/82.Java8.Performance-Model-of-Streams/82.Java8.Performance-Model-of-Streams.html> last update: 26 Oct 2018 |