Как оптимизировать для скорости#
Ниже приведены практические рекомендации, которые помогут вам писать эффективный код для проекта 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. В этом случае рекомендуемая стратегия следующая:
Профиль реализацию на Python для поиска основного узкого места и его изоляции в выделенная функция на уровне модуля. Эта функция будет перереализована как скомпилированный модуль расширения.
Если существует хорошо поддерживаемая лицензия BSD или MIT C/C++ реализация того же алгоритма, которая не слишком велика, вы можете написать Обертка Cython для него и включить копию исходного кода библиотеки в дерево исходного кода scikit-learn: эта стратегия используется для классов
svm.LinearSVC,svm.SVCиlinear_model.LogisticRegression(обертки для liblinear и libsvm).В противном случае напишите оптимизированную версию вашей функции Python с помощью Cython напрямую. Эта стратегия используется для
linear_model.ElasticNetиlinear_model.SGDClassifierклассы, например.Переместите Python-версию функции в тесты и использовать его для проверки согласованности результатов скомпилированного расширения с эталонной, легко отлаживаемой версией на Python.
После оптимизации кода (не простого узкого места, обнаруживаемого профилированием), проверьте, возможно ли иметь крупнозернированный параллелизм который поддается многопроцессорность используя
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#
Простой алгоритмический трюк: теплые перезапуски#
Смотрите глоссарий для warm_start