如何最佳化速度#

以下提供一些實用指南,以協助您為 scikit-learn 專案撰寫有效率的程式碼。

注意

雖然檢測程式碼效能以便檢查效能假設始終很有用,但強烈建議您在投入成本高昂的實作最佳化之前,檢閱文獻,以確保實作的演算法是該任務的最先進技術。

在複雜的實作細節最佳化上投入數小時的努力,往往會因為後續發現簡單的演算法技巧,或是完全使用更適合該問題的另一種演算法而變得無關緊要。

一個簡單的演算法技巧:暖啟動」章節提供了一個此類技巧的範例。

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 實作,找出主要的瓶頸,並將其隔離在專用的模組級函數中。此函數將重新實作為編譯的擴充模組。

  2. 如果存在維護良好的 BSD 或 MIT C/C++ 演算法實作,而且該實作不大,您可以為其撰寫 Cython 包裝函式,並將該程式庫的原始碼副本包含在 scikit-learn 原始碼樹中:此策略用於類別 svm.LinearSVCsvm.SVClinear_model.LogisticRegression(liblinear 和 libsvm 的包裝函式)。

  3. 否則,直接使用 Cython 撰寫 Python 函數的最佳化版本。例如,linear_model.ElasticNetlinear_model.SGDClassifier 類別即採用此策略。

  4. 將 Python 版本的函數移至測試中,並使用它來檢查編譯的擴充功能的結果是否與黃金標準(易於偵錯的 Python 版本)一致。

  5. 程式碼最佳化後(並非檢測就能發現的簡單瓶頸),請檢查是否可以使用 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 magic 命令查看整體效能檢測結果

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

上述結果顯示,執行主要由點積運算(委派給 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')

這會在 IPython 終端機應用程式和其他前端(例如 qtconsole 和 notebook)中註冊 %lprun 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 欄的頂端值,可以很容易地找出最昂貴的運算式,它們值得額外注意。

記憶體使用量檢測#

您可以使用 memory_profiler 來詳細分析任何 Python 程式碼的記憶體使用量。首先,安裝最新版本

pip install -U memory_profiler

然後,以類似於 line_profiler 的方式設定 magic。

在 IPython 0.11+ 中,首先建立設定檔

ipython profile create

然後在 ~/.ipython/profile_default/ipython_config.py 中與行檢測器一起註冊擴充功能

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

這會在 IPython 終端機應用程式和其他前端(例如 qtconsole 和 notebook)中註冊 %memit%mprun magic 命令。

%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 定義的另一個有用的 magic 是 %memit,它類似於 %timeit。它可以如下使用

In [1]: import numpy as np

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

如需更多詳細資訊,請使用 %memit?%mprun? 查看 magic 的 docstring。

使用 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 擴展的效能,可以使用 gprof,前提是必須先使用 gcc -pg 重新編譯專案,並在 debian/ubuntu 上使用直譯器的 python-dbg 變體:然而,這種方法也需要使用 -pg 重新編譯 numpyscipy,這使得整個過程相當複雜。

幸運的是,存在兩種替代的效能分析器,它們不需要您重新編譯所有內容。

使用 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 條目