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
JMH - Java Micro-Benchmark Harness
 

Java Magazin, Januar 2017
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 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) {
        Options opt = new OptionsBuilder()
                .include(".*" + "Counter" + ".*")

                .timeUnit(TimeUnit.MILLISECONDS)
                .forks(1)
                .mode(Mode.SingleShotTime)
                .measurementBatchSize(100_000_000)

                .measurementIterations(5)

                .warmupBatchSize(10_000_000)

                .warmupIterations(5)

                .build();
        Runner runner =  new Runner(opt);
        try { runner.run();
        } catch (RunnerException e) {
                e.printStackTrace();
        }
    }
}
 
 

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 final class Counter1_inc {

  …

  public void inc_ss_jmhStub(InfraControl control, int batchSize, RawResults result,

                              Counter1_jmh l_counter10_0 , Blackhole_jmh l_blackhole1_1) {
    …
    result.startTime = System.nanoTime();
    for (int b = 0; b < batchSize; b++) {
        …
        l_blackhole1_1.consume( l_counter10_0.inc() );
    }
    result.stopTime = System.nanoTime();
    …
  }

  …

}
 
 

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()
                .include(".*" + "Counter" + ".*")

                 .timeUnit(TimeUnit. SECONDS)
                .forks(1 0 )
                .mode(Mode. Throughput )
                . measurementBatchSize(1 )

                 .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 final class Counter1_inc {

  …

  public void inc_thrpt_jmhStub(InfraControl control, RawResults result,

                                Counter1_jmh l_counter10_0, Blackhole_jmh l_blackhole1_1)

                                throws Throwable {
    long operations = 0;
    …
    result.startTime = System.nanoTime();
     do  {
        l_blackhole1_1.consume(l_counter10_0.inc());
        operations++;
    }  while (!control.isDone);
    result.stopTime = System.nanoTime();
    …
    result.operations = operations;

  …

  }
}

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 final class Counter1_inc {

  …

  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 {
    …
    int rnd = (int)System.nanoTime();
    int rndMask = …
    long time = 0;
    …
    do {
        rnd = (rnd * 1664525 + 1013904223);
        boolean sample = (rnd & rndMask) == 0;
         if (sample) {
            time = System.nanoTime ();
        }
        for  (int b = 0; b < batchSize ; b++) {
            …
            l_blackhole1_1.consume(l_counter10_0.inc());
        }
         if (sample) {
            buffer.add(( System.nanoTime () - time) / opsPerInv);
            …
            rndMask = …

        }
    } while(! control.isDone );
    …
  }

  …
}
 
 
 
 

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

/KRE1/  Angelika Langer: How fast are the Java 8 Streams?
URL: http://www.angelikalanger.com/Articles/EffectiveJava/89.Performance.Micro-Benchmarking/89.Performance.Micro-Benchmarking.html
/CAL/      
Google Caliper
URL: http://openjdk.java.net/projects/code-tools/jmh/
/JMH/  
OpenJDK Project jmh
URL: http://hg.openjdk.java.net/code-tools/jmh/file/tip/jmh-samples/src/main/java/org/openjdk/jmh/samples/
/JMHD/              
JMH-Beispiele mit Erläuterungen
URL: http://hg.openjdk.java.net/code-tools/jmh/file/tip/jmh-samples/src/main/java/org/openjdk/jmh/samples/
/ECL/ 
Aufsetzen eine JHM-Projekts in Eclipse
/SHI/
Aleskey Shipilev: Nanotrusting the 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/90.Performance.JMH-Micro-Benchmark-Harness/90.Performance.JMH-Micro-Benchmark-Harness.html  last update: 26 Oct 2018