zurück zum Artikel

Python: Richtige JIT-Optimierung – damit nichts schiefgeht

Eldar Sultanow

(Bild: Shutterstock)

JIT-optimierter Code beschleunigt Python-Programme auf GPUs rasant. Doch wer nicht aufpasst, zerstört schnell den Performancegewinn oder rechnet sogar falsch.

Forscherinnen und Entwickler sehen sich heute oftmals mit Fragestellungen konfrontiert, die immer komplexer werden – auch die Datenmengen nehmen an KomplexitĂ€t zu. Die Leistung der Hardware wird aber ebenfalls immer besser und steht sogar per Knopfdruck in der Cloud zur VerfĂŒgung [1]. Allerdings ist es gar nicht so einfach, diese Leistung optimal auszuschöpfen. Nicht selten laufen Berechnungsroutinen auf leistungsstarker Hardware, nutzen dabei aber nur einen Bruchteil dieser Leistung. Wer on Demand beispielsweise in der AWS-Cloud die EC2 P3-Instanz [2]vom Typ p3.8xlarge (diese beinhaltet vier GPUs) verwendet, bezahlt pro Stunde 12,24 US-Dollar.

Wie strÀflich wÀre es, dort eine Berechnungsroutine laufen zu lassen, die weder die JIT-Optimierung noch die GPU nutzt: Das dauert unnötig lÀnger und kostet dementsprechend mehr Geld. Von welchen Faktoren hier die Rede ist, wie sehr viel schneller eine JIT- oder GPU-optimierte Routinen lÀuft und wie Entwicklerinnen und Entwickler sie mit Python umsetzen können, zeigt dieser Artikel. AusgewÀhlte Beispiele veranschaulichen die Fallstricke, die auftreten können.

Die Programmiersprachen C++ und Python sind sehr gut geeignet, um rechenintensive, leistungsoptimierte Routinen zu schreiben – erst im letzten Jahr hat Nvidia Neuerungen fĂŒr beide Programmiersprachen eingefĂŒhrt [3]. Dieser Artikel konzentriert sich auf Python, betrachtet dabei aber auch Alternativen ĂŒber den Tellerrand hinaus (Mathematica, C++). Daher benötigen Entwicklerinnen das entsprechende Handwerkszeug:

Angeregt durch den französischen Mathematiker Jacques Ozanam beschĂ€ftigte sich sein italienischer Kollege Pietro Mengoli in den 1670er Jahren mit speziellen diophantischen Gleichungen – sie lassen lediglich ganzzahlige Lösungen zu. Ein bekanntes Problem, das von solchen Gleichungen handelt, ist das Sechs-Quadrate-Problem [4]: Finde drei natĂŒrliche Zahlen x, y, z, deren Differenzen z-y, z-x, y-x jeweils Quadratzahlen sind, wobei die Differenzen z2-y2, z2-x2, y2-x2 der Quadrate ebenfalls Quadratzahlen sind.

Die Ergebnisse umfassen sehr große Zahlen. Ozanam hatte damals schon eine Lösung prĂ€sentiert, nĂ€mlich x=1873432, y=2288168 und z=2399057. Derartige Probleme in höheren Zahlenregionen sind als Anschauungsmaterial ideal, da es Fallstricke in der JIT-optimierten Programmierung mit sehr großen Zahlen gibt, wie sich spĂ€ter noch zeigt.

Bemerkenswert ist, dass Ozanam bereits im 17. Jahrhundert derartig großen Zahlen gefunden hat, fĂŒr die diese Gleichung erfĂŒllt ist. Hatte er doch keine technischen Hilfsmittel wie Computer, Programmiersprachen oder Ähnliches zur Hand. Unsere heutigen Werkzeuge sind in der Lage, solch aufwendige Probleme zu lösen. So hat beispielsweise ein Team am Massachussetts Institute of Technology (MIT) im Jahr 2019 mit x=-80538738812075974, y=80435758145817515 und z=12602123297335631 eine Lösung fĂŒr die diophantische Gleichung x3+y3+z3=42 gefunden. Unter den Zahlen bis 100 war die 42 die letzte noch offene Zahl, die als Summe dreier Kubikzahlen darstellbar ist. Es wundert nicht, dass das Interesse an diesem RĂ€tsel auch bei Fans des Science-Fiction-Klassikers "Per Anhalter durch die Galaxis" groß war. Das Team hat fĂŒr die Berechnung die ungenutzte Leistung von mehr als einer halben Million Heim-PCs [5] verwendet. Derartige Gleichungen gehören zu dem seit 160 Jahren ungelösten "Summe dreier Kubikzahlen"-Problem. Es gibt weitere Fragestellungen, die ohne hohe Rechenpower auch in Hunderten von Jahren nicht zu bewĂ€ltigen wĂ€ren. Hierzu zĂ€hlen vor allem Aufgaben aus dem Umfeld der kĂŒnstlichen Intelligenz (KI) wie Spracherkennung, Bilderkennung oder Mustererkennung in massenweise numerischen Daten.

Zur Vereinfachung liegt der Fokus auf den drei Quadratzahlen x2, y2 und z2, deren Differenz wieder Quadratzahlen sind. Hier gibt es auch Ergebnisse, die nicht ganz so groß sind: x=153, y=185 und z=697 oder x=520, y=533 und z=925. Ein kleiner Quercheck 9252-5332 ergibt 571536, was tatsĂ€chlich eine Quadratzahl ist, nĂ€mlich 7562. NachzuprĂŒfen, ob die anderen Differenzen ebenfalls stimmen, sei an dieser Stelle der Leserschaft ĂŒberlassen. Eine große Anzahl solcher Treffer bis in die Zahlenregion von 12 Millionen wurden mithilfe einer Just-in-Time-optimierten Routine (JIT) ermittelt und ist in einem GitHub-Repository [6] (siehe Datei pythagorean_12000000.csv) verfĂŒgbar. Der dafĂŒr zustĂ€ndige Algorithmus ist auf StackExchange Mathematics [7] beschrieben, fĂŒr JIT optimiert und ebenfalls im GitHub-Repository (siehe Datei pythagorean_gendata_jit.py) verfĂŒgbar.

@jit('void(uint64, uint64[:])')
def generateData(limit: np.uint64, triples: np.ndarray) -> np.uint32:
    A=np.zeros(limit, dtype=np.uint64)
    B=np.zeros(limit, dtype=np.uint64)
    rows = np.uint32(0)
    for w in np.arange(1, limit+1, dtype=np.uint64):
        count = np.uint32(0)
        for a in np.arange(1, w+1, dtype=np.uint64):
            sqr = np.uint64(sqrt(w*w-a*a))
            if sqr*sqr == w*w-a*a:
                count+=1
                A[count]=a
                B[count]=np.uint64(sqrt(w*w-a*a))
        if count>1:
            for i in np.arange(1, count+1, dtype=np.uint64):
                for j in np.arange(1, count+1, dtype=np.uint64):
                    if i!=j:
                        x=np.uint64(A[i])
                        t=np.uint64(B[i])
                        s=np.uint64(A[j])
                        z=np.uint64(B[j])
                        if z > x:
                            y = np.uint64(sqrt(z*z-x*x))
                            if y*y == z*z-x*x:
                                arr = np.array([x, y, z, s, t, w], dtype=np.uint64)
                                triples[rows] = arr
                                rows+=1
    return rows

Listing 1: Optimierter Algorithmus zur Lösung des Sechs-Quadrate-Problems

Wer die Routine in Listing 1 zur Suche von Lösungen startet, die Werte bis zu 50000 annehmen können (limit=50000), benötigt dazu auf einem HP Z-Book (32 GByte RAM, Intel Core i7-9750H, 2.60GHz, 6 Kerne, 12 logische, NVIDIA Quadro T1000) 4,8 Sekunden und findet 1074 Tripel fĂŒr x, y und z. FĂŒhrt man die Routine ohne Optierung auf demselben Rechner aus, dann benötigt sie 1082,8 Sekunden (18 Minuten), um diese 1074 Tripel zu ermitteln. Demnach hat die JIT-Optimierung eine Beschleunigung um etwa den Faktor 225 erbracht.

Wie kommt nun die Optimierung zustande? DafĂŒr ist das Paket Numba zustĂ€ndig – ĂŒber from numba import jit lĂ€sst sich der dort definierte @jit-Dekorator importieren. Die Schwierigkeit in der JIT-optimierten Programmierung besteht darin, dass die Verwendung von Befehlen eingeschrĂ€nkt ist und Entwickler nicht einfach beliebigen Code optimieren können. Deshalb muss er optimierbare Operationen in eine dafĂŒr gesondert vorgesehene Routine auslagern. Im vorliegenden Fall ist es die Funktion generateData, die mit dem @jit-Dekorator versehen ist.

Die Routine sucht mithilfe dreier verschachtelter for-Schleifen, in denen sie einfache Operationen wie Addition, Subtraktion, Multiplikation und Wurzelziehen verwendet, nach den Zahlen x, y, z. Am Ende gibt sie die Anzahl der gefunden Ergebnisse zurĂŒck. Das Speichern der gefundenen Lösungen in eine CSV-Datei erfolgt außerhalb dieser Routine. FĂŒgen Programmierer derartigen, beispielsweise auf pandas basierenden Code zur Erzeugung von CSV-Dateien in die per @jit-Dekorator versehene Methode ein, erhalten sie beim Aufruf des Python-Programms einen Fehler. Der Grund dafĂŒr ist, dass derartiger Code nicht optimierbar ist. Allerdings existiert eine Reihe von Fallstricken, die zwar keinen Fehler, aber einen enormen Performance-Verlust und falsche Ergebnisse verursachen.

Der Code in Listing 1 ist so beschaffen, dass er ein leeres, bereits mit vorgegebener GrĂ¶ĂŸe initialisiertes Array an die optimierte Routine ĂŒbergibt. Das Programm befĂŒllt dieses Array innerhalb der Route selbst. Alternativ könnten Entwickler dem Gedanken verfallen, das Ergebnis-Array dynamisch mit den gefundenen Lösungen wachsen zu lassen. Eine Suche von Lösungen, deren Werte x, y, z maximal 50000 annehmen, ergibt viel weniger Ergebnisse als die festgelegte Grenze von 50000 – in der Tat sind 1074 Treffer deutlich weniger als 50000. Auf diese Weise wĂŒrde das Programm auch keinen Platz verschwenden.

Der Programm-Code im Listing 2 realisiert diese Platzersparnis und hĂ€lt sich dabei an die Vorgabe, dass er an die optimierte Routine ein initialisiertes Array ĂŒbergeben soll. Allerdings zerstört diese Vorgehensweise den gesamten Performancegewinn und eine Suche (wieder mit limit = 50000) dauert nun 1203,3 Sekunden (20 Minuten). Es ist also von Vorteil, wenn sich Programmierer auf diese "Platzverschwendung" einlassen. Wer dieses Beispiel selbst nachstellen möchte, findet den vollstĂ€ndigen Code im GitHub-Repository (siehe Datei pythagorean_gendata_jit_antipattern.py).

arr = np.array([x, y, z, s, t, w], dtype=np.uint64)
old_size = triples.shape
rows = np.uint32(old_size[0])
cols = np.uint32(old_size[1])
triples.resize((rows + 1, cols), refcheck=False)
triples[rows] = arr

Listing 2: Fallstrick 1 – "Teure" Operationen in der optimierten Routine.

Bei der GegenĂŒberstellung der Laufzeiten, wie sie in Tabelle 1 zu sehen sind, ergibt sich ein beeindruckender Vergleich: Die korrekt umgesetzte Optimierung bringt eine Beschleunigung um den Faktor 225.

Keine Optimierung
Optimierung
Optimierung mit Fallstrick 1
1082,8 Sekunden
4,8 Sekunden
1203,3 Sekunden

Tabelle 1: GegenĂŒberstellung der Laufzeiten

Setzen Programmierer eine lang laufende Routine ein, deren Laufzeit nicht nur Tage, sondern Wochen dauert, ist es wĂŒnschenswert, in gewissen ZeitabstĂ€nden Zwischenergebnisse zu sichern – beispielsweise in einer CSV-Datei. Der Schreibvorgang darf aber nicht innerhalb der optimierten Routine, sondern muss außerhalb erfolgen. Listing 3 zeigt eine Routine mit drei ineinander verschachtelten for-Schleifen zur alternativen Generierung potenzieller Tupel fĂŒr das Sechs-Quadrate-Problem. Der vollstĂ€ndige Code ist im GitHub-Repository (siehe Datei pythagorean_gendata2_nofile_jit.py) verfĂŒgbar. Dieser Code lĂ€sst sich per JIT gut optimieren und bietet die die erwartete Beschleunigung um dreistellige Faktoren.

@jit('void(uint64)')
def generateData(limit: np.uint64):
    for t in np.arange(0, limit+1, dtype=np.uint64):
        for s in np.arange(0, t, dtype=np.uint64):
            for u in np.arange(0, limit+1, dtype=np.uint64)

Listing 3: Ineinander geschachtelte For-Schleifen mit festen Bereichen kann JIT optimieren.

Speichern Entwickler die gefunden Ergebnisse in einer Datei zwischen, entfĂ€llt dabei die gesamte Optimierung. Jede Fundstelle wird an den (außerhalb der JIT-Optimierung laufenden) Aufrufer zurĂŒckgegeben, der sie wiederum in eine Datei schreibt (Listing 4). Der Mechanismus hebt die gesamte JIT-Optimierung auf. Im GitHub-Repository [8] liegt der vollstĂ€ndige Code zum Ausprobieren bereit (siehe Datei pythagorean_gendata2_jit_antipattern.py).

@jit('void(uint64, uint64[:])')
def generateData(limit: np.uint64, triplet: np.ndarray) -> np.ndarray:
    s0 = triplet[0]
    t0 = triplet[1]
    u0 = triplet[2]
    for t in np.arange(t0, limit+1, dtype=np.uint64):
        for s in np.arange(s0, t, dtype=np.uint64):
            for u in np.arange(u0, limit+1, dtype=np.uint64):
               ïżœ
               if sqr*sqr == t_s:
                   return np.array([s, t, u, ss, tt, uu, t_u, t_u_s, t_s], dtype=np.uint64)

Listing 4: Aufheben der Optimierung durch dynamische Schleifen (Fallstrick 2).

Der dritte Fallstrick zeigt sich anhand eines in der Kryptographie verbreiteten Problems: Elliptische Kurven. Eine hĂ€ufige Fragestellung, die direkt damit im Zusammenhang steht, lautet: Liegen auf der Kurve rationale oder sogar ganzzahlige Punkte? Die Frage wurde beispielsweise auf StackExchange Mathematics [9]fĂŒr die Kurve y2=x6−4x2+4 gestellt und beantwortet. Es sei an dieser Stelle erwĂ€hnt, dass es mĂ€chtigere mathematische (beispielsweise probabilistische) Methoden fĂŒr die Suche von rationalen Punkten auf elliptischen Kurven gibt. Zu vereinfachten Illustrationszwecken des Performancegewinns via JIT und GPU bleibt dieses Beispiel beim einfachen Bruteforce.Die Routine in Listing 5, die in diesem Code korrekt optimiert ist, dient zur Suche von ganzzahligen Lösungen dieser Kurve.

@jit('void(uint64)')
def findIntegerSolutions(limit: np.uint64):
    for x in np.arange(0, limit+1, dtype=np.uint64):
        y = np.uint64(x**6-4*x**2+4)
        sqr = np.uint64(np.sqrt(y))
        if np.uint64(sqr*sqr) == y:
            print([x,sqr,y])

Listing 5: Optimierte Routine zur Suche von ganzzahligen Punkten auf der Kurve y2=x6−4x2+4

Eine Suche nach solchen Punkten (x,y) mit x bis zu 100.000000 (Hundertmillionen) dauert auf der gleichen Hardware wie bei den vorherigen Beispielen auch nur 5 Sekunden. Jedoch liefert die Routine neben den zwei richtigen Lösungen auch ein paar, die gar keine sind:

Die NumPy-Datentypen und das Numba-Paket sind aufeinander abgestimmt. Programmierer, die von der Optimierung profitieren möchten, sollten deshalb NumPy-Datentypen verwenden. Der grĂ¶ĂŸtmögliche NumPy-Integer ist uint64 (unsignierter 64 Bit Integer). Er kann Werte annehmen, die bis zu 264-1 =18.446.744.073.709.551.615 groß sind. Da jedoch die Variable x eine Schleife von 0 bis 100.000000 durchlĂ€uft und es gemĂ€ĂŸ der Kurvengleichung hoch 6 gerechnet wird, sprengt es die Grenze. Es entsteht ein Überlauf, der falsche Zahlen und falsche Ergebnisse produziert. Leider ist es fĂŒr Entwickler alles andere als einfach, damit umzugehen. Unter Verwendung des Pakets gmpy2 fĂŒr hohe ZahlenprĂ€zision tauchen solche falschen Lösungen zwar nicht mehr auf, jedoch dauert die in Listing 6 implementierte Routine statt 5 Sekunden nun 842 Sekunden (14 Minuten).

Nimmt man die JIT-Optimierung aus Listung 6 heraus, dauert sie etwa gleich lang, nĂ€mlich 769 Sekunden (13 Minuten). Am schlechtesten ist die Performance, wenn die Optimierung aus Listing 5 entfernt wird, man also nichtoptimiert die NumPy-Datentypen anstelle der gmpy2-Datentypen verwendet. In diesem Fall treten keine Falschlösungen auf, allerdings dauert die Routine statt 5 Sekunden nun 6667 Sekunden (1,8 Stunden). Es zeigt sich, dass die hauptsĂ€chliche Optimierung in gmpy2 erfolgt. Mithilfe des in Python verfĂŒgbaren multiprocessing-Pakets ist es möglich, die Routine wieder stark zu beschleunigen – nĂ€mlich runter auf 49 Sekunden (siehe Funktion find_integer_solutions_gmpy2_multiprocessing im Github-Repository [10]).

@jit('void(uint64)')
def findIntegerSolutionsGmpy2(limit: np.uint64):
    for x in np.arange(0, limit+1, dtype=np.uint64):
        x_ = mpz(int(x))**2
        y = x_**3-mpz(4)*x_+mpz(4)
        if gmpy2.is_square(y):
            print([x,gmpy2.sqrt(y),y])

Listing 6: Die Routine aus Listing 5 auf gmpy2-PĂ€zisionsganzzahlen umgestellt.

Alternativ kann auch das Werkzeug Mathematica (Wolfram) Abhilfe schaffen. Es ist dazu in der Lage, solche Berechnungen mit hoher PrĂ€zision durchzufĂŒhren (Listing 7). Allerdings dauert die Berechnung in diesem Fall 13856 Sekunden (3,8 Stunden).

F[x_] := x^6 - 4 x^2 + 4;
sQ[n_] := FractionalPart@Sqrt[n + 0``1] == 0;
For[i = 0, i < 1000000000, 
 i++, {x = i; y = F[x]; 
  If[sQ[y] == True, Print[{x, Sqrt[y]}], Continue]}]

Listing 7: Die Routine aus Listing 5 in Mathematica liefert nur korrekte Lösungen.

Schließlich soll es auch noch um die Implementierung von CUDA gehen: In diesem Fall lĂ€uft der Code auf einer GPU, wozu Entwickler den Code aus Listing 5 entsprechend anpassen mĂŒssen. Listing 8 zeigt das Ergebnis der Anpassung. Hierbei kommt fĂŒr die optimierte Funktion statt des @jit-Dekorators der @cuda.jit-Dekorator zum Einsatz. Wenn das Programm die Funktion aufruft, lassen sich die BlockgrĂ¶ĂŸe 128 und die Anzahl der Threads pro Block 255 ĂŒbergeben. Die beiden Werte ergeben miteinander multipliziert die Gesamtzahl der Threads. Die BlockgrĂ¶ĂŸe legt fest, wie viele Threads sich einen bestimmten Bereich des gemeinsamen Speichers teilen. Den Code in Listing 8 hat der Autor des Artikels auf einer Maschine mit 128 GByte RAM, einem Intel Xeon CPU E5-2630 v4, 2.20GHz und zwei Grafikkarten vom Typ Tesla V100 mit jeweils 16 GByte RAM ausgefĂŒhrt. Die AusfĂŒhrungsdauer liegt bei 316 Sekunden (5 Minuten). Die Performance ist beeindruckend, allerdings enthĂ€lt das Ergebnis erneut zwei falsche Lösungen.

%%time
from numba import jit, cuda
import numpy as np
from math import sqrt

@cuda.jit
def findIntegerSolutionsCuda(arr):
    i=0
    for x in range(0, 1000000000+1):
        y = float(x**6-4*x**2+4)
        sqr = int(sqrt(y))
        if sqr*sqr == int(y):
            arr[i][0]=x
            arr[i][1]=sqr
            arr[i][2]=y
            i+=1

arr=np.zeros((10,3))
findIntegerSolutionsCuda[128, 255](arr)

print(arr)

Listing 8: FĂŒr CUDA umgeschriebene Routine

Um die Auswirkung der Optimierungen hinsichtlich Performance versus PrĂ€zision transparent zu machen, stellt Tabelle 2 die Werte gegenĂŒber. Die Auswirkung von PrĂ€zision auf Performance ist immens (Faktor 168 bis 1333).

Kein JIT, numpy
64 Bit Integer
JIT-Optimierung, numpy
64 Bit Integer
JIT-Optimierung, gmpy2 PrÀzisions-Ganzzahlen Kein JIT, gmpy2 PrÀzisions-Ganzzahlen
Mathematica* CUDA.JIT gmpy2, multicore * C++ mit GMP *

6667 Sekunden 5 Sekunden 842 Sekunden 769 Sekunden 13856 Sekunden 316 Sekunden 49 Sekunden 7 Sekunden
Nur korrekte Lösungen Korrekte und falsche Lösungen Nur korrekte Lösungen Nur korrekte Lösungen Nur korrekte Lösungen Korrekte und falsche Lösungen Nur korrekte Lösungen Nur korrekte Lösungen

Tabelle 2: Vergleich zu den Auswirkungen der Optimierung.
*AusfĂŒhrung auf Surface Pro 6 (16GB RAM, Intel i7-8650U, 1.90GHz, 4 Kerne, 8 logische, Intel UHD Graphics 620)

Und welche Ergebnisse bekommen Programmierer, die lieber C++ als Python einsetzen? Dieser Ansatz ist zwar eher unhandlich und sicher nicht so populÀr wie die Verwendung von Python. Das Ergebnis ist jedoch erstaunlich. Listing 9 zeigt den Code (der komplette Code steht ebenfalls auf GitHub [11]zum Download bereit).

Die Performance der Bruteforce-Suche von ganzzahligen Punkten auf der Kurve y2=x6−4x2+4 als Balkendiagramm.

Um PrÀzisionsverlust zu vermeiden, kam bei diesem Beispiel die Bibliothek GMP (The GNU Multiple Precision Arithmetic Library [12]) zum Einsatz. Das Programm hat dann auf einem Surface 6 Pro binnen sieben Sekunden die korrekten Lösungen gefunden und dabei nicht eine einzige Falschlösung ausgespuckt. Hierbei waren insgesamt nur acht Threads beteiligt.

int main(int argc, char ** argv) {
    uint64_t const gbegin = std::stoll(argc >= 2 ? argv[1] : "0"),
        gend = std::stoll(argc >= 3 ? argv[2] : "0");
    size_t const nthreads = std::thread::hardware_concurrency();
    std::cout << "Num threads: " << nthreads << std::endl
        << "Begin: " << gbegin << ", End: " << gend << std::endl;

    auto stime = Time();
    if (gbegin < gend) {
        std::mutex cout_mux;
        std::vector<std::future<void>> threads;
        for (size_t ithread = 0; ithread < nthreads; ++ithread)
            threads.emplace_back(std::async(std::launch::async, [&, ithread]{
                uint64_t const block = (gend - gbegin + nthreads - 1) / nthreads,
                    begin = gbegin + ithread * block,
                    end = std::min<size_t>(gbegin + (ithread + 1) * block, gend);
                mpz_class x, t0, t1, y, root, rem;
                for (uint64_t ix = begin; ix < end; ++ix) {
                    x = uint32_t(ix >> 32); x <<= 32; x |= uint32_t(ix);
                    mpz_pow_ui(t0.get_mpz_t(), x.get_mpz_t(), 6);
                    mpz_pow_ui(t1.get_mpz_t(), x.get_mpz_t(), 2);
                    y = t0 - 4 * t1 + 4;
                    mpz_sqrtrem(root.get_mpz_t(), rem.get_mpz_t(), y.get_mpz_t());
                    if (rem == 0) {
                        std::lock_guard<std::mutex> lock(cout_mux);
                        std::cout << "[" << mpz_to_str(x) << ", " << mpz_to_str(root)
                            << ", " << mpz_to_str(y) << "]" << std::endl;
                    }
                }
            }));
    }
    stime = Time() - stime;
    std::cout << "Time " << std::fixed << std::setprecision(3)
        << stime << " sec" << std::endl;
}

Listing 9: Die Routine aus Listing 5 wurde hier in C++ umgesetzt.

Das konsistente rechenintensive (aber nicht komplizierte) Beispiel legt dar, welche PrĂ€zisionseinbußen eine wirklich starke Beschleunigung nach sich zieht. Aber auch, mit welchen Performance-Einbußen Entwickler rechnen mĂŒssen, wenn PrĂ€zision an erster Stelle steht. Zusammenfassend lĂ€sst sich sagen, dass Python mittels JIT- oder CUDA-Optimierung zwar eine hoch performante Implementierung erlaubt, Programmierer sich aber in sehr hohen Zahlenregionen mit ÜberlĂ€ufen konfrontiert sehen. Der beste Kompromiss zwischen Performance und PrĂ€zision ist der Einsatz von gmpy2 (ohne JIT-Optimierung aber mit multiprocessing). Denn in dieser Konstellation sind keine ÜberlĂ€ufe und damit einhergehend Falschlösungen entstanden und die Laufzeit betrug etwa 15 Minuten (mit multiprocessing 49 Sekunden) und keine Stunden wie im Falle der (prĂ€zisionsverlustfreien) Variante NumPy oder Mathematica.

Als unschlagbar hat sich die Implementierung in C++ herausgestellt: Sie lieferte auf einem Surface 6 Pro nur korrekte Lösungen ohne PrĂ€zisionsverlust. Wenn es also ausschließlich um PrĂ€zision und Performance geht, werden Programmierer nicht um den Einsatz C oder C++ herumkommen.

Eldar Sultanow
ist Architekt bei Capgemini. Seine Schwerpunkte sind moderne Softwarearchitekturen, Data Science und Unternehmensarchitekturmanagement.

(fms [13])


URL dieses Artikels:
https://www.heise.de/-7127649

Links in diesem Artikel:
[1] https://www.heise.de/tests/GPU-Server-im-iX-Test-Dells-DSS-8440-fuer-KI-und-ML-Anwendungen-4687829.html
[2] https://aws.amazon.com/de/ec2/instance-types/p3/
[3] https://www.heise.de/news/Nvidia-erweitert-das-Programmiermodell-in-CUDA-11-5-6229169.html
[4] https://www.spektrum.de/wissen/pietro-mengoli-1626-1686-ueber-die-unendlichkeit-hinaus/1737066
[5] https://news.mit.edu/2019/answer-life-universe-and-everything-sum-three-cubes-mathematics-0910
[6] https://github.com/Sultanow/pythagorean
[7] https://math.stackexchange.com/a/3278708/993738
[8] https://github.com/Sultanow/elliptic_curves/tree/master/python
[9] https://math.stackexchange.com/q/4347945/993738
[10] https://github.com/Sultanow/elliptic_curves/blob/master/python/find_integer_solutions.py
[11] https://github.com/Sultanow/elliptic_curves/tree/master/cpp
[12] https://gmplib.org/
[13] mailto:fms@heise.de