Über unsMediaKontaktImpressum
David Georg Reichelt 04. Januar 2022

Performance-Regressionen frühzeitig erkennen und vermeiden

Performance-Regressionen, d. h. Verschlechterungen der Softwareperformance zwischen zwei Versionen, führen bei Benutzern selten zu Begeisterung. Wenn wir ein Projekt beginnen, scheint es oft einfach: Effiziente Algorithmen nutzen, der geplanten Architektur folgen und typische Antipattern vermeiden reicht anfangs oft aus, um Performance-Regressionen aus dem Weg zu gehen. Während wir an einem Projekt arbeiten, ändert sich das aber oft: Architektur und Quelltext werden komplexer und die Suche nach Performance-Regressionen gleicht der Suche nach der Nadel im Heuhaufen.

Die Nadel im Heuhaufen kann man mit einem Magnet finden – da wir aber keinen Magnet haben, müssen wir die Performance messen. Durch regelmäßige Messungen wird es möglich, Regressionen und ggf. Verbesserungen zu erkennen und zu verstehen. Hierfür können Performance-Benchmarks oder -tests genutzt werden, die durch regelmäßige Messung auf Continuous-Integration-Servern Regressionen in neuen Versionen aufdecken.

Dieser Artikel zeigt, wie wir unsere Softwareperformance durch regelmäßige Messungen in unserer CI überwachen können. Dabei wird erst die Performance-Messung in der Java Virtual Machine (JVM) eingeführt und anschließend zwei konkrete Tools vorgestellt, die Messumgebungen bereitstellen und die Problemsuche erleichtern.

Performance-Messung in der JVM

Wer schon einmal versucht hat, die Performance verschiedener Quelltextschnipsel zu vergleichen, wird feststellen, dass sich die JVM wie eine störrische Katze verhält: Selten benimmt sie sich wie erwünscht, und wenn doch, dann versteht man nicht, wieso.

Listing 1 zeigt beispielhaft, wie man naiverweise die Performance messen kann: Durch Messung der Startzeit, Ausführung des Workloads und Messung der Endzeit. Vergleicht man, wie lange es dauert, 1.000 bzw. 2.000 Zahlen zu addieren, erhält man für denselben Workload völlig unterschiedliche Ausführungszeiten. Die Ausführungszeiten schwanken auf aktuellen Rechnern sowohl für die Addition von 1.000 als auch von 2.000 Zahlen um ca. 6 ms. Die Messwerte unterscheiden sich dabei so stark untereinander, dass man nicht feststellen kann, ob es schneller ist, sequenziell 1.000 oder 2.000 Zahlen zu addieren.

Listing 1: Naive Performancemessung

public class SimpleMeasurement {

   public static void main(final String[] args) {
      long start = System.nanoTime();
      
      addRandomNumbers(1000);

      long end = System.nanoTime();
      long durationInMus = (end - start) / 1000;
      System.out.println("Duration: " + durationInMus + " ");
   }

   private static void addRandomNumbers(final int count) {
      int sum = 0;
      for (int i = 0; i < count; i++) {
         sum += random.nextInt();
      }
      System.out.println("Sum: " + sum);
   }
}

Wie man sich denken kann, benötigt es erheblich mehr Zeit, 2.000 Zahlen sequenziell zu addieren, als 1.000. Durch die starken Messschwankungen kann man den Unterschied aber nicht feststellen. Genauso kann man Performance-Unterschiede mit einfachen Messungen bei komplexeren Workloads nicht feststellen – vor allem, wenn es sich nur um eine kleine Änderung zwischen zwei Versionen und nicht um eine Verdopplung des Workloads handelt.

Wieso das so ist, dem gehen wir im nächsten Abschnitt auf den Grund. Anschließend schauen wir uns an, mit welchem Messprozess man dieses Problem lösen kann.

Prozess

