Java Performance Messen – Mit Sampling oder Instrumentierung?

Keine Kommentare

In einigen kürzlich geführten Diskussionen nahm ich eine Verwirrung über den Unterschied zwischen den Messverfahren Sampling und Instrumentierung wahr.
Häufig wird über die Vor- und Nachteile der Verfahren gesprochen ohne zu verstehen, wie diese funktionieren. Einige Tools, wie zum Beispiel der rechts abgebildete JProfiler 7, fragen direkt beim Start das gewünschte Verfahren ab.
Doch wie kann man die für das Problem passende Strategie wählen, ohne die zu Grunde liegenden Konzepte zu verstehen?

Gehen wir also erstmal einen Schritt zurück und fragen uns, wie man überhaupt die Performanz eines Computerprogramms messen kann:
a) Wir fügen dem echten Anwendungscode eine Art Messcode hinzu, welcher uns genau ermittelt, wie lang der echte Code zur Ausführung benötigte.
b) Wir nutzen einen externen Beobachter, welcher den echten Anwendungscode bei der Ausführung beobachtet und mitteilen kann, welcher Code zu bestimmten Zeitpunkten ausgeführt wurde.

Beide Verfahren funktionieren und liefern Ergebnisse. Dennoch sind sie fundamental verschieden. In diesem Post werde ich deshalb versuchen sie zu demonstrieren.

Die genutzen Beispiele basieren auf Java und der JVM, lassen sich aber grundsätzlich auch auf andere Sprachen, insbesondere auf .Net und seiner CLR, übertragen.


Wie können wir in Java Messcode hinzufügen?
Code hinzuzufügen scheint die scheinbar einfachere Variante zu sein, gibt es doch eine Vielzahl von etablierten Verfahren um Performancemesscode in die Anwendung zu integrieren:

  • Manuell hinzugefügte System.out.println-Ausgaben für wichtige Methoden. Die Ausführungszeit wird direkt in das Log geschrieben.
  • Verwendung von selbstgeschriebenen javax.management Beans, welche Ausführungszeiten speichern. Diese werden manuell ermittelt und können toolgestütz abgefragt werden.
  • Durch Verwendung von AOP Bibliotheken werden Aspekte zur Ausführungszeitmessung erstellt.
  • Mittels eines JVMTI-Agenten werden über Standard APIs Codemodifikationen und Zeitmessungen durchgeführt.

Natürlich ist diese Aufzählung nicht vollständig, und die Verfahren überschneiden sich teilweise, jedoch verwenden alle diese Verfahren zusätzlichen Code vor und nach dem gemessenen Code. Die einfachen Verfahren verwenden üblicherweise Dateilogging oder JMX (JSR-3, JSR-250). JMX wurde zwar dafür entworfen, Metriken zu sammeln und dynamisch Konfigurationsänderungen vorzunehmen, jedoch muss bei der Entwicklung die Verwendung direkt vorgesehen werden.
Die ausgereifteren Methoden sind dagegen dynamischer und erfordern keine direkte Einprogrammierung. Sie fügen Code mittels „Instrumentierung“ dynamisch hinzu, meist unter der Verwendung von Bytecode-Modifikation.

Wie kann in Java Code beobachtet werden?
Idealerweise beobachtet man von außerhalb der Laufzeitumgebung (der JVM). Zwar wurde JMX in JSR-160 auch extern verfügbar gemacht, jedoch ist JMX, wie bereits erläutert, zu oberflächlich und erfordert zusätzliche Programmierung. Andere Management APIs erlauben uns zwar den Status der JVM Abzufragen, ermöglichen aber keine Analyse von Codegeschwindigkeit. Um eine bessere, pseudoexterne, Beobachtung durchführen zu können, erzeugen wir also direkt in der beobachteten JVM einen Thread, welcher diese Beobachtungsaufgabe übernimmt.
Dieser Thread sieht sich von Zeit zu Zeit die anderen laufenden Threads an und misst ihre Aktivität. Das Intervall zwischen diesen Beobachtungen sollte jedoch relativ kurz gewählt werden um viele Details zu sehen. Diese externe Beobachtung wird „Sampling“ genannt. Dabei wird auf der Zeitachse gesampelt, d. h. es es wird nicht ständig, dafür jedoch über alle Anfragen oder Threads gemessen.

Da ich in diesem Beitrag die beiden Verfahren mittels einfach zu verstehendem Code vergleiche, sind die Implementierungen nicht besonders clever oder optimieriert.

Beispielcode

Vorausgehend kann der verwendete Code als ZIP Attachment, oder von SamplingVsInstrumentation auf meinem privaten GitHub heruntergeladen werden.

Wir haben eine Klasse namens Demo, welche unseren gesamten Produktionscode enthält. Die Methoden simulieren eine gewisse Laufzeit und sind praktischerweise anhand der erwarteten Ausführungszeit benannt: method100ms()
Dies erlaubt die einfachere Auswertung der Ausgaben. Leider sind in echtem Code die Methoden nie mit ihrer Ausführungszeit benannt. 🙂
Es gibt auch eine Methode namens method0ms(), welche trotz Ihres Namens zwar Code ausführt, jedoch sehr viel schneller als eine Millisekunde ist.

Der DemoRunner besitzt zwei Methoden, um diesen Code von der Demo Klasse aufzurufen:
a) mixedDemo() wird die Methoden [100, 1, 100, 500, 1, 100, 1, 50, 50] aufrufen.
b) massDemo() wird die Methode method0ms 100 Millionen Mal aufrufen.

Als können wir das Beispiel so starten:

public static void main(final String[] args) {
	mixedDemo();
	massDemo();
}

Wir können diesen Code laufen lassen, wissen jedoch nicht, wie lange er zur Ausführung gebraucht hat. Wir können aber eine einfache externe Beobachtung durchführen: Mit Hilfe einer Stoppuhr.
Auf meiner Maschine (Dell E6420, Intel 2520 2,5Ghz 4 Core CPU, 64bit Win, Java 1.6.0_27) benötigt mixedDemo() etwa eine Sekunde. Die vielen 0 ms Aufrufe schlagen mit fast 3 Sekunden zu Buche.

Wollen wir genauer messen, so fügen wir einen einfachen Messcode hinzu:

public static void main(final String[] args) {
	long start = System.currentTimeMillis();
	mixedDemo();
	long end = System.currentTimeMillis();
	System.out.printf("%s Demo completed in %dms%n", DemoType.MIXED, end - start);
 
	start = System.currentTimeMillis();
	massDemo();
	end = System.currentTimeMillis();
	System.out.printf("%s Demo completed in %dms%n", DemoType.MASS, end - start);
}

Dies liefert uns:

Running Demo with [100, 1, 100, 500, 1, 100, 1, 50, 50] methods
MIXED Demo completed in 967ms
Running Demo with 100000000 0ms methods
MASS Demo completed in 2781ms

Messung erzeugt Overhead
Jede Messung wird die Ergebnisse verzerren. Allgemein spricht man davon, dass der wahre Wert durch sogenannte systematische Fehler sowie zufällige Fehler beeinflusst wird.
Systematische Fehler werden durch Messwerkzeuge hervorgerufen und können in gewissem Maße gemessen oder geschätzt werden. Zufällige Fehler sind jedoch nicht vorhersagbar.
Wenn die CPU Messcode anstelle von echtem Code ausführt sprechen wir in der Regel von „Overhead“, welcher systematische Fehler hervorruft. Jedoch verhindert die verbrauchte CPU Zeit auch möglicherweise die nebenläufige Ausführung von anderem Code und erzeugt so zufällige Fehler, sowohl im gemessenen, als auch im ungemessenen Code. Insbesondere kritisch ist die von aussen messbare Verlangsamung von Transaktionen im System. Erhöhter Ressourcenverbrauch ist in der Regel akzeptabel.

Instrumentierung

Um besser zu sehen was der Demo Code tatsächlich tut, werde ich einen Instrumentierungsagenten auf Basis von AOP mit Hilfe von aspectj loadtime weaving bauen. Dadurch wird vor bestimmten Codestellen mittels einer sogenannten „pointcut expression“ Messcode eingefügt.
aspectj wird dann den Bytecode der Klassen modifzieren, sobald diese geladen werden. Der pointcut-Ausdruck beschreibt die Signatur der zu instrumentierenden Methode.
Ich verwende in diesem Fall einen @Around advice, dem ein sogenannter JoinPoint, also eine Referenz auf die eigentlich auszuführende Codestelle, übergeben wird. Aspectj selbst nutzt einen JVMTI-Agenten um die Bytecodemodifikation für mich durchzuführen. Ich schreibe lediglich den Aspekt in dem ich messe.

Der interessante Teil des codes sieht so aus:

@Around("   call(void de.codecentric.performance.Demo.method* (..)) ")
public void aroundDemoMethodCall(final ProceedingJoinPoint thisJoinPoint) throws Throwable {
	long start = System.currentTimeMillis();
	thisJoinPoint.proceed();
	long end = System.currentTimeMillis();
	String currentMethod = thisJoinPoint.getSignature().toString();
	if (executionPath.size() < MAX_EXECUTION_PATH) {
		executionPath.add(currentMethod);
	}
	MethodStatistics statistics = methodStatistics.get(currentMethod);
	if (statistics == null) {
		statistics = new MoreMethodStatistics(currentMethod);
		methodStatistics.put(currentMethod, statistics);
	}
	statistics.addTime(end - start);
	overhead += System.currentTimeMillis() - end;
}

Direkt im Around Advice muss ich schon explizit angeben welche Methoden ich abfangen möchte: call(void de.codecentric.performance.Demo.method* (..)).
Ich speichere zuerst die Startzeit, bevor ich an den eigentlichen Code delegieren. Sobald dieser durchgelaufen ist speichere ich die Endzeit.
Anschließend speichere ich diesen Methodennamen in einem „execution path“, solange dieser das Maximum noch nicht erreicht hat. Ferner ermittele und speichere ich Statistiken für diese Methode. Zuletzt speichere ich die Zeit, die ich für diese Messungen benötigt habe in einem Feld namens „overhead“.

Lasse ich die Demo mit Instrumentierung laufen, so erhalte ich:

Running Demo with [100, 1, 100, 500, 1, 100, 1, 50, 50] methods
MIXED Demo completed in 950ms
Trace Aspect recorded following results:
  void de.codecentric.performance.Demo.method500ms() 501ms (min: 501ms, max: 501ms) - 1 invocations
  void de.codecentric.performance.Demo.method100ms() 303ms (min: 101ms, max: 101ms) - 3 invocations
  void de.codecentric.performance.Demo.method50ms() 102ms (min: 51ms, max: 51ms) - 2 invocations
  void de.codecentric.performance.Demo.method1ms() 6ms (min: 2ms, max: 2ms) - 3 invocations
Code Execution Path:
  void de.codecentric.performance.Demo.method100ms()
  void de.codecentric.performance.Demo.method1ms()
  void de.codecentric.performance.Demo.method100ms()
  void de.codecentric.performance.Demo.method500ms()
  void de.codecentric.performance.Demo.method1ms()
  void de.codecentric.performance.Demo.method100ms()
  void de.codecentric.performance.Demo.method1ms()
  void de.codecentric.performance.Demo.method50ms()
  void de.codecentric.performance.Demo.method50ms()
Agent internal Overhead 2ms
Agent Overhead 91ms
 
Running Demo with 100000000 0ms methods
MASS Demo completed in 7261ms
Trace Aspect recorded following results:
void de.codecentric.performance.Demo.method0ms() 2892ms (min: 0ms, max: 2ms) - 100000000 invocations
Code Execution Path:
  void de.codecentric.performance.Demo.method0ms()
  void de.codecentric.performance.Demo.method0ms()
  [...]
  void de.codecentric.performance.Demo.method0ms()
  void de.codecentric.performance.Demo.method0ms()
Execution Path incomplete!
Agent internal Overhead 2836ms
Agent Overhead 4ms

Wir können deutlich sehen, dass die Instrumentierung die Ausführungsreihenfolge sowie genaue Ausführungszeiten korrekt ermittelt hat.
Jedoch sehen wir im zweiten Beispiel ein Problem. Der Ausführungspfad wäre sehr sehr lang gewesen. Um keinen OutOfMemoryError zu erzeugen, musste ich den Pfad limitieren.

Und der Overhead?

Mein Code misst selbständig Overhead, jedoch ist auch diese Messung ungenau. Zum einen ist da der interne overhead, also die Zeit, die der Agent zum Speichern der Statistiken benötigt. Ich habe ihn intern genannt, da er nicht wirklich von der Laufzeit der gemessenen Methode zu trennen ist.
Und dann gibt es den externen overhead, welcher die Zeit zum Starten und Stoppen des Agenten beinhaltet.
Nun ist der Overhead im ersten Beispiel relativ gering, lediglich das Ausgeben der Ergebnisse benötigte etwas Zeit. Dieser Teil ist im zweiten Lauf zwar schneller, jedoch war der interne Overhead enorm. Und es gibt auch noch ein Problem mit der korrekten Ermittlung des Overheads. Die Gesamtzeit minus die Methodenzeit sind ingesamt 7261ms – 2892ms = 4369 ms. Jedoch behauptet der Agent, nur für das Fehlen von 2836ms verantwortlich zu sein. Die fehlende Zeit ist zum einen der Messungenauigkeit, zum anderen dem nicht gemessenen Zusatzcode (wie dem Methodenaufruf von aroundDemoMethodCall(JoinPoint)) geschuldet.

Sampling

Mein Sampling Code ist trivial. Er erzeugt einen Daemon Thread, welcher alle 10 ms in den Main Thread schaut und die Aktivität aufzeichnet. Der interessante Teil ist dieser:

@Override
public void run() {
	lastSample = System.currentTimeMillis();
	while (true) {
		try {
			Thread.sleep(interval);
		} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
		}
 
		String currentMethod = getCurrentMethod();
		long currentSample = System.currentTimeMillis();
 
		addMeasurementsIfStillInMethod(currentMethod, currentSample);
 
		lastMethod = currentMethod;
		lastSample = currentSample;
 
		overhead += System.currentTimeMillis() - currentSample;
	}
}
 
private void addMeasurementsIfStillInMethod(final String currentMethod, final long currentSample) {
	if (currentMethod.equals(lastMethod)) {
		MethodStatistics statistics = methodStatistics.get(currentMethod);
		if (statistics == null) {
			statistics = new MethodStatistics(currentMethod);
			methodStatistics.put(currentMethod, statistics);
		}
		statistics.addTime(currentSample - lastSample);
	} else {
		if (executionPath.size() < MAX_EXECUTION_PATH) {
			executionPath.add(getParentMethod() + " > " + currentMethod);
		}
	}
}
 
private String getCurrentMethod() {
	StackTraceElement topOfStack = monitoredThread.getStackTrace()[0];
	return formatStackElement(topOfStack);
}

Der Agent schläft also das vorgegebene Intervall und sucht nach dem Erwachen die aktuell vom Main Thread ausgeführte Methode mittels monitoredThread.getStackTrace()[0]. Dann nimmt er die aktuelle Zeit um herauszufinden wie lange er seit dem letzten Mal wirklich geschlafen hat (zwar sollte dies immer um die 10ms sein, jedoch kann dies auch abweichen!). Dann ermittelt der Agent ob die zuletzt beobachtete Methode immernoch ausgeführt wird. Das ist deshalb wichtig, da nur so der Agent die geschlafene Zeit auf diese Methode aufgerechnet werden kann. Taucht eine neue Methode auf speichern wir diese wieder im Execution Path. Auch hier beachten wir ein eventuelles Limit.
Zuletzt setzen wir den neuen Zustand und berechnen unseren Overhead.

Agent monitoring thread main with sampling interval of 10ms
Running Demo with [100, 1, 100, 500, 1, 100, 1, 50, 50] methods
MIXED Demo completed in 916ms
Agent stopped - Results:
  void de.codecentric.performance.Demo.method500ms() 488ms
  void de.codecentric.performance.Demo.method100ms() 285ms
  void java.lang.Thread.sleep() 101ms
Code Execution Path:
  void de.codecentric.performance.Demo.runCode() > void de.codecentric.performance.Demo.method100ms()
  void de.codecentric.performance.Demo.runCode() > void de.codecentric.performance.Demo.method500ms()
  void de.codecentric.performance.Demo.runCode() > void de.codecentric.performance.Demo.method100ms()
  void de.codecentric.performance.Demo.method50ms() > void java.lang.Thread.sleep()
Agent internal Overhead 4ms
Agent Overhead 36ms
Agent monitoring thread main with sampling interval of 10ms
 
