Angelika Langer - Training & Consulting
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | Twitter | Lanyrd | Linkedin
 
HOME 

  OVERVIEW

  BY TOPIC
    JAVA
    C++

  BY COLUMN
    EFFECTIVE JAVA
    EFFECTIVE STDLIB

  BY MAGAZINE
    JAVA MAGAZIN
    JAVA SPEKTRUM
    JAVA WORLD
    JAVA SOLUTIONS
    JAVA PRO
    C++ REPORT
    CUJ
    OTHER
 

GENERICS 
LAMBDAS 
IOSTREAMS 
ABOUT 
CONTACT 
Effective Java

Effective Java
Performance Analysen
Micro-Benchmarking
 

Java Magazin, November 2016
Klaus Kreft & Angelika Langer

Dies ist die Überarbeitung eines Manuskripts für einen Artikel, der im Rahmen einer Kolumne mit dem Titel "Effective Java" im Java Magazin erschienen ist.  Die übrigen Artikel dieser Serie sind ebenfalls verfügbar ( click here ).

 
 
 

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:
 
 
 
C:\>java -jar timerbench.jar

Java(TM) SE Runtime Environment, 1.8.0_74-b02

Java HotSpot(TM) 64-Bit Server VM, 25.74-b02

Windows 7, 6.1, amd64

Running with 1 threads and [-server]:

       granularity_currentTime: 15454682,889 +-   9856,596 ns

          granularity_nanotime:     311,903 +-      0,752 ns

           latency_currentTime:      11,170 +-      0,045 ns

              latency_nanotime:      11,329 +-      0,108 ns

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

/LAN1/  Angelika Langer: How fast are the Java 8 Streams?
URL: https://jaxenter.com/java-performance-tutorial-how-fast-are-the-java-8-streams-118830.html https://jaxenter.com/follow-up-how-fast-are-the-java-8-streams-122522.html
/KLS1/       Klaus Kreft & Angelika Langer: Das Performance Modell der Streams in Java 8
URL: http://www.angelikalanger.com/Articles/EffectiveJava /82.Java8.Performance-Model-of-Streams/82.Java8.Performance-Model-of-Streams.html
/SHI1/   Aleskey Shipilev: Timer Benchmark
URL: https://github.com/shipilev/timers-bench
/SHI2/               Aleskey Shipilev: Nanotrusting the Nanotime
URL: https://shipilev.net/blog/2014/nanotrusting-nanotime /

 
 
 

If you are interested to hear more about this and related topics you might want to check out the following seminar:
Seminar
High-Performance Java - programming, monitoring, profiling, and tuning techniques
4 day seminar ( open enrollment   and on-site) 
 

 
  © 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