Kolumne

Aus der Java-Trickkiste: Microbenchmarking

Arno Haase
java-trickkiste

© Software & Support Media

Performancemessungen sind notorisch schwierig. Je kürzer ein Stück Code läuft, desto schwieriger wird es, sinnvolle Werte zu ermitteln. Und wenn es um einige wenige Statements geht, überdecken die Effekte des Messens leicht das Verhalten des zu messenden Codes.

Naive Messungen

final Random random = new Random();
final long start = System.nanoTime ();
random.nextInt ();
final long end = System.nanoTime ();
System.out.println (end - start);

Listing 1 zeigt einen naiven Ansatz dazu, wie lange das Ermitteln einer Zufallszahl dauert. Der Code ruft vor und nach dem zu messenden Aufruf jeweils System.nanoTime() auf und gibt die Differenz der beiden Zeitstempel aus. Dieser Ansatz ist leicht zu verstehen. Er liefert aber keine nützlichen Ergebnisse, weil er eine Reihe von Faktoren außer Acht lässt:

  • Die JVM übersetzt den Bytecode zur Laufzeit („Just in Time“) in Maschinensprache. Hotspot tut das z. B. per Default erst, wenn ein Stück Code 1 000 Mal aufgerufen wurde. Bis dahin wird der Bytecode interpretiert, was erheblich langsamer ist. Meistens interessiert man sich für die Performance des übersetzten Codes; Listing 1 gibt der JVM keine Zeit zum Aufwärmen.
  • Ein einziger Aufruf von random.nextInt() braucht – wenn Hotspot ihn optimiert hat – nur wenige Nanosekunden. Wenn das Betriebssystem in dieser Zeit aktiv wird – Interrupts, Thread Scheduling etc. – dann sind diese Effekte sehr viel größer als der eigentliche Messwert. Man sollte für die Messung den Aufruf häufig wiederholen, dann fallen solche Effekte weniger ins Gewicht.
  • Schließlich sollte man die gesamte Messung mehrmals wiederholen, um zu sehen, wie groß die statistischen Schwankungen sind. Wenn bei drei aufeinanderfolgenden Messungen praktisch die gleichen Werte herauskommen, sind diese Werte wahrscheinlich genauer, als wenn sie um einen Faktor zwei auseinanderliegen.

Etwas weniger naive Messungen

static void doMeasure() {
  final long start = System.nanoTime ();
  for (int i=0; i<1_000_000; i++) {
    random.nextInt ();
  }
  final long end = System.nanoTime ();
  System.out.println (end - start);
}

Ein Lösungsansatz besteht darin, die Messung in eine Methode auszulagern und den zu messenden Code in einer Schleife auszuführen (Listing 2). Man muss diese Methode dann mehrmals nacheinander aufrufen – der erste Aufruf dient dem Aufwärmen von Hotspot, und die darauffolgenden Aufrufe liefern dann mehrere Messwerte. Dieser Code liefert sicherlich weniger schlechte Zahlen als der erste naive Ansatz. Er hat aber immer noch einige Probleme.

Vor allem ist der zu testende Code nicht gut vom Testrahmenwerk getrennt. Die Schleife in Listing 2 ruft immer wieder random.nextInt() auf – und HotSpot kann diese Aufrufe inlinen und anschließend mehrere von ihnen zusammenfassen.

In produktivem Code ist das eine wünschenswerte Optimierung, weil sie die Gesamtperformance verbessert. Aber sie verfälscht unsere Messergebnisse, weil eine Million Aufrufe in einer Schleife unter Umständen eben nicht eine Million Mal so lange brauchen wie ein einzelner Aufruf.

Eine weitere Schwierigkeit tritt auf, wenn man mehrere Alternativen vergleichen will, z. B. die relative Performance von Random, SecureRandom und ThreadLocalRandom. Wenn man die Messungen einfach nacheinander ausführt, sind sie nämlich nicht voneinander isoliert, und es kann sein, dass sie sich unter der Oberfläche beeinflussen. Wenn einer von ihnen Garbage erzeugt, kann es sein, dass der Garbage Collector die darauffolgende Messung unterbricht und so die Messungen systematisch verfälscht.

Außerdem ist es aufwändig, auf diese naive Art nebenläufige Messungen durchzuführen. Das Verwalten der Threads und vor allem das Zusammenführen von Ergebnissen enthält eine Menge Fallstricke.

Java Microbenchmarking Harness

Das Open-Source-Framework Java Microbenchmarking Harness (JMH) löst diese Probleme. Das Framework ist dadurch geadelt, dass es auf der Seite des OpenJDK liegt. Es ist aber nicht Teil des JDK.