Die Ausführung desselben Workloads ist immer vom guten Willen der Ausführungsumgebung abhängig: Wird die CPU-Taktfrequenz schnell und dauerhaft erhöht (ohne, dass Temperaturprobleme auftreten), wird die Workloadbearbeitung schneller. Beginnt das Betriebssystem parallele Prozesse, wird derselbe Workload mehr Zeit beanspruchen. Und je nach Scheduling einzelner Prozesse und Threads wird der eine oder andere Workload schneller fertig. Die Ausführungszeit desselben Workloads ist also sowieso nichtdeterministisch, kann also nicht vorher bestimmt werden.

Moderne Ausführungsumgebungen wie die JVM enthalten weitere Quellen von Nichtdeterminismus. Diese entstehen, weil die JVM uns die Portierung auf verschiedene Plattformen und die Speicherverwaltung erleichtert: Die Plattformunabhängigkeit erreicht die JVM, indem sie Bytecode auf (beinahe) beliebigen Betriebssystemen und beliebiger Hardware ausführt. Damit die Ausführung beschleunigt wird, nimmt die JVM dabei zur Laufzeit verschiedene Optimierungen vor und kompiliert Bestandteile des Bytecodes auf Maschinencode der Zielhardware (Just-In-Time-Compilation – JIT). Damit der Speicher ohne manuelle Verwaltung nicht voll läuft, wird er regelmäßig im Rahmen der Garbage Collection bereinigt.

Dieses Vorgehen der JVM führt dazu, dass die Performance verstärkt nichtdeterministisch ist: Während der Kompilierung wird Rechenzeit beansprucht und die Ausführung wird langsamer. Je nachdem, welche Optimierung die JVM auswählt, kann das Programm im Endzustand langsamer oder schneller werden. Entscheidet die JVM, dass eine Garbage Collection nötig ist, wird die Bearbeitung des Workloads verlangsamt.

Abb. 1 verdeutlicht dies: Wird derselbe Workload innerhalb einer VM wiederholt, finden am Anfang (ca. bis Iteration 1.000 in dem Beispiel) Optimierungs- und JIT-Prozesse statt, die die Ausführung nichtdeterministisch beeinflussen. Ist diese Phase abgeschlossen, stabilisieren sich die Messwerte. Gelegentlich treten aber noch Garbage Collections auf.

Die Ausführung desselben Workloads wird daher innerhalb einer JVM und zwischen verschiedenen JVMs unterschiedliche Ausführungsdauern aufweisen.

Messung

Um die wilde JVM zu bändigen, müssen wir Messungen mit wissenschaftlicher Genauigkeit ausführen und wiederholen. Wichtig ist dabei vor allem, dass die Messausführung so gestaltet wird, dass die Ergebnisse reproduzierbar sind – zweimal dasselbe Experiment sollte also zum selben Ergebnis führen.

Die Messergebnisse unterscheiden sich innerhalb einer VM aufgrund der beschriebenen nichtdeterministischen Effekte und zwischen VMs, u. a. aufgrund der unterschiedlichen Optimierungen, die die JVM ausführt. Um Messergebnisse innerhalb einer VM (weitestgehend) reproduzierbar zu machen, müssen wir die Messung des Workloads innerhalb einer VM wiederholen, bis ein sog. stationärer Zustand eingetreten ist. Die Workload-Ausführungen bis zum stationären Zustand werden Warmup-Iterationen genannt. Der stationäre Zustand ist dadurch gekennzeichnet, dass keine übermäßigen Schwankungen zwischen den Workload-Wiederholungen mehr auftreten. Eine anerkannte Definition sowie statistische Kriterien für den stationären Zustand stehen noch aus. Für konkrete Anwendungsfälle ist es hilfreich, den Verlauf der Ausführungsdauern optisch zu inspizieren.

Ist das Warmup abgeschlossen, so bleibt in der Regel eine gewisse Schwankung, u. a. durch Garbage Collections, bestehen. Um trotz der Schwankungen repräsentative Messwerte zu erhalten, werden nach dem Warmup mehrere Mess-Iterationen ausgeführt. Deren Mittelwert wird anschließend genutzt.

Die Messergebnisse unterscheiden sich zwischen VMs, da die unterschiedlichen nichtdeterministischen Effekte zu verschiedenen stationären Zuständen führen. Es ist daher nicht ausreichend, nur eine JVM-Ausführung zu nutzen. Stattdessen muss der gesamte Messprozess inklusive Warmup- und Mess-Iterationen eine bestimmte Anzahl von VM-Starts (auch: Forks) wiederholt werden, bis eine gute Grundlage für den Vergleich von Messungen gegeben ist.

Ein weiteres Problem der Messung (v. a. kleiner Workloads) ist, dass der Aufruf der Zeitfunktion selbst Zeit benötigt und Ungenauigkeit erzeugt. Um die Genauigkeit zu erhöhen, wird der Workload daher oft innerhalb einer Iteration mehrfach ausgeführt, so dass in einer Mess-Iteration die aggregierte Ausführungszeit mehrerer Workload-Ausführungen erfasst wird. Abb. 2 fasst diesen Prozess zusammen.

Für unsere Messung der Addition von 1.000 und 2.000 Zahlen heißt das, dass wir innerhalb einer VM eine Messumgebung benötigen: Zuerst muss das Warmup ausgeführt werden, das aus Wiederholungen der Messung besteht, und anschließend wird die eigentliche Messung ausgeführt. Listing 2 zeigt beispielhaft eine mögliche Implementierung (Diese Messumgebung unterscheidet sich von realen Messumgebungen in verschiedenen Details, u. a. fehlt die Wiederholung des Workloads zwischen dem Erfassen der Zeit).

Listing 2: Beispielhafte Messumgebung

public class MoreAccurateMeasurement {
   
   private static final Random random = new Random();
   
   public static void main(final String[] args) {
      double warmupAverage = getExecutionAverage(1000);
      double average = getExecutionAverage(1000);
      
      System.out.println("Duration: " + average + " ");
   }

   private static double getExecutionAverage(final int iterations) {
      int[] durations = new int[iterations];
      for (int i = 0; i < iterations; i++) {
         long start = System.nanoTime();
         
         addRandomNumbers(1000);

         long end = System.nanoTime();
         durations[i] = (int) ((end - start) / 1000);
      }
      double average = IntStream.of(durations).sum() / ((double) iterations);
      return average;
   }

   private static void addRandomNumbers(final int count) {
      int sum = 0;
      for (int i = 0; i < count; i++) {
         sum += random.nextInt();
      }
      System.out.println("Sum: " + sum);
   }
}

Durch den mehrfachen Start dieser Messumgebung erhalten wir Messwerte, wie die in Abb. 3 dargestellten. Optisch sehen wir hier den Unterschied klar. Ist der Unterschied unklarer, weil nur geringe Performance-Unterschiede vorliegen, können statistische Tests wie der T-Test bestätigen, dass ein Performance-Unterschied mit hoher Signifikanz vorhanden (oder nicht vorhanden) ist [1].

Tools zur Performance-Messung

Bevor wir nun anfangen, die Abläufe zur Performance-Messung neu zu implementieren, sollten wir auf bestehende Werkzeuge hierfür schauen. Es existiert bereits ein bunter Haufen von Werkzeugen, u. a. Lasttestwerkzeugen wie JMeter und Gatling oder Benchmarking-Werkzeuge wie JMH oder nanobench.

