Unterstützung von perf map-kompatiblen Profilern in Python¶
Der Linux-Profiler perf und samply sind mächtige Werkzeuge, mit denen du deine Anwendung profilieren und Informationen über ihre Leistung gewinnen kannst. Zu beiden Werkzeugen gibt es ein lebendiges Umfeld, das bei der Auswertung der erzeugten Daten hilft.
Das Hauptproblem beim Einsatz dieser Profiler mit Python-Anwendungen ist, dass sie nur Informationen über native Symbole bekommen, also über die Namen von in C geschriebenen Funktionen und Prozeduren. Das bedeutet, dass die Namen und Dateinamen der Python-Funktionen in deinem Code in der Ausgabe des Profilers nicht auftauchen.
Seit Python 3.12 kann der Interpreter in einer besonderen Betriebsart laufen, in der Python-Funktionen in der Ausgabe kompatibler Profiler erscheinen. Ist diese Betriebsart eingeschaltet, schiebt der Interpreter vor der Ausführung jeder Python-Funktion ein kleines, zur Laufzeit erzeugtes Codestück ein und teilt dem Profiler über perf-map-Dateien mit, wie dieses Codestück und die zugehörige Python-Funktion zusammenhängen.
Bemerkung
Die Unterstützung für das Profilieren gibt es unter Linux und macOS auf ausgewählten Architekturen. Perf gibt es unter Linux, samply lässt sich sowohl unter Linux als auch unter macOS verwenden. Die Unterstützung von samply unter macOS gibt es ab Python 3.15. Ob dein System unterstützt wird, zeigt die Ausgabe des Build-Schritts configure oder die Ausgabe von python -m sysconfig | grep HAVE_PERF_TRAMPOLINE.
Betrachte zum Beispiel das folgende Skript:
def foo(n):
result = 0
for _ in range(n):
result += 1
return result
def bar(n):
foo(n)
def baz(n):
bar(n)
if __name__ == "__main__":
baz(1000000)
Wir können perf laufen lassen, um CPU-Aufrufspuren mit 9999 Hertz abzutasten:
$ perf record -F 9999 -g -o perf.data python my_script.py
Anschließend können wir die Daten mit perf report auswerten:
$ perf report --stdio -n -g
# Children Self Samples Command Shared Object Symbol
# ........ ........ ............ .......... .................. ..........................................
#
91.08% 0.00% 0 python.exe python.exe [.] _start
|
---_start
|
--90.71%--__libc_start_main
Py_BytesMain
|
|--56.88%--pymain_run_python.constprop.0
| |
| |--56.13%--_PyRun_AnyFileObject
| | _PyRun_SimpleFileObject
| | |
| | |--55.02%--run_mod
| | | |
| | | --54.65%--PyEval_EvalCode
| | | _PyEval_EvalFrameDefault
| | | PyObject_Vectorcall
| | | _PyEval_Vector
| | | _PyEval_EvalFrameDefault
| | | PyObject_Vectorcall
| | | _PyEval_Vector
| | | _PyEval_EvalFrameDefault
| | | PyObject_Vectorcall
| | | _PyEval_Vector
| | | |
| | | |--51.67%--_PyEval_EvalFrameDefault
| | | | |
| | | | |--11.52%--_PyCompactLong_Add
| | | | | |
| | | | | |--2.97%--_PyObject_Malloc
...
Wie du siehst, erscheinen die Python-Funktionen nicht in der Ausgabe, sondern nur _PyEval_EvalFrameDefault (die Funktion, die den Python-Bytecode auswertet). Das ist leider nicht besonders hilfreich, weil alle Python-Funktionen dieselbe C-Funktion zur Auswertung des Bytecodes verwenden und wir daher nicht wissen können, welche Python-Funktion zu welcher bytecode-auswertenden Funktion gehört.
Führen wir dasselbe Experiment stattdessen mit eingeschalteter perf-Unterstützung durch, bekommen wir:
$ perf report --stdio -n -g
# Children Self Samples Command Shared Object Symbol
# ........ ........ ............ .......... .................. .....................................................................
#
90.58% 0.36% 1 python.exe python.exe [.] _start
|
---_start
|
--89.86%--__libc_start_main
Py_BytesMain
|
|--55.43%--pymain_run_python.constprop.0
| |
| |--54.71%--_PyRun_AnyFileObject
| | _PyRun_SimpleFileObject
| | |
| | |--53.62%--run_mod
| | | |
| | | --53.26%--PyEval_EvalCode
| | | py::<module>:/src/script.py
| | | _PyEval_EvalFrameDefault
| | | PyObject_Vectorcall
| | | _PyEval_Vector
| | | py::baz:/src/script.py
| | | _PyEval_EvalFrameDefault
| | | PyObject_Vectorcall
| | | _PyEval_Vector
| | | py::bar:/src/script.py
| | | _PyEval_EvalFrameDefault
| | | PyObject_Vectorcall
| | | _PyEval_Vector
| | | py::foo:/src/script.py
| | | |
| | | |--51.81%--_PyEval_EvalFrameDefault
| | | | |
| | | | |--13.77%--_PyCompactLong_Add
| | | | | |
| | | | | |--3.26%--_PyObject_Malloc
Den Profiler samply verwenden¶
samply ist ein moderner Profiler, der sich als Alternative zu perf verwenden lässt. Er nutzt dieselben perf-map-Dateien, die Python erzeugt, und ist damit mit der Profiling-Unterstützung von Python kompatibel. Besonders nützlich ist samply unter macOS, wo perf nicht verfügbar ist.
Um samply mit Python zu verwenden, installiere es zunächst nach der Anleitung unter https://github.com/mstange/samply und führe dann aus:
$ samply record PYTHONPERFSUPPORT=1 python my_script.py
Das öffnet eine Weboberfläche, in der du die Profiling-Daten interaktiv auswerten kannst. Der Vorteil von samply ist, dass es eine moderne, webbasierte Oberfläche zur Auswertung von Profiling-Daten bietet und sowohl unter Linux als auch unter macOS funktioniert.
Unter macOS erfordert die Unterstützung von samply Python 3.15 oder neuer. Außerdem kann samply unter macOS wegen Einschränkungen des Betriebssystems keine signierten Python-Programmdateien profilieren. Du kannst Python-Programmdateien profilieren, die du selbst kompiliert hast oder die unsigniert oder lokal signiert sind (etwa alles, was über Homebrew installiert wurde). Um sich unter macOS an laufende Prozesse anzuhängen, führe einmal samply setup aus (und bei jeder Aktualisierung von samply erneut), damit sich die samply-Programmdatei selbst signiert.
Wie man die perf-Profiling-Unterstützung einschaltet¶
Die perf-Profiling-Unterstützung lässt sich entweder von Anfang an über die Umgebungsvariable PYTHONPERFSUPPORT oder die Option -X perf einschalten oder dynamisch über sys.activate_stack_trampoline() und sys.deactivate_stack_trampoline().
Die Funktionen aus sys haben Vorrang vor der Option -X, und die Option -X hat Vorrang vor der Umgebungsvariablen.
Beispiel mit der Umgebungsvariablen:
$ PYTHONPERFSUPPORT=1 perf record -F 9999 -g -o perf.data python my_script.py
$ perf report -g -i perf.data
Beispiel mit der Option -X:
$ perf record -F 9999 -g -o perf.data python -X perf my_script.py
$ perf report -g -i perf.data
Beispiel mit den sys-APIs in der Datei example.py:
import sys
sys.activate_stack_trampoline("perf")
do_profiled_stuff()
sys.deactivate_stack_trampoline()
non_profiled_stuff()
… und dann:
$ perf record -F 9999 -g -o perf.data python ./example.py
$ perf report -g -i perf.data
Wie man die besten Ergebnisse erzielt¶
Für die besten Ergebnisse solltest du die Frame-Pointer eingeschaltet lassen. Auf unterstützten GCC-kompatiblen Toolchains baut CPython sich selbst mit -fno-omit-frame-pointer und ähnlichen Flags (Einzelheiten siehe --without-frame-pointers). Diese Flags erlauben es Profilern, den Stapel allein über den Frame-Pointer abzuwickeln und nicht über DWARF-Debug-Informationen. Der Grund: Weil der Code, der für die perf-Unterstützung eingeschoben wird, dynamisch erzeugt wird, hat er keine DWARF-Debug-Informationen.
Ob dein System mit diesem Flag kompiliert wurde, kannst du so prüfen:
$ python -m sysconfig | grep 'no-omit-frame-pointer'
Erscheint keine Ausgabe, bedeutet das, dass dein Interpreter nicht mit Frame-Pointern kompiliert wurde und daher möglicherweise keine Python-Funktionen in der Ausgabe von perf zeigen kann.
Wie man ohne Frame-Pointer arbeitet¶
Wenn du mit einem Python-Interpreter arbeitest, der ohne Frame-Pointer kompiliert wurde, kannst du den perf-Profiler dennoch verwenden, allerdings ist der Zusatzaufwand etwas höher, weil Python die Abwicklungsinformationen für jeden Python-Funktionsaufruf zur Laufzeit erzeugen muss. Außerdem braucht perf länger, um die Daten zu verarbeiten, weil es den Stapel über die DWARF-Debug-Informationen abwickeln muss, und das ist ein langsamer Vorgang.
Um diese Betriebsart einzuschalten, kannst du die Umgebungsvariable PYTHON_PERF_JIT_SUPPORT oder die Option -X perf_jit verwenden. Damit wird die JIT-Betriebsart für den perf-Profiler eingeschaltet.
Bemerkung
Wegen eines Fehlers im Werkzeug perf funktioniert die JIT-Betriebsart nur mit perf-Versionen höher als v6.8. Die Korrektur wurde außerdem in Version v6.7.2 des Werkzeugs zurückportiert.
Beachte beim Prüfen der Version des perf-Werkzeugs (möglich über perf version), dass manche Distributionen eigene Versionsnummern mit einem --Zeichen ergänzen. Das heißt, dass perf 6.7-3 nicht zwangsläufig perf 6.7.3 ist.
In der perf-JIT-Betriebsart brauchst du einen zusätzlichen Schritt, bevor du perf report ausführen kannst: Du musst den Befehl perf inject aufrufen, um die JIT-Informationen in die Datei perf.data einzuspeisen.:
$ perf record -F 9999 -g -k 1 --call-graph dwarf -o perf.data python -Xperf_jit my_script.py
$ perf inject -i perf.data --jit --output perf.jit.data
$ perf report -g -i perf.jit.data
oder mit der Umgebungsvariablen:
$ PYTHON_PERF_JIT_SUPPORT=1 perf record -F 9999 -g --call-graph dwarf -o perf.data python my_script.py
$ perf inject -i perf.data --jit --output perf.jit.data
$ perf report -g -i perf.jit.data
Der Befehl perf inject --jit liest perf.data, nimmt automatisch die von Python erzeugte perf-Dump-Datei auf (in /tmp/perf-$PID.dump) und erzeugt dann perf.jit.data, das alle JIT-Informationen zusammenführt. Er sollte außerdem eine Menge Dateien jitted-XXXX-N.so im aktuellen Verzeichnis erzeugen. Das sind ELF-Abbilder aller JIT-Trampoline, die Python erzeugt hat.
Warnung
Bei --call-graph dwarf nimmt das Werkzeug perf Momentaufnahmen des Stapels des profilierten Prozesses und speichert die Informationen in der Datei perf.data. Standardmäßig ist der Stapelauszug 8192 Bytes groß, aber du kannst die Größe ändern, indem du sie hinter einem Komma angibst, etwa --call-graph dwarf,16384.
Die Größe des Stapelauszugs ist wichtig: Ist sie zu klein, kann perf den Stapel nicht abwickeln und die Ausgabe bleibt unvollständig. Ist sie andererseits zu groß, kann perf den Prozess nicht so häufig abtasten wie gewünscht, weil der Zusatzaufwand höher ist.
Besonders wichtig ist die Stapelgröße beim Profilieren von Python-Code, der mit niedrigen Optimierungsstufen (etwa -O0) kompiliert wurde, weil solche Builds zu größeren Stapel-Frames neigen. Wenn du Python mit -O0 kompilierst und in deiner Profiling-Ausgabe keine Python-Funktionen siehst, versuche, die Größe des Stapelauszugs auf 65528 Bytes (das Maximum) zu erhöhen:
$ perf record -F 9999 -g -k 1 --call-graph dwarf,65528 -o perf.data python -Xperf_jit my_script.py
Unterschiedliche Kompilierflags können die Stapelgrößen erheblich beeinflussen:
Builds mit
-O0haben üblicherweise viel größere Stapel-Frames als solche mit-O1oder höherZusätzliche Optimierungen (
-O1,-O2usw.) verringern die Stapelgröße üblicherweiseFrame-Pointer (
-fno-omit-frame-pointer) ermöglichen im Allgemeinen ein zuverlässigeres Abwickeln des Stapels