|
|||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | |||||||||||||||||||||
|
Effective Java
|
||||||||||||||||||||
Wir haben
uns im letzten Beitrag unserer Serie /
KRE1
/ mit sogenannten
Micro-Benchmarks beschäftigt und haben grob erläutert, dass Micro-Benchmarks
dem Performance-Vergleich verschiedener Implementierungsalternativen dienen.
Dabei haben wir darauf hingewiesen, wie fehleranfällig Micro-Benchmarks
sind. In diesem Beitrag wollen wir uns das Werkzeug JMH (Java Micro-Benchmark
Harness) ansehen. JMH ist ein Benchmark-Rahmen, den man für eigene Micro-Benchmarks
verwenden kann. Wobei hilft JMH? Welches Problem löst JMH?
Wer schon mal einen Micro-Benchmark selber
gemacht hat, weiß aus Erfahrung, dass es ziemlich viel Arbeit ist, mittels
eines Benchmarks halbwegs verlässliche Performance-Kennzahlen zu beschaffen.
Den größten Teil der Arbeit steckt man in den Benchmark-Rahmen, den man
sich baut, um darin die auszumessenden Algorithmen ablaufen zu lassen.
Wir haben im letzten Beitrag einige der Probleme geschildert, die sich
daraus ergeben, dass die Messergebnisse eines Benchmarks u.a. durch die
JVM beeinflusst werden. Exemplarisch haben wir einige dieser Einflussfaktoren
(Garbage Collector, JIT-Compiler) angeschaut (siehe /
KRE1
/).
Weil sie zu irreführenden Messwerten führen können, versucht man, in
seinen Benchmark-Rahmen verschiedene Maßnahmen einzubauen, mit denen die
verzerrenden Effekte der JVM-Aktivitäten vermieden werden, z.B. Aufwärmphasen,
explizite Garbage Collection, wiederholte Messungen in Schleifen, etc.).
Weil Maßnahmen zur Vermeidung von JVM-bedingten
Messwertverfälschungen in jedem Micro-Benchmark gebraucht werden, sind
mit der Zeit wiederverwendbare Benchmark-Gerüste (sogenannte Benchmark
Harnesses) entstanden. Eines der ersten Werkzeuge dieser Art war Caliper
von Google (siehe /
CAL
/). Der zurzeit populärste Benchmark
Harness ist JMH, der Java Micro-Benchmark Harness (siehe /
JMH
/).
Er ist bei Oracle entstanden und wird dort im Rahmen der Weiterentwicklung
von Sprache und JDK verwendet. Als Urheber des JMH gilt Aleksey Shipilev;
er arbeitet als Performance Experte bei Oracle. JMH stammt ursprünglich
aus der Entwicklung der JRockit-JVM und die Performance Teams sowohl der
JRockit-JVM als auch der HotSpot-JVM haben zur Entwicklung des JMH beigetragen.
Vor einigen Jahren (ca. 2013) wurde JMH der Java-Community zur Verfügung
gestellt und seitdem kann ihn jeder für seine eigenen Benchmarks verwenden.
Mittlerweile ist JMH der De-facto-Standard und genießt den Ruf, sämtliche
Benchmark-Probleme verlässlich zu lösen. Ob das stimmt, wollen wir
uns in diesem Beitrag anschauen.
Eine erschöpfende Betrachtung des JMH würde
den Beitrag allerdings sprengen. JMH ist umfangreich und komplex und
bietet Lösungen für eine Vielzahl von Problemen beim Micro-Benchmarking
an. Wir werden uns im Rahmen dieses Beitrags lediglich einen ausgewählten
Aspekt anschauen, nämlich die Messwertverfälschungen durch die Monomorphic
Call Transformation des JIT-Compilers und wie JMH diese Messwertverzerrungen
vermeidet. Anhand eines Beispiels werden wir sehen, wie JMH prinzipiell
funktioniert, und werden eines seiner Features, nämlich die Messmodi,
genauer betrachten.
An sich sieht ein Micro-Benchmark mit JMH
ähnlich aus wie ein Micro-Benchmark mit eigenem Benchmark-Rahmen: man
implementiert die Algorithmen, deren Performance verglichen werden soll,
lässt sie im Benchmark-Rahmen wiederholt ablaufen, nimmt Zeitstempel und
berechnet je Algorithmus eine Performance-Kennzahl. Dazu kommen sämtliche
vor- und nachbereitenden Arbeiten wie z.B. das Allozieren und Initialisieren
von Daten, die die Algorithmen brauchen, und sämtliche eben schon erwähnten
Maßnahmen zur Vermeidung von Messwertverfälschungen.
Im JMH müssen die auszumessenden Algorithmen
als Methoden implementiert sein. Diese Methoden werden mit einer speziellen
von JMH definierten Annotation namens
@Benchmark
gekennzeichnet. Aus diesem annotierten Code generiert JMH den eigentlichen
Benchmark-Code, der später abläuft und die Performance-Messungen macht.
Den generierten Benchmark muss man anschließend nur noch starten. Dafür
gibt es verschiedene Möglichkeiten: über die Kommandozeile auf Betriebsystemebene
oder programmatisch über einen Aufruf aus der
main()
-Methode
heraus. Für den Ablauf können Optionen angegeben werden: entweder auf
der Kommandoebene oder programmatisch mithilfe eines
OptionBuilder
s.
Wir werden später in diesem Beitrag Beispiele dafür sehen.
Das heißt, mit JMH muss man nicht mehr tun,
als die richtigen Annotationen an den geeigneten Stellen zu verwenden und
passende Optionen für den Ablauf des Benchmarks anzugeben. Um alles
andere kümmert sich JMH. Schauen wir es uns näher an. Überlegen
wir uns als erstes, welche Probleme sich mit JMH lösen lassen.
Konzeptionelle Probleme im Benchmark
Beim Micro-Benchmarking gibt es zwei verschiedene
Fehlerquellen: konzeptionelle Fehler und die Einflüsse der Ablaufumgebung.
Dem Entwickler unterlaufen gelegentlich konzeptionelle
Fehler. Es passiert erstaunlich oft, dass die Alternativen, die im Benchmark
ausgemessen werden, gar nicht vergleichbar sind, oder nicht repräsentativ,
oder sonstwie sinnlos.
Bei der Vermeidung solcher Fehler hilft JMH überhaupt nicht. Eher im Gegenteil. Mit Hilfe von JMH ist es relativ leicht, einen Benchmark laufen zu lassen. Das verführt dazu, einfach mal irgendwas miteinander zu vergleichen. Da mit JMH produzierte Messwerte als verlässlich gelten, werden aus den Ergebnissen ohne weitere Überprüfung Schlüsse gezogen, die zum Teil schlicht falsch sind. JMH ist ein Werkzeug, das dem Entwickler beim Micro-Benchmarking hilft, ihm aber die konzeptionellen Überlegungen nicht abnimmt. Auch unter Verwendung von JMH kann man immer noch beliebigen Unfug beim Benchmarking machen. Kontext-Einflüsse im Benchmark
Sehen wir uns Dinge an, bei denen JMH tatsächlich
hilft.
Polymorphe Aufrufe im Benchmark-Rahmen
Der JIT-Compiler der der HotSpot-JMV von
Oracle versucht beim Ablauf einer Applikation den Code der Applikation
zu optimieren. Eine der Optimierungen, die er anwendet, ist die sogenannten
Monomorphic
Call Transformation.
Sie kann zu irreführenden Messwerten führen.
Deshalb kümmert man sich im Benchmark-Rahmen darum, die verfälschenden
Effekte dieser Optimierung zu vermeiden.
Bei der Monomorphic Call Transformation
geht es die Optimierung
von polymorphen Methoden (d.h. Methoden, die in einem Supertyp definiert
und in den Subtypen überschrieben sind). Wenn eine solche Methode über
eine Supertyp-Referenz aufgerufen wird, dann entscheidet sich erst zur
Laufzeit, welche der überschriebenen Varianten der Methode tatsächlich
ausgeführt wird. Es hängt davon ab, von welchem Subtyp das Objekt ist,
auf das die Supertyp-Referenz jeweils verweist.
Der JIT-Compiler schaut sich für jede solche
Aufrufstelle an, welche Methode tatsächlich angestoßen wird. Wenn er
feststellt, dass immer wieder derselbe Subtyp an der Supertyp-Referenz
hängt und deshalb immer wieder dieselbe Methode gerufen wird, dann folgert
er, dass der Aufruf zwar prinzipiell polymorph sein könnte, aber in der
Realität an dieser Code-Stelle immer monomorph erfolgt. Dann ist die
Heuristik: es wird wohl monomorph weiter gehen. Die betreffende Methode
wird optimiert: der JIT-Compiler macht ein sogenanntes
Inlining
.
Beim Inlining wird der Methodenaufruf durch
den Rumpf der aufzurufenden Methode ersetzt. Dadurch wird der Overhead
des Aufrufs (Stackframe auf- und abbauen, Argumente auf den Stack legen,
etc.) eliminiert, was sich insbesondere für Methoden mit kurzem Rumpf
lohnt. Ein solches Inlining macht der JIT-Compiler für kurze Methoden,
die häufig aufgerufen werden. Er macht es, wie oben beschrieben, auch
für die Aufrufe von polymorphen Methoden, die monomorph genutzt werden.
An diesen Stellen ist das Inlining aber gefährlich. Was passiert, wenn
die Heuristik nicht stimmt und nach unzähligen monomorphen Aufrufen dann
doch einmal ein anderer Subtyp an der Supertyp-Referenz hängt? Dann
ist der optimierte Code völlig fehl am Platze, denn es müsste eine andere
Methode angestoßen werden. Der JIT-Compiler muss also seine vermeintlich
monomorphen Aufrufstellen im Auge behalten und seine Optimierungen wieder
rückgängig machen, wenn sich die Heuristik als falsch herausstellt.
In den jüngsten Versionen der JVM ist diese Deoptimierung sogar zweistufig.
Wenn nur zwei verschiedene Methoden an einer polymorphen Aufrufstelle vorkommen
(bimorphic call), dann wird der Aufruf immer noch ein bisschen optimiert
(es wird Asembler-Code für einen speziellen Vtable-Dispatch generiert).
Erst wenn eine dritte Methode dazu kommt, wird komplett deoptimiert und
ein ganz normaler polymorpher Dispatch gemacht.
Ein Beispiel zur Illustration der Monomorphic
Call Transformation: Wir nehmen drei identisch implementierte Klassen
Counter1
,
Counter2
und
Counter3
mit einem gemeinsamen Supertyp
Counter
.
public interface Counter { int inc(); } public class Counter1 implements Counter { private int x; public int inc() { return x++; } } public class Counter2 implements Counter { private int x; public int inc() { return x++; } } public class Counter3 implements Counter { private int x; public int inc() { return x++; }
}
Man würde erwarten, dass bei einem Benchmark
herauskommt, dass die Performance der
inc()
-Methoden
der drei Klassen identisch ist. Machen wir mal einen eigenen kleinen
Benchmark:
public class Measure { public static void main(String[] args) { measure (new Counter1()); measure (new Counter2()); measure (new Counter3()); } private static int measure( Counter cnt ) { final int SAMPLESIZE = 5; final int LOOPSIZE = 100_000_000; int result = 0; for (int i = 0; i < SAMPLESIZE; i++) { long start = System. nanoTime (); for (int j = 0; j < LOOPSIZE; j++) { result += cnt.inc() ; } long stop = System. nanoTime (); System. out .println((stop - start) / 1_000_000 + " msecs"); } return result; }
}
Die Messergebnisse sehen so aus:
Counter1 33 msecs 33 msecs 29 msecs 29 msecs 29 msecs Counter2 178 msecs 167 msecs 165 msecs 166 msecs 169 msecs Counter3 279 msecs 275 msecs 277 msecs 278 msecs
278 msecs
Was man hier sieht, ist nicht etwa die unterschiedliche
Performance der drei Counter-Klassen, sondern man sieht den Effekt der
Monomorphic Call Transformation. Solange der JIT-Compiler nur eine Klasse
sieht, hält er den Aufruf der
inc()
-Methode
über die Supertyp-Referenz für monomorph und optimiert den Aufruf.
Deshalb sind die Werte für die erste Counter-Klasse sehr gut. Wenn der
JIT-Compiler die zweite Counter-Klasse sieht, dann deoptimiert er, aber
noch nicht völlig. Erst wenn die dritte Counter-Klasse dazu kommt, wird
die
inc()
-Methode ohne jede Optimierung
aufgerufen. Deshalb sind die Werte für die letzte Counter-Klasse am
schlechtesten.
Wir haben einen Fehler im Benchmark-Rahmen
gemacht: wir haben die auszumessenden Alternativen als polymorphe Methoden
(
inc()
) implementiert und über eine Supertyp-Referenz
(vom Typ
Counter
) aufgerufen. Als Resultat
erhält man völlig irreführende Performance-Kennzahlen.
Um den irreführenden Effekt der JIT-Compilation zu beseitigen, kann man unterschiedliche Maßnahmen im Benchmark ergreifen. - Man könnte dafür sorgen, dass die inc() -Methode nicht über eine Supertyp-Referenz aufgerufen wird. Wenn wir in unserem Benchmark drei measure() -Methoden mit den drei Argumenttypen Counter1 , Counter2 und Counter3 hätten, dann wäre der Aufruf der inc() -Methode nicht mehr polymorph und die oben geschilderte JIT-Optimierung wirkte sich nicht mehr störend auf die Messwerte aus. - Man könnte den Aufruf der inc() -Methode über eine Supertyp-Referenz beibehalten, aber dann die Messungen für die verschiedenen Alternativen voneinander trennen, damit der JIT-Compiler stets nur eine davon zu sehen bekommt. Das kann man erreichen, indem man die Messung für jede der drei Counter-Klassen in einer separaten JVM ablaufen lässt. - Man könnte dafür sorgen, dass der JIT-Compiler vor der Messung alle Alternativen bereits gesehen hat, so dass er seine Optimierungen und Deoptimierungen bereits hinter sich gebracht hat. Das erreicht man durch einen sogenannten Warm-up. Man lässt alle drei Alternativen erst mal laufen, ohne zu messen, und erst danach wird die Messung gemacht. Dabei muss man ermitteln, wie lang der Warm-up sein muss. Dazu schaltet man mit den JVM-Option -XX:+PrintCompilation Trace-Ausgaben des JIT-Compilers ein, an denen man sehen kann, ob der JIT-Compiler noch aktiv optimiert, oder ob er bereits mit seinen Optimierungen und Deoptimierungen an den fraglichen Methoden fertig ist. Micro-Benchmarking mit JMH
Schauen wir uns an, wie JMH das Problem
mit der Monomorphic Call Transformation löst. Hier derselbe Benchmark
mit Hilfe von JMH
[1]
:
public interface Counter { int inc(); } @State(Scope.Thread) public class Counter1 implements Counter { private int x; @Benchmark public int inc() { return x++; } } @State(Scope.Thread) public class Counter2 implements Counter { private int x; @Benchmark public int inc() { return x++; } } @State(Scope.Thread) public class Counter3 implements Counter { private int x; @Benchmark public int inc() { return x++; }
}
Für einen JMH-Benchmark müssen die zu vergleichenden
Algorithmen, d.h. die drei
inc()
-Methoden,
mit der JMH-Annotation
@Benchmark
gekennzeichnet
werden. Die
@State
-Annotation steuert,
ob Objekte vom Typ
Counter1
, usw. von
mehreren Threads im Benchmark gemeinsam verwendet werden sollen (
@State(Scope.Benchmark)
)
oder ob jeder Thread eine threadlokale Kopie davon bekommen soll (
@State(Scope.Thread)
).
Wie man sieht, definiert JMH diverse Annotationen
und hat einen Annotation Processor, der die JMH-Annotationen in unserem
Source-Code heraussucht und daraus den eigentlichen Benchmark generiert.
Das, was hinterher als JMH-Benchmark abläuft und Messwerte produziert,
ist generierter Code. In die Generierung fließen die Annotationen ein,
aber auch weitere JMH-Optionen, die wir mithilfe eines
OptionBuilder
s
setzen, ehe der Benchmark angestoßen wird. Diese Optionen sieht man
hier:
public class Measure {
public static
void main(String[] args) {
.timeUnit(TimeUnit.MILLISECONDS)
.measurementIterations(5) .warmupBatchSize(10_000_000) .warmupIterations(5)
.build();
Wir haben den JMH-Benchmark so konfiguriert,
dass er unserem handgeschriebenen Benchmark weitgehend ähnelt. Wir haben
den Modus
Mode.SingleShotTime
gewählt,
weil JMH dann einen Stub generiert, in dem unsere Alternativen in einer
Schleife aufgerufen werden - so wie wir es zuvor in unserem Benchmark auch
gemacht haben. Unsere
LOOPSIZE
wird
in JMH als
measurementBatchSize
spezifiziert. Wir hatten alle Messungen fünfmal (
SAMPLESIZE
)
wiederholt; die Wiederholungsrate wird im JMH als
measurementIterations
angegeben. Die Optionen
warmupBatchSize
und
warmIterations
machen analoge Einstellungen
für die Aufwärmphase. Mit der
include
-Option
werden die Klassen angegeben, in denen die mit
@Benchmark
gekennzeichneten Methoden zu finden sind. Mit der
t
imeUnit
-Option
sagen wir, dass wir die Messwerte in Millisekunden haben wollen.
Hier sind die Ergebnisse des Benchmarks mit
JMH:
Benchmark Mode Cnt Score Error Units Counter1.inc ss 5 256,942 ± 26,858 ms/op Counter2.inc ss 5 253,503 ± 4,603 ms/op
Counter3.inc ss 5 254,520 ± 3,868
ms/op
Anders als in unserem Benchmark sind hier alle Alternativen in etwa gleich schnell, so wie es sein sollte. Wie kommt es zustande? Was macht JMH anders? Generierte Stubs
Zunächst einmal werden im JMH-generierten
Benchmark die verschiedenen
inc()
-Methoden
nicht über eine Supertyp-Referenz aufgerufen, so wie wir es in unserem
Benchmark gemacht haben. Stattdessen generiert JMH verschiedene Klassen
Counter1_inc
,
Counter2_inc
und
Counter3_inc
mit
jeweils einem Stub, der ein Argument vom konkreten Subtyp nimmt und nicht
nur ein Argument vom Supertyp
Counter
.
Der Stub sieht in etwa so aus:
@Generated("org.openjdk.jmh.generators.core.BenchmarkGenerator")
… public void inc_ss_jmhStub(InfraControl control, int batchSize, RawResults result,
Counter1_jmh
l_counter10_0
, Blackhole_jmh l_blackhole1_1) {
…
}
Der konkrete Subtyp Typ ist im oben gezeigten Stub eine generierte Klasse Counter1_jmh , die von unserer Counter1 -Klasse abgeleitet ist. Der Aufruf der inc() -Methode ist nicht polymorph und die gesamte Problematik der Messwertverzerrung durch die Monomorphic Call Transformation kann nicht auftreten. Separate JVMs
Außerdem laufen im JMH die verschiedenen
Messungen in unterschiedlichen JVMs ab. Wir haben in unserem selbstgeschriebenen
Benchmark-Rahmen alle Messungen in einer einzigen JVM gemacht. Damit
beeinflussen sich die verschiedenen Alternativen unter Umständen, weil
der JIT-Compiler alle Alternativen zu sehen bekommt. Dann können Optimierungen
gefolgt von Deoptimierungen wie bei der Monomorphic Call Transformation
passieren. Wenn hingegen jede Alternative in einer eigenen JVM abläuft,
dann sieht der JIT-Compiler immer nur eine der auszumessenden Methoden
und das Problem der gegenseitigen Beeinflussung kann nicht auftreten.
Gesteuert wird der Ablauf in separaten JVMs über die forks -Option des OptionBuilder s im JMH. Wie haben forks(1) angegeben, d.h. jede Alternative läuft in einer eigenen JVM. Warmup
Zusätzlich haben wir über die Optionen
warmupBatchSize
und
warmIterations
eine Aufwärmphase
angestoßen. In diesem Warmup sieht der JIT-Compiler bereits alle drei
auszumessenden
inc()
-Methoden und kann
Optimierungen und Deoptimierungen bereits vor der Messung machen. Man
kann ausdrücklich auf die Aufwärmphase verzichten, wenn man will. Dazu
müsste man
warmIterations
(0)
spezifizieren.
Die Benchmark-Modi im JMH
Wenn man den JMH für unser Beispiel mit
Default-Optionen ablaufen lässt, dann verwendet er nicht den
SingleShotTime
-Modus,
sondern er arbeitet mit dem
Throughput
-Modus.
Außerdem macht er automatisch einen Warmup, arbeitet mit 10 separaten
JVMs und wiederholt alles 20x. Das entspricht folgenden Aufrufoptionen:
Options opt
= new OptionsBuilder()
.timeUnit(TimeUnit.
SECONDS)
.measurementIterations(20 ) .warmupBatchSize(1 ) .warmupIterations(20 )
.build();
In unserem Beispiel kommt mit den Default-Einstellungen
folgendes heraus:
Benchmark Mode Cnt Score Error Units Counter1.inc thrpt 200 426879455,117 ± 1442279,233 ops/s Counter2.inc thrpt 200 429321116,992 ± 693804,164 ops/s
Counter3.inc thrpt 200 429413434,006 ± 648654,799
ops/s
Man bekommt beim
Throughput
-Modus
keine "Zeit pro Operation", sondern das Inverse, nämlich "Operationen
pro Zeiteinheit". Die Default-Zeiteinheit ist die Sekunde; man hätte
sie mit der
timeUnit()
-Option ändern
können. Wegen
forks(10)
und
measurementIteration(20)
wird jeder Algorithmus 200x ausgemessen und läuft wegen
warmup
Iteration(20)
genauso häufig in der Aufwärmphase.
Ob diese Default-Einstellungen sinnvoll sind,
muss man selber entscheiden. Bei dieser Entscheidung hilft JMH nicht.
Generell muss man erst einmal die vielen verschiedenen Features von JMH
verstehen, ehe man beurteilen kann, wie man die Optionen setzen muss, damit
aussagekräftige Performance-Kennzahlen herauskommen.
Man kann
Throughput
-Modus
sehen, dass alle drei Alternativen gleich gut sind, so wie es sein sollte,
und so wie wir es auch im
SingleShotTime
-Modus
gesehen haben. Wozu braucht man dann die verschiedenen Benchmark-Modi?
Es gibt im JMH vier Benchmark-Modi:
SingleShotTime
,
Throughput
,
AverageTime
und
SampleTime
.
Den
SingleShotTime
-Modus
haben wir bereits in unserem Beispiel benutzt. Die auszumessende Methode
wird in einer Schleife aufgerufen und die Zeitstempel werden vor und nach
der Schleife genommen. Die Länge der Schleife wird über die Option
measurementBatchSize
gesteuert. Als Performance-Kennzahl wird die Ablaufzeit der gesamten
Schleife geliefert. In unserem Beispiel waren es knapp 240 ms für die
gesamte Schleife mit all ihren 100.000.000 Schleifenschritten, die wir
spezifiziert hatten.
Der
Throughput
-Modus
funktioniert anders. Die auszumessende Methode wird ebenfalls in einer
Schleife aufgerufen, aber die Schleife endet, wenn eine gewisse Zeitspanne
abgelaufen ist. Diese relevante Zeitspanne wird über die JMH-Option
measurementTime
gesteuert; als Default wird 1 Sekunde verwendet. Es wird gezählt, wie
oft in dieser Zeitspanne die auszumessende Alternative ausgeführt wurde
und daraus wird dann der Durchsatz berechnet.
Der von JMH generierte Stub für diesen Mess-Modus
sieht in etwa so aus:
@Generated("org.openjdk.jmh.generators.core.BenchmarkGenerator")
… public void inc_thrpt_jmhStub(InfraControl control, RawResults result, Counter1_jmh l_counter10_0, Blackhole_jmh l_blackhole1_1)
throws Throwable {
…
}
Wie man sieht, spielt die
measurementBatchSize
im
Throughput
-Modus keine Rolle für
die Messung, denn die Messschleife endet nicht, wenn der Schleifenzähler
die BatchSize erreicht, sondern dann, wenn die Zeit abgelaufen ist. Trotzdem
wird die
measurementBatchSize
zum
Rechnen verwendet: der Durchsatz wird durch die
measurementBatchSize
dividiert. Das sieht man in dem oben gezeigten generierten Stub nicht.
Die Umrechnung des
operations
-Zählers
in einen Durchsatz pro Zeiteinheit passiert erst bei der Ausgabe der Messwerte.
Dann wird von Sekunden auf die per
timeUnit
spezifizierte Zeiteinheit umgerechnet. Dabei wird auch durch die
measurementBatchSize
dividiert, falls sie spezifiziert wurde. Der Default ist übrigens
measurementBatchSize
(1)
,
so dass die
measurementBatchSize
im
Throughput
-Modus im Normalfall gar
nicht spezifiziert wird.
Der
AverageTime
-Modus
funktioniert genauso wie der
Throughput
-Modus.
Der einzige Unterschied ist, dass am Ende aus der Zahl der Operationen
nicht der Durchsatz in op/s, sondern die Zeit pro Operation berechnet wird.
Wenn wir in unserem obigen JMH-Beispiel alle Optionen (wie
measurementBatchSize
,
measurementIterations
,
etc.) beibehalten und nur den Modus von
SingleShotTime
auf
AverageTime
ändern, dann kommt folgendes
heraus:
Benchmark Mode Cnt Score Error Units Counter1.inc avgt 5 249,989 ± 0,953 ms/op Counter2.inc avgt 5 249,982 ± 0,668 ms/op
Counter3.inc avgt 5 250,047 ± 1,044 ms/op
Im
SingleShotTime
-Modus
haben wir etwas über 250 ms/op gemessen. Die Werte sind also in unserem
Beispiel ähnlich. Prinzipiell ist jedoch der
AverageTime
-Modus
unproblematischer als der
SingleShotTime
-Modus.
Das Problem beim
SingleShotTime
-Modus
ist die Schleife, denn die Länge der Schleife hat Auswirkungen darauf,
wie gut der JIT-Compiler die Schleife optimiert. Lange Schleifen (d.h.
solche mit einem hohen Schleifenzähler) werden tendenziell stärker optimiert
als kurze Schleifen. Es ist schwer zu entscheiden, welche Schleifengröße
die aussagekräftigsten Ergebnisse liefert. Diese Fragestellung taucht
im
AverageTime
-Modus nicht auf, weil
die Schleife zeitgesteuert endet. Das macht es dem JIT-Compiler schwer,
die Schleifen unterschiedlich zu optimieren.
Schauen wir uns noch an, was der
S
ample
Time
-Modus
liefert mit den gleichen Einstellungen für
measurementBatchSize
,
measurementIterations
,
etc. folgende Ergebnisse:
Benchmark Mode Cnt Score Error Units Counter1.inc sample 20 317,850 ± 4,572 ms/op Counter2.inc sample 20 314,940 ± 3,757 ms/op
Counter3.inc sample 20 315,569 ± 1,162 ms/op
Der
S
ample
Time
-Modus
macht ebenfalls eine zeitgesteuerte Schleife, aber er nimmt - anders als
der
SingleShot
-Modus - nicht ständig
die Zeitstempel, sondern nur gelegentlich, d.h. er nimmt zufallsgesteuerte
Stichproben. Man kann zusätzlich die Wiederholungsrate des auszumessenden
Algorithmus steuern über die
measurementBatchSize
-Option.
Der generierte Stub für den
S
ample
Time
-
Messmodus
sieht so aus:
@Generated("org.openjdk.jmh.generators.core.BenchmarkGenerator")
… public void inc_sample_jmhStub(InfraControl control, SampleBuffer buffer, int targetSamples, long opsPerInv, int batchSize, Counter1_jmh l_counter10_0,
Blackhole_jmh l_blackhole1_1) throws Throwable {
}
…
Dieser Modus ist für Situationen gedacht,
in denen der Overhead des Timers sich negativ bemerkbar macht. Das passiert
beispielsweise, wenn der auszumessende Algorithmus eine sehr kurze Ablaufzeit
hat. Dann macht der Benchmark kaum etwas anderes, als ständig den
Timer aufzurufen. Timer skalieren aber nicht; je heftiger sie benutzt
werden, desto größer wird der Overhead. (Aleksey Shipilev hat dazu
interessante Messungen gemacht, siehe /
SHI
/)). Die
Messergebnisse werden dann fälschlicherweise den Overhead des Timer widerspiegeln
und nichts über die Performance des auszumessenden Algorithmus aussagen.
Um den Timer-Overhead zu reduzieren, werden im
S
ample
Time
-Modus
lediglich Stichproben genommen.
Den Overhead des Timers kann man auch in
unserem Beispiel sehen, wenn man nämlich die Schleifenlänge auf 1 reduziert
(mit
measurementBatchSize
(1)
).
Dann wird vor und nach jedem einzelnen Aufruf der
inc()
-Methode
der Zeitstempel genommen. Wir haben zur Illustration alle Benchmark-Modi
mit
measurementBatchSize
(1)
laufen lassen und so sehen die Ergebnisse aus:
Optionen: .forks(1) .mode(Mode.AverageTime) .mode(Mode.SampleTime) .mode(Mode.SingleShotTime) .warmupBatchSize(1) .measurementBatchSize(1) .timeUnit(TimeUnit.NANOSECONDS) .warmupIterations(5)
.measurementIterations(5)
Benchmark Mode Cnt Score Error Units Counter1.inc avgt 5 2,308 ± 0,043 ns/op Counter2.inc avgt 5 2,310 ± 0,015 ns/op Counter3.inc avgt 5 2,307 ± 0,167 ns/op Counter1.inc sample 53098 29,898 ± 1,883 ns/op Counter2.inc sample 52420 29,343 ± 1,444 ns/op Counter3.inc sample 52484 31,133 ± 2,486 ns/op Counter1.inc ss 5 1427,600 ± 2999,308 ns/op Counter2.inc ss 5 1055,000 ± 653,816 ns/op
Counter3.inc ss 5 1489,600 ±
1557,576 ns/op
Bei einer
measurementBatchSize
=
1
sieht man deutlich, dass für eine so kurze Methode wie
inc()
weder
Sampling
noch
SingleShot
sinnvolle Ergebnisse liefern. Die Ergebnisse im
SingleShotTime
-Modus
sind katastrophal, weil vor und nach jedem einzelnen Aufruf der
inc()
-Methode
ein Zeitstempel genommen wird. Die Ergebnisse im
SampleTime
-Modus
sind deutlich besser, weil nur gelegentlich vor und nach einem Aufruf der
inc()
-Methode
die Zeitstempel geholt werden. Im AverageTime-Modus hat die
measurementBatchSize
keine Bedeutung. Die Zeitstempel werden nur vor und nach der gesamten
zeitgesteuerten Schleife geholt und der Timer-Overhead ist minimal.
Anders sieht es aus, wenn man mit einer großen
Schleife arbeitet. Hier die Ergebnisse mit
measurementBatchSize
=
1
00.000.000
.
Optionen: .forks(1) .mode(Mode.AverageTime) .mode(Mode.SampleTime) .mode(Mode.SingleShotTime) .warmupBatchSize(10_000_000) .measurementBatchSize(100_000_000) .timeUnit(TimeUnit.MILLISECONDS) .warmupIterations(5)
.measurementIterations(5)
Benchmark Mode Cnt Score Error Units Counter1.inc avgt 5 250,044 ± 1,066 ms/op Counter2.inc avgt 5 249,910 ± 0,708 ms/op Counter3.inc avgt 5 249,980 ± 0,458 ms/op Counter1.inc sample 20 314,809 ± 2,573 ms/op Counter2.inc sample 20 315,307 ± 3,261 ms/op Counter3.inc sample 20 323,040 ± 4,648 ms/op Counter1.inc ss 5 254,078 ± 8,167 ms/op Counter2.inc ss 5 254,138 ± 5,565 ms/op
Counter3.inc ss 5 252,712 ± 7,891
ms/op
Hier liegen die Messergebnisse relativ dicht
beieinander, egal welchen Benchmark-Modus man wählt. Das liegt aber
auch daran, dass wir für den
SingleShotTime
-Modus
einen hinreichend großen Wert für die
measurementBatchSize
gewählt haben. Wenn man anstelle von 100.000.000 nur 1.000 als Wiederholungsfaktor
wählt, dann sehen die Werte im
SingleShotTime
-Modus
deutlich schlechter aus als in den anderen beiden Modi.
Welcher JMH-Modus ist der richtige (für meinen Benchmark)?
Angesichts der unterschiedlichen Ergebnisse
bleibt die Frage: welchen Modus muss ich für meine Vergleichsmessung hernehmen?
Es keineswegs so, dass der Default automatisch immer passt.
Der Default ist
Mode.Throughput
mit
measurementTime = 1s
und
measurementBatchSize
= 1
. Wer lieber Zeit pro Aufruf sehen möchte, bekommt mit
Mode.A
verageTime
das gleiche Messverfahren, aber das inverse Ergebnis. Dieser Default
funktioniert gut für kurze Operationen im Nanosekundenbereich oder auch
im Millisekundenbereich. Für größere Operationen müsste man ganz
offensichtlich die
measurementTime
(default:
1 sec) heraufsetzen.
Schlecht ist dieser Modus für Operationen, die keine konstante Ablaufzeit haben. Ein Beispiel wäre das Einfügen von Elementen in der Mitte einer Liste. Es dauert mit zunehmender Listengröße immer länger. Bei den zeitgesteuerten Messmodi beobachtet man dann, dass für längere Messintervalle schlechtere Werte pro Operation herauskommen, weil die Liste größer geworden ist und damit dann auch die berechnete Durchschnittzeit pro Operation höher ist. Diese Durchschnittswerte kann man auch gar nicht mehr vergleichen, z.B. zwischen LinkedList und ArrayList . Im Messinterval von einer Sekunde ist die ArrayList vielleicht viel größer geworden als die LinkedList . Einen Benchmark für Operationen mit nicht-konstanten Ablaufzeiten kann man nur sinnvoll im SingleShotTime -Modus machen, wobei man eine angemessen große Schleifengröße ( measurementBatchSize ) wählen sollte. Wenn die Schleife zu kurz ist, macht sich der Timer-Overhead bemerkbar - wie vorher schon beschrieben. Dann kann man den S ample Time -Modus versuchen. Er ist immer dann sinnvoll, wenn der Timer-Overhead stört. Zusammenfassung
JMH hat zahlreiche nützliche Features
(siehe /
JMHD
/). Wir haben uns in diesem Beitrag nur
einen einzigen Aspekt des JMH-Benchmark-Rahmens näher angeschaut, nämlich
die Messmodi. Das ist aber nur die "Spitze des Eisbergs". Wir haben
nicht über die Aufwärmphase gesprochen; auch dafür gibt es unterschiedliche
Modi. Wir haben nicht über das Sharing, Initialisieren und Aufräumen
von Daten gesprochen, die von den auszumessenden Operationen benutzt werden-
Wie haben nicht genauer angeschaut, wie JMH hilft, Messwertverzerrungen
durch JIT-Optimierungen wie Dead Code-Elimination, Constant Folding, Loop
Unrolling, etc. zu vermeiden. JMH hat Unterstützung für das Benchmarking
von Operation, an denen mehrere Threads beteiligt sind. Man kann für
einzelne Methoden vergleichen, wie ihre Performance mit und ohne JIT-Optimierung
oder mit und ohne Inlining ist (über Compiler Controls). Bis hin zu
diversen Profilern für GC, ClassLoading, JIT-Compilation und Assembler-Code.
Es erfordert einen gewissen Lernaufwand, ehe man alle Features sinnvoll
einsetzen kann.
JMH ist von unschätzbarem Wert für Entwickler,
die Performance-Vergleiche auf dem Micro-Level anstellen wollen. In viele
der Lösungen, die JMH anbietet, sind Kenntnisse über die Implementierung
der HotSpot-JVM eingeflossen. Vergleichbares kann man mit einem eigenen
Benchmark-Rahmen kaum leisten. Trotzdem ist auch JMH keine "Silver Bullet".
Man muss verstehen, was JMH wie macht, damit man beurteilen kann, ob es
zur eigenen Fragestellung passt. Mit unpassenden JMH-Konfigurationen
kann man genauso irreführende Zahlen produzieren, wie mit einem naiven
selbstgemachten Benchmark.
Als abschließende Bemerkung sei daran erinnert, dass Performance-Messungen natürlich spannend und gelegentlich auch wichtig sind, aber Micro-Benchmarking ist und bleibt auch mit JMH schwierig und fehleranfällig und erfordert Expertenwissen - wie schon die Komplexität des JMH zeigt. In der Praxis der Applikationsentwicklung ist in den meisten Fällen die Performance einer bestimmten Operation relativ egal. Wichtig sind Performanceüberlegungen nur für Operationen auf dem Performance-kritischen Pfad einer Applikation - und diesen Pfad muss man erst einmal mit Hilfe eines Profilers bestimmen. Ehe man nicht weiß, wo die Performance verloren geht, hat es auch keinen Sinn, über schnellere oder langsamere Alternativen für irgendwelche Operationen nachzudenken. Die Gefahr beim Micro-Benchmarking mit JMH ist, dass Performance-Kennzahlen relativ schnell zu beschaffen sind und dann überbewertet werden, indem sie auf Situationen übertragen werden, wo sie nicht zutreffen. Die Ergebnisse eines Benchmarks sagen nur, wie schnell oder langsam eine Operation im Kontext dieses speziellen Benchmarks war. Das sollte man immer im Hinterkopf behalten. Literaturverweise
|
|||||||||||||||||||||
© Copyright 1995-2018 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/90.Performance.JMH-Micro-Benchmark-Harness/90.Performance.JMH-Micro-Benchmark-Harness.html> last update: 26 Oct 2018 |