Im JVM-Umfeld ist vor allem JMH zur Definition von Benchmarks verbreitet. Die regelmäßige Ausführung von JMH-Benchmarks wird aus Ressourcengründen aber nur selten durchgeführt. Statt immer alle Regressions-Benchmarks oder -Tests auszuführen, ist es viel schneller, nur diejenigen auszuführen, bei denen eine Regression möglich ist. Daher ermöglicht das Jenkins-Plugin Peass-CI die Automatisierung der Regressions-Testselektion für JMH, so dass in der aktuellen Version nur noch für diese Version relevante Workloads getestet werden.

JMH

Um nicht jedesmal selbst eine Testumgebung (Harness) zur Performance-Messung bauen zu müssen, bietet JMH (Java Measurement Harness) Grundfunktionalitäten hierfür an [2]. JMH wurde als Teil von OpenJDK entwickelt, um Regressionen in der JVM selbst zu erkennen. Es wird auch außerhalb der JVM-Entwicklung genutzt und hat sich zu einem verbreiteten Benchmarking-Tool entwickelt. Neben der Automatisierung von VM-Starts, Warmup- und Messiterationen enthält JMH verschiedene Funktionalitäten, um beinahe mühelos exakte Performance-Messungen zu erhalten.

Ein Problem bei Performance-Messungen kann sein, dass die JVM Methodenaufrufe, die zu konstanten oder nicht benötigten Zwischenergebnissen führen, entfernt. Dadurch wird deren Performance nicht mehr korrekt gemessen. Eine einfache Lösung ist, die Ergebnisse zu verwerten, wie bei unserer simplen Messung durch das Schreiben in System.out. Da dies ggf. unkalkulierbaren Performance-Overhead verursacht (vor allem bei vielen Ausgaben), bietet JMH sogenannte Blackholes.

Listing 3 zeigt den Benchmark in JMH-Format. Gegenüber dem Aufruf der main-Methode sind zwei Änderungen vorgenommen: Es wurden die @Benchmark-Annotation und das Blackhole hinzugefügt. Das Blackhole konsumiert anschließend die Ergebnisdaten, so dass das Ergebnis der Addition nicht entfernt werden kann.

Listing 3: Beispiel-Benchmark in JMH

public class MyBenchmark {

   private static final Random random = new Random();

   @Benchmark
   public void testMethod(Blackhole bh) {
       addRandomNumbers(2000, bh);
   }

   private static void addRandomNumbers(final int count, Blackhole bh) {
     int sum = 0;
     for (int i = 0; i < count; i++) {
        sum += random.nextInt();
     }
     bh.consume(sum);
  }

}

Zur korrekten Einrichtung der Ausführungsumgebung für JMH ist es nötig, den jmh-Annotationsprozessor auszuführen und eine ausführbare jar zu generieren. Dies wird in der Regel durch das passende Buildtool-Plugin (in Maven: maven-shade-plugin) automatisiert, so dass ein einfacher Aufruf-Build (in Maven: mvn clean package) zur Generierung der jar ausreicht. Anschließend kann die Ausführung mit java -jar erfolgen. Führen wir dieses Beispielprojekt aus, erhalten wir Ausführungszeiten wie die in Listing 4 und Listing 5.

Listing 4: Messergebnisse 1.000 Additionen

Benchmark                 Mode  Cnt          Score       Error  Units
MyBenchmark.testMethod   thrpt    4     239789,978  ± 5088,711  ops/s

Listing 5: Messergebnisse 2.000 Additionen

Benchmark                Mode  Cnt       Score      Error  Units
MyBenchmark.testMethod  thrpt    4  119992,948 ± 4138,739  ops/s

Wir sehen, dass sich die Werte nicht stark von den selbst gemessenen Werten unterscheiden: Während wir selbst Ausführungsdauern von ca. 3 Mikrosekunden für 1000 Ausführungen gemessen haben, misst JMH ca. 4 Mikrosekunden (=1 s÷239789 * 1.000.000 mikrosekunde / s = 4,17). Die Ersetzung von System.out.println durch Blackhole.consume verändert die Performance also nicht maßgeblich. Unabhängig von den absoluten Werten bleibt die zentrale Aussage über die Relation zwischen beiden Workloads bestehen: 2.000 sequenzielle Additionen sind langsamer als 1.000 sequenzielle Additionen.

