Laufzeituntersuchungen

Allgemeine Vorbemerkungen

Python wird immer wieder vorgeworfen, dass es im Vergleich zu einer Reihe anderer Programmiersprachen langsam sei. Häufig stellt dies kein echtes Problem dar, aber bei Bedarf gibt es zur Optimierung von Pythonskripten eine Reihe von Möglichkeiten. Die konsequente Verwendung von NumPy kann bei dazu geeigneten Anwendungen einen erheblichen Geschwindigkeitsvorteil bringen. Unter Umständen kann es auch sinnvoll sein, besonders zeitkritische Programmteile in C zu implementieren. In diesem Fall bietet sich die Verwendung des bereits in einem früheren Kapitel erwähnten Cython an. Mit dessen Hilfe ist es auch sehr einfach möglich, die Rechenzeit durch das Festlegen des Datentyps von Variablen zu reduzieren. Alternativ bietet sich auch die »just in time«-Kompilierung zum Beispiel mit PyPy [2] oder Numba [3] an, die die Programmausführung beschleunigen kann.

Im Einzelfall sollte man zunächst überlegen, ob das Laufzeitproblem wirklich schwerwiegend ist oder ob man für die Optimierung letztlich mehr Zeit investieren muss als man gewinnt. Es lohnt sich dabei, auf den Altmeister Donald E. Knuth zu hören, der schon vor mehr als 40 Jahren schrieb:

There is no doubt that the grail of efficiency leads to abuse. Programmers waste enormous amounts of time thinking about, or worrying about, the speed of noncritical parts of their programs, and these attempts at efficiency actually have a strong negative impact when debugging and maintentance are considered. We should forget about small efficiencies, say about 97 % of the time: premature optimization is the root of all evil.

Yet we should not pass up our opportunities in that critical 3 %. A good programmer will not be lulled into complacency by such reasoning, he will be wise to look carefully at the critical code; but only after that code has been identified. [1]

Bevor man also überhaupt mit der Optimierung eines Programms beginnt, sollte man zunächst immer erst feststellen, wo das Programm die meiste Zeit verbringt. Es lohnt sich nicht, Zeit in die Optimierung von Programmteilen zu investieren, deren Laufzeit im Vergleich zur gesamten Laufzeit unerheblich ist. Nach jeder Optimierung wird man erneut den laufzeitkritischsten Programmteil identifizieren, um so in eventuell mehreren Schritten zu einer hoffentlich akzeptablen Laufzeit zu kommen.

Vor der Optimierung eines Programms sollte man immer bedenken, dass sich dabei Fehler einschleichen können. Es nützt alles nicht, wenn man das Programm geändert hat, so dass es viel schneller läuft, dann aber nicht mehr das tut was es eigentlich tun soll. Daher sollte man mindestens eine funktionstüchtigte Version des Programms aufbewahren, z.B. eine Kopie, die eine Endung .bak erhält. Wesentlich besser ist es natürlich, ein Versionskontrollsystem zu verwenden, beispielsweise Git, das wir im Kapitel Versionskontrolle mit Git beschrieben haben. Außerdem ist es sinnvoll, Tests zu programmieren, die es erlauben, die neue Programmversion auf Korrektheit zu überprüfen. Techniken hierfür werden im Kapitel Testen von Programmen besprochen.

Bevor wir einige Möglichkeiten diskutieren, die Laufzeit von Python-Skripten zu bestimmen, wollen wir im nächsten Abschnitt zunächst auf einige Schwierigkeiten bei der Laufzeitmessung hinweisen.

Fallstricke bei der Laufzeitmessung

Python stellt mit dem Modul time eine Möglichkeit zur Verfügung, die aktuelle Zeit und damit letztlich auch Zeitdifferenzen zu bestimmen.

In [1]: import time

In [2]: time.ctime()
Out[2]: 'Thu Dec 22 14:39:30 2016'

Auch wenn die aktuelle Zeit hier in einem gut lesbaren Format ausgegeben wird, eignet sich dieses Ergebnis nur schlecht zur Bildung von Zeitdifferenzen. Besser ist es, die Zahl der Sekunden seit Beginn der „Zeitrechnung“ zu bestimmen. Dabei beginnt die Zeitrechnung auf Unix-Systemen am 1.1.1970 um 00:00:00 UTC.

In [3]: time.time()
Out[3]: 1482413973.190686

Damit lässt sich nun die Zeit bestimmen, die ein bestimmter Python-Code benötigt, wie folgendes Beispiel zeigt.

1
2
3
4
5
6
7
8
import time

summe = 0
start = time.time()
for n in range(1000000):
    summe = summe+1
ende = time.time()
print('{:5.3f}s'.format(ende-start))

Hier wird die Zeitdauer gemessen, die die Schleife in den Zeilen 5 und 6 benötigt. Allerdings ist diese Zeit keineswegs immer genau gleich lang. Das um eine Schleife erweiterte Skript

1
2
3
4
5
6
7
8
9
import time

for _ in range(10):
    summe = 0
    start = time.time()
    for n in range(1000000):
        summe = summe+1
    ende = time.time()
    print('{:5.3f}s'.format(ende-start), end='  ')

liefert zum Beispiel die folgende Ausgabe

0.150s  0.108s  0.104s  0.103s  0.107s  0.106s  0.104s  0.103s  0.103s  0.103s

wobei das Ergebnis beim nächsten Lauf oder erst recht auf einem anderen Computer deutlich anders aussehen kann. Es kann also sinnvoll sein, über mehrere Durchläufe zu mitteln, wie es das timeit-Modul tut, das wir im nächsten Abschnitt besprechen werden.

Bei der Ermittlung von Laufzeiten ist weiter zu bedenken, dass der Prozessor auch von anderen Aufgaben in Anspruch genommen wird, so dass wir gerade zwar die während des Laufs verstrichene Zeit bestimmt haben, nicht aber die Zeit, die der Prozessor hierfür tatsächlich aufgewendet hat. Dies illustrieren wir im folgenden Beispiel, in dem wir das Skript zeitweilig pausieren lassen. Damit wird in Zeile 9 simuliert, dass andere Prozesse für eine Unterbrechung der Ausführung unseres Skripts sorgen. Außerdem benutzen wir in den Zeilen 5 und 11 time.process_time(), um die vom Prozessor aufgewandte Zeit für den Prozess zu bestimmen, in dem unser Skript abgearbeitet wird.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import time

summe = 0
start = time.time()
start_proc = time.process_time()
for n in range(10):
    for m in range(100000):
        summe = summe+1
    time.sleep(1)
ende = time.time()
ende_proc = time.process_time()
print('Gesamtzeit: {:5.3f}s'.format(ende-start))
print('Systemzeit: {:5.3f}s'.format(ende_proc-start_proc))

Die Ausgabe

Gesamtzeit: 10.248s
Systemzeit: 0.238s

zeigt, dass die Gesamtdauer des Skripts erwartungsgemäß um etwa 10 Sekunden länger ist als die in Anspruch genommene Prozessorzeit.

Vorsicht ist auch geboten, wenn man den zu testenden Codeteil der Übersichtlichkeit halber in eine Funktion auslagert, da dann die Zeit für den Funktionsaufruf relevant werden kann. Dies ist besonders der Fall, wenn die eigentliche Auswertung der Funktion nur sehr wenig Zeit erfordert. So liefert der folgende Code

1
2
3
4
5
6
7
8
import time

summe = 0
start_proc = time.process_time()
for n in range(10000000):
    summe = summe+1
ende_proc = time.process_time()
print('Systemzeit: {:5.3f}s'.format(ende_proc-start_proc))

eine Laufzeit von 1,122 Sekunden, während der äquivalente Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import time

def increment_by_one(x):
    return x+1

summe = 0
start_proc = time.process_time()
for n in range(10000000):
    increment_by_one(summe)
ende_proc = time.process_time()
print('Systemzeit: {:5.3f}s'.format(ende_proc-start_proc))

mit 1,529 Sekunden gemessen wurde und somit um fast 40 Prozent langsamer läuft.

Unabhängig von den genannten Problemen bedeutet jede Laufzeitmessung immer einen Eingriff in die Ausführung des Skripts, so dass die gemessene Laufzeit unter Umständen deutlich gegenüber der normalen Laufzeit des entsprechenden Codes erhöht sein kann.

Die in den Beispielen verwendete Methode der Laufzeitbestimmung hat Nachteile. Unter anderem erfordert sie eine explizite Modifizierung des Codes, was häufig unerwünscht ist. Im Folgenden besprechen wir einige ausgewählte Alternativen, die entsprechend den jeweiligen Erfordernissen eingesetzt werden können.

Das Modul timeit

Um die Laufzeit von Einzeilern oder kleineren Codeteilen zu testen, kann man das Python-Modul timeit heranziehen. Dies ist zum Beispiel dann nützlich, wenn man sich ein Bild davon machen möchte, welche Codevariante die schnellere sein wird. Im Allgemeinen wird dabei über mehrere oder sogar viele Wiederholungen gemittelt, um zu einem möglichst zuverlässigen Ergebnis zu kommen. Die wohl einfachste Möglichkeit, timeit einzusetzen, besteht in der Benutzung der IPython-Shell.

Einen Laufzeitvergleich zwischen zwei Arten eine Zahl zu quadrieren, kann man in IPython folgendermaßen vornehmen:

In [1]: n = 5

In [2]: %timeit n*n
10000000 loops, best of 3: 166 ns per loop

In [3]: %timeit n**2
1000000 loops, best of 3: 252 ns per loop

Das Prozentzeichen wird timeit vorangestellt, um es als so genannten »magischen Befehl« zu kennzeichnen, also einen Befehl der IPython-Shell und nicht ein Python-Kommando. Da timeit in diesem Fall nicht als Python-Kommando interpretiert werden kann, könnte man sogar auf das Prozentzeichen verzichten. Es zeigt sich, dass die Quadrierung durch Multiplikation mit 166 Nanosekunden schneller ausgeführt wird als die Quadrierung durch Potenzierung, die 252 Nanosekunden benötigt. Natürlich hängt die Laufzeit vom verwendeten Prozessor ab und ist auch nicht unbedingt auf die letzte Stelle genau reproduzierbar.

Wie in der Ausgabe dieses Beispiels zu sehen ist, wird der Befehl, dessen Laufzeit bestimmt werden soll, mehrfach ausgeführt. Dabei wird die Zahl der Wiederholungen automatisch so bestimmt, dass sich eine vernünftige Gesamtlaufzeit ergibt.

Um die Laufzeit von mehrzeiligem Code zu untersuchen, wendet man den magischen timeit-Befehl auf eine ganze Zelle an, indem man ein zweites Prozentzeichen voranstellt. Dies ist in folgendem Beispiel gezeigt.

In [4]: %%timeit
   ...: summe = 0
   ...: for n in range(1000):
   ...:     summe = summe+n
   ...:
10000 loops, best of 3: 104 us per loop
In [5]: %timeit sum(range(1000))
10000 loops, best of 3: 22.2 us per loop

Im ersten Fall verwenden wir %%timeit mit zwei Prozentzeichen, damit sich dieser Befehl auf die nächsten drei Zeilen und nicht nur die nächste Zeile bezieht. Im zweiten Fall genügt dagegen wiederum %timeit. In diesem Beispiel liegt die Ausführungszeit im Mikrosekundenbereich, wobei die explizite Schleife fast fünfmal mehr Zeit benötigt.

Auch wenn man mit der IPython-Shell sehr bequem die Laufzeit von Codestücken untersuchen kann, mag es gelegentlich notwendig sein, das timeit-Modul direkt in einem Python-Skript einzusetzen. Daher wollen wir uns nun die Anwendung dieses Moduls ansehen.

Das folgende Beispiel untersucht den Laufzeitunterschied bei der Berechnung des Sinus mit Hilfe des math-Moduls und mit NumPy in Abhängigkeit von der Anzahl der Funktionsargumente.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import numpy as np
import math
import timeit
import matplotlib.pyplot as plt

def f_numpy(nmax):
    x = np.linspace(0, np.pi, nmax)
    result = np.sin(x)

def f_math(nmax):
    dx = math.pi/(nmax-1)
    result = [math.sin(n*dx) for n in range(nmax)]

x = []
y = []
for n in np.logspace(0.31, 6, 20):
    nint = int(n)
    t_numpy = timeit.timeit("f_numpy({})".format(nint),
                            "from __main__ import f_numpy",
                            number=20)
    t_math = timeit.timeit("f_math({})".format(nint),
                            "from __main__ import f_math",
                            number=20)
    x.append(nint)
    y.append(t_math/t_numpy)
plt.plot(x, y)
plt.xscale("log")
plt.show()

Zunächst definieren wir in den Zeilen 6 bis 12 zwei Funktionen, die jeweils den Sinus für eine vorgegebene Anzahl von Argumenten berechnen, einmal mit Hilfe von NumPy und einmal mit Hilfe des math-Moduls. In den Zeilen 16 bis 25 wird für verschiedene Argumentanzahlen die Laufzeit für die beiden Varianten bestimmt. Sehen wir uns einen der Aufrufe zur Laufzeitbestimmung genauer an, konkret den Code in den Zeilen 18 bis 20. Nachdem wir in Zeile 3 das timeit-Modul geladen hatten, können wir in Zeile 18 die timeit-Funktion aus diesem Modul aufrufen. Das erste Argument enthält den auszuführenden Code, in unserem Fall also einfach den Funktionsaufruf von f_numpy.

Nachdem der Code als String zu übergeben ist, können wir problemlos in der gezeigten Weise ein Argument oder auch mehrere übergeben. Da die von timeit aufgerufenen Funktion keinen Zugriff auf den Namensraum des umgebenden Skripts besitzt, würde es nicht funktionieren, das Argument einfach als nint in dem String unterzubringen. Tatsächlich ist nicht einmal die Funktion f_numpy bekannt. Der timeit-Funktion wird daher in Zeile 19 explizit mitgeteilt, dass zunächst aus unserem Hauptskript, auf das mit __main__ Bezug genommen wird, f_numpy zu importieren ist. In Zeile 20 verlangen wir schließlich noch, dass zwanzig Funktionsläufe durchgeführt werden sollen, um eine gemittelte Laufzeit berechnen zu können. Eine automatische Bestimmung einer sinnvollen Zahl von Wiederholungen nimmt timeit hier im Gegensatz zur Verwendung in IPython nicht vor.

Wie die folgende Abbildung zeigt, bietet NumPy für sehr kleine Argumentanzahlen keinen Geschwindigkeitsvorteil, ganz im Gegenteil. Dies hängt damit zusammen, dass im Zusammenhang mit der Verwendung von Arrays einiges an Zusatzarbeit anfällt. Bei mehr als etwa 100 Argumenten erlaubt NumPy in unserem Fall jedoch eine schnellere Berechnung des Sinus. Der Geschwindigkeitsvorteil kann auf der hier verwendeten Hardware immerhin einen Faktor 4 bis 5 betragen.

_images/profiling_1.png

Das Modul cProfile

Das timeit-Modul, das wir gerade beschrieben haben, ist sehr gut geeignet, um die Laufzeit eines bestimmten Codesegments zu untersuchen. Bei der Optimierung eines Programms interessiert man sich jedoch vor allem dafür, welche Teile des Programms wieviel Zeit benötigen. Dann können die rechenintensiven Codeteile identifiziert und gezielt optimiert werden.

Häufig ist dies jedoch nicht nötig, und es genügt festzustellen, wieviel Zeit in den einzelnen Funktionen oder Methoden verbracht wurde. Dies funktioniert dann besonders gut, wenn man den Code sinnvoll modularisiert, was ja auch im Hinblick auf das Testen von Vorteil ist, wie wir im Kapitel Testen von Programmen betont hatten. Im Folgenden werden wir beschreiben, wie man mit Hilfe des Moduls cProfile feststellen kann, wieviel Zeit in welchen Funktionen während des Programmlaufs verbracht wird.

Als Beispiel ziehen wir das folgende Skript mit Namen pi.py zur Berechnung der Kreiszahl π heran, wobei eine Berechnung auf 100.000 Stellen durchgeführt wird. Das Skript basiert auf dem Gauss-Legendre oder Brent-Salamin-Algorithmus und nutzt aus, dass Python beliebig lange Integers zulässt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
from math import sqrt

def division(numerator, denominator, stellen):
    resultat = str(numerator//denominator)+"."
    for n in range(stellen):
        numerator = (numerator % denominator)*10
        resultat = "%s%s" % (resultat, numerator//denominator)
    return resultat

def wurzel_startwert(quadrat):
    """bestimme näherungsweise die Wurzel aus einem langen Integer

       Es wird die Wurzel auf der Basis der ersten 12 oder 13 Stellen
       mit Hilfe des entsprechenden Floats gezogen.
    """
    str_quadrat = str(quadrat)
    nrdigits = len(str_quadrat)
    keepdigits = 12
    if nrdigits % 2:
        keepdigits = keepdigits+1
    lead_sqrt_estimate = sqrt(float(str_quadrat[:keepdigits]))
    return int(lead_sqrt_estimate)*10**((nrdigits-keepdigits)//2)+1

def wurzel(quadrat):
    x = wurzel_startwert(quadrat)
    xold = 0
    while x != xold:
        xold = x
        x = (x*x+quadrat)//(2*x)
    return x

def agm_iteration(a, b):
    return (a+b)//2, wurzel(a*b)

def ausgabe(x, zeilenlaenge=80):
    str_x = "\u03c0="+str(x)+"\u2026"
    while len(str_x) > 0:
        print(str_x[:zeilenlaenge])
        str_x = str_x[zeilenlaenge:]

stellen = 100000
skalenfaktor = 10**(stellen+6)
a = skalenfaktor
b = wurzel(skalenfaktor**2//2)
c_sum = 0
faktor_two = 2
while a != b:
    a, b = agm_iteration(a, b)
    faktor_two = faktor_two*2
    c_sum = c_sum+faktor_two*(a*a-b*b)
numerator = 4*a**2
denominator = skalenfaktor**2-c_sum
ergebnis = division(numerator, denominator, stellen)
ausgabe(ergebnis)

Die gesamte Ausgabe ist zu lang, um sie hier vollständig wiederzugeben, so dass wir uns auf die ersten beiden Zeilen beschränken.

π=3.1415926535897932384626433832795028841971693993751058209749445923078164062862
08998628034825342117067982148086513282306647093844609550582231725359408128481117

Von den verschiedenen Varianten, cProfile zu benutzen, wählen wir hier eine, bei der wir das zu untersuchende Programm nicht modifizieren müssen. Dazu rufen wir das Modul mit geeigneten Argumenten auf:

$ python -m cProfile -o pi.prof pi.py

Hierbei wird das Programm pi.py unter der Kontrolle des cProfile-Moduls ausgeführt. Die Option -o legt fest, dass die Ergebnisse in der Datei pi.prof gespeichert werden sollen. Dabei handelt es sich um eine Binärdatei, die mit Hilfe des pstats-Moduls analysiert werden kann. Dazu geht man folgendermaßen vor:

In [1]: import pstats

In [2]: p = pstats.Stats('pi.prof')

In [3]: p.sort_stats('time').print_stats(8)
Fri Dec 23 15:36:56 2016    pi.prof

         2882 function calls in 68.377 seconds

   Ordered by: internal time
   List reduced from 76 to 8 due to restriction <8>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       18   41.008    2.278   49.819    2.768 pi.py:27(wurzel)
        1   17.776   17.776   17.776   17.776 pi.py:4(division)
       18    8.812    0.490    8.812    0.490 pi.py:12(wurzel_startwert)
        1    0.424    0.424   68.377   68.377 pi.py:1(<module>)
       17    0.320    0.019   47.346    2.785 pi.py:36(agm_iteration)
        1    0.024    0.024    0.037    0.037 pi.py:40(ausgabe)
     1251    0.011    0.000    0.011    0.000 {built-in method builtins.print}
     1270    0.002    0.000    0.002    0.000 {built-in method builtins.len}

Out[3]: <pstats.Stats at 0x7f1a26ed4ac8>

Nachdem in Eingabe 1 das pstats-Modul geladen wurde, wird in Eingabe 2 die zuvor erzeugte binäre Datei pi.prof eingelesen. Man erhält so eine pstats.Stats-Instanz, die nun analysiert werden kann. In den meisten Fällen wird man die Daten nach der benötigten Zeit sortieren und auch nur die obersten Datensätze ausgeben wollen, da die Gesamtliste unter Umständen recht lang sein kann. In Eingabe 3 sortieren wir mit der sort_stats-Methode nach der Zeit, die in der jeweiligen Funktion verbracht wurde. Anschließend wird mit der print_stats-Methode dafür gesorgt, dass nur die ersten acht Zeilen ausgegeben werden.

Das Schlüsselwort time in der sort_stats-Methode verlangt eine Sortierung nach der Zeit, die in der jeweiligen Funktion verbracht wurde. Wird von einer Funktion aus eine andere Funktion aufgerufen, so wird die Uhr für die aufrufende Funktion angehalten. Dies ist zum Beispiel in der Funktion wurzel der Fall, die in Zeile 25 die Funktion wurzel_startwert aufruft. Für die Funktion wurzel wurde gemäß der obigen Ausgabe eine Zeit tottime von 41,008 Sekunden gemessen. Diese enthält nicht die 8,812 Sekunden, die von wurzel_startwert benötigt werden. Die von wurzel benötigte Gesamtzeit lässt sich in der Spalte cumtime (cumulative time, also aufsummierte Zeit) zu 49,819 Sekunden ablesen. Dies entspricht bis auf einen Rundungsfehler der Summe der Zeiten, die in wurzel und wurzel_startwert verbracht wurden. Ist man an den aufsummierten Zeiten interessiert, so kann man das Schlüsselwort cumtime in der sort_stats-Methode verwenden.

In [4]: p.sort_stats('cumtime').print_stats(8)
Fri Dec 23 15:36:56 2016    pi.prof

         2882 function calls in 68.377 seconds

   Ordered by: cumulative time
   List reduced from 76 to 8 due to restriction <8>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000   68.377   68.377 {built-in method builtins.exec}
        1    0.424    0.424   68.377   68.377 pi.py:1(<module>)
       18   41.008    2.278   49.819    2.768 pi.py:27(wurzel)
       17    0.320    0.019   47.346    2.785 pi.py:36(agm_iteration)
        1   17.776   17.776   17.776   17.776 pi.py:4(division)
       18    8.812    0.490    8.812    0.490 pi.py:12(wurzel_startwert)
        1    0.024    0.024    0.037    0.037 pi.py:40(ausgabe)
     1251    0.011    0.000    0.011    0.000 {built-in method builtins.print}

Out[4]: <pstats.Stats at 0x7f1a26ed4ac8>

Die Ausgabe zeigt auch, dass es nicht immer auf die Zeit ankommt, die pro Aufruf einer Funktion benötigt wird. Diese Information findet sich in der Spalte percall. So benötigt division in unserem Beispiel 17,776 Sekunden je Aufruf, während wurzel nur 2,278 Sekunden je Aufruf benötigt. Allerdings wird division nur einmal aufgerufen, während wurzel achtzehnmal aufgerufen wird. Damit ist der Beitrag von wurzel zur Gesamtlaufzeit erheblich größer als der Beitrag von division.

Es kann sinnvoll sein, die in der Spalte ncalls angegebene Anzahl der Aufrufe einer Funktion auf Plausibilität zu überprüfen. Gelegentlich stellt sich dabei heraus, dass eine Funktion unnötigerweise mehrfach aufgerufen wird. So kann es vorkommen, dass eine Funktion in einer Schleife aufgerufen wird, obwohl sich die Funktionsargumente in der Schleife nicht ändern. Eine entsprechende Anpassung des Programms kann dann auf einfache Weise zu einer Beschleunigung führen.

Mit den beschriebenen Ausgaben lässt sich nun feststellen, in welchen Teilen des Programms der größte Anteil der Rechenzeit verstreicht. Man kann sich somit bei der Optimierung des Programms auf diese Teile konzentrieren. Dabei kann es natürlich vorkommen, dass nach einer Optimierung andere Programmteile in den Fokus rücken. Es kann aber auch sein, dass man feststellen muss, dass die meiste Rechenzeit in einem Programmteil benötigt wird, der sich nicht mehr optimieren lässt. Dann muss man sich die Frage stellen, ob es sinnvoll ist, die Optimierungsbemühungen überhaupt fortzusetzen, da eine Optimierung der anderen Programmteile kaum eine Auswirkung auf die Gesamtrechenzeit haben wird. Um die Situation einschätzen zu können, sind Laufzeitanalysen, wie wir sie hier vorgestellt haben, praktisch unerlässlich.

Zeilenorientierte Laufzeitbestimmung

Gelegentlich kann es vorkommen, dass die im letzten Abschnitt beschriebene funktionsbasierte Laufzeitauswertung nicht ausreicht, um ein in Hinblick auf die Laufzeit kritisches Codestück zu identifizieren. In diesem Fall kann man zu einer zeilenorientierten Laufzeitmessung greifen. Wir beschreiben hier das von Robert Kern entwickelte Modul line_profiler [4].

Der besseren Übersichtlichkeit wegen empfiehlt es sich, eine zeilenorientierte Laufzeitmessung auf eine einzelne Funktion oder nur wenige Funktionen zu beschränken. Dazu bestimmt man am besten mit den zuvor beschriebenen Methoden die zeitkritischsten Funktionen. Für Funktionen, die mit einem @profile-Dekorator versehen sind, wird eine zeilenorientierte Laufzeitmessung durchgeführt. Wir wollen speziell die Funktionen wurzel und wurzel_startwert betrachten. Der entsprechende Codeteil sieht dann folgendermaßen aus.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
   @profile
   def wurzel_startwert(quadrat):
       str_quadrat = str(quadrat)
       nrdigits = len(str_quadrat)
       keepdigits = 12
       if nrdigits % 2:
           keepdigits = keepdigits+1
       lead_sqrt_estimate = sqrt(float(str_quadrat[:keepdigits]))
       return int(lead_sqrt_estimate)*10**((nrdigits-keepdigits)//2)+1

   @profile
   def wurzel(quadrat):
       x = wurzel_startwert(quadrat)
       xold = 0
       while x != xold:
           xold = x
           x = xold*xold+quadrat
           x = x//(2*xold)
       return x

Der restliche Code bleibt unverändert. Wesentlich sind hier die beiden @profile-Dekoratoren. Für die folgende Diskussion haben wir den Iterationsschritt des Newton-Verfahrens in zwei Zeilen (17 und 18) aufgeteilt. Außerdem haben wir einen Docstring entfernt, der hier nicht wesentlich ist.

Von der Befehlszeile kann man nun das Skript unter Verwendung der zeilenorientierten Laufzeitmessung ausführen:

kernprof -l -v pi.py

kernprof ist der Name eines Skripts, das die Verwendung des Moduls line_profiler automatisiert, wenn man die Option -l angibt. Die Option -v gibt man an, wenn die Ausgabe direkt angezeigt werden soll. In jedem Fall werden die relevanten Daten ähnlich wie beim cProfile-Modul in eine Binärdatei geschrieben. Sofern nicht mit der Option -o etwas anderes angegeben wird, ergibt sich der Name der Datei durch Anhängen der Endung .lprof. In unserem Falle heißt sie also pi.py.lprof. Aus ihr kann man mit

python -m line_profiler pi.py.lprof

die folgende Ausgabe erzeugen:

Timer unit: 1e-06 s

Total time: 8.71038 s
File: pi.py
Function: wurzel_startwert at line 10

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    10                                           @profile
    11                                           def wurzel_startwert(quadrat):
    12        18      8621108 478950.4     99.0      str_quadrat = str(quadrat)
    13        18           61      3.4      0.0      nrdigits = len(str_quadrat)
    14        18           20      1.1      0.0      keepdigits = 12
    15        18           39      2.2      0.0      if nrdigits % 2:
    16                                                   keepdigits = keepdigits+1
    17        18          207     11.5      0.0      lead_sqrt_estimate =
                                                       sqrt(float(str_quadrat[:keepdigits]))
    18        18        88949   4941.6      1.0      return int(lead_sqrt_estimate)
                                                       *10**((nrdigits-keepdigits)//2)+1

Total time: 49.5045 s
File: pi.py
Function: wurzel at line 20

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    20                                           @profile
    21                                           def wurzel(quadrat):
    22        18      8710713 483928.5     17.6      x = wurzel_startwert(quadrat)
    23        18           31      1.7      0.0      xold = 0
    24       288          898      3.1      0.0      while x != xold:
    25       270          254      0.9      0.0          xold = x
    26       270      3026189  11208.1      6.1          x = xold*xold+quadrat
    27       270     37766390 139875.5     76.3          x = x//(2*xold)
    28        18           31      1.7      0.0      return x

In der Ausgabe zur Funktion wurzel_startwert haben wir die Zeilen 17 und 18 wegen der Zeilenlänge nachträglich umgebrochen. Die Ausgabe zeigt uns in der Funktion wurzel_startwert nun deutlich, welcher Teil der Funktion für die Ausführungsdauer von fast 9 Sekunden verantwortlich ist, nämlich die Umwandlung eines Integers in einen String. Dieser Schritt ist hier erforderlich, um die Zahl der Ziffern in dem Integer quadrat zu bestimmen.

Interessant ist auch die Funktion wurzel, die für einen größten Teil der Laufzeit verantwortlich ist. In den Zeilen 26 und 27 sehen wir, dass der Großteil der Zeit im Newton-Iterationsschritt verbracht wird. Dabei spielt die Berechnung des Quadrats von xold kaum eine Rolle. Es ist vielmehr die Division in Zeile 27, die einen sehr hohen Zeitaufwand erfordert. Zwar ist die Zeit für die Berechnung des Startwerts in Zeile 22 auf einen einzelnen Aufruf bezogen größer, aber nachdem die Division 270-mal aufgerufen wird, ist sie für mehr als Dreiviertel der Laufzeit der Funktion wurzel verantwortlich.

Bei der Programmentwicklung kann es praktisch sein, das Modul line_profiler in IPython zu verwenden. Im Folgenden ist ein Beispiel gezeigt, das einen Vergleich zwischen der Wurzelfunktion aus dem math-Modul und der Wurzelberechnung mit Hilfe des Newton-Verfahrens anstellt.

In [1]: %load_ext line_profiler

In [2]: import math

In [3]: def newton_sqrt(quadrat):
   ...:     x = 1
   ...:     while abs(quadrat-x*x) > 1e-13:
   ...:         x = 0.5*(x*x+quadrat)/x
   ...:     return x
   ...:

In [4]: def comparison(x):
   ...:     sqrt1 = math.sqrt(x)
   ...:     sqrt2 = newton_sqrt(x)
   ...:     print(sqrt1, sqrt2)
   ...:

In [5]: %lprun -f newton_sqrt comparison(500)
22.360679774997898 22.360679774997898
Timer unit: 1e-06 s

Total time: 7e-05 s
File: <ipython-input-3-e6f13bf0d844>
Function: newton_sqrt at line 1

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     1                                           def newton_sqrt(quadrat):
     2         1            4      4.0      5.7      x = 1
     3        10           34      3.4     48.6      while abs(quadrat-x*x) > 1e-13:
     4         9           29      3.2     41.4          x = 0.5*(x*x+quadrat)/x
     5         1            3      3.0      4.3      return x

Zunächst lädt man in Eingabe 1 line_profiler als Erweiterung. Nachdem man die nötigen Funktionen definiert und für dieses Beispiel auch noch das math-Modul geladen hat, kann man in Eingabe 5 mit Hilfe von %lprun die zeilenorientierte Laufzeitmessung ausführen. Dazu gibt man mit der Option -f die Funktion an, in der die Laufzeitmessung benötigt wird. Diese Option ersetzt also den @profile-Dekorator. Bei Bedarf kann die Option -f auch mehrfach angegeben werden. Am Ende steht der Aufruf der Funktion, mit der der gewünschte Code ausgeführt wird, hier also comparison(500).

Nachdem wir uns in diesem Kapitel auf die Messung von Laufzeiten konzentriert hatten, sei abschließend noch angemerkt, dass man auch die Entwicklung des Speicherbedarfs während der Ausführung eines Skripts messen kann. Dies ist besonders dann nützlich, wenn man mit größeren Arrays arbeitet oder an die Grenzen des Arbeitsspeichers stößt. Um im Skript zeilenweise die Entwicklung des Speicherbedarfs messen zu können, verwendet man das Modul memory_profiler.

[1]D. E. Knuth, Computing Surveys 6, 261 (1974). Das angegebene Zitat befindet sich auf Seite 268.
[2]Weitere Informationen zu diesem Projekt findet man unter www.pypy.org.
[3]Weitere Informationen zu diesem Projekt findet man unter numba.pydata.org.
[4]Die Quellen zu diesem Modul findet man unter https://github.com/rkern/line_profiler.