|
||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | ||||||||||||||||||||
|
Effective Java
|
|||||||||||||||||||
Wir wollen uns in diesem und den folgenden Beiträgen unserer Serie mit Performance-Messungen und -Analysen befassen. Wir beginnen damit, dass wir über Performance-Messungen im Rahmen sogenannter Micro-Benchmarks nachdenken wollen. Warum macht man überhaupt Micro-Benchmarks? Wie macht man sie? Warum sind so fehleranfällig? In diesem Zusammenhang wollen wir uns (in einem der nächsten Beiträge) das Werkzeug JMH (Java Micro-Benchmark Harness) ansehen. Wobei hilft es? Welches Problem löst es? Was ist ein Micro-Benchmark?
Ein Benchmark ist eine Performance-Messung,
die stets dem Vergleich mehrerer Alternativen dient. Beispielsweise möchte
man vielleicht wissen, ob dieser Algorithmus ein bestimmtes Problem performanter
löst als jener Algorithmus. Oder ob eine Lösung auf dem aktuellen Release
der JVM schneller oder langsamer läuft als auf einem älteren Release.
Es geht stets um den Vergleich; eine einzelne Messung allein hat keinerlei
Bedeutung.
Wir wollen uns speziell mit Micro-Benchmarks befassen. Das sind Performance-Vergleiche auf einem feingranularen Level. Generell wird unterschieden zwischen Micro- und Macro-Benchmarks. Bei einem Macro-Benchmark interessiert man sich für die Performance von Transaktionen, Use Cases oder anderen größeren Ablaufeinheiten innerhalb einer Applikation. Beim Micro-Benchmark liegt das Augenmerk auf kleineren Einheiten: man will die Performance mehrerer Algorithmen zur Lösung einer kleineren Aufgabe miteinander vergleichen oder man möchte wissen, wie die Performance verschiedener Sprachmittel oder JDK-Abstraktionen im Vergleich ist (z.B. Methodenaufruf via Reflection vs. regulärem Methodenaufruf). Die Idee ist die gleiche: man möchte pro Alternative eine Kennzahl, die die Performance widerspiegelt, um beurteilen zu können, welche Alternative am schnellsten abläuft. Lediglich die Granularität ist unterschiedlich zwischen Micro- und Macro-Benchmark. Warum macht man einen Micro-Benchmark?Die Gründe für einen Micro-Benchmark können vielfältig sein. Hier einige Beispiele:Neugier Man hat irgendwo aufgeschnappt, dass zum Beispiel ein neues Feature im jüngsten Java-Release schneller als vergleichbare ältere Feature sein soll, und man möchte einfach mal wissen, ob es stimmt und wie viel schneller es ist. Ein Beispiel ist die Performance des Stream-APIs in Java 8. Es war in einem Konferenz-Video zu hören, dass die Streams angeblich schneller seien als alles bisher Dagewesene. Natürlich wollten wir wissen, ob es in dieser Allgemeinheit stimmt und wie groß der Performance-Boost ist. Also haben wir Micro-Benchmarks gemacht (siehe / LAN1 / und / KLS1 /) und festgestellt, dass sequentielle Streams in der Regel langsamer sind als eine for -Schleife, was bei parallelen Streams durchaus anders sein kann, wobei es von der Hardware und der Stream-Source und vielen anderen Faktoren abhängt - und anderem auch von der Art, wie gemessen wird, aber davon wird noch die Rede sein. Prototyping Die Erkenntnisse, die durch einen Micro-Benchmark gewonnen werden, können aber auch von praktischem Nutzen für die aktuelle Projektarbeit sein. Bekanntermaßen ist es schwierig, in der Designphase eines Softwareentwicklungsprojekts schon abzuschätzen, wie sich Designentscheidungen auf die Performance der späteren Software auswirken werden. Fehlentscheidungen in dieser frühen Phase des Projekts führen nicht selten zu heftigen Performance-Einbußen, die vielleicht erst in der Testphase erkannt werden und dann kaum noch zu beseitigen sind. Um solche Fehleinschätzungen zu vermeiden, kann man bereits in der Design-Phase anhand von Prototypen versuchen, die Performance verschiedener Lösungsansätze abzuschätzen. Zwar sind die Ergebnisse solcher Micro-Benchmarks anhand von Prototypen mit Vorsicht zu genießen, aber eine erste Einschätzung zwecks Vermeidung grober Fehleinschätzungen können sie trotzdem liefern. Implementierung In der Implementierungsphase steht der Entwickler immer wieder vor der Frage: Läuft es schneller, wenn ich es so implementiere, oder ist es günstiger, es anders zu machen? Läuft meine Implementierung mit der JVM-Option -X1 schneller als mit der Option -X2? Auch zu solchen Fragestellungen kann ein Micro-Benchmark bei der Entscheidung helfen. Allerdings ist Vorsicht und Augenmaß geboten. In den meisten Applikationen liegt nur ein geringer Teil der Software auf dem performancekritischen Pfad. Nur für diesen Teil lohnt sich die Suche nach der schnellsten Lösung und der Aufwand einer vergleichenden Performance-Messung. Im größten Teil der Implementierung kommt es nicht darauf an, ob eine Lösungsalternative in paar Millisekunden schneller ist als eine andere. Ehe auf gut Glück an der Performance gefeilt wird, sollte immer erst mit einem Profiler bestimmt werden, welche Methoden tatsächlich auf dem performancekritischen Pfad der Applikation liegen. Nur in diesen Methoden lohnt sich der Aufwand, alternative Algorithmen bezüglich ihrer Performance zu vergleichen und so die Performance zu optimieren. Anders sieht es aus, wenn man Bibliotheken oder Frameworks implementiert, die von anderen Entwicklern genutzt werden sollen. Solche Grundbausteine sollten immer so performant wie möglich sein. Das ist eine klassische Situation, in der die Performance der Implementierungsalternativen mittels eines Benchmarks beurteilt wird. Weiterentwicklungen Wenn Software weiter entwickelt wird, ist in der Regel die Erwartung, dass das neue Release nicht langsamer sein darf als das alte Release. In der Applikationsentwicklung läuft diese Anforderung eher auf einen Macro-Benchmark hinaus. Für die Entwickler bei Oracle zum Beispiel, ist es anders. Für die JDK-Komponenten werden Micro-Benchmarks gemacht, bei denen die Implementierungen aus der alten Java-Version als Baseline dienen, gegen die die Performance der neuen Implementierungen verglichen wird. Das sind Micro-Benchmarks, die hohes Expertenwissen verlangen, weil es um Ablaufzeiten im Nanosekundenbereich geht. Ein Beispiel ist die Klasse sun.misc.Unsafe . Sie war ursprünglich als JDK-interne Hilfsklasse für die effiziente Implementierung der JDK-Klassen gedacht, wird aber mittlerweile von diversen anderen Bibliotheken und Frameworks verwendet. Die Klasse sun.misc.Unsafe soll zukünftig verschwinden und wird ab Java 9 sukzessive durch andere Mittel ersetzt. Da die Methoden aus sun.misc.Unsafe häufig aus Performancegründen benutzt werden, darf der Ersatz auf gar keinen Fall langsamer sein als das ursprüngliche Unsafe -Feature. Die Performance-Experten bei Oracle haben sich für ihre Benchmarks ein Hilfsmittel gebaut: den Java Micro-Benchmark Harness (JMH), den man auch für eigene Micro-Benchmarks verwenden kann. Dieses Werkzeug wollen wir uns im nächsten Artikel ansehen. Wie macht man einen Micro-Benchmark?
Das Prinzip ist des Micro-Benchmarks ist
theoretisch sehr einfach. Man lässt jeden auszumessenden Algorithmus
ablaufen und nimmt vor und nach jedem Algorithmus einen Zeitstempel. Die
Differenz der beiden Zeitstempel ist eine Kennzahl für die Performance
des jeweiligen Algorithmus. Ganz so einfach ist es in der Realität natürlich
nicht.
Es geht schon damit los, dass der Timer,
der die Zeitstempel liefert, nicht kontinuierlich hoch gezählt wird, sondern
sprunghaft mit einer gewissen Taktrate, z.B. alle 500 ns. Wenn meine
Algorithmen aber nur 5-10 ns lang laufen, dann werden Anfangs- und Endestempel
öfter mal gleich sein. Die Performance-Kennzahlen, die dabei herauskommen,
sind dann mehr oder weniger zufällig von Null verschieden, d.h. sie haben
keinerlei Aussagekraft. Deshalb wiederholt man die auszumessenden Algorithmen
in einer Schleife so oft, bis der Messwert für die gesamte Schleife deutlich
größer als die Update-Rate des Timers ist.
Ein Benchmark sieht also im einfachsten Falle
so aus:
long start = Timer.get(); for (int i=0;i<LOOPSIZE;i++) alternative_x();
long result_x = Timer.get()-start;
Als Timer kommen
System.currentTimeMillis()
oder
System.nanoTime()
in Frage. Um
für eine bestimmte Plattform die Eigenschaften dieser Timer zu bestimmen,
kann man den Timer-Benchmark von Aleksey Shipilev verwenden (Download von
/
SHI1
/; siehe auch /
SHI2
/). Er
liefert Informationen über die Update-Rate (granularity) und über die
Zeit, die vergeht, bis der Timer den Zeitstempel liefert (latency). Hier
ein Beispiel:
Da bei etwa gleicher Latenz (10-15 ns) die
Update-Rate bei
System.nanoTime()
deutlich
höher ist (alle 300-400 ns) als bei
System.currentTimeMillis()
(alle 15 ms), wird meistens
System.nanoTime()
verwendet.
Die Probleme beim Micro-Benchmarking beschränken sich aber nicht darauf, dass man möglicherweise den Timer falsch eingeschätzt und die Schleife vergessen hat. Es gibt weitere Störfaktoren und Fehlerquellen, die dazu führen, dass die Messergebnisse eines Benchmarks irreführend sind. Zahlen kommen bei einem Benchmark immer heraus. Ob sie irgendeine Aussagekraft haben, ist eine ganz andere Frage. Man sollte sich seine Benchmark-Ergebnisse stets sehr, sehr kritisch anschauen. Man macht mehr Fehler, als man denkt. Schauen wir uns einige der typischen Fehler an. Warum ist Micro-Benchmarking so fehleranfällig?
Es gibt zwei grundsätzlich verschiedene
Fehlerquellen: konzeptionelle Fehler und verzerrende Einflüsse aus der
Ablaufumgebung.
Konzeptionelle Fehler
Es passiert erstaunlich oft, dass die alternativen
Algorithmen, die im Benchmark ausgemessen werden, gar nicht vergleichbar
sind, oder nicht repräsentativ, oder sonstwie sinnlos.
Ein Beispiel: Nehmen wir an, es soll bestimmt
werden, ob das implizite Lock, das man per
synchronized
-Schlüsselwort
verwendet, schneller oder langsamer ist, als das
ReentrantLock
.
Dazu könnte man in einer Schleife wiederholt das betreffende Lock anfordern
und wieder freigeben. Wir haben tatsächlich einmal einen solchen Benchmark
gesehen. Der betreffende Entwickler hatte seinen Benchmark mit einem
einzigen Thread ausgeführt und hatte aus seinen Messwerten geschlossen,
dass v langsamer sind als das implizite Lock.
An diesem Beispiel sehen wir einen kardinalen
Fehler. Es ist fragwürdig, die Performance von Locks messen zu wollen,
indem man nur einen einzigen Thread auf das Lock zugreifen lässt. Das
ist eher ein Sonderfall und definitiv kein typischer Anwendungsfall.
Locks sind dafür da, um mehrere Threads zu koordinieren. Einen Lock-Benchmark
würde man also mit "vielen Threads" machen, wobei man unterschiedliche
Situationen mit unterschiedlich vielen Threads ausmessen würde. Hier hat
der Entwickler einen Sonderfall (nur ein Thread) ausgemessen und daraus
Schlüsse für andere Szenarien (mehrere Threads) gezogen. Das ist falsch.
Es ist wichtig, einen Micro-Benchmark mit repräsentativen Szenarien zu
machen oder mit ganz unterschiedlichen Szenarien, um sich einen Überblick
über das Performance-Verhalten zu verschaffen.
Ein anderer klassischer Fehler besteht darin,
dass die Datenbereitstellung mit gemessen wird. Die meisten Algorithmen,
die in einem Benchmark verglichen werden, benötigen Input-Daten. Das
Bereitstellen dieser Input-Daten kann aber länger dauern, als der eigentliche
Algorithmus, der sie verarbeitet. Also will man die Datenbereitstellung
nicht mit messen. Es passiert aber trotzdem versehentlich immer wieder,
dass Bereitstellen der Input-Daten in den Messwert eingeht.
Ein Beispiel: Wir wollen messen, wie schnell
das Einfügen eines Key-Value-Paars in eine Map ist, d.h. wie schnell ist
Map.put(key,value)
ist für unterschiedlichen
Map
-Implementierungen?
Wie könnte man es machen? Vielleicht so?
final Key KEY = new Key();
final Value VALUE = new Value();
long start = System.nanoTime(); for (int i=0;i<LOOPSIZE;i++) map.put(KEY,VALUE);
long result = System.nanoTime()-start;
Falsch! Da die Keys in einer Map eindeutig
sein müssen, haben wir nur ein einziges Mal tatsächlich ein Key-Value-Paar
eingefügt. Nach diesem ersten Einfügen wird nur noch das Value ausgetauscht.
Aus den Performance-Kennzahlen für diesen Sonderfall (Value in vorhandenem
Key-Value-Paar ersetzen) kann man keine Schlüsse auf die Performance des
Einfügens eines neuen Key-Value-Paares ziehen. Also müssen wir wohl
in jedem Schleifenschritt einen anderen Key einfügen, damit aussagekräftige
Messwerte bekommen. Vielleicht so?
long start = System.nanoTime(); for (int i=0;i<LOOPSIZE;i++) map.put(new Key(i),new Value(i));
long result = System.nanoTime()-start;
Auch falsch! Jetzt haben wir nicht nur
Map.put()
,
sondern auch noch die Konstruktion von Key und Value gemessen. Unterschiede
in der Performance der
put()
-Methoden
bei verschiedenen Map-Implementierungen werden unter Umständen nicht mehr
erkennbar sein, weil die Messwerte durch die Datenvorbereitung verzerrt
sind. Jetzt könnte man auf die Idee kommen, die Zeit für die Konstruktoren
zu messen und diese dann von den Messwerten wieder abzuziehen. Erfahrungsgemäß
führen solche Bereinigungen zu höchst fragilen Ergebnissen. Das merkt
man spätestens dann, wenn die bereinigten Messwerte plötzlich negativ
sind …
Was macht man also, um Messwertverzerrungen
durch die Datenvorbereitung zu vermeiden? Man könnte es so versuchen:
final Key[] keys = new Key[LOOPSIZE]; for (int j=0;j<LOOPSIZE;j++) keys[j] = new Key(j);
final Value VALUE = new Value();
long start = System.nanoTime(); for (int i=0;i<LOOPSIZE;i++) map.put(keys[i],VALUE);
long result = System.nanoTime()-start;
Besser! Es geht zwar immer noch die Zeit für den Zugriff auf das Array mit in den Messwert ein, aber ein Array-Zugriff ist relativ preiswert und führt hoffentlich zu keiner spürbaren Verzerrung der Messwerte. Dafür bekommt man nun möglicherweise andere Probleme. Es wird viel Speicher alloziert für das Key-Array. Der Speicher wird nach der Messung nicht mehr gebraucht; es entsteht Garbage. Dies wiederum könnte dazu führen, dass der Garbage Collector mitten in der nächsten Messung eine Garbage Collection Pause verursacht, die mit gemessen wird. Und schon ist der Messwert wieder unbrauchbar. Wie man damit umgeht, schauen wir uns im nächsten Abschnitt an. Womit wir bei der zweiten Fehlerquelle wären: dem Kontext. Kontext-Einflüsse
Die Algorithmen, die in einem Benchmark
verglichen werden, laufen in einem Benchmark-Rahmen auf einer Java-Laufzeitumgebung
(JRE) mit einem Betriebssystem und einer Hardware darunter. Jede dieser
Schichten hat Einfluss auf die Ergebnisse eines Micro-Benchmarks. Lassen
wir Hardware und Betriebssystem einmal aus Acht. Allein die Java-Laufzeitumgebung
kann Messergebnisse erheblich verzerren.
Das geschieht zum einen durch den Garbage Collector, zum anderen durch den JIT-(Just-In-Time)- Compiler. Garbage Collection
Die Garbage Collectoren in der JVM machen
gelegentlich sogenannte Stop-the-World-Pausen, in denen sie sämtliche
Applikationsthreads anhalten, damit sie ungestörten Zugriff auf den Speicher
haben. Insbesondere diese Stop-the-World-Pausen können länger dauern
und sich spürbar auf die Performance-Messwerte auswirken, die wir in einem
Micro-Benchmark zu ermitteln versuchen.
Es gibt zwei verschiedene Situationen: - Die Garbage Collection soll in den Messwert für einen Algorithmus eingehen, weil der Algorithmus viel Garbage erzeugt. - Die Garbage Collection soll nicht in irgendwelche Messwerte eingehen, weil sie mit den Algorithmen nichts zu tun hat.
In beiden Fällen haben wir das Problem,
dass der Garbage Collector selbst entscheidet, wann er seine Garbage-Collection-Pausen
macht. Deswegen lassen sich die Kosten, die ein Algorithmus durch Speicherallokation
und -freigabe verursacht, in einem Micro-Benchmark nur sehr ungenau abbilden.
Sehen wir uns die beiden oben skizzierten Situationen an.
Wenn wir einen Algorithmus haben, der viel
Garbage erzeugt, dann möchten wir die Kosten der verursachten Garbage
Collection dem Performance-Messwert für diesen Algorithmus zuschlagen.
Das klappt aber nicht immer, weil der Garbage Collector nach eigenen Heuristiken
entscheidet, wann er aufräumt. Es kann sein, dass der Garbage Collector
den von einem Algorithmus erzeugten Garbage erst nach der Messung wegräumt.
Dann sieht der Garbage-erzeugende Algorithmus schneller aus, als er in
Wirklichkeit ist, weil ein Teil der von ihm verursachten Kosten nicht mit
gemessen wird. Selbst wenn es gelingt, dass die Garbage Collection während
der Messung passiert, dann ist das Ergebnis noch immer ungenau, weil der
Garbage Collector nicht nur die Objekte wegräumt, die der betreffende
Algorithmus als Garbage hinterlassen hat, sondern es wird alles aufgeräumt,
was sich seit der letzten Garbage Collection angesammelt hat. Die Garbage-Collection-Kosten,
die ein Algorithmus verursacht, lassen sich nicht verlässlich im Messwert
erfassen.
Wenn die Garbage Collection nicht durch einen der auszumessenden Algorithmen verursacht wird, dann soll die Zeit für die Garbage-Collection-Pause nicht in den Performance-Messwerten auftauchen. Betrachten wir das Beispiel von oben, wo wir ein Array mit Keys angelegt haben, weil wir es als Input-Daten für die Messung der Performance von Map.put(key,value) brauchten. Weder das Allozieren noch das Wegräumen des Arrays soll gemessen werden, weil beides mit der Performance von Map.put(key,value) nichts zu tun hat. Nun wird aber das Array nach der Messung nicht mehr gebraucht; es ist Garbage und der Garbage Collector wird es irgendwann wegräumen. Genau diese Garbage Collection könnte passieren, wenn die Messung für den nächsten Algorithmus stattfindet. Der betreffende Algorithmus sieht dann schlechter aus, als er in Wirklichkeit ist, weil in den Messwert eine Garbage Collection eingeht, die mit dem Algorithmus nichts zu tun hat. Dieser Effekt lässt sich oft vermeiden, indem unmittelbar vor der Messung eine explizite Garbage Collection mit System.gc() ausgelöst wird, in der Hoffnung, dass dann während der Messung keine Garbage Collection mehr erforderlich ist. Ob es geklappt hat, kann man überprüfen, indem man mit der JVM-Option -verbose:gc Trace-Ausgaben des Garbage Collectors einschaltet. Wenn der Benchmark seinerseits Ausgaben über Beginn und Ende der Messung ausgibt, kann man an den Console-Ausgaben sehen, ob Garbage Collection Pausen mitgemessen wurden oder nicht. JIT-Compilation
In der JVM läuft ein Just-in-time-(JIT)-Compiler,
der den Ablauf der Applikation beobachtet und feststellt, wo die sogenannten
HotSpots sind, also Stellen im Code, an denen viel CPU-Zeit verbraucht
wird. Diese HotSpots werden dann vom JIT-Compiler optimiert. Diese
JIT-Compilation des Laufzeitsystems kann zu Verzerrungen der Messwerte
führen, die der Micro-Benchmark liefert.
Ein Problem ist die Tatsache, dass die Optimierungen
des JIT-Compilers im Kontext des Micro-Benchmarks unter Umständen ganz
anders sind als in der realen Applikationsumgebung. Dann lassen sich
die Messergebnisse aus dem Benchmark nicht auf die reale Situation übertragen.
Eine andere Schwierigkeit mit dem JIT-Compiler
ergibt sich, wenn die auszumessenden Algorithmen unterschiedlich gut optimiert
werden. Wenn man zwei Algorithmen bezüglich ihrer Performance miteinander
vergleichen will, dann kann es passieren, dass der JIT-Compiler den einen
Algorithmus heftig optimiert hat und den anderen nicht. Dann wird der
optimierte Algorithmus schneller aussehen als der nicht-optimierte Algorithmus.
Kann man die resultierenden Performance-Kennzahlen wirklich miteinander
vergleichen und aus ihnen schließen, dass der eine Algorithmus schneller
ist als der andere? Wohl kaum, denn es kann Situationen geben, in denen
der vermeintlich schnellere Algorithmus ohne JIT-Compilierung abläuft
und dann womöglich langsamer ist als der angeblich schlechtere Algorithmus.
Man muss ich beim Benchmarking deshalb Gedanken
darüber machen, wie sich der JIT-Compiler auf die Messwerte auswirkt.
Es gibt eine Reihe von Optimierungen, von denen bekannt ist, dass sie Benchmark-Ergebniss
verfälschen. Ein klassisches Beispiel für eine solche Optimierung ist
die sogenannte
Dead-Code-Elimination
. Das ist eine sehr ehrgeizige
Optimierung, bei der der JIT-Compiler überprüft, ob das Ergebnis einer
Berechnung überhaupt verwendet wird. Wenn er feststellt, dass eine Methode
ein Ergebnis produziert und dieses auch als Returnwert zurück liefert,
aber keiner nimmt das Ergebnis entgegen und verwendet es, dann eliminiert
der JIT-Compiler die gesamte Ergebnisberechnung.
Oft sind es genau die Micro-Benchmarks, in
denen solche Dead-Code-Situationen entstehen. Zum Zwecke des Micro-Benchmarks
lassen wir die auszumessenden Algorithmen ablaufen, um deren Performance
zu messen, nicht weil wir uns für ihre Returnwerte interessieren. Das
merkt der JIT-Compiler und optimiert uns genau den Code weg, dessen Performance
wir messen wollten. Die resultierenden Messergebnisse sehen phantastisch
schnell aus, haben aber leider rein gar nichts mit der Performance des
betreffenden Algorithmus zu tun. Es ist also stets Vorsicht geboten,
wenn der Benchmark Messergebnisse liefert, die verdächtig gut aussehen.
Ganz generell sollte man die Messergebnisse
am Ende plausibilisieren können. Warum ist denn der eine Algorithmus
so viel schneller als der andere? Liegt es an der Algorithmik? Oder
an der Java-Implementierung? Oder daran, dass der eine Algorithmus vom
JIT-Compiler besser optimiert werden kann als der andere? Die Analyse
geht bisweilen so weit, dass man sich den generierten Assembler-Code anschaut,
um zu verstehen, warum die Messwerte so sind, wie sie sind. Dafür gibt
es Hilfsmittel, z.B. das JITWatch-Tool, das wir uns in einem späteren
Beitrag ansehen wollen. Nicht selten stellt man bei der Plausibilisierung
fest, dass sich ein Fehler in den Benchmark-Rahmen eingeschlichen hat und
die unerklärlich guten oder schlechten Messwerte einfach nur durch die
Art der Messung verursacht wurden und nicht wirklich die Performance der
Alternativen widerspiegeln.
Wir haben uns nun exemplarisch mit der Dead-Code-Eliminierung
einen Aspekt der JIT-Compilation herausgegriffen, von dem bekannt ist,
dass er sich auf die Ergebnisse vom Micro-Benchmarks auswirkt. Es gibt
noch andere ähnliche Effekte, z.B. Monomorphic Call Transformation, Constant
Folding und Schleifenoptimierungen. All diese Dinge muss man beim Micro-Benchmarking
im Auge behalten, damit man die verzerrenden Effekte erkennt und ggf. beseitigt.
Den Effekt der Dead-Code-Eliminierung kann man dadurch beseitigen, dass
man das Ergebnis der Berechnung benutzt und den Returnwert des Algorithmus
irgendwie verwendet, damit kein toter Code da ist. Das ist aber auch
wieder mit Schwierigkeiten verbunden, weil man die Verwendung des Returnwerts
nicht mit messen will. Also muss sich eine Verwendung überlegen, die
nach Möglichkeit nichts (d.h. keine Performance) kostet.
Neben den Kontexteinflüssen der JVM durch den Garbage Collector und den JIT-Compiler wirken sich natürlich auch die Hardware und das Betriebsystem auf die Messwerte eines Micro-Benchmarks aus. Deshalb kann man die Ergebnisses eines Micro-Benchmarks nicht auf eine andere Plattform übertragen und nur mit Vorsicht verallgemeinern. Im Zweifelsfall hebt man den Benchmark auf und lässt ihn auf einer anderen Plattfor noch einmal laufen, um zu sehen, ob noch immer das gleiche heraus kommt. Zusammenfassung
Wir haben in diesem Beitrag gesehen, dass
Micro-Benchmarks vergleichende Performance-Messungen sind und dass die
Messungen fehleranfällig sein können. Zum einen werden konzeptionelle
Fehler gemacht, weil der Benchmark falsch aufgesetzt wird. Es kann passieren,
dass die Input-Daten nicht repräsentativ sind oder die Datenvorbereitung
mit gemessen wird und die Ergebnisse verzerrt. Zum anderen sind die Ergebnisse
eines Benchmarks stark vom Kontext beeinflusst, in dem der Benchmark abläuft.
Der Garbage Collector hat Einfluss auf die Performance-Kennzahlen. Der
JIT-Compiler kann sie massiv verzerren; siehe zum Beispiel die Dead Code
Elimination.
Man versucht im Benchmark-Rahmen die meisten der Störfaktoren vonseiten des Java-Laufzeitsystems zu beseitigen, indem man Aufwärmrunden macht oder die Garbage Collection explizit auslöst und die Effekte anhand von Trace-Ausgaben überwacht. Wer schon mal einen Micro-Benchmark gemacht hat, weiß aus Erfahrung, dass man Performance-Vergleiche keineswegs aus dem Ärmel schüttelt. Halbwegs verlässliche Performance-Kennzahlen zu beschaffen ist ziemlich viel Arbeit. Ein Teil des Aufwands steckt in dem Benchmark-Rahmen. Deshalb gibt es mittlerweile wiederverwendbare Benchmark-Gerüste (sogenannte Benchmark Harnesses). Der zurzeit populärste Benchmark Harness ist JMH (Java Micro-Benchmark Harness), der bei Oracle für eigene Zwecke entstanden ist. JMH hat den Ruf, sämtliche Benchmark-Probleme verlässlich zu lösen. Ob das stimmt, wollen wir uns im nächsten Beitrag anschauen. Literaturverweise
|
||||||||||||||||||||
© Copyright 1995-2018 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/89.Performance.Micro-Benchmarking/89.Performance.Micro-Benchmarking.html> last update: 26 Oct 2018 |