Peass

Das Implementieren von Benchmarks in JMH ist der goldene Weg, um Performance-Regressionen zu erkennen. Es hat in der täglichen Softwareentwicklung aber drei Nachteile:

  1. Die Testausführung ist ressourcenaufwändig,
  2. die Testdefinition erfordert viel Entwickleraufwand und
  3. die Ursache von Performance-Unterschieden ist oft unklar.

Das Jenkins-Plugin Peass (Performance Analysis of Software Systems) bietet eine Lösung für diese Nachteile [3]:

  1. Es enthält eine Regressionstest-Selektion, die bestimmt, für welche Tests in der aktuellen Version Messungen ausgeführt werden müssen und reduziert so die benötigten Ressourcen fürs Testen.
  2. Darüber hinaus transformiert Peass bestehende JUnit-Tests in Performancetests, und erlaubt es damit, die bestehenden Testdefinitionen zu nutzen.
  3. Um die Ursache von Performance-Unterschieden zu bestimmen, enthält Peass eine Ursachenanalyse.

Eine Regressionstest-Selektion hilft uns immer dann, wenn wir eine große Menge von Tests haben und nur die aufrufen wollen, bei denen eine Regression aufgetreten sein kann. Für die Performance-Messung ist das besonders relevant, da die Messung selbst erheblich zeitaufwändiger ist als bspw. die einfache Ausführung eines Unittests. Eine große (JMH-)Testsuite ist somit gar nicht regelmäßig in der CI ausführbar.

Grundannahme der Regressionstest-Selektion ist, dass wir bei bei der Entwicklung von Software in einzelnen Commits oft nur einige Klassen oder sogar nur einzelne Methoden einer Klasse ändern. Es ergibt daher keinen Sinn, immer die Performance aller Benchmarks zu messen: Die Performance eines Benchmarks kann sich nur dann geändert haben, wenn sich der aufgerufene Quelltext ändert. Peass bestimmt daher durch eine instrumentierte Ausführung die (geordnete) Liste aller aufgerufenen Methoden und führt Benchmarks nur dann aus, wenn sich eine aufgerufene Methode oder deren Reihenfolge geändert hat.

Abb. 4 verdeutlicht dies: Ruft Test 1 Methode A1 und Methode 1B auf, und nur Methode 1B wird geändert, dann muss Test 1 gemessen werden. Tests 2-5 müssen aber nicht gemessen werden, solange keine von ihnen aufgerufenen Methoden (oder die Tests selbst) geändert werden.

Problematisch ist nun noch, dass die Definition eigener Benchmarks für wichtige Teile einer Software zu aufwändig ist. Peass bietet deshalb die Möglichkeit, Unittests zu transformieren (oder bestehende JMH-Benchmarks zu nutzen). Hier werden die Unittests automatisiert so umgeschrieben, dass der Workload innerhalb einer VM für die angegebenen Aufwärm- und Mess-Iterationen ausgeführt und eine konstante Anzahl an Wiederholungen innerhalb einer Iteration wiederholt wird. Peass automatisiert sowohl die Transformation als auch den mehrfachen Start der Ausführungs-VMs, so dass man als Entwickler lediglich die Konfiguration vornehmen muss.

Unittests weisen darüber hinaus mit oft vorhandenen Assertions einen Mechanismus auf, der in der Regel dazu führt, dass die JVM Ergebnisse von Workloads nicht wegoptimiert. Man muss also als Entwickler lediglich die Ausführung konfigurieren und auswählen, welche Unittests geeignet sind, die Performance zu messen.

Listing 6: Additionstest

public class AdditionTest {
   @Test
   public void testApp() {
      int sum = AdditionClass.addRandomNumbers(1000);
      Assert.assertNotNull(sum);
   }
}

