如何优化速度#

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

Note

尽管对代码进行性能分析总是有用的,以便**检查性能假设**,但也强烈建议**查阅文献**,确保在投入昂贵的实现优化之前,所实现的算法是该任务的最新技术。

多次,投入在优化复杂实现细节上的数小时努力,因后续发现的简单**算法技巧**,或因使用更适合问题的另一种算法而被证明是无关紧要的。

章节 一个简单的算法技巧:热重启 给出了这样一个技巧的例子。

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

一般来说,scikit-learn项目强调源代码的**可读性**,以便项目用户能够轻松地深入源代码,了解算法在其数据上的行为,同时也便于开发者维护。

因此,实现新算法时建议**首先使用Numpy和Scipy在Python中实现**,注意避免使用循环代码,利用这些库的向量化习语。实际上,这意味着尝试**用等效的Numpy数组方法调用替换任何嵌套的for循环**。目标是避免CPU在Python解释器中浪费时间,而不是处理数字以适应你的统计模型。通常,考虑NumPy和SciPy的性能提示是一个好主意:https://scipy.github.io/old-wiki/pages/PerformanceTips

然而,有时算法无法用简单的向量化Numpy代码有效表达。在这种情况下,推荐的策略如下: 1. 分析 Python 实现以找到主要瓶颈,并将其隔离在**专用模块级函数**中。此函数将被重新实现为编译扩展模块。

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

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

  3. 将 Python 版本的函数移至测试中,并使用它来检查编译扩展的结果是否与易于调试的 Python 版本一致。

  4. 一旦代码优化(不仅仅是通过分析可发现的简单瓶颈),检查是否可以实现**粗粒度并行性**,即通过使用 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 次函数调用,耗时 1.840 CPU 秒

   按内部时间排序

   ncalls   tottime   percall   cumtime   percall 文件名:行号(函数)
     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

然后在 ~/.ipython/profile_default/ipython_config.py 中注册 line_profiler 扩展:

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

这将注册 %lprun 魔法命令在 IPython 终端应用程序和其他前端(如 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)
计时器单位:1e-06 秒

文件:sklearn/decomposition/nmf.py
函数:_nls_subproblem 在第 137 行
总时间:1.73153 秒

行号     命中次数      时间(微秒)  每次命中时间  占总时间百分比  行内容
==============================================================
   137                                            def _nls_subproblem(V, W, H_init, tol, max_iter):
   138                                                """非负最小二乘求解器
   ...
   170                                                """
   171        48         5863    122.1      0.3      if (H_init < 0).any():
   172                                                    raise ValueError("传递给 NLS 求解器的 H_init 包含负值。")
   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                                               # 论文中对齐的值
   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 列的顶部值,可以很容易地找到最昂贵的表达式,这些表达式值得额外关注。

内存使用分析#

您可以通过 memory_profiler 详细分析任何 Python 代码的内存使用情况。首先,安装最新版本:

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 终端应用程序和其他前端,如 qtconsole 和 notebook。

%mprun 对于逐行检查程序中关键函数的内存使用非常有用。它与上一节中讨论的 %lprun 非常相似。例如,从 memory_profilerexamples 目录中:

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 文档 包含了一个教程和开发此类模块的参考指南。

有关在 scikit-learn 中使用 Cython 开发的更多信息,请参阅 Cython 最佳实践、约定和知识

编译扩展的性能分析#

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

使用 yep 和 gperftools#

无需特殊编译选项的简单性能分析使用 yep:

使用调试器 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 扩展,可以在使用 gcc -pg 重新编译项目后使用 gprof ,并在 debian / ubuntu 上使用 python-dbg 解释器变体:然而这种方法还需要使用 -pg 重新编译 numpyscipy ,这相当复杂。

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

使用 valgrind / callgrind / kcachegrind#

kcachegrind#

yep 可以用来创建一个性能分析报告。 kcachegrind 提供了一个图形化环境来可视化这个报告:

# 运行 yep 来分析某个 Python 脚本
python -m yep -c my_file.py
# 使用 kcachegrind 打开 my_file.py.callgrin
kcachegrind my_file.py.prof

Note

yep 可以通过参数 --lines-l 来执行,以编译一个“逐行”的性能分析报告。

使用 joblib.Parallel 实现多核并行#

参见 joblib 文档

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

参见术语条目 warm_start