Microbenchmarking mit JMH

Microbenchmarking in Java ist kein einfaches Thema. Dies liegt zum einen an den vielen Optimierungen, die der Compiler vornimmt und die zur Laufzeit in der JVM erfolgen (JIT), zum anderen aber auch einfach an der Thematik an sich. Vor allem sollte man sich bei dem Thema über die Aussagekraft der Messung Gedanken machen. Nur aus der Tatsache, dass in einem Sonderfall eine bestimmte Performance gemessen wurde, können nicht unbedingt allgemeingültige Schlüsse gezogen werden. Das bedeutet, es muss bei diesem Thema schon einiges an Gedanken über das Testsetup investiert werden.

Darüber hinaus ist natürlich das ganze Thema Microbenchmarking in Java in der Regel mit Vorsicht zu genießen, da in den meisten Business-Anwendungen vermutlich die Bottlenecks nicht durch Micro-Optimierungen zu beseitigen sind, sondern ganz woanders liegen. Daher sollte auch nur mit dem Optimieren auf dieser Ebene begonnen werden, sofern dies wirklich erforderlich ist. Dies bekommt natürlich bei der Entwicklung technischer Frameworks eine höhere Bedeutung.

Für den Fall dass man sich in der Situation befindet, einen Microbenchmark schreiben zu müssen, sollte man auf keinen Fall einfach nur mit einer einfachen Schleife und dem Messen über System.nanoTime() arbeiten, da hier viel nicht berücksichtigt bleibt. Seit einiger Zeit gibt es jetzt das, von den OpenJDK Entwicklern bereitgestellte, Werkzeug JMH (Java Microbenchmark Harness), welches für solche Messungen bevorzugt verwendet werden sollte.

Einbinden in Projekt

JMH steht als Maven-Dependency zur Verfügung und kann auf diese Weise einfach in ein Projekt eingebunden werden

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>{jmh.version}</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>{jmh.version}</version>
    <scope>provided</scope>
</dependency>

Definieren eines Benchmarks

Um einen Benchmark zu definieren, ist nichts weiter erforderlich, als eine Methode, welche die zu messende Codezeilen anstößt, mit der Annotation @Benchmark zu kennzeichnen.

@Benchmark
public int calculateSumStream(MyState state) {
    return state.zahlen.stream().mapToInt(i -> i).sum();
}

Eine Klasse kann beliebig viele Benchmark-Methoden enthalten. Die Optionen für die Messung können entweder individuell über weitere Annotationen an den Methoden konfiguriert werden, oder zentral über die Optionen die bei Aufruf mit dem Runner übergeben werden.

Anstoßen eines Benchmarks

Um eine Messung direkt aus der IDE heraus zu starten, reicht es, in einer eigenen Main-Methode, die Main von JMH aufzurufen.

Alternativ dazu kann auch die Variante mit dem Runner verwendet werden, welche es ermöglicht Optionen für die Messung mit Hilfe eines Builders zu konfigurieren.

Options opt = new OptionsBuilder()
        .include(".*" + MyBenchmark.class.getSimpleName() + ".*")
        .forks(1)
        .warmupForks(1)
        .warmupIterations(40)
        .measurementIterations(40)
        .warmupBatchSize(1)
        .measurementBatchSize(1)
        .timeUnit(TimeUnit.MILLISECONDS)
        .mode(Mode.AverageTime)
        .build();
new Runner(opt).run();

Die empfohlene Vorgehensweise ist allerdings, nicht aus der IDE heraus zu starten, sondern ein ÜberJar bauen zu lassen, welches dann per java -jar zuer Ausführung gebracht wird. Dafür muss das Maven-Shade-Plugin entsprechend konfiguriert werden. Von JMH wird ein entsprechender Archetype angeboten, welcher solch ein vorbereitetes Projekt erzeugt.

Konfiguration

Vor dem Durchführen der echten Durchläufe, für die eine Messung erfolgt, können Warmup-Durchläufe geschaltet werden, was in der Regel auch zu empfehlen ist. Die Anzahl der Warmup- und der Durchläufe für die Messung können konfiguriert werden über @Warmup bzw. @Measurement.  Wie oft im Rahmen einer Iteration eine Funktion aufgerufen wird, kann über die Batchsize gesteuert werden. Die komplette Messung mit allen Warmup- und echten Iterationen kann mehrfach geforked in jeweils einem neuen Java-Prozess ausgeführt werden. Dies kann über @Fork gesteuert werden.