Listing 6 zeigt einen beispielhaften Unittest für das Zahlen-Addieren-Beispiel. Existieren nun zwei Versionen in einem git-Repository und werden in einer Version 1.000 und in der anderen Version 2.000 Zahlen addiert, dann kann Peass die beiden Versionen messen und die Performance der Unittests vergleichen.

Abb. 5 zeigt das Ergebnis der Messungen: Da die regelmäßigen Assertions andere Charakteristika als System.out und Blackhole haben, sind die konkreten gemessenen Werte wieder leicht unterschiedlich gegenüber den vorherigen Messungen. Die Relation bleibt aber gleich: Eher ist P gleich NP, als dass 1.000 und 2.000 Zahlen sequenziell addieren gleich schnell ist.

Performance-Änderungen und Commits in großen Projekten sind oft komplexer und schwerer zu finden als funktionale Probleme – und selbst in Zeiten schwer verfügbarer neuer Hardware ist der damit einhergehende Personalaufwand oft zu groß. Um die Suche zu beschleunigen, bietet Peass zusätzlich eine Ursachenanalyse an. Hierfür wird die Ausführungszeit jedes Knoten des Aufrufbaums separat gemessen, und ermittelt, wo Performance-Änderungen und Quelltext-Änderungen vorliegen. Da die Messung einzelner Knoten Overhead verursacht, gibt es verschiedene Messmodi: Der Aufrufbaum kann ebenenweise gemessen werden, so dass die Messung einer Ebene nicht die darüber liegenden Ebenen beeinflusst. Dies ist zeitaufwändig. Alternativ kann der gesamte Aufrufbaum gemessen werden, was die Messung beschleunigt, aber aufgrund des Overheads weniger exakt ist. Darüber hinaus besteht die Möglichkeit, die Pfade im Aufrufbaum zu messen, die zu Quelltextänderungen führen – dabei entsteht nur ein geringer Mess-Overhead, die Zeit für die Messung selbst ist aber relativ gering.

Abb. 6 zeigt dies in unserem Beispiel: Beide Methoden weisen eine Verlangsamung auf, aber nur AdditionTest#testApp weist eine Quelltextänderung auf. Dadurch wird es möglich, Ursachen von Performance-Änderungen schnell zu identifizieren.

Fazit

Um sicherzustellen, dass Performance-Regressionen nicht ins Produktivsystem deployt werden, muss man die Performance regelmäßig messen.

Dieser Messprozess erfordert ein durchdachtes Vorgehen. Dabei muss die Messung innerhalb einer VM wiederholt werden, bis das Warmup abgeschlossen ist. Anschließend werden die Mess-Iterationen durchgeführt. Da einzelne VM-Ausführungen unterschiedliche Mittelwerte haben können, ist es notwendig, die VM mehrfach zu starten und die Messwerte zu vergleichen.

Als Entwickler ist es selten hilfreich, das Rad neu zu erfinden. Stattdessen bieten etablierte Tools wie JMH die Möglichkeit, durch einfache Annotationen die Performance zu messen. Die Definition und Wartung der Benchmarks ist allerdings zeit- und die Ausführung ressourcenaufwändig. Daher ermöglicht es das Tool Peass, die ausgeführten Tests durch eine Regressions-Testselektion zu reduzieren und die Workloads von Unittests für die Messung zu verwenden. Durch eine Ursachenanalyse ermöglicht es Peass anschließend, die Ursachen von Performance-Änderungen schnell zu lokalisieren – und Regressionen vor der Produktion zu beseitigen.

Autor

David Georg Reichelt

David Georg Reichelt ist Forscher im Bereich Softwareengineering an der Universität Leipzig. Zu Beginn seiner Forschung arbeitete er an verschiedenen Projekten in den Digital Humanities und in der Energieinformatik mit.
>> Weiterlesen
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben