Kodokon kodokon.com

Performance: Profiling, GIL, Generatoren vs. Listen, Speicherfallen

Miss, bevor du optimierst: timeit und cProfile, was der GIL wirklich blockiert, die Genügsamkeit von Generatoren und die Speicherfallen von Strings und Slices.

11 Min. · 3 Fragen

Diese Lektion in Kodokon öffnen

Nie raten, messen. timeit isoliert einen verlässlichen Mikro-Benchmark: Es schaltet den Garbage Collector ab und wiederholt die Ausführung, um das Rauschen zu glätten. cProfile kartiert die Aufrufe eines ganzen Programms - sortiere nach cumtime (kumulierte Zeit, Unteraufrufe inbegriffen), um die wahren Schuldigen zu erkennen. Die goldene Regel jeder Performance-Arbeit: Die langsame Zeile ist fast nie die, die du vermutest.

BASH
python -m timeit "sum(x * x for x in range(1000))"
python -m timeit -s "s = 'abc' * 100" "s.upper()"
python -m cProfile -s cumtime -m timeit "min(range(50))"
Die Option -s von timeit bereitet den Kontext außerhalb der Messung vor.

Der GIL (Global Interpreter Lock) garantiert, dass innerhalb eines Prozesses immer nur ein Thread Python-Bytecode ausführt. Folge: Threads beschleunigen reine Berechnung nicht. Für I/O bleiben sie jedoch perfekt, denn der GIL wird bei Netzwerk- oder Festplattenlesevorgängen freigegeben, ebenso von vielen C-Bibliotheken wie NumPy. Um mehrere Kerne mit Berechnung auszulasten, geh über multiprocessing oder concurrent.futures.ProcessPoolExecutor. Python 3.13 bietet eine experimentelle "free-threaded"-Variante ohne GIL, in der Produktion noch selten.

PYTHON
import sys

squares_list = [x * x for x in range(1_000_000)]
squares_gen = (x * x for x in range(1_000_000))

print(sys.getsizeof(squares_list), "bytes")
print(sys.getsizeof(squares_gen), "bytes")
print(sum(squares_gen))
Etwa 8 MB für die Liste, etwa 200 Bytes für den Generator.

Ein Generator wiegt ein paar hundert Bytes, unabhängig von der Länge der Sequenz: Er speichert nur seinen Ausführungszustand und erzeugt jeden Wert auf Anfrage. Verkette sie zu Pipelines - sum(x * x for x in data) -, um Ströme zu verarbeiten, ohne je eine Zwischenliste zu materialisieren. Ein Kompromiss, den du kennen musst: Ein Generator ist einmalig nutzbar. Einmal erschöpft, erzeugt er nichts mehr, stillschweigend - eine klassische Fehlerquelle, bei der die zweite Iteration "leer" erscheint.

PYTHON
import timeit

def concat(n: int) -> str:
    out = ""
    for i in range(n):
        out += str(i)
    return out

def join(n: int) -> str:
    return "".join(str(i) for i in range(n))

t1 = timeit.timeit(lambda: concat(20_000), number=10)
t2 = timeit.timeit(lambda: join(20_000), number=10)
print(f"concat: {t1:.3f} s / join: {t2:.3f} s")
Miss auf deiner Maschine: join bleibt die portable, sichere Wahl.

Wissenscheck

Stelle sicher, dass du die wichtigsten Punkte dieser Lektion behalten hast.

  1. Was genau verhindert der GIL?
    • Jede Form von Parallelität in Python, einschließlich multiprocessing
    • Die gleichzeitige Ausführung von Python-Bytecode durch mehrere Threads desselben Prozesses - I/O und viel C-Code geben ihn frei
    • Die Nutzung mehrerer Threads in einem einzigen Programm
    • Gleichzeitige Schreibzugriffe auf dieselbe Variable
  2. Warum bleibt sys.getsizeof eines Generators winzig im Vergleich zur äquivalenten Liste?
    • Der Generator speichert kein Element: Er behält nur seinen Ausführungszustand und berechnet jeden Wert auf Anfrage
    • getsizeof kann Generatoren nicht messen
    • Generatoren komprimieren ihre Daten intern
  3. Warum schlägt ''.join(parts) das in einer Schleife wiederholte out += part?
    • join nutzt mehrere Threads, um den String zusammenzusetzen
    • Es ist ein Mythos: Die Performance ist seit Python 3 identisch
    • Da str unveränderlich sind, kopiert jedes += den ganzen String neu (quadratische Kosten); join berechnet die endgültige Größe und alloziert nur einmal
    • join vermeidet das Erzeugen der str-Zwischenobjekte des Generators