JMH arbeitet als AnnotationProcessor – ein weniger bekanntes Java-Feature, das immerhin schon seit Java 6 zum JDK gehört – und sorgt dafür, dass beim Kompilieren der Java-Sourcen auf Basis von Annotationen ein Test-Harness generiert wird. Wenn man den startet, führt er die Messungen durch und gibt die Ergebnisse aus.

Die einfachste und in der JMH-Doku empfohlene Art, einen Benchmark aufzusetzen, ist ein Maven-Archetyp. Ohne hier darauf einzugehen, was das ist: Wenn man Maven 3 installiert hat und auf der Kommandozeile den Befehl aus Listing 3 ausführt (als einen einzigen Befehl, also ohne Zeilenumbrüche), dann erzeugt das ein neues Verzeichnis test mit einem fertig initialisierten JMH-Benchmark.

mvn archetype:generate
    -DinteractiveMode=false
    -DarchetypeGroupId=org.openjdk.jmh
    -DarchetypeArtifactId=jmh-java-benchmark-archetype
    -DgroupId=org.sample
    -DartifactId=test
    -Dversion=1.0-SNAPSHOT

Zum Ausführen des Benchmarks wechselt man zunächst in das Verzeichnis test. Dort kompiliert man dann das Projekt mit mvn clean package und kann es dann mit java -jar target/benchmarks.jar starten. Diese JAR-Datei enthält alle benötigten Bibliotheken, und der Aufruf startet die generierte Klasse org.openjdk.jmh.Main.

Man kann das Ganze auch in eine IDE integrieren, das ist aber etwas hakelig – und liefert weniger genaue Messergebnisse, weil IDEs eigentlich immer irgendwelche Hintergrundtasks ausführen.

Der initial erzeugte Benchmark liegt in der Klasse MyBenchmark. Er ist zwar vollständig und ausführbar, er tut aber noch nichts. Listing 4 zeigt einen Benchmark, der die Performance der drei oben genannten Random-Klassen vergleicht.

Die Klasse enthält eine ganze Reihe von Annotationen, um das Verhalten von JMH zu konfigurieren. Die meisten von ihnen sind nicht zwingend erforderlich und haben sinnvolle Defaults. Sie sind hier aber explizit aufgeführt, um die Konfigurationsmöglichkeiten zu zeigen.

@Warmup(iterations=3, time=3, timeUnit=TimeUnit.SECONDS)
@Measurement(iterations=5, time=10, timeUnit=TimeUnit.SECONDS)
@Fork(2)
@BenchmarkMode (Mode.AverageTime)
@OutputTimeUnit (TimeUnit.NANOSECONDS)
@State (Scope.Benchmark)
@Threads (1)
public class MyBenchmark {
  private final Random random = new Random ();
  private final SecureRandom secureRandom = new SecureRandom ();

  @Benchmark
  public void measureRandom() {
    random.nextInt ();
  }

  @Benchmark
  public void measureSecureRandom() {
    secureRandom.nextInt ();
  }

  @Benchmark
  public void measureThreadLocal() {
    ThreadLocalRandom.current ().nextInt ();
  }
}

Die wichtigste Annotation ist @Benchmark. Sie muss über einer Methode stehen und sorgt dafür, dass JMH für diese Methode einen Benchmark generiert. Alle anderen Annotationen sind optional.

Die Annotation @Warmup steht an der Benchmark-Klasse und steuert das Aufwärmen von HotSpot vor den eigentlichen Messungen. Man kann angeben, wie viele Aufwärmiterationen laufen und wie lange sie jeweils dauern sollen. Die Annotation @Measurement steuert analog dazu die Anzahl und Dauer der eigentlichen Messungen. Listing 4 führt jeweils drei Warmup-Iterationen von je drei Sekunden und anschließend fünf Messungen von je zehn Sekunden aus.

Mit @Fork kann man steuern, ob und wie häufig nacheinander JMH neue JVMs mit separaten Messungen startet. Wenn man jede Messung in einem separaten Betriebssystemprozess mit einer neuen JVM ausführt (und das ist das Default-Verhalten), dann sind die Messungen optimal voneinander isoliert und können sich nicht beispielsweise durch Garbage-Altlasten gegenseitig beeinflussen.

Außerdem beeinflusst das Speicherlayout des Betriebssystems die Performance spürbar – mehrere aufeinanderfolgende Messungen in verschiedenen JVM-Instanzen liefern unterschiedliche Ergebnisse, auch wenn innerhalb jeder JVM die Ergebnisse sehr stabil sind. Wenn man jede Messung mehrmals in verschiedenen Forks wiederholt, werden diese Effekte zumindest sichtbar.

@BenchmarkMode steuert, wie JMH die Messdaten erfasst und aufbereitet. Mode.AverageTime in Listing 4 ermittelt, wie lange ein einzelner Aufruf dauert. Alternativ kann man z. B. auch die Anzahl der Aufrufe je Zeiteinheit ermitteln. Was zu @OutputTimeUnit führt: Diese Annotation steuert die Zeiteinheit, in der die Ergebnisse dargestellt werden. In Listing 4 ist der untersuchte Code sehr kurz, deshalb ist ein Output in Nanosekunden sinnvoll.

Die Annotation @State ist nötig, weil die Benchmark-Klasse Attribute enthält. Sie steuert, wie lange JMH dieselbe Instanz von MyBenchmark verwendet bzw. wie häufig es eine neue Instanz erzeugt. Listing 4 verwendet Scope.Benchmark und sorgt dafür, dass Instanzen für die gesamte Dauer eines Benchmarklaufs verwendet werden.

Die Annotation @Threads schließlich steuert, wie viele Threads während einer Messung parallel die jeweilige Benchmark-Methode aufrufen sollen – in Listing 4 ist das zunächst einmal ein einziger.

Wenn man diese Benchmark-Suite startet, erhält man Zahlen dafür, wie lange das Erzeugen einer Zufallszahl dauert und wie groß das Vertrauensintervall dieser Zahlen ist. Die Werte sind wenig überraschend: ThreadLocalRandom ist deutlich schneller als Random, und SecureRandom ist noch einmal sehr viel langsamer. Die genauen Werte hängen von der verwendeten Hardware, Betriebssystem und Java-Version ab, sodass ich meine Messwerte absichtlich nicht nenne – wen sie interessieren, der sollte Listing 4 ausführen.

Man kann dieselbe Messung mit mehreren parallelen Threads ausführen, indem man in der Annotation @Threads als Parameter z. B. 8 einträgt (die Zahl der Hyperthreads meiner CPU). Diese Threads arbeiten dann mit derselben Instanz von MyBenchmark und teilen sich dadurch die Instanzen von Random bzw. SecureRandom.

ThreadLocalRandom wird durch die parallele Ausführung kaum langsamer, zumindest so lange die Zahl der Threads nicht die Parallelität des Prozessors überschreitet. Das ist nicht weiter überraschend, weil sie ja, wie der Name schon sagt, keinen Zustand hat, den sich mehrere Threads teilen müssen.

SecureRandom wird etwa um einen Faktor 10 langsamer – es arbeitet intern mit einem Lock, sodass alle Threads aufeinander warten müssen.

Die Klasse Random schließlich verwaltet ihren Shared State mit einer CAS-Schleife. Das ist bei geringer Contention schneller als ein Lock („Compare And Set“ bzw. „Compare And Swap“ – zur Erklärung siehe z. B. die Javadoc von AtomicReference.compareAndSet() oder hier). In unserem extremen Test führt es aber dazu, dass teure Operationen oft mehrfach ausgeführt werden müssen: Die durchschnittliche Ausführungsdauer steigt um einen Faktor von etwa 100.

Fazit

Es ist extrem schwierig, aussagekräftige Microbenchmarks selbst zu implementieren. Das Framework JMH erledigt den Löwenteil dieser Arbeit exzellent, und man sollte es eigentlich immer verwenden, wenn man Performancemessungen „im Kleinen“ durchführen möchte.

Dieser Artikel will zum Ausprobieren und Spielen anregen. JMH kann deutlich mehr als hier vorgestellt: Man kann z. B. mit @Param Tests parametrieren, mit der Klasse BlackHole gezielt Ressourcen verbrauchen oder mit @CompilerControl detailliert das Verhalten des Java-Compilers beeinflussen.

Auf jeden Fall sollte man nie auf Basis von „intuitiven“ Annahmen Designentscheidungen treffen, sondern solche Annahmen immer durch Messungen überprüfen. Man lernt überraschend oft etwas dazu, und ganz nebenbei macht es auch noch Spaß.

Geschrieben von
Arno Haase
Arno Haase
Arno Haase ist freiberuflicher Softwareentwickler. Er programmiert Java aus Leidenschaft, arbeitet aber auch als Architekt, Coach und Berater. Seine Schwerpunkte sind modellgetriebene Softwareentwicklung, Persistenzlösungen mit oder ohne relationaler Datenbank und nebenläufige und verteilte Systeme. Arno spricht regelmäßig auf Konferenzen und ist Autor von Fachartikeln und Büchern. Er lebt mit seiner Frau und seinen drei Kindern in Braunschweig.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: