Python-Code (mit Cython) beschleunigen

26.08.2020, 10:32 - Autor: Jasmin Horvath

PYPY - Der schnellere Interpreter

Bevor wir uns mit Cython beschäftigen wollen wir einmal zwei typische Zeitfresser betrachten...

Erstellen und durchsuchen von Listen:

import time
max_nr = 100000

ts = time.time()
l = []
for i in range(max_nr):
    l.append(i+1)

found = 0
for i in range(max_nr):
    if i in l:
        found += 1
te = time.time()
print(str(te - ts) + " Sek. für das Erstellen und Durchsuchen der Liste")

Bei diesem Beispiel wir eine Liste erstellt und mit 100.000 Einträgen (den Zahlen 1 bis 100.000) befüllt. Danach werden die Zahlen 0 - 99.999 durchlaufen und in der Liste gesucht. Sollten Sie probleme haben diesen einfach Codebeispielen zu folgen sollten Sie sich zuerst den Python 3.x Crashkurs ansehen! Lassen wir das Programm mit dem Python 3.x Interpreter laufen erhalten wir folgende Meldung:

mark@debian ~$ python3 liste.py 
27.124310970306396 Sek. für das Erstellen und Durchsuchen der Liste

PyPy ist ein alternativer Interpreter der auf Geschwindigkeit optimiert ist - allerdings hinkt das Projekt dem aktuellen Stand etwas hinterher und daher ist es ratsam lieber 1 oder 2 Versionen hinter den aktuellen Version zu bleiben. Außerdem sind einige Module nicht mit PyPy kompatibel. Nähere Intfomationen finden sich auf der Homepage des Projektes: https://pypy.org/

mark@debian ~$ pypy liste.py 
1.3602039814 Sek. für das Erstellen und Durchsuchen der Liste

Nicht ganz 1,4 Sekunden statt über 27,1 Sekunden kann ich durchaus sehen lassen und das ohne jegliche Veränderungen am Programm!

Das verarbeiten von größeren Datenmengen:

import time, sys

def sum_all(n):
    sum = 0
    for i in range(1, n + 1):
        sum += i
    return sum

max_nr = 1000000009

ts = time.time()
print(sum_all(max_nr))
te = time.time()
print(str(te - ts) + " Sek. mit " + sys.executable)

Um das Verarbeiten von großen Datenmengen zu simulieren habe ich dieses kurze Testprogramm geschrieben das mit Hilfe der Funktion sum_all() die Zahlen von 1 bis n aufsummiert. Lassen wir das Programm laufen:

mark@debian ~$ python3 test_pypy.py 
500000000500000045
36.72523760795593 Sek. mit /usr/bin/python3

Und nun mit PyPy:

mark@debian ~$ pypy test_pypy.py 
500000000500000045
0.786007165909 Sek. mit /usr/bin/pypy

... auch hier liefert PyPy eine extreme Geschwindigkeitssteigerung ohne jegliche Anpassungen! Aber sehen wir uns nun noch Cython an...

C-Module mit Cython erstellen

Cython hat den Vorteil, dass wir damit eigene C-Module erstellen können und das ganz ohne C bzw C++ zu beherrschen. Damit können wir den offiziellen Python-Interpreter verwenden und haben auch eine Kompatibilitätsprobleme mit diversen Modulen. Allerdings müssen wir den Code etwas modifizieren und vor allem zuerst identifizieren welche Teile des Codes die meiste Prozessorzeit belegen.

Dann müssen wir cython installieren. Dies können wir mit pip3 install cython bzw. py.exe -3.6 -m pip install cython unter Windows erreichen.

Danach können wir das entsprechende C-Modul schreiben und unter sum_all.pyx speichern:

cpdef long sum_all(long n):
    cdef long sum = 0
    cdef long i
    for i in range(1, n + 1):
        sum += i
    return sum

Aus dem def wurde ein cpdef, was soviel bedeutet wie eine Funktion die von C und von Python aus aufgerufen werden kann. Außerdem haben wir mit long sum_all(...) den Rückgabewert der Funktion typisiert. Genau das gleiche macht long n für den Funktionsparameter. Die so genannte dynamische Typisierung von Python ist bei der Ausführung ein echter Zeitfresser denn der Interpreter ist ständig gezwungen den Typ zu Vergleichen bzw. zu ermitteln um was für einen Typ es sich im Moment handelt. Was uns Entwicklern also ein wenig lästige Tipparbeit erspart verursacht einen sehr hohen Aufwand bei der Ausführung und bremst ein Programm ungemein aus!

Mit cdef long sum = 0 initialisieren wir die Variable sum mit 0 und lgen fest, dass sie nur von C aus ansprechbar ist. Da die Funktion einen Rückgabewert hat der ohnehin zurückgeliefert wird muss die Funktionsvariable sum nicht von Python aus erreichbar sein. In diesem Fall wäre dies ohnehin nicht der Fall, da eine Funktionsvariable nicht von anderen Teilen des Programms ansprechbar ist aber je genauer wir hier arbeiten umso performanter läuft der Code später. Gleiches erreichen wir mit der Schleifenvariable i die wir auch vorab initialisieren allerdings ohne ihr einen Wert zuzuweisen.

Damit haben wir die Typen für alle Variablen fest definiert. Hierbei sollte man allerdings beachten, dass damit auch einige Probleme einher gehen. Wenn man den Wertebereich eines Datentyps verlässt kommt es zu einem Speicherüberlauf und damit zu falschen Ergebnissen und sollte man immer den größtmöglichen Datentyp verwenden riskiert man übermäßig viel Speicher zu verschwenden! Man ist also in dem gleichen "Dilemma" wie ein C Programmierer. Dazu später mehr...

Danach müssen wir eine setup.py erstellen:

from distutils.core import setup
from Cython.Build import cythonize

setup(ext_modules = cythonize("sum_all.pyx"))

... und das Cython-Modul kompilieren:

mark@debian ~$  python3 setup.py build_ext --inplace
Compiling sum_all.pyx because it changed.
[1/1] Cythonizing sum_all.pyx
/home/mark/.local/lib/python3.6/site-packages/Cython/Compiler/Main.py:369: FutureWarning: Cython directive 'language_level' not set, using 2 for now (Py2). This will change in a later release! File: /home/mark/sum_all.pyx
  tree = Parsing.p_module(s, pxd, full_module_name)
running build_ext
building 'sum_all' extension
x86_64-linux-gnu-gcc -pthread -DNDEBUG -g -fwrapv -O2 -Wall -g -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 -fPIC -I/usr/include/python3.6m -c sum_all.c -o build/temp.linux-x86_64-3.6/sum_all.o
x86_64-linux-gnu-gcc -pthread -shared -Wl,-O1 -Wl,-Bsymbolic-functions -Wl,-Bsymbolic-functions -Wl,-z,relro -Wl,-Bsymbolic-functions -Wl,-z,relro -g -fstack-protector-strong -Wformat -Werror=format-security -Wdate-time -D_FORTIFY_SOURCE=2 build/temp.linux-x86_64-3.6/sum_all.o -o /home/mark/sum_all.cpython-36m-x86_64-linux-gnu.so

Hierbei wird die Datei sum_all.cpython-36m-x86_64-linux-gnu.so erstellt. Sobald dies erfolgt ist können wir das neue Modul verwenden:

#!/usr/bin/python
import sum_all, time

def sum_all_py(n):
    sum = 0
    for i in range(1, n + 1):
        sum += i
    return sum

max_nr = 1000000009

ts = time.time()
print(sum_all.sum_all(max_nr))
te = time.time()
print(str(te - ts) + " Sek. mit Cython")

ts = time.time()
print(sum_all_py(max_nr))
te = time.time()
print(str(te - ts) + " Sek. mit Python")

Zuerst wurde das neue erstelle Modul mit import sum_all importiert.

Um einen direkten Vergleich zu haben, haben wir die Funktion sum_all() nochmals in der ursprünglichen Version in die Datei eingefügt. Danach haben wir jeweils die Startzeit (ts) genommen, die Funktion aus dem Modul bzw. direkt aus dem Python-Code aufgerufen sowie das Ergebnis ausgegeben, die Endzeit (te) festgehalten und eine Meldung mit der Ausführungszeit der jeweiligen Methode ausgegeben:

mark@debian ~$ python3 use_cmodule.py 
500000000500000045
0.2465343475341797 Sek. mit Cython
500000000500000045
37.35157293891907 Sek. mit Python

... und das Ergebnis kann sich sehenlassen - die 0.24 Sekunden sind im Vergliech zu PyPy nochmals über drei mal so schnell!

Das Problem bei der statischen Typisierung

Um zu zeigen was passiert wenn wir den Wertebereich eines Datentyps überschreiten haben wir das Cython-Modul wie folgt angepasst:
cpdef char sum_all(long n):
    cdef char sum = 0
    cdef long i
    for i in range(1, n + 1):
        sum += i
    return sum

... wir haben also anstatt des Datentyps long den Datentyp char verwendet. Übersetzen wir das Modul und passen das Programm nun erneut laufen erhalten wir folgende Ausgabe:

mark@debian ~$ python3 use_cmodule.py  
45
0.2448270320892334 Sek. mit Cython
500000000500000045
39.131327867507935 Sek. mit Python

Ok, hier spingt uns der Fehler direkt ins Auge denn beim Summieren von so großen und so vielen positiven Zahlen kann das Ergebnis unmöglich 45 sein! Aber oftmals sind derartige Fehler nicht so einfach zu erkennen! Wer also die Komfortzone von Python verlässt muss sich dann auch mit derartigen Problemen beschäfrtigen. Aber sehen wir uns das Beispiel einfach einmal näher an was genau passiert:

Wir haben die Variable sum und den Rückgabewert der Funktion als char anstatt long definiert. Dieser Datentyp ist nur 1 Byte bzw. 8 Bit lang und kann daher nur die Werte von -128 bis 127 darstellen. Dazu sollen wir uns ansehen wie die Werte intern gespeichert werden:
Wert -128 64 32 16 8 4 2 1
1 0 0 0 0 0 0 0 1
12 0 0 0 0 1 1 0 0
127 0 1 1 1 1 1 1 1
-128 1 0 0 0 0 0 0 0
-127 1 0 0 0 0 0 0 1

Die Binärzahl 10000001 enspricht also 1 x -128 + 1 x 1 also der Zahl -127 und die Binärzahl 0b00001100 entspricht 1 x 8 + 1 x 4 also der Zahl 12. Daher ist die größte positive Binärzahl 01111111 also 1 x 64 + 1 x 32 + 1 x 16 + 1 x 8 + 1 x 4 + 1 x 2 + 1 x 1! In so eineem Fall spricht man auch von einem signed Datentyp - also einem Datentyp der sowohl positive als auch negative Zahlen darstellen kann. Würde man nun die Zahl 128 binär darstellen wollen dann wäre das 10000000. Bei einem unsigned int mit 8 Bit wäre die 8. Stelle auch die postive 128 und das würde klappen. Aber spätenstens bei 100000000 also der Zahl 256 würden die 8 bit auch nicht mehr reichen und es würde ein 9. Bit an Speicher benötigt werden.

Sie sehen also es ist essentiell wichtig ist wie ein Bitmuster im Speicher zu interpretieren ist um das nochmals zu verdeutlichen hier ein kleines Beispiel wie ein und das selbe Bitmuster unterschiedliche Ergebnisse liefert je nach dem wie man es interpretiert:

>>> bin = "10100111"
>>> int(bin, 2)
167
>>> if bin[0:1] == "1": -128 + int(bin[1:], 2)
-89
>>> chr(int(bin, 2))
"§"

Zuerst definieren wir die Variable bin mit dem Muster 10100111 und dann wird diese mit der int() Funktion in eine positive Ganzzahl umgewandelt. Die zweite Konvertierung prüft ob die erste Stelle (bin[0:1]) eine 1 enthält und addiert dann -128 mit dem Ergebnis der Umwandlung der Stellen 2 bis 8 (int(bin[1:], 2)) des Binmusters um das Muster so als unsigned Integer zu behandeln. Schließlich wird die Indexzahl 167 noch mit Hilfe der ASCII-Tabelle als ein Zeichen interpretiert und dies ergibt §. Python nimmt uns Entwicklern diese Arbeit ab und kümmert sich darum immer zu wissen welche Variable gerade welchen Datentyp hat und übernimmt in einigen eindeutigen Fällen sogar die Typenkonvertierung für uns. Sobald wir von dieser dynamischen Typisierung zur statischen Typisierung wechseln und selber festlegen welcher Datentyp für welche Variable gelten soll und welcher Datentyp von einer Funktion zurückgegeben wird obliegt es auch uns als Entwickler sicherzustellen, dass die Daten richtig interpretiert werden und die Datentypen ausreichend große Daten aufnehmen können.

Vor allem die Größe ist ein wirkliches Problem denn auch hier nimmt und Python die Speicherveraltung ab:

>>> a = 1
>>> type(a)
<class "int">
>>> a.__sizeof__()
28
>>> a = 999999999999999 * 88888888888888
>>> type(a)
<class "int">
>>> a.__sizeof__()
40
>>> a = "bla"
>>> type(a)
<class "str">
>>> a.__sizeof__()
52
>>> a = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua."
>>> type(a)
<class "str">
>>> a.__sizeof__()
204

Aber sehen wir uns einmal an wie C und C++ mit Speichergrößen umgehen und was passiert wenn die maxiamal mögliche größe überschritten wird. Bleiben wir dabei bei dem bereits bekannten Beispiel der 8 Bit langen Ganzzahl. Sobald wir eine größere Zahl als 255 abspeichern wollen reichen selbst bei einem unsigned Integer die 8 bit nicht mehr aus - sehen wir uns dazu die folgende Zahlen an:

 11111111 (1 x 128 + 1 x 64 + 1 x 32 + 1 x 16 + 1 x 8 + 1 x 4 + 1 x 2 + 1 x 1 = 255)
100000000 (1 x 256 + 0 x 128 + 0 x 64 + 0 x 32 + 0 x 16 + 0 x 8 + 0 x 4 + 0 x 2 + 0 x 1 = 256)
100000001 (1 x 256 + 0 x 128 + 0 x 64 + 0 x 32 + 0 x 16 + 0 x 8 + 0 x 4 + 0 x 2 + 1 x 1 = 257)

Sie sehen aber auch, dass wir ab 256 9 statt 8 Bit benötigen also sehen wir uns ein vereinfachtes Beispiel an wie dies im RAM-Speicher aussehen könnte:

0x7F171CEFC374 0x7F171CEFC375
bit1 bit2 bit3 bit4 bit5 bit6 bit7 bit8 bit1 bit2 bit3 bit4 bit5 bit6 bit7 bit8
0 0 1 1 1 0 1 0 1 1 1 1 1 1 1 1

Nehmen wir nun an, dass in der Speicheradresse 0x7F171CEFC374 das Zeichen : bzw. der Binärwärt 00111010 einer anderen Variable bespeichert sind. Dies könnte zB das letzte Zeichen einer Zeichenkette darstellen. In der Speicheradresse 0x7F171CEFC374 legen wir dann die Ganzzahl 255 in einem 8 bit Integer ab (11111111). Danach veranlassen wir das Programm zu dieser Zahl die 1 zu addieren und dies würde den Speicher wie folgt verändern:

0x7F171CEFC374 0x7F171CEFC375
bit1 bit2 bit3 bit4 bit5 bit6 bit7 bit8 bit1 bit2 bit3 bit4 bit5 bit6 bit7 bit8
0 0 1 1 1 0 1 1 0 0 0 0 0 0 0 0

Wie Sie sehen ist die 9. Stelle hinaus gewandert und hat auch die Speicheradresse 0x7F171CEFC374 verändert. Wir haben also zwei Probleme:

1. wurde aus dem : am Ende der Zeichenkette nun ein ;. Damit wurde diese Variable unabsichtlich verändert. Dies kann dazu führen, dass Berechnungen mit falschen Werten ausgeführt werden oder Daten übersprungen werden wenn ein Zäher plötzlich unabsichtlich um eins erhöht würde.

2. wird beim Auslesen der Variable nur die Speicheradresse 0x7F171CEFC375 berüchsichtigt. Das eine bit ist also abgeschnitten worden und der Wert der Variable ist nun 0 statt 256!

Genau darum erhielten wir auch das falsche Ergebnis als wir die Variable sum und den Rückgabewert der Funktion auf den Datentyp char gesetzt hatten. Der Wert wurde immer und immer wieder überschritten und andere Bereiche im RAM-Speicher überschrieben was zu allen möglichen weiteren Seiteneffekten führen könnte und dadruch wurde immer nur ein Teil des Bitmusters der eigentlichen Summe berücksichtigt und damit weiter gerechnet was das falsche Ergebnis erklärt.

Fazit

Python-Programme müssen nicht unbedingt langsam laufen. Außerdem geht es durchaus einfache Lösungen ohne langwierig mit der Verarbeitungsgeschwindigkeit der diversen Datentypen zu experimentieren oder diverse Module und Funktionen miteinander zu vergleichen um die performanteste Lösung zu finden. Dabei sind weder C/C++ Kenntnisse (abgesehen von den Datentypen und deren Wertebereiche) nötig um C-Module mit Cython zu schreiben!

Wenn Sie dies erwägen sollten Sie sich aber auch der "Tücken" und "Probleme" bewusst sein die eine für C/C++ typische statische Typisierung mit sich bringen und diese in der Planung und Umsetzung des C-Modules berücksichtigen um keine unerwarteten Überraschungen zu erleben.