Running Demo with 100000000 0ms methods
MASS Demo completed in 2959ms
Agent stopped - Results:
  void de.codecentric.performance.Demo.method0ms() 2736ms
Code Execution Path:
  void de.codecentric.performance.DemoRunner.massDemo() > void de.codecentric.performance.DemoRunner.massDemo()
  void de.codecentric.performance.Demo.runCode() > void de.codecentric.performance.Demo.method0ms()
Agent internal Overhead 0ms
Agent Overhead 0ms

Wir können gut sehen, dass der Agent durch das Sampling die Ausführungen von einer Millisekunde verpasst hat. Jedoch sehen wir etwas neues: ein Thread.sleep(), welches wir bei der Instrumentierung nicht gesehen haben. Da Sampling über den Stacktrace sehr einfach an die Aufrufhierarchie, z.B. an die aufrufende Methode mittels monitoredThread.getStackTrace()[1] kommt, finden wir heraus, dass die Methode method50ms in wirklichkeit ein Thread.sleep() aufruft.
Doch es fehlen nicht nur die 1 ms Aufrufe, auch sind dadurch die aufeinander folgenden zwei 100 ms Aufrufe als eine 200ms lange Ausführung gesehen worden. Dies glättet quasi die Daten automatisch, indem es die irrelevanten Ausführungen überspringt. Die Ursache dafür ist klar: der Agent kann nichts sehen was kürzer als sein Sampling Intervall ist. Doch es gibt noch andere interesssante Punkte bei der Wahl des Sampling Intervalls zu beachten. Ein gutes Paper zu diesem Thema ist: „Evaluating the Accuracy of Java Profilers

Wir können beim zweiten Beispiel sehen, dass das Sampling ebensogut die Laufzeit ermittelt hat. Insbesondere gibt es auch kein Problem mit der Execution Path Länge.

Overhead zum Zweiten

Im ersten Beispiel ist der Overhead vergleichbar mit dem der Instrumentierung. Jedoch ist der Overhead im zweiten Beispiel drastisch geringer. Uns fehlen nur 223ms (2959ms – 2736ms), jedoch scheint dies nicht wesentlich in den agenteninternen Messungen zu liegen. Auch läuft der Agent in einem eigenen Thread, weshalb er nicht die Ausführung des gemessenen Codes verlängert und auf Mehrkernsystemen effizienter arbeiten kann.

Zusammenfassung

Sowohl Instrumentierung als auch Sampling haben unterschiedliche Charakteristiken mit ihren eigenen Vor- und Nachteilen.
Diese sind durch die konzeptuellen Unterschiede bedingt und können zwar durch cleveren Aufbau der Agenten vermindert, jedoch nie ganz beseitigt werden.

Instrumentierung

  • Hat Zugriff auf die Ausführungsanzahl, genaue tatsächliche/max/min/durchschnittliche Ausführungszeit und genaue Ausführungsreihenfolge.
  • Benötigt Konfiguration der zu messenden Codestellen. Konfiguration muss ausbalanciert sein und massenhaft aufgerufene Methoden, sowie Methoden deren Ausführungszeit kürzer als die Messcodezeit ist, ausschließen.
  • Produziert erheblich größere Datenmengen.

Sampling

  • Stabiler Overhead, der hauptsächlich durch das Samplingintervall, nicht durch den gemessenen Code, bestimmt wird.
  • Ausführungshotspots werden anstelle einer feingranularen Codereihenfolge gemessen.
  • Kann unbekannten Code aufspüren.
  • Läuft effizient auf Mehrkernsystemen.

Fabian Lange ist Lead Agent Engineer bei Instana und bei der codecentric als Performance Geek bekannt. Er baut leidenschaftlich gerne schnelle Software und hilft anderen dabei, das Gleiche zu tun.
Er kennt die Java Virtual Machine bis in die letzte Ecke und beherrscht verschiedenste Tools, die JVM, den JIT oder den GC zu verstehen.
Er ist beliebter Vortragender auf zahlreichen Konferenzen und wurde unter anderem mit dem JavaOne Rockstar Award ausgezeichnet.

Share on FacebookGoogle+Share on LinkedInTweet about this on TwitterShare on RedditDigg thisShare on StumbleUpon

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.