Dieser Artikel basiert auf einem Vortrag, den ich heute auf der SymfonyLive-Konferenz in Berlin zum ersten Mal gehalten habe.
„Es fühlt sich langsam an.“
Wenige Sätze sind in der Softwareentwicklung so häufig und zugleich so wenig hilfreich. Vage Klagen über Performance führen zu vagen Versuchen, sie zu beheben: Raten, wo das Problem liegen könnte, Code auf Verdacht ändern und auf das Beste hoffen. Dieses Vorgehen kostet Zeit und adressiert selten die tatsächliche Ursache.
Was wir stattdessen brauchen, ist ein diszipliniertes Vorgehen. Eines, das Bauchgefühl durch Daten ersetzt und „es fühlt sich langsam an“ in eine präzise Aussage darüber überführt, was langsam ist, warum es langsam ist und ob unsere Lösung tatsächlich geholfen hat.
Drei einander ergänzende Disziplinen verschaffen uns diese Präzision: Tracing, Profiling und Benchmarking.
Tracing: was geschah
Ein Trace ist eine vollständige, sequenzielle Aufzeichnung jedes Funktionsaufrufs, der während der Ausführung eines Programms stattfindet. Er erfasst, welche Funktionen in welcher Reihenfolge mit welchen Argumenten aufgerufen wurden, was sie zurückgaben und wie lange jeder Aufruf dauerte. Ein Trace erzählt die vollständige Geschichte eines Ausführungspfades von Anfang bis Ende.
Dieser Detailgrad ist von unschätzbarem Wert, wenn du den tatsächlichen Ablauf der Ausführung verstehen möchtest und nicht nur deren aggregierte Kosten. Ein Trace beantwortet Fragen wie: „Welche Folge von Aufrufen hat zu diesem Zustand geführt?“ oder „Warum wurde diese Funktion tausendmal aufgerufen, obwohl ich nur einen Aufruf erwartet habe?“
Die Herausforderung bei Traces ist ihre Größe. Eine nicht-triviale Anwendung kann Trace-Dateien mit Millionen von Zeilen erzeugen. Das Lesen einer rohen Trace-Datei ist für kleine Programme möglich, wird aber schnell unpraktikabel. An dieser Stelle wird Visualisierung unverzichtbar.
Flamegraphs sind eine der wirkungsvollsten Methoden, um Trace-Daten zu erschließen. In einem Flamegraph repräsentiert die x-Achse aggregierte Zeit und die y-Achse die Tiefe des Call-Stacks. Ein breiter Balken bedeutet, dass eine Funktion viel Zeit verbraucht hat. Flamegraphs machen Flaschenhälse unmittelbar sichtbar: Man sucht nach den breitesten Balken und folgt ihnen im Stack nach unten, um die Ursache zu finden.
Flamecharts zeigen dieselben Daten anders. Hier repräsentiert die x-Achse chronologische Zeit statt aggregierter Kosten. Das macht Flamecharts besser geeignet, um zu verstehen, wann etwas in welcher Reihenfolge geschah. Wenn du wissen musst, ob ein Flaschenhals am Anfang oder Ende eines Requests auftritt oder ob eine Funktion während einer bestimmten Phase wiederholt aufgerufen wird, zeigt dir ein Flamechart das.
Xdebug kann Trace-Daten für PHP-Anwendungen erzeugen. Inferno, ein in Rust geschriebenes Werkzeugset, kann diese Trace-Daten dann in Flamegraphs und Flamecharts verwandeln. Zusammen bilden sie eine wirkungsvolle Kombination, um zu verstehen, was dein Code zur Laufzeit tatsächlich tut.
Profiling: was am meisten kostete
Während Tracing den vollständigen Ablauf der Ereignisse aufzeichnet, beantwortet Profiling eine andere Frage: Wo wird die meiste Zeit verbracht? Ein Profiler aggregiert Ausführungsdaten und erzeugt eine statistische Zusammenfassung. Statt jeden einzelnen Funktionsaufruf in Reihenfolge anzuzeigen, sagt er dir, welche Funktionen die meiste CPU-Zeit verbraucht haben, wie oft jede Funktion aufgerufen wurde und wie die Beziehung zwischen Aufrufern und Aufgerufenen aussieht.
Zwei zentrale Kennzahlen beim Profiling sind Self Time und Inclusive Time. Self Time ist die Zeit, die im eigenen Code einer Funktion verbracht wird, ohne die Funktionen, die sie selbst aufruft. Inclusive Time umfasst alles: den eigenen Code der Funktion plus alle von ihr aufgerufenen Funktionen. Eine Funktion mit hoher Inclusive Time, aber niedriger Self Time ist nicht selbst das Problem, sondern ruft etwas auf, das es ist. Der Kette der Inclusive Time vom Einstiegspunkt bis zur Funktion mit hoher Self Time zu folgen, ist der Weg, den eigentlichen Flaschenhals zu finden.
Profiling-Daten lassen sich auf verschiedene Weise visualisieren. Eine tabellarische Ansicht erlaubt es, Funktionen nach Kosten zu sortieren und zwischen Aufrufern und Aufgerufenen zu navigieren. Ein gerichteter Call Graph stellt dieselben Daten als visuellen Graphen dar, bei dem Knotengröße und Kantendicke die Kosten widerspiegeln. Heiße Pfade werden unmittelbar als dicke, dunkle Linien sichtbar, die sich durch den Graphen ziehen. Beide Ansichten haben ihren Platz: Tabellen eignen sich besser für präzise Analysen, Graphen besser für den schnellen Überblick und für die Kommunikation von Erkenntnissen an andere.
Xdebug kann Profiling-Daten im Cachegrind-Format erzeugen, das sich mit Werkzeugen wie QCacheGrind analysieren oder mit gprof2dot als gerichteter Call Graph visualisieren lässt.
PHP-SPX ist eine leichtgewichtige Alternative, die einen anderen Ansatz verfolgt: Sie hat geringeren Overhead und bringt ein integriertes Web-Interface mit, das sowohl Timeline-Ansichten als auch Flamegraphs bietet, ohne dass externe Werkzeuge nötig sind. Xdebug und PHP-SPX schließen einander nicht aus. PHP-SPX eignet sich gut für schnelles Feedback, während Xdebugs Cachegrind-Ausgabe sich in ein reichhaltigeres Ökosystem von Analysewerkzeugen für tiefergehende Untersuchungen einfügt.
Benchmarking: der Beweis
Tracing und Profiling helfen dir dabei, ein Performance-Problem zu finden und zu verstehen. Benchmarking ist das, was dir sagt, ob deine Lösung tatsächlich gewirkt hat und ob sich die Verbesserung über die Zeit hält.
Ein Benchmark ist eine kontrollierte, wiederholbare Messung. Er isoliert einen bestimmten Code-Abschnitt und misst dessen Ausführungszeit unter definierten Bedingungen. Das Schlüsselwort lautet „kontrolliert“: Ein Benchmark, der bei jedem Lauf andere Ergebnisse liefert, ist nicht brauchbar. Gleichbleibende Hardware, gleichbleibende Konfiguration, ausreichend viele Iterationen zur Glättung von Varianz und das Bewusstsein für Faktoren wie Opcode-Caching, Garbage Collection und externe I/O sind allesamt notwendig, um Ergebnissen vertrauen zu können.
Die eigentliche Stärke von Benchmarking liegt im Vergleich. Einen Benchmark vor und nach einer Änderung laufen zu lassen, liefert eine konkrete Antwort: „Diese Änderung hat diese Operation 40 % schneller gemacht“ oder „Diese Änderung hat keinen messbaren Unterschied gemacht.“ Benchmarks über Commits oder Releases hinweg laufen zu lassen, erlaubt es, Regressionen zu erkennen, bevor sie die Produktion erreichen. Wenn Benchmarks in Continuous Integration eingebunden sind, wird Performance zu einer Eigenschaft, die aktiv überwacht wird, statt lautlos zu verfallen.
Hyperfine ist ein Kommandozeilenwerkzeug zum Messen der Ausführungszeit beliebiger Kommandos. Es kümmert sich um Aufwärmläufe, statistische Auswertung und den Vergleich mehrerer Kommandos.
PHPBench ist speziell für das Benchmarking von PHP-Code auf Funktions- oder Methodenebene entworfen, mit Unterstützung für Iterationen, Revolutions und statistische Zusicherungen über die Ergebnisse.
Ein stimmiger Workflow
Diese drei Disziplinen entfalten ihre größte Wirkung, wenn sie als Teile eines einzigen Workflows zusammenspielen und nicht als isolierte Techniken. Du beobachtest eine vermeintliche Verlangsamung. Du nimmst einen Trace auf und visualisierst ihn, um den verdächtigen Bereich einzugrenzen. Du profilst, um die Kosten zu bestätigen und den genauen Flaschenhals zu bestimmen. Du setzt eine Lösung um. Du benchmarkst vorher und nachher, um die Verbesserung zu belegen und eine Baseline für die Regressionserkennung zu schaffen.
Jeder Schritt baut auf dem vorhergehenden auf und speist den nächsten. Tracing grenzt die Suche ein. Profiling bestätigt und quantifiziert. Benchmarking validiert und schützt. Einen dieser Schritte auszulassen bedeutet, entweder das Problem zu erraten, die Lösung zu erraten oder zu hoffen, dass die Lösung hält.
Performance-Debugging sollte keine Krisenreaktion sein. Es sollte ein regelmäßiger Bestandteil davon sein, wie wir Software entwickeln. Die Werkzeuge existieren. Der Workflow ist geradlinig. Das Einzige, was sich ändern muss, ist die Gewohnheit, erst dann zu diesen Werkzeugen zu greifen, wenn sich etwas „langsam anfühlt“.