Es sind verschiedene Modi für die Messung auswählbar:

  • Throughput – Misst die Operationen pro Zeiteinheit
  • AverageTime – Misst die Zeit für eine Operation
  • SampleTime – Ermittelt wie lange eine Methode benötigt
  • SingleShotTime – Führt eine Methode nur einmal aus (Für Coldtesting)
  • Eine Kombination der Modes hintereinander
  • Alle Modes nacheinander

Die Zeiteinheit für die Messung kann über die entsprechende TimeUnit konfiguriert werden.

Zustand

Es besteht die Möglichkeit einen Zustand zu halten zwischen den Messungen und die Daten mit denen die Funktionen arbeiten sollen zu verwalten. Dafür sollte eine State-Klasse genutzte werden. Diese kann z.B. als innere statische Klasse definiert werden, welche mit @State annotiert ist. Die Methode für den Benchmark kann nun mit einem Parameter des entsprechenden Typs versehen werden, um ein State-Objekt übergeben zu bekommen. Der Scope (die Lebensdauer) eines State Objektes kann in der @State Annotation festgelegt werden (Thread,Benchmark,Group) . Um Daten vorzubereiten, oder nach der Messung abzuräumen, können Methoden mit @Setup und @TearDown versehen werden, ähnlich wie von Testing-Frameworks bekannt. Es kann dabei festgelegt werden, ob die Methoden einen Aufruf, eine Iteration, oder einen kompletten Benchmark umschließen sollen.

@State(Scope.Thread)
public static class MyState {

    public List zahlen;
    public int[] zahlenArray = new int[100000];

    @Setup(Level.Trial)
    public void doSetup() {
        zahlenArray = IntStream.range(1, 100000).toArray();
        zahlen = Arrays.stream(zahlenArray).boxed().collect(Collectors.toList());
    }

    @TearDown(Level.Trial)
    public void doTearDown() {
        zahlen = null;
        zahlenArray = null;
    }
}

Ergebnisse

Die Ergebnisse der Messung werden auf der Konsole ausgegeben. Alternativ ist es auch möglich, bei Aufruf des Runners, die Messergebnisse als Rückgabe-Collection zu erhalten und diese dann programmatisch auszuwerten.
Ein Beispielergebnis:

# Run complete. Total time: 00:06:42

Benchmark                                  Mode  Cnt  Score    Error  Units
DemoBenchmark.calculateAverageStream       avgt   40  0,141 ±  0,002  ms/op
DemoBenchmark.calculateMaxCollections      avgt   40  0,136 ±  0,003  ms/op
DemoBenchmark.calculateMaxStream           avgt   40  0,301 ±  0,002  ms/op
DemoBenchmark.calculateSumStream           avgt   40  0,106 ±  0,010  ms/op
DemoBenchmark.calculateSumStreamPrimitive  avgt   40  0,033 ±  0,001  ms/op

Weitere Tipps

Es sollte im Allgemeinen vermieden werden mit eigenen Schleifen zu Arbeiten um den Code öfter auszuführen. Die Anzahl der Aufrufe und Durchläufe sollte über die Parameter von JMH gesteuert werden. Natürlich dürfen Schleifen, sofern sie denn Bestandteil des Codes sind, mit gemessen werden.

Es ist angeraten, in den Methoden ermittelte Werte als Rückgabe zu liefern, oder sie an ein Dummy-Consumer-Objekt zu übergeben, das ansonsten die Gefahr besteht, dass das nicht Weiterverwenden des Wertes zu entsprechenden Optimierungen führt, welche die Aussagekraft der Messung natürlich drastisch reduzieren würden.

Wie man sehen kann, sind Microbenchmarks mit JMH sehr einfach zu realisieren. Allerdings, wie bereits Eingangs erwähnt, sei hier noch einmal darauf hingewiesen, dass Microbenchmarks in Java mit Vorsicht zu genießen sind. Das Verhalten in einer isolierten Testumgebung entspricht höchstwahrscheinlich nicht exakt dem, wie es im Gesamtkontext der Anwendung vorzufinden ist.

Ein Demo-Projekt ist unter https://github.com/GEDOPLAN/jmh-demo zu finden.

Advertisements

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s

%d Bloggern gefällt das: