Optimierung für Geschwindigkeit#

Die folgenden Richtlinien helfen Ihnen, effizienten Code für das scikit-learn-Projekt zu schreiben.

Hinweis

Während es immer nützlich ist, Ihren Code zu profilieren, um Leistungsannahmen zu überprüfen, wird dringend empfohlen, die Fachliteratur zu konsultieren, um sicherzustellen, dass der implementierte Algorithmus der Stand der Technik für die jeweilige Aufgabe ist, bevor Sie in kostspielige Implementierungsoptimierungen investieren.

Immer wieder waren stundenlange Bemühungen zur Optimierung komplizierter Implementierungsdetails irrelevant, da später einfache algorithmische Tricks entdeckt wurden oder ein anderer Algorithmus verwendet wurde, der besser für das Problem geeignet war.

Der Abschnitt Ein einfacher algorithmischer Trick: Warm-Starts gibt ein Beispiel für einen solchen Trick.

Python, Cython oder C/C++?#

Im Allgemeinen legt das scikit-learn-Projekt Wert auf die Lesbarkeit des Quellcodes, um es den Benutzern des Projekts zu erleichtern, in den Quellcode einzutauchen, um zu verstehen, wie der Algorithmus auf ihren Daten funktioniert, aber auch aus Gründen der Wartbarkeit (durch die Entwickler).

Bei der Implementierung eines neuen Algorithmus wird daher empfohlen, mit der Implementierung in Python unter Verwendung von Numpy und Scipy zu beginnen und darauf zu achten, Schleifen durch Aufrufe äquivalenter Numpy-Array-Methoden zu vermeiden. In der Praxis bedeutet dies, zu versuchen, verschachtelte For-Schleifen durch Aufrufe äquivalenter Numpy-Array-Methoden zu ersetzen. Ziel ist es, zu vermeiden, dass die CPU Zeit im Python-Interpreter verschwendet, anstatt Zahlen zu verarbeiten, um Ihr statistisches Modell anzupassen. Es ist generell eine gute Idee, die Leistungs-Tipps von NumPy und SciPy zu beachten: https://scipy.github.io/old-wiki/pages/PerformanceTips

Manchmal lässt sich ein Algorithmus jedoch nicht effizient in einfache vektorisierte Numpy-Codes ausdrücken. In diesem Fall ist die empfohlene Strategie die folgende:

  1. Profilieren Sie die Python-Implementierung, um den Hauptengpass zu finden, und isolieren Sie ihn in einer dedizierten Funktion auf Modulebene. Diese Funktion wird als kompiliertes Erweiterungsmodul neu implementiert.

  2. Wenn es eine gut gepflegte BSD- oder MIT-C/C++-Implementierung desselben Algorithmus gibt, die nicht zu groß ist, können Sie einen Cython-Wrapper dafür schreiben und eine Kopie des Quellcodes der Bibliothek in den scikit-learn-Quellbaum aufnehmen: Diese Strategie wird für die Klassen svm.LinearSVC, svm.SVC und linear_model.LogisticRegression verwendet (Wrapper für liblinear und libsvm).

  3. Andernfalls schreiben Sie eine optimierte Version Ihrer Python-Funktion direkt mit Cython. Diese Strategie wird beispielsweise für die Klassen linear_model.ElasticNet und linear_model.SGDClassifier verwendet.

  4. Verschieben Sie die Python-Version der Funktion in die Tests und verwenden Sie sie, um zu überprüfen, ob die Ergebnisse der kompilierten Erweiterung mit dem Goldstandard, der leicht zu debuggenden Python-Version, übereinstimmen.

  5. Sobald der Code optimiert ist (kein durch Profiling erkennbarer einfacher Engpass), prüfen Sie, ob großkörnige Parallelität möglich ist, die für Multiprocessing geeignet ist, indem Sie die Klasse joblib.Parallel verwenden.

Python-Code profilieren#

Um Python-Code zu profilieren, empfehlen wir, ein Skript zu schreiben, das Ihre Daten lädt und vorbereitet, und dann den integrierten Profiler von IPython zu verwenden, um den relevanten Teil des Codes interaktiv zu untersuchen.

Angenommen, wir möchten das Non Negative Matrix Factorization Modul von scikit-learn profilieren. Richten wir eine neue IPython-Sitzung ein, laden wir den Datensatz digits und wie im Beispiel Erkennung handgeschriebener Ziffern.

In [1]: from sklearn.decomposition import NMF

In [2]: from sklearn.datasets import load_digits

In [3]: X, _ = load_digits(return_X_y=True)

Bevor Sie die Profiling-Sitzung starten und mit iterativen Optimierungsversuchen beginnen, ist es wichtig, die gesamte Ausführungszeit der zu optimierenden Funktion ohne jeglichen Overhead des Profilers zu messen und sie für spätere Referenzzwecke irgendwo zu speichern.

In [4]: %timeit NMF(n_components=16, tol=1e-2).fit(X)
1 loops, best of 3: 1.7 s per loop

Um das allgemeine Performance-Profil mit dem Magic Command %prun anzuzeigen

In [5]: %prun -l nmf.py NMF(n_components=16, tol=1e-2).fit(X)
         14496 function calls in 1.682 CPU seconds

   Ordered by: internal time
   List reduced from 90 to 9 due to restriction <'nmf.py'>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
       36    0.609    0.017    1.499    0.042 nmf.py:151(_nls_subproblem)
     1263    0.157    0.000    0.157    0.000 nmf.py:18(_pos)
        1    0.053    0.053    1.681    1.681 nmf.py:352(fit_transform)
      673    0.008    0.000    0.057    0.000 nmf.py:28(norm)
        1    0.006    0.006    0.047    0.047 nmf.py:42(_initialize_nmf)
       36    0.001    0.000    0.010    0.000 nmf.py:36(_sparseness)
       30    0.001    0.000    0.001    0.000 nmf.py:23(_neg)
        1    0.000    0.000    0.000    0.000 nmf.py:337(__init__)
        1    0.000    0.000    1.681    1.681 nmf.py:461(fit)

Die Spalte tottime ist am interessantesten: Sie gibt die Gesamtzeit an, die mit der Ausführung des Codes einer bestimmten Funktion verbracht wird, wobei die Zeit, die mit der Ausführung von Unterfunktionen verbracht wird, ignoriert wird. Die tatsächliche Gesamtzeit (lokaler Code + Aufrufe von Unterfunktionen) wird durch die Spalte cumtime angegeben.

Beachten Sie die Verwendung von -l nmf.py, was die Ausgabe auf Zeilen beschränkt, die den String "nmf.py" enthalten. Dies ist nützlich, um schnell die Hotspots des nmf Python-Moduls selbst zu betrachten und alles andere zu ignorieren.

Hier ist der Anfang der Ausgabe desselben Befehls ohne den Filter -l nmf.py.

In [5] %prun NMF(n_components=16, tol=1e-2).fit(X)
         16159 function calls in 1.840 CPU seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     2833    0.653    0.000    0.653    0.000 {numpy.core._dotblas.dot}
       46    0.651    0.014    1.636    0.036 nmf.py:151(_nls_subproblem)
     1397    0.171    0.000    0.171    0.000 nmf.py:18(_pos)
     2780    0.167    0.000    0.167    0.000 {method 'sum' of 'numpy.ndarray' objects}
        1    0.064    0.064    1.840    1.840 nmf.py:352(fit_transform)
     1542    0.043    0.000    0.043    0.000 {method 'flatten' of 'numpy.ndarray' objects}
      337    0.019    0.000    0.019    0.000 {method 'all' of 'numpy.ndarray' objects}
     2734    0.011    0.000    0.181    0.000 fromnumeric.py:1185(sum)
        2    0.010    0.005    0.010    0.005 {numpy.linalg.lapack_lite.dgesdd}
      748    0.009    0.000    0.065    0.000 nmf.py:28(norm)
...

Die obigen Ergebnisse zeigen, dass die Ausführung stark von Skalarproduktoperationen (delegiert an BLAS) dominiert wird. Daher sind wahrscheinlich keine großen Gewinne durch eine Neufassung des Codes in Cython oder C/C++ zu erwarten: In diesem Fall werden von den 1,7 Sekunden Gesamt-Ausführungszeit fast 0,7 Sekunden in kompiliertem Code verbracht, den wir als optimal betrachten können. Durch die Neufassung des restlichen Python-Codes und unter der Annahme, dass wir eine Steigerung von 1000 % für diesen Teil erzielen könnten (was angesichts der geringen Tiefe der Python-Schleifen höchst unwahrscheinlich ist), würden wir global nicht mehr als eine 2,4-fache Beschleunigung erzielen.

Daher können bei diesem speziellen Beispiel nur durch algorithmische Verbesserungen wesentliche Verbesserungen erzielt werden (z. B. durch das Finden von Operationen, die sowohl kostspielig als auch nutzlos sind, um deren Berechnung zu vermeiden, anstatt zu versuchen, deren Implementierung zu optimieren).

