35.6 Optimierung 

Als Programmierer sollten Sie mit der Zeit einen Sinn für die Ästhetik und Eleganz eines Programms entwickeln, der Ihnen sagt, wann Ihr Programm »schön« ist. Zwei wichtige Punkte eines eleganten Programms sind die Einfachheit des Ansatzes und die Laufzeiteffizienz. Diese beiden Grundsätze stehen sich in gewissem Maße gegenüber, denn häufig ist der effizienteste Ansatz nicht gerade von Klarheit geprägt.
Aus diesem Grund ist es bei einem nicht-zeitkritischen Programm absolut legitim, Effizienz und Einfachheit gegeneinander abzuwägen und zu einem gesunden Kompromiss zu gelangen. Bei einem zeitkritischen Programm ist die beste Lösung hingegen immer die effizienteste. Wir werden uns in diesem Kapitel mit der Optimierung der Laufzeit eines Python-Programms beschäftigen. Abgesehen von der Laufzeit können Programme noch in Hinblick auf andere Bereiche optimiert werden. So ist es beispielsweise durchaus üblich, eine Speicherplatzoptimierung durchzuführen, und letztlich kann ein Programm auch im Hinblick auf die Einfachheit und Klarheit des Quelltextes hin optimiert werden. Der Begriff »Optimierung« wird im Folgenden ausschließlich im Sinne von »Laufzeitoptimierung« verstanden.
Beachten Sie, dass wir uns dabei rein auf Python-spezifische Optimierungsstrategien konzentrieren. Den höchsten Laufzeitgewinn erzielen Sie jedoch mit der Optimierung der Algorithmik selbst. Doch das Optimieren von Algorithmen ist ein Thema für sich und soll hier keine Berücksichtigung finden. Beachten Sie zudem, dass wir häufig kleinere Python-Codes einander gegenüberstellen werden, die den gleichen Effekt haben, sich jedoch teils gravierend in ihrer Laufzeit unterscheiden. Die in diesem Zusammenhang als »falsch« dargestellte Alternative ist natürlich nicht tatsächlich falsch, sondern im Vergleich zur anderen Variante nur ineffizient. Dennoch führen beide Alternativen zum gesteckten Ziel, sodass Sie frei entscheiden können, welcher der Varianten Sie den Vorzug gewähren möchten.
Bei einigen der hier gezeigten Optimierungsmöglichkeiten, beispielsweise dem »Inlinen« von Funktionsaufrufen oder dem Umgehen von Lookups, ist der absolute Laufzeitgewinn gering und der Verlust an Übersichtlichkeit im Quellcode deutlich. Deswegen sollten Sie solche Optimierungsstrategien nur einsetzen, wenn es unbedingt nötig ist. Beachten Sie auch, dass die angegebenen Laufzeitmessungen beispielhafte Spezialfälle beschreiben, die nicht auf beliebige reale Implementierungen übertragbar sind.
Zu guter Letzt werden wir Ihnen den alternativen Interpreter PyPy vorstellen, der im Gegensatz zum Referenz-Interpreter CPython über einen Just-in-time-Compiler verfügt. Deshalb lassen sich insbesondere algorithmisch aufwendige Programme mit PyPy schneller ausführen als mit CPython.
35.6.1 Die Optimize-Option 

Grundsätzlich können Sie das Laufzeitverhalten eines Python-Programms beeinflussen, indem Sie es mit der Kommandozeilenoption -O ausführen. Diese Option veranlasst den Interpreter dazu, den resultierenden Byte-Code zu optimieren. Das bedeutet, dass assert-Anweisungen und Konstrukte wie
if __debug__:
mache_etwas()
nicht ins Kompilat aufgenommen werden und somit keinen Einfluss mehr auf das Laufzeitverhalten des optimierten Programms haben.
Durch die Kommandozeilenoption -OO ist es möglich, das Programm über das normale Maß hinaus zu optimieren. Wenn dies gewünscht ist, werden alle im Quelltext enthaltenen Docstrings ignoriert und nicht mit in das Kompilat aufgenommen. Auch damit erreichen Sie ein wenig mehr Laufzeiteffizienz, obwohl sich der Gewinn in Grenzen halten sollte. Es ist dann aber beispielsweise für die Built-in Function help nicht mehr möglich, eine Hilfeseite zu Elementen Ihres Moduls zu generieren, weil keine Docstrings mehr vorhanden sind.
35.6.2 Mutabel vs. immutabel 

Bei der Einführung der Basisdatentypen zu Beginn dieses Buchs haben wir unterschieden zwischen Datentypen, die mutabel (also veränderlich), und solchen, die immutabel (also unveränderlich) sind. Dass diese Unterscheidung auch performancetechnische Relevanz hat, sehen Sie in diesem Abschnitt.
Im folgenden Beispiel soll ein Tupel mit den Zahlen von 0 bis 1000 gefüllt werden. Selbstverständlich kennen wir dafür effiziente Möglichkeiten, doch versuchen wir es einmal mit der naiven Herangehensweise:
tup = ()
for i in range(1000):
tup += (i,)
Zunächst wird das Tupel angelegt und dann in einer Schleife über alle Zahlen von 0 bis 1000 die jeweils aktuelle Zahl an das Tupel angehängt. Leider haben wir beim Schreiben dieses Codes nicht berücksichtigt, dass es sich bei tuple um einen unveränderlichen Datentyp handelt. Das bedeutet, dass beim Hinzufügen eines Wertes zu unserem Tupel tup jedes Mal eine neue tuple-Instanz erzeugt wird und die Einträge der alten Instanz in diese neue umkopiert werden müssen. Das kostet Zeit.
Als Alternative zu diesem Beispiel verwenden wir nun das veränderliche Pendant des Tupels, die Liste:
lst = []
for i in range(1000):
lst += [i]
Im Gegensatz zur vorherigen Version muss hier beim Anhängen einer neuen Zahl keine neue list-Instanz erzeugt werden, was sich beim Vergleich der Laufzeit der beiden Beispiele deutlich niederschlägt: Die zweite Variante kann ca. 16-mal so schnell ausgeführt werden wie die erste.
Interessant ist in diesem Beispiel auch die Entwicklung des Laufzeitunterschieds in Abhängigkeit von der Anzahl der einzutragenden Zahlen. Je mehr Elemente ein Tupel enthält, desto aufwendiger ist es, seine Elemente in eine neue tuple-Instanz zu überführen. Hätten wir die oben genannten Beispiele also mit Zahlen zwischen 0 und 10000 durchgeführt, hätte sich ein noch um einiges eindrucksvollerer Laufzeitunterschied zwischen den beiden Varianten ergeben.
[»] Hinweis
Das hier gezeigte Beispiel hat in Python-Versionen vor 2.5 auch für Strings funktioniert. In neueren Versionen greifen hier Optimierungen des Python-Interpreters, die verhindern, dass in jedem Schleifendurchlauf ein neuer String erzeugt wird.
35.6.3 Schleifen 

Betrachten wir noch einmal das zweite Beispiel aus dem vorangegangenen Abschnitt:
lst = []
for i in range(1000):
lst += [i]
Wenn eine Schleife zum Erzeugen einer Liste, eines Dictionarys oder eines Sets verwendet wird, sollten Sie sich stets überlegen, ob Sie dasselbe Ergebnis nicht auch mit einer List bzw. Dict oder Set Comprehension erreichen können. Diese können schneller ausgeführt werden als eine analoge for-Schleife. Die folgende List Comprehension, die die gleiche Liste wie die oben dargestellte Schleife erzeugt, kann mehr als doppelt so schnell ausgeführt werden:
lst = [i for i in range(1000)]
[»] Hinweis
Ein Aufruf der Built-in Function map ist ähnlich effizient wie eine List Comprehension.
35.6.4 Funktionsaufrufe 

Eine weitere – laufzeittechnisch gesehen – teure Angelegenheit sind Funktionsaufrufe, weswegen Sie Funktionsaufrufe in häufig durchlaufenen Schleifen auf ihre Notwendigkeit hin überprüfen sollten. Bei besonders simplen Funktionen kann es sinnvoll sein, die Funktion zu »inlinen«, den Funktionsinhalt also direkt in die Schleife zu schreiben.
Der folgende – zugegebenermaßen etwas künstliche – Code:
def f(s):
return s.upper()
ergebnis = f("Hallo Welt")
kann durch diese mehr als 1,5-mal so schnelle Variante ersetzt werden:
ergebnis = "Hallo Welt".upper()
Insbesondere bei dieser Optimierung ist anzumerken, dass der Laufzeitgewinn durch das »inlinen« von f natürlich in Relation gesehen werden muss zur Laufzeit von f selbst.
35.6.5 C 

Ein in Python geschriebener Programmteil ist aufgrund des zwischengeschalteten Interpreters in der Regel langsamer als ein vergleichbares C-Programm. Aus diesem Grund sollten Sie an einer laufzeitkritischen Stelle so oft wie möglich auf Algorithmen zurückgreifen, die in C implementiert wurden. So lohnt es sich beispielsweise immer, eine Built-in Function einzusetzen, anstatt den entsprechenden Algorithmus selbst zu implementieren.
In Kapitel 37, »Anbindung an andere Programmiersprachen«, erfahren Sie, wie Sie eigene Module oder Programmteile in C schreiben.
35.6.6 Lookups 

Wenn über einen Modulnamen auf eine Funktion zugegriffen wird, die in diesem Modul enthalten ist, muss bei jedem Funktionsaufruf ein sogenannter Lookup durchgeführt werden. Dieser Lookup ist nicht erforderlich, wenn eine direkte Referenz auf das Funktionsobjekt besteht. Stellen Sie sich einmal vor, Sie wollten die Quadratwurzeln aller natürlichen Zahlen zwischen 0 und 100 bestimmen. Dazu kommt einem zunächst folgender Ansatz in den Sinn:
import math
wurzeln = [math.sqrt(i) for i in range(100)]
Wesentlich effizienter ist es jedoch, die Funktion sqrt des Moduls math direkt zu referenzieren und über diese Referenz anzusprechen:
import math
s = math.sqrt
wurzeln = [s(i) for i in range(100)]
Die Schleife der zweiten Variante kann ca. 1,5-mal so schnell ausgeführt werden wie die Schleife der ersten Variante.
35.6.7 Exceptions 

[...]
35.6.8 Keyword Arguments 

Die Übergabe von Positionsparametern beim Funktions- oder Methodenaufruf ist im Vergleich zu Schlüsselwortparametern grundsätzlich effizienter. Dazu schauen wir uns folgende Funktion an, die vier Parameter erwartet:
def f(a, b, c, d):
return "{} {} {} {}".format(a,b,c,d)
Der Funktionsaufruf
f("Hallo", "du", "schöne", "Welt")
kann ca. 1,1-mal so schnell ausgeführt werden wie der Funktionsaufruf
f(a="Hallo", b="du", c="schöne", d="Welt")
An dieser Stelle möchten wir nochmal darauf hinweisen, dass sich solche feinen Optimierungen nur für Funktionen lohnen, die extrem häufig aufgerufen werden.
35.6.9 Alternative Interpreter: PyPy 

Die in den bisherigen Abschnitten besprochenen Tipps zur Optimierung beziehen sich auf die Programmiersprache Python selbst und dabei insbesondere im Zusammenhang mit ihrer Referenzimplementierung CPython. Neben CPython gibt es eine Reihe alternativer Interpreter, die jeweils einen besonderen Fokus setzen. Ein im Zusammenhang mit der Optimierung erwähnenswerter Python-Interpreter ist PyPy[ 157 ](PyPy ist übrigens selbst in Python geschrieben. ), der im Gegensatz zu CPython über einen Just-in-Time Compiler (JIT Compiler) verfügt. Ein solcher JIT-Compiler übersetzt besonders häufig ausgeführte Teile eines laufenden Programms in Maschinen-Code, um diese effizienter zu machen. Da die Kompilierung erst zur Laufzeit erfolgt, können Informationen über den aktuellen Programmlauf bei der Optimierung berücksichtigt werden, die bei Ahead-of-Time-Kompilierung nicht einfließen können. Eine Just-in-Time Kompilierung ist besonders bei rechenintensiven Programmen sinnvoll. PyPy steht wie CPython unter der freien MIT-Lizenz und kann unter http://www.pypy.org heruntergeladen werden.
Um die Stärken von PyPy zu demonstrieren, verwenden wir das folgende kurze Beispielprogramm, das die Zahlen von 0 bis 10000000 mit alternierendem Vorzeichen aufsummiert:
ergebnis = 0
for n in range(10000000):
ergebnis += (-1)**n * n
Dieses Beispielprogramm lässt sich mit PyPy ca. 5-mal so schnell ausführen wie mit CPython. Selbstverständlich hängt der Laufzeitgewinn stark von der Art des ausgeführten Programms ab. Ein I/O-gebundenes Programm, beispielsweise eines, das hauptsächlich Festplattenzugriffe durchführt, lässt sich durch Just-in-Time-Kompilierung kaum beschleunigen.
[»] Hinweis
Die amerikanische Firma Dropbox hat die Entwicklung des quelloffenen Python-Interpreters Pyston gestartet. Der auf der Compiler-Architektur LLVM aufbauende Interpreter erlaubt – ähnlich wie PyPy – eine Just-in-Time-Kompilierung und soll deutlich effizienter werden als CPython.
Pyston ist vergleichsweise jung und bislang noch als experimentell anzusehen. Trotzdem lohnt es sich, ein Auge auf das Projekt zu werfen.