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 "-O0" haben üblicherweise viel größere Stapel-Frames
    als solche mit "-O1" oder höher

  * Zusätzliche Optimierungen ("-O1", "-O2" usw.) verringern die
    Stapelgröße üblicherweise

  * Frame-Pointer ("-fno-omit-frame-pointer") ermöglichen im
    Allgemeinen ein zuverlässigeres Abwickeln des Stapels