Es ist jedoch immer noch interessant zu prüfen, was innerhalb der Funktion _nls_subproblem geschieht, die der Hotspot ist, wenn wir nur Python-Code betrachten: Sie nimmt etwa 100 % der akkumulierten Zeit des Moduls ein. Um das Profil dieser spezifischen Funktion besser zu verstehen, installieren wir line_profiler und integrieren es in IPython.

pip install line_profiler

Unter IPython 0.13+ erstellen Sie zuerst ein Konfigurationsprofil.

ipython profile create

Registrieren Sie dann die line_profiler-Erweiterung in ~/.ipython/profile_default/ipython_config.py.

c.TerminalIPythonApp.extensions.append('line_profiler')
c.InteractiveShellApp.extensions.append('line_profiler')

Dadurch wird der Magic Command %lprun in der IPython-Terminalanwendung und anderen Frontends wie qtconsole und notebook registriert.

Starten Sie nun IPython neu und nutzen Sie dieses neue Werkzeug.

In [1]: from sklearn.datasets import load_digits

In [2]: from sklearn.decomposition import NMF
  ... : from sklearn.decomposition._nmf import _nls_subproblem

In [3]: X, _ = load_digits(return_X_y=True)

In [4]: %lprun -f _nls_subproblem NMF(n_components=16, tol=1e-2).fit(X)
Timer unit: 1e-06 s

File: sklearn/decomposition/nmf.py
Function: _nls_subproblem at line 137
Total time: 1.73153 s

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
   137                                           def _nls_subproblem(V, W, H_init, tol, max_iter):
   138                                               """Non-negative least square solver
   ...
   170                                               """
   171        48         5863    122.1      0.3      if (H_init < 0).any():
   172                                                   raise ValueError("Negative values in H_init passed to NLS solver.")
   173
   174        48          139      2.9      0.0      H = H_init
   175        48       112141   2336.3      5.8      WtV = np.dot(W.T, V)
   176        48        16144    336.3      0.8      WtW = np.dot(W.T, W)
   177
   178                                               # values justified in the paper
   179        48          144      3.0      0.0      alpha = 1
   180        48          113      2.4      0.0      beta = 0.1
   181       638         1880      2.9      0.1      for n_iter in range(1, max_iter + 1):
   182       638       195133    305.9     10.2          grad = np.dot(WtW, H) - WtV
   183       638       495761    777.1     25.9          proj_gradient = norm(grad[np.logical_or(grad < 0, H > 0)])
   184       638         2449      3.8      0.1          if proj_gradient < tol:
   185        48          130      2.7      0.0              break
   186
   187      1474         4474      3.0      0.2          for inner_iter in range(1, 20):
   188      1474        83833     56.9      4.4              Hn = H - alpha * grad
   189                                                       # Hn = np.where(Hn > 0, Hn, 0)
   190      1474       194239    131.8     10.1              Hn = _pos(Hn)
   191      1474        48858     33.1      2.5              d = Hn - H
   192      1474       150407    102.0      7.8              gradd = np.sum(grad * d)
   193      1474       515390    349.7     26.9              dQd = np.sum(np.dot(WtW, d) * d)
   ...

Wenn Sie sich die höchsten Werte in der Spalte % Time ansehen, können Sie leicht die teuersten Ausdrücke identifizieren, die zusätzliche Aufmerksamkeit verdienen würden.

Speicherbedarfs-Profiling#

Sie können den Speicherbedarf jedes Python-Codes mithilfe von memory_profiler im Detail analysieren. Installieren Sie zuerst die neueste Version.

pip install -U memory_profiler

Richten Sie dann die Magics ähnlich wie bei line_profiler ein.

Unter IPython 0.11+ erstellen Sie zuerst ein Konfigurationsprofil.

ipython profile create

Registrieren Sie dann die Erweiterung in ~/.ipython/profile_default/ipython_config.py zusammen mit dem line profiler.

c.TerminalIPythonApp.extensions.append('memory_profiler')
c.InteractiveShellApp.extensions.append('memory_profiler')

Dadurch werden die Magic Commands %memit und %mprun in der IPython-Terminalanwendung und anderen Frontends wie qtconsole und notebook registriert.

%mprun ist nützlich, um den Speicherverbrauch von Schlüsselfunktionen in Ihrem Programm Zeile für Zeile zu untersuchen. Es ist sehr ähnlich zu %lprun, das im vorherigen Abschnitt besprochen wurde. Zum Beispiel aus dem memory_profiler examples Verzeichnis.

In [1] from example import my_func

In [2] %mprun -f my_func my_func()
Filename: example.py

Line #    Mem usage  Increment   Line Contents
==============================================
     3                           @profile
     4      5.97 MB    0.00 MB   def my_func():
     5     13.61 MB    7.64 MB       a = [1] * (10 ** 6)
     6    166.20 MB  152.59 MB       b = [2] * (2 * 10 ** 7)
     7     13.61 MB -152.59 MB       del b
     8     13.61 MB    0.00 MB       return a

Ein weiteres nützliches Magic, das memory_profiler definiert, ist %memit, das analog zu %timeit ist. Es kann wie folgt verwendet werden.

In [1]: import numpy as np

In [2]: %memit np.zeros(1e7)
maximum of 3: 76.402344 MB per loop

Weitere Details finden Sie in den Docstrings der Magics, indem Sie %memit? und %mprun? verwenden.

Cython verwenden#

Wenn das Profiling des Python-Codes ergibt, dass der Overhead des Python-Interpreters um eine Größenordnung oder mehr größer ist als die Kosten der eigentlichen numerischen Berechnung (z. B. for-Schleifen über Vektorkomponenten, verschachtelte Auswertung von bedingten Ausdrücken, Skalar-Arithmetik...), ist es wahrscheinlich ratsam, den Hotspot-Teil des Codes als eigenständige Funktion in einer .pyx-Datei zu extrahieren, statische Typdeklarationen hinzuzufügen und dann Cython zu verwenden, um ein C-Programm zu generieren, das als Python-Erweiterungsmodul kompiliert werden kann.

Die Cython-Dokumentation enthält ein Tutorial und einen Referenzleitfaden für die Entwicklung eines solchen Moduls. Weitere Informationen zur Entwicklung in Cython für scikit-learn finden Sie unter Cython Best Practices, Conventions and Knowledge.

Kompilierte Erweiterungen profilieren#

Beim Arbeiten mit kompilierten Erweiterungen (geschrieben in C/C++ mit einem Wrapper oder direkt als Cython-Erweiterung) ist der Standard-Python-Profiler nutzlos: Wir benötigen ein spezielles Werkzeug, um zu untersuchen, was innerhalb der kompilierten Erweiterung selbst vor sich geht.

Yep und gperftools verwenden#

Einfaches Profiling ohne spezielle Kompilierungsoptionen mit yep.

Debugger verwenden, gdb#

  • Es ist hilfreich, gdb zum Debuggen zu verwenden. Dazu muss ein Python-Interpreter verwendet werden, der mit Debug-Unterstützung (Debug-Symbole und korrekte Optimierung) erstellt wurde. Zum Erstellen einer neuen Conda-Umgebung (die Sie nach dem Erstellen/Installieren möglicherweise deaktivieren und reaktivieren müssen) mit einem aus der Quelle erstellten CPython-Interpreter.

    git clone https://github.com/python/cpython.git
    conda create -n debug-scikit-dev
    conda activate debug-scikit-dev
    cd cpython
    mkdir debug
    cd debug
    ../configure --prefix=$CONDA_PREFIX --with-pydebug
    make EXTRA_CFLAGS='-DPy_DEBUG' -j<num_cores>
    make install
    

gprof verwenden#

Um kompilierte Python-Erweiterungen zu profilieren, könnte man gprof verwenden, nachdem das Projekt mit gcc -pg neu kompiliert wurde und die python-dbg-Variante des Interpreters unter Debian/Ubuntu verwendet wird: Dieser Ansatz erfordert jedoch auch, dass numpy und scipy mit -pg neu kompiliert werden, was ziemlich kompliziert zu realisieren ist.

Glücklicherweise gibt es zwei alternative Profiler, die keine Neukompilierung von allem erfordern.

Valgrind / callgrind / kcachegrind verwenden#

kcachegrind#

yep kann verwendet werden, um einen Profiling-Bericht zu erstellen. kcachegrind bietet eine grafische Umgebung zur Visualisierung dieses Berichts.

# Run yep to profile some python script
python -m yep -c my_file.py
# open my_file.py.callgrin with kcachegrind
kcachegrind my_file.py.prof

Hinweis

yep kann mit dem Argument --lines oder -l ausgeführt werden, um einen Profiling-Bericht "Zeile für Zeile" zu kompilieren.

Multi-Core-Parallelität mit joblib.Parallel#

Siehe Joblib-Dokumentation.

Ein einfacher algorithmischer Trick: Warm-Starts#

Siehe den Glossareintrag für warm_start.