如何优化速度#

以下提供了一些实用指南,以帮助您为scikit-learn项目编写高效的代码。

备注

虽然分析您的代码总是有用的,以便 check performance assumptions ,也强烈建议 review the literature 在投资昂贵的实施优化之前,确保所实现的算法是任务的最新技术水平。

随着时间的推移,为了优化复杂的实现细节而投入的时间和时间,由于随后发现的简单, algorithmic tricks ,或者完全使用另一种更适合该问题的算法。

该科 一个简单的算法技巧:热重启 举了一个这样的伎俩的例子。

Python、Cython还是C/C++?#

总的来说,scikit-learn项目强调 readability 源代码,使项目用户能够轻松深入研究源代码,以便了解算法如何在他们的数据上表现,同时也易于维护(由开发人员)。

因此,当实施新算法时,建议 start implementing it in Python using Numpy and Scipy 避免使用这些库的向量化惯用法来循环代码。在实践中,这意味着试图 replace any nested for loops by calls to equivalent Numpy array methods .目标是避免中央处理器在Python解释器中浪费时间,而不是处理数字以适应您的统计模型。一般来说,考虑NumPy和SciPy性能提示是个好主意:https://scipy.github.io/old-wiki/pages/PerformanceTips

然而,有时算法无法用简单的载体化Numpy代码有效地表达。在这种情况下,建议的策略如下:

  1. Profile Python实现找到主要瓶颈并将其隔离在 dedicated module level function .该功能将作为已编译的扩展模块重新实现。

  2. 如果存在维护良好的SD或MIT C/C++ 实现相同的算法,不太大,可以写一个 Cython wrapper 并在scikit-learn源树中包含库源代码的副本:此策略用于类 svm.LinearSVC , svm.SVClinear_model.LogisticRegression (liblinear和libsvm的包装器)。

  3. 否则,请使用编写Python函数的优化版本 Cython 直接.此策略用于 linear_model.ElasticNetlinear_model.SGDClassifier 例如班级。

  4. Move the Python version of the function in the tests 并使用它检查已编译扩展的结果是否与金标准一致,易于调试的Python版本。

  5. 代码优化后(不是通过分析进行简单的瓶颈假脱机),检查是否可以 coarse grained parallelism 这是值得的 multi-processing 通过使用 joblib.Parallel

分析Python代码#

为了分析Python代码,我们建议编写一个脚本来加载和准备数据,然后使用IPython集成分析器交互式地探索代码的相关部分。

假设我们想分析scikit-learn的非负矩阵分解模块。让我们设置一个新的IPython会话并加载数字数据集,就像在 识别手写数字 示例::

In [1]: from sklearn.decomposition import NMF

In [2]: from sklearn.datasets import load_digits

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

在开始分析会话并进行尝试性优化迭代之前,重要的是要测量我们想要在没有任何类型分析器负载的情况下优化的函数的总执行时间,并将其保存在某个地方以供以后参考::

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)

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

上述结果表明,执行很大程度上由点产品运营(委托给NPS)主导。因此,用Cython或C/C++重写此代码可能不会带来巨大的收益:在这种情况下,在1.7秒的总执行时间中,近0.7秒用于我们可以认为是最佳的已编译代码。通过重写其余的Python代码并假设我们可以在这部分上实现1000%的提升(考虑到Python循环的浅性,这是不太可能的),我们在全球范围内获得的速度不会超过2.4倍。

因此,重大改进只能通过以下方式实现 algorithmic improvements 在这个特定的例子中(例如,试图找到既昂贵又无用的操作以避免计算它们,而不是试图优化它们的实现)。

然而,检查里面发生的事情仍然很有趣 _nls_subproblem 如果我们只考虑Python代码,这是一个热点:它占用了模块100%的累积时间。为了更好地理解这个特定函数的配置文件,让我们安装 line_profiler 并将其连接到IPython:

pip install line_profiler

Under 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终端应用程序和其他前端(如qtconsole和notebook)中使用magic命令。

现在重新启动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 .

Under IPython 0.11+ ,首先创建配置文件:

ipython profile create

然后在 ~/.ipython/profile_default/ipython_config.py 在line profiler旁边::

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

这将注册 %memit%mprun IPython终端应用程序和其他前端(如qtconsole和 笔记本

%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生成适合编译为Python扩展模块的C程序。

Cython's documentation 包含用于开发此类模块的教程和参考指南。有关在Cython中进行scikit-learn开发的更多信息,请参阅 Cython最佳实践、惯例和知识 .

分析已编译的扩展#

当使用已编译的扩展(用C/C++和包装器编写或直接作为Cython扩展)时,默认的Python分析器是无用的:我们需要一个专用工具来内省已编译的扩展本身内部发生的事情。

使用Yep和gperftools#

无需特殊编译选项即可轻松进行分析使用是的:

使用调试器,gDB#

  • 使用起来很有帮助 gdb 调试.为了做到这一点,必须使用带有调试支持(调试符号和适当的优化)构建的Python解释器。要使用源代码构建的CPython解释器创建新的conda环境(您可能需要在构建/安装后停用并重新激活该环境):

    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#

为了分析已编译的Python扩展,可以使用 gprof 在重新编译该项目后, gcc -pg 和使用 python-dbg debian / ubuntu上解释器的变体:然而这种方法还需要具备 numpyscipy 重新编译 -pg 工作起来相当复杂。

幸运的是,存在两种替代分析器,它们不需要您重新编译所有内容。

使用valgrind / callgrind / 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 documentation

一个简单的算法技巧:热重启#

请参阅术语表条目 warm_start