Как оптимизировать для скорости#

Ниже приведены практические рекомендации, которые помогут вам писать эффективный код для проекта scikit-learn.

Примечание

Хотя всегда полезно профилировать ваш код, чтобы проверить предположения о производительности, также настоятельно рекомендуется обзор литературы чтобы убедиться, что реализованный алгоритм соответствует современному уровню для задачи, прежде чем вкладываться в дорогостоящую оптимизацию реализации.

Много раз и много часов усилий, вложенных в оптимизацию сложных деталей реализации, оказались нерелевантными из-за последующего открытия простых алгоритмические приёмыили с использованием другого алгоритма, который лучше подходит для задачи.

Раздел Простой алгоритмический трюк: теплые перезапуски приводит пример такого приёма.

Python, Cython или C/C++?#

В целом, проект scikit-learn подчеркивает читаемость исходного кода, чтобы пользователям проекта было легко погрузиться в исходный код, чтобы понять, как алгоритм ведет себя на их данных, а также для удобства поддержки (разработчиками).

При реализации нового алгоритма рекомендуется начать реализацию на Python с использованием Numpy и Scipy путём избегания циклов в коде с использованием векторизованных идиом этих библиотек. На практике это означает попытку заменить любые вложенные циклы for вызовами эквивалентных методов массивов Numpy. Цель - избежать траты времени процессором в интерпретаторе Python вместо обработки чисел для обучения вашей статистической модели. Обычно рекомендуется учитывать советы по производительности NumPy и SciPy: https://scipy.github.io/old-wiki/pages/PerformanceTips

Однако иногда алгоритм не может быть эффективно выражен в простом векторизованном коде Numpy. В этом случае рекомендуемая стратегия следующая:

  1. Профиль реализацию на Python для поиска основного узкого места и его изоляции в выделенная функция на уровне модуля. Эта функция будет перереализована как скомпилированный модуль расширения.

  2. Если существует хорошо поддерживаемая лицензия BSD или MIT C/C++ реализация того же алгоритма, которая не слишком велика, вы можете написать Обертка Cython для него и включить копию исходного кода библиотеки в дерево исходного кода scikit-learn: эта стратегия используется для классов svm.LinearSVC, svm.SVC и linear_model.LogisticRegression (обертки для liblinear и libsvm).

  3. В противном случае напишите оптимизированную версию вашей функции Python с помощью Cython напрямую. Эта стратегия используется для linear_model.ElasticNet и linear_model.SGDClassifier классы, например.

  4. Переместите Python-версию функции в тесты и использовать его для проверки согласованности результатов скомпилированного расширения с эталонной, легко отлаживаемой версией на Python.

  5. После оптимизации кода (не простого узкого места, обнаруживаемого профилированием), проверьте, возможно ли иметь крупнозернированный параллелизм который поддается многопроцессорность используя joblib.Parallel класс.

Профилирование кода на Python#

Для профилирования кода на Python рекомендуется написать скрипт, который загружает и подготавливает ваши данные, а затем использовать встроенный профилировщик IPython для интерактивного исследования соответствующей части кода.

Предположим, мы хотим профилировать модуль неотрицательного матричного разложения scikit-learn. Давайте настроим новую сессию IPython и загрузим набор данных digits, как в Распознавание рукописных цифр пример:

In [1]: from sklearn.decomposition import NMF

In [2]: from sklearn.datasets import load_digits

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

Before starting the profiling session and engaging in tentative optimization iterations, it is important to measure the total execution time of the function we want to optimize without any kind of profiler overhead and save it somewhere for later reference:

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

Чтобы посмотреть на общий профиль производительности с использованием %prun магическая команда:

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)

The tottime столбец является наиболее интересным: он показывает общее время, затраченное на выполнение кода данной функции, игнорируя время, затраченное на выполнение подфункций. Реальное общее время (локальный код + вызовы подфункций) дается cumtime столбец.

Обратите внимание на использование -l nmf.py ограничивает вывод строками, содержащими строку "nmf.py". Это полезно для быстрого просмотра горячих точек самого модуля nmf Python, игнорируя всё остальное.

Вот начало вывода той же команды без -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)
...

Приведенные выше результаты показывают, что выполнение в основном определяется операциями скалярного произведения (делегированными blas). Следовательно, вероятно, не стоит ожидать значительного выигрыша от переписывания этого кода на Cython или C/C++: в данном случае из общего времени выполнения 1.7с почти 0.7с тратится на скомпилированный код, который можно считать оптимальным. Переписав остальной код на Python и предположив, что мы можем достичь ускорения в 1000% на этой части (что крайне маловероятно, учитывая простоту циклов Python), мы получим не более чем 2.4-кратное ускорение в целом.

Следовательно, значительные улучшения могут быть достигнуты только алгоритмические улучшения в этом конкретном примере (например, попытка найти операции, которые являются одновременно дорогостоящими и бесполезными, чтобы избежать их вычисления, а не пытаться оптимизировать их реализацию).

Тем не менее, всё ещё интересно проверить, что происходит внутри _nls_subproblem функция, которая является горячей точкой, если рассматривать только код Python: она занимает около 100% накопленного времени модуля. Чтобы лучше понять профиль этой конкретной функции, давайте установим line_profiler и подключить к IPython:

pip install line_profiler

В IPython 0.13+, сначала создайте профиль конфигурации:

ipython profile create

Затем зарегистрируйте расширение line_profiler в ~/.ipython/profile_default/ipython_config.py:

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

Это зарегистрирует %lprun волшебная команда в приложении IPython terminal и других интерфейсах, таких как qtconsole и notebook.

Теперь перезапустите IPython и давайте использовать эту новую игрушку:

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)
   ...

Глядя на верхние значения % Time столбец, очень легко определить наиболее затратные выражения, которые заслуживают дополнительного внимания.

Профилирование использования памяти#

Вы можете детально проанализировать использование памяти любого кода Python с помощью memory_profiler. Сначала установите последнюю версию:

pip install -U memory_profiler

Затем настройте магические команды аналогично line_profiler.

В IPython 0.11+, сначала создайте профиль конфигурации:

ipython profile create

Затем зарегистрируйте расширение в ~/.ipython/profile_default/ipython_config.py наряду с профилировщиком строк:

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

Это зарегистрирует %memit и %mprun магические команды в приложении IPython terminal и других интерфейсах, таких как qtconsole и notebook.

%mprun полезно для построчного изучения использования памяти ключевыми функциями в вашей программе. Очень похоже на %lprun, обсуждавшийся в предыдущем разделе. Например, из memory_profiler examples каталог:

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

Ещё один полезный магический метод, который memory_profiler определяет %memitявляется простым базовым подходом к выбору признаков. Он удаляет все признаки, дисперсия которых не соответствует некоторому порогу. По умолчанию он удаляет все признаки с нулевой дисперсией, т.е. признаки, которые имеют одинаковое значение во всех образцах. %timeit. Его можно использовать следующим образом:

In [1]: import numpy as np

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

Для получения более подробной информации см. документацию магических методов, используя %memit? и %mprun?.

Использование Cython#

Если профилирование кода Python показывает, что накладные расходы интерпретатора Python больше на порядок или более, чем стоимость фактических численных вычислений (например, for циклы по компонентам вектора, вложенная оценка условного выражения, скалярная арифметика…), вероятно, достаточно извлечь наиболее ресурсоёмкую часть кода как отдельную функцию в .pyx файл, добавьте статические объявления типов и затем используйте Cython для генерации C-программы, подходящей для компиляции как модуль расширения Python.

The Документация Cython содержит руководство и справочник по разработке такого модуля. Для получения дополнительной информации о разработке на Cython для scikit-learn, см. Лучшие практики, соглашения и знания Cython.

Профилирование скомпилированных расширений#

При работе со скомпилированными расширениями (написанными на C/C++ с оберткой или непосредственно как расширение Cython) стандартный профилировщик Python бесполезен: нам нужен специальный инструмент для интроспекции того, что происходит внутри самого скомпилированного расширения.

Использование yep и gperftools#

Простое профилирование без специальных опций компиляции используйте yep:

Используя отладчик, gdb#

  • Полезно использовать gdb на debug. Для этого необходимо использовать интерпретатор Python, собранный с поддержкой отладки (отладочные символы и соответствующая оптимизация). Чтобы создать новое окружение conda (которое может потребоваться деактивировать и повторно активировать после сборки/установки) с интерпретатором CPython, собранным из исходников:

    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
    make install
    

Использование gprof#

Для профилирования скомпилированных расширений Python можно использовать gprof после перекомпиляции проекта с gcc -pg и используя python-dbg вариант интерпретатора на debian / ubuntu: однако этот подход требует также наличия numpy и scipy перекомпилирован с -pg что довольно сложно заставить работать.

К счастью, существуют два альтернативных профилировщика, которые не требуют перекомпиляции всего.

Использование valgrind / callgrind / kcachegrind#

kcachegrind#

yep может использоваться для создания отчета профилирования. kcachegrind предоставляет графическую среду для визуализации этого отчета:

# 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

Примечание

yep может быть выполнен с аргументом --lines или -l для компиляции профилирующего отчёта 'строка за строкой'.

Многопоточный параллелизм с использованием joblib.Parallel#

См. документация joblib

Простой алгоритмический трюк: теплые перезапуски#

Смотрите глоссарий для warm_start