CPython & PyPy im JIT-Vergleich
Kann CPython PyPy dank neuer Features ausstechen?Tim Bird | shutterstock.com
Die alternative Python-Laufzeitumgebung PyPy setzt einen speziell entwickelten JIT-Compiler ein, um gegenüber der Standard-Runtime CPython potenziell massive Geschwindigkeitssteigerungen zu erzielen. Für diese Performance muss allerdings häufig die Kompatibilität mit dem restlichen Python-Ökosystem (insbesondere C-Extensions) zurückstehen.
Inzwischen verspricht ein neuer, nativer JIT-Compiler allerdings auch eine deutliche verbesserte Performance für CPython. Darüber hinaus steht die Laufzeitumgebung nun auch als alternative Build-Version zur Verfügung, die die GIL eliminiert. Das ermöglicht Free-Threaded-Operationen – und eröffnet damit weitere Wege zu erheblichen Leistungssteigerungen. Ob CPython damit auf dem Weg ist, PyPy zu verdrängen, haben wir in einem Benchmark-Vergleich herausgefunden.
Wo PyPy unübertroffen bleibt
Aufgrund seiner Indirektion und Abstraktion performt CPython bei einfachen numerischen Operationen seit je her schlecht. In CPython gibt es etwa keine primitiven Ganzzahlen auf Maschinenebene. Das hat zur Folge, dass Benchmarks wie der folgende tendenziell recht schlecht ausfallen:
def transform(n: int):
q = 0
for x in range(0, n * 500):
q += x
return q
def main():
return [transform(x) for x in range(1000)]
main()
Auf einem Ryzen 5 3600 mit sechs Kernen benötigt Python 3.14 etwa neun Sekunden, um diesen Benchmark auszuführen. PyPy hingegen erledigt das in nur etwa 0,2 Sekunden. Bei mathematischen Berechnungen profitiert der neue Python-JIT-Compiler noch nicht wirklich: Ist er aktiv, sinkt die Zeit geringfügig auf etwa acht Sekunden.
Kommt die No-GIL-Version von Python auf eine Multithread-Version desselben Codes zur Anwendung, sieht das folgendermaßen aus:
def transform(n: int):
q = 0
for x in range(0, n * 500):
q += x
return q
def main():
result = []
with ThreadPoolExecutor() as pool:
for x in range(1000):
result.append(pool.submit(transform, x))
return [_.result() for _ in result]
main()
Der Unterschied ist, gelinde gesagt, dramatisch. Python 3.14 erledigt diese Aufgabe in 1,7 Sekunden. Das ist zwar immer noch ein ganzes Stück weit weg von den PyPy-Ergebnissen, aber ein ausreichend großer Sprung, um die Nutzung von Threads und No-GIL zu rechtfertigen.
Ironischerweise verlangsamt die Multithread-Version auf PyPy die Execution drastisch – statt 0,7 stehen 2,1 Sekunden auf der Uhr. Das liegt daran, dass PyPy weiterhin über einen GIL-ähnlichen Sperrmechanismus verfügt und damit keine vollständige Parallelität zwischen den Threads besteht. Die JIT-Kompilierung von PyPy funktioniert am besten, wenn alles in einem einzigen Thread ausgeführt wird.
Einen Prozess- gegen einen Thread-Pool auszutauschen, ist an dieser Stelle auch nicht wirklich hilfreich. Eine Prozess-Pool-Version des oben angeführten Beispiels beschleunigt die Ausführung zwar etwas (auf PyPy um 1,3 Sekunden) – allerdings sind Prozess-Pools und Multiprocessing auf PyPy bei weitem nicht so gut optimiert wie in CPython.
Die Zeitwerte für “Vanilla” Python 3.14 nochmals im Überblick:
Ohne JIT, GIL: 9 Sekunden;
Mit JIT, GIL: 8 Sekunden;
Ohne JIT, ohne GIL: 9,5 Sekunden.
Der No-GIL-Build ist bei Single-Thread-Operationen immer noch etwas langsamer als die reguläre Version. JIT hilft hier ein wenig, aber nicht viel. Die gleiche Aufschlüsselung für Python 3.14 und einen Prozess-Pool:
Ohne JIT, GIL: 1,75 Sekunden;
Mit JIT, GIL: 1,5 Sekunden;
Ohne JIT, ohne GIL: 2 Sekunden.
Mit anderen Skript-Formen sehen die Werte für Python 3.14 folgendermaßen aus:
Threaded-Version ohne GIL: 1,7 Sekunden;
Multiprocessing-Version mit GIL: 2,3 Sekunden;
Multiprocessing-Version mit GIL und JIT: 2,4 Sekunden;
Multiprocessing-Version ohne GIL: 2,1 Sekunden.
Und die Ergebnisse von PyPy:
Single-Threaded-Skript: 0,2 Sekunden;
Multithreaded-Skript: 2,1 Sekunden;
Multiprocessing-Skript: 1,3 Sekunden.
Das n-Body-Problem
Ein weiterer Benchmark mit mathematischem Schwerpunkt, bei dem Vanilla Python traditionell schlecht abschneidet, ist n-body. Auch dieses Problem lässt sich nur schwer durch parallele Berechnungen beschleunigen. Es ist zwar möglich, aber nicht einfach.
Mit 1.000.000 Wiederholungen liefert der n-body-Benchmark folgende Ergebnisse:
Python 3.14, ohne JIT: 7,1 Sekunden;
Python 3.14, mit JIT: 5,7 Sekunden;
Python 3.15a4, ohne JIT: 7,6 Sekunden;
Python 3.15a4, mit JIT: 4,2 Sekunden.
Für die JIT-fähigen Editionen von Python ist das relativ beeindruckend. PyPy durchläuft denselben Benchmark allerdings in nur 0,7 Sekunden.
Pi berechnen
Manchmal hat sogar PyPy Probleme mit Mathematik-lastigen Python-Programmen. Werfen Sie etwa einen Blick auf diese Implementierung, um die Nachkommastellen von Pi zu berechnen. Das ist ein weiteres Beispiel für einen Task, der sich kaum oder gar nicht parallelisieren lässt – deswegen nutzen wir an dieser Stelle einen Single-Threaded-Test.
Die Berechnung von 20.000 Nachkommastellen führt zu folgendem Ergebnis:
Python 3.14, ohne JIT: 13,6 Sekunden;
Python 3.14, mit JIT: 13,5 Sekunden;
Python 3.15, ohne JIT: 13,7 Sekunden;
Python 3.15, mit JIT: 13,5 Sekunden;
PyPy: 19,1 Sekunden.
Überraschend ist hier vor allem, dass PyPy in diesem Szenario schlechter performt als Standard-Python.
Hier hat CPython aufgeholt
Eine Variante des Google-n-Gram-Benchmarks verarbeitet eine mehrere Megabyte große CSV-Datei und erstellt dazu einige Statistiken. Dadurch ist dieser Benchmark stärker I/O-gebunden als die vorherigen, die eher CPU-gebunden waren. Dennoch ist es möglich, ihn für nützliche Informationen über die Runtime-Geschwindigkeit hernazuziehen.
Wir haben drei Varianten dieses Benchmarks erstellt: Single-Threaded, Multi-Threaded und Multi-Process. Hier ein Blick auf die erstgenannte Version:
import collections
import time
import gc
import sys
try:
print ("JIT enabled:", sys._jit.is_enabled())
except Exception:
...
def main():
line: str
fields: list[str]
sum_by_key: dict = {}
start = time.time()
with open("ngrams.tsv", encoding="utf-8", buffering=2 << 24) as file:
for line in file:
try:
fields = line.split("\t", 3)
except:
continue
try:
sum_by_key[fields[1]] += int(fields[2])
except:
sum_by_key[fields[1]] = int(fields[2])
summation = collections.Counter(sum_by_key)
max_entry = summation.most_common(1)
stop = time.time()
print(stop - start)
if len(max_entry) == 0:
print("No entries")
else:
print("max_key:", max_entry[0][0], "sum:", max_entry[0][1])
try:
gc.freeze()
gc.disable()
except Exception:
...
main()
So verarbeitet Python 3.14 diesen Benchmark mit verschiedenen Skript-Versionen:
Single-Threaded, GIL: 4,2 Sekunden;
Single-Threaded, JIT, GIL: 3,7 Sekunden;
Multi-Threaded, ohne GIL: 1,05 Sekunden;
Multi-Processing, GIL: 2,42 Sekunden;
Multi-Processing, JIT, GIL: 2,4 Sekunden;
Multiprocessing, kein GIL: 2,1 Sekunden.
Die Ergebnisse mit PyPy:
Single-Threaded: 2,75 Sekunden;
Multi-Threaded: 14,3 Sekunden (kein Tippfehler);
Multiprocessing: 8,7 Sekunden.
In diesem Szenario kann die CPython-Version ohne GIL und mit Multithreading sogar PyPy in seiner Optimalkonfiguration schlagen. Bislang existiert zwar noch keine CPython-Version, die JIT und Free Threading unterstützt – aber diese ist nicht mehr weit entfernt und könnte das Bild noch weiter verschieben.
CPython vs. PyPy – das JIT-Fazit
Zusammenfassend lässt sich festhalten, dass PyPy grundlegende, nicht optimierte Versionen eines Mathematik-lastigen Skripts immer noch besser verarbeitet als CPython. Aber: CPython erzielt durch Free Threading und Multiprocessing drastische, relative Verbesserungen. PyPy kann diese integrierten Funktionen zwar nicht nutzen, aber seine Grundgeschwindigkeit ist so hoch, dass Threading oder Multiprocessing für einige Aufgaben nicht wirklich erforderlich sind. Beispielsweise lässt sich das n-body-Problem nur schwer parallelisieren. Die Berechnung von Pi ist so gut wie gar nicht parallelisierbar. Es ist also von Vorteil, diese Algorithmen schnell in Single-Thread-Versionen auszuführen.
Herausgestochen hat bei unserem Vergleichstest, dass die Vorteile von PyPy nicht universell – oder gar konsistent – sind. Sie variieren vielmehr je nach Szenario. Und selbst innerhalb desselben Programms kann es eine enorme Vielfalt an Szenarien geben. Einige Programme laufen mit PyPy enorm schnell, aber vorab einzuschätzen, welche das sind, ist nicht trivial und nur über einen Benchmark der Anwendung herauszufinden. Zu beachten ist darüber hinaus, dass auch Multiprocessing mit PyPy weniger gut funktioniert, weil es im Vergleich zu CPython einen deutlich langsameren Mechanismus zur Datenserialisierung zwischen Prozessen einsetzt.
So schnell PyPy auch sein mag, die hier vorgestellten Benchmarks verdeutlichen die Vorteile echter Parallelität mit Threads in bestimmten Szenarien. (fm)
Dieser Artikel ist im Original bei unserer Schwesterpublikation Infoworld.com erschienen.
Hier finden Sie den kompletten Artikel: