代碼優化指南:人生苦短,我用Python

  • 2020 年 1 月 16 日
  • 筆記

選自pythonfiles

機器之心編譯

代碼地址:https://github.com/apatrascu/hunting-python-performance

第一部分請查看從環境設置到內存分析。以下是 Python 代碼優化的第二部分,主要從 Python 腳本與 Python 解釋器兩個方面闡述。在這一部分中我們首先會關注如何追蹤 Python 腳本的 CPU 使用情況,並重點討論 cProfile、line_profiler、pprofile 和 vprof。而後一部分重點介紹了一些可用於在運行 Python 腳本時對解釋器進行性能分析的工具和方法,主要討論了 CPython 和 PyPy 等。

CPU 分析——Python 腳本

在這一節,我將介紹一些有助於我們解決 Python 中分析 CPU 使用的難題的工具。

CPU 性能分析(profiling)的意思是通過分析 CPU 執行代碼的方式來分析這些代碼的性能。也就是說要找到我們代碼中的熱點(hot spot),然後看我們可以怎麼處理它們。

接下來我們會看看你可以如何追蹤你的 Python 腳本的 CPU 使用。我們將關注以下分析器(profiler):

  • cProfile
  • line_profiler
  • pprofile
  • vprof

測量 CPU 使用

import time      def primes(n):      if n == 2:          return [2]      elif n < 2:          return []      s = []      for i in range(3, n+1):          if i % 2 != 0:              s.append(i)      mroot = n ** 0.5      half = (n + 1) / 2 - 1      i = 0      m = 3      while m <= mroot:          if s[i]:              j = (m * m - 3) / 2              s[j] = 0              while j < half:                  s[j] = 0                  j += m          i = i + 1          m = 2 * i + 3      l = [2]      for x in s:          if x:              l.append(x)      return l      def benchmark():      start = time.time()      for _ in xrange(40):          count = len(primes(1000000))      end = time.time()      print "Benchmark duration: %r seconds" % (end-start)      benchmark()

這一節我將使用與前一節基本一樣的腳本,你也可以在 GitHub 上查看:https://gist.github.com/apatrascu/8524679175de08a54a95e22001a31d3b

另外,記住在 PyPy2 上,你需要使用一個支持它的 pip 版本:

pypy -m ensure pip

其它東西可以通過以下指令安裝:

pypy -m pip install

cProfile

在 CPU 性能分析上最常用的一個工具是 cProfile,主要是因為它內置於 CPython2 和 PyPy2 中。這是一個確定性的分析器,也就是說它會在運行我們的負載時收集一系列統計數據,比如代碼各個部分的執行次數或執行時間。此外,相比於其它內置的分析器(profile 或 hotshot),cProfile 對系統的開銷更少。

當使用 CPython2 時,其使用方法是相當簡單的:

python -m cProfile 03.primes-v1.py

如果你使用的是 PyPy2:

pypy -m cProfile 03.primes-v1.py

其輸出如下:

Benchmark duration: 30.11158514022827 seconds           23139965 function calls in 30.112 seconds       Ordered by: standard name       ncalls  tottime  percall  cumtime  percall filename:lineno(function)          1    0.000    0.000   30.112   30.112 03.primes.py:1(<module>)         40   19.760    0.494   29.896    0.747 03.primes.py:3(primes)          1    0.216    0.216   30.112   30.112 03.primes.py:31(benchmark)         40    0.000    0.000    0.000    0.000 {len}   23139840    6.683    0.000    6.683    0.000 {method  append  of  list  objects}          1    0.000    0.000    0.000    0.000 {method  disable  of  _lsprof.Profiler  objects}         40    3.453    0.086    3.453    0.086 {range}          2    0.000    0.000    0.000    0.000 {time.time}

即使是這樣的文本輸出,我們也可以直接看到我們腳本的大多數時間都在調用 list.append 方法。

如果我們使用 gprof2dot,我們可以用圖形化的方式來查看 cProfile 的輸出。要使用這個工具,我們首先必須安裝 graphviz。在 Ubuntu 上,可以使用以下命令:

apt-get install graphviz  pip install gprof2dot

再次運行我們的腳本:

python -m cProfile -o output.pstats 03.primes-v1.py  gprof2dot -f pstats output.pstats | dot -Tpng -o output.png

然後我們會得到下面的 output.png 文件:

這樣看起來就輕鬆多了。讓我們仔細看看它輸出了什麼。你可以看到來自腳本的函數調用圖(callgraph)。在每個方框中,你可以一行一行地看到:

  • 第一行:Python 文件名、行數和方法名
  • 第二行:這個方框所用的時間佔全局時間的比例
  • 第三行:括號中是該方法本身所用時間佔全局時間的比例
  • 第四行:調用次數

比如說,在從上到下第三個紅色框中,方法 primes 佔用了 98.28% 的時間,65.44% 的時間是在該方法之中做什麼事情,它被調用了 40 次。剩下的時間被用在了 Python 的 list.append(22.33%)和 range(11.51%)方法中。

這是一個簡單的腳本,所以我們只需要重寫我們的腳本,讓它不用使用那麼多的 append 方法,結果如下:

import time      def primes(n):      if n==2:          return [2]      elif n<2:          return []      s=range(3,n+1,2)      mroot = n ** 0.5      half=(n+1)/2-1      i=0      m=3      while m <= mroot:          if s[i]:              j=(m*m-3)/2              s[j]=0              while j<half:                  s[j]=0                  j+=m          i=i+1          m=2*i+3      return [2]+[x for x in s if x]      def benchmark():      start = time.time()      for _ in xrange(40):          count = len(primes(1000000))      end = time.time()      print "Benchmark duration: %r seconds" % (end-start)      benchmark()

以下測試了腳本在使用前和使用 CPython2 後的運行時間:

python 03.primes-v1.py  Benchmark duration: 15.768115043640137 seconds      python 03.primes-v2.py  Benchmark duration: 6.56312108039856 seconds

用 PyPy2 測量:

pypy 03.primes-v1.py  Benchmark duration: 1.4009230136871338 seconds    pypy 03.primes-v2.py  Benchmark duration: 0.4542720317840576 seconds

我們在 CPython2 上得到了 2.4 倍的提升,在 PyPy2 上得到了 3.1 倍的提升。很不錯,其 cProfile 調用圖為:

你也可以以程序的方式查看 cProfile:

import cProfile    pr = cProfile.Profile()  pr.enable()    function_to_measure()    pr.disable()  pr.print_stats(sort= time )

這在一些場景中很有用,比如多進程性能測量。更多詳情請參閱:https://docs.python.org/2/library/profile.html#module-cProfile

line_profiler

這個分析器可以提供逐行水平的負載信息。這是通過 C 語言用 Cython 實現的,與 cProfile 相比計算開銷更少。

其源代碼可在 GitHub 上獲取:https://github.com/rkern/line_profiler,PyPI 頁面為:https://pypi.python.org/pypi/line_profiler/。和 cProfile 相比,它有相當大的開銷,需要多 12 倍的時間才能得到一個分析結果。

要使用這個工具,你首先需要通過 pip 添加:pip install pip install Cython ipython==5.4.1 line_profiler(CPython2)。這個分析器的一個主要缺點是不支持 PyPy。

就像在使用 memory_profiler 時一樣,你需要在你想分析的函數上加上一個裝飾。在我們的例子中,你需要在 03.primes-v1.py 中的 primes 函數的定義前加上 @profile。然後像這樣調用:

kernprof -l 03.primes-v1.py  python -m line_profiler 03.primes-v1.py.lprof

你會得到一個這樣的輸出:

Timer unit: 1e-06 s    Total time: 181.595 s  File: 03.primes-v1.py  Function: primes at line 3    Line #      Hits         Time  Per Hit   % Time  Line Contents  ==============================================================       3                                           @profile       4                                           def primes(n):       5        40          107      2.7      0.0      if n == 2:       6                                                   return [2]       7        40           49      1.2      0.0      elif n < 2:       8                                                   return []       9        40           44      1.1      0.0      s = []      10  39999960     34410114      0.9     18.9      for i in range(3, n+1):      11  39999920     29570173      0.7     16.3          if i % 2 != 0:      12  19999960     14976433      0.7      8.2              s.append(i)      13        40          329      8.2      0.0      mroot = n ** 0.5      14        40           82      2.0      0.0      half = (n + 1) / 2 - 1      15        40           46      1.1      0.0      i = 0      16        40           30      0.8      0.0      m = 3      17     20000        17305      0.9      0.0      while m <= mroot:      18     19960        16418      0.8      0.0          if s[i]:      19      6680         6798      1.0      0.0              j = (m * m - 3) / 2      20      6680         6646      1.0      0.0              s[j] = 0      21  32449400     22509523      0.7     12.4              while j < half:      22  32442720     26671867      0.8     14.7                  s[j] = 0      23  32442720     22913591      0.7     12.6                  j += m      24     19960        15078      0.8      0.0          i = i + 1      25     19960        16170      0.8      0.0          m = 2 * i + 3      26        40           87      2.2      0.0      l = [2]      27  20000000     14292643      0.7      7.9      for x in s:      28  19999960     13753547      0.7      7.6          if x:      29   3139880      2417421      0.8      1.3              l.append(x)      30        40           33      0.8      0.0      return l

我們可以看到兩個循環在反覆調用 list.append,佔用了腳本的大部分時間。

pprofile

地址:http://github.com/vpelletier/pprofile

據作者介紹,pprofile 是一個「行粒度的、可感知線程的確定性和統計性純 Python 分析器」。

它的靈感來源於 line_profiler,修復了大量缺陷,但因為其完全是用 Python 寫的,所以也可以通過 PyPy 使用。和 cProfile 相比,使用 CPython 時分析的時間會多 28 倍,使用 PyPy 時的分析時間會長 10 倍,但具有粒度更大的細節水平。

而且還支持 PyPy 了!除此之外,它還支持線程分析,這在很多情況下都很有用。

要使用這個工具,你首先需要通過 pip 安裝:pip install pprofile(CPython2)/ pypy -m pip install pprofile(PyPy),然後像這樣調用:

pprofile 03.primes-v1.py

其輸出和前面工具的輸出不同,如下:

886.8774709701538 seconds  Command line: [ 03.primes-v1.py ]  Total duration: 886.878s  File: 03.primes-v1.py  File duration: 886.878s (100.00%)  Line #|      Hits|         Time| Time per hit|      %|Source code  ------+----------+-------------+-------------+-------+-----------       1|         2|  7.10487e-05|  3.55244e-05|  0.00%|import time       2|         0|            0|            0|  0.00%|       3|         0|            0|            0|  0.00%|       4|        41|   0.00029397|     7.17e-06|  0.00%|def primes(n):       5|        40|  0.000231266|  5.78165e-06|  0.00%|    if n == 2:       6|         0|            0|            0|  0.00%|        return [2]       7|        40|  0.000178337|  4.45843e-06|  0.00%|    elif n < 2:       8|         0|            0|            0|  0.00%|        return []       9|        40|  0.000188112|  4.70281e-06|  0.00%|    s = []      10|  39999960|      159.268|  3.98171e-06| 17.96%|    for i in range(3, n+1):      11|  39999920|      152.924|  3.82312e-06| 17.24%|        if i % 2 != 0:      12|  19999960|      76.2135|  3.81068e-06|  8.59%|            s.append(i)      13|        40|   0.00147367|  3.68416e-05|  0.00%|    mroot = n ** 0.5      14|        40|  0.000319004|   7.9751e-06|  0.00%|    half = (n + 1) / 2 - 1      15|        40|  0.000220776|  5.51939e-06|  0.00%|    i = 0      16|        40|  0.000243902|  6.09756e-06|  0.00%|    m = 3      17|     20000|    0.0777466|  3.88733e-06|  0.01%|    while m <= mroot:      18|     19960|    0.0774016|  3.87784e-06|  0.01%|        if s[i]:      19|      6680|    0.0278566|  4.17015e-06|  0.00%|            j = (m * m - 3) / 2      20|      6680|    0.0275929|  4.13067e-06|  0.00%|            s[j] = 0      21|  32449400|      114.858|   3.5396e-06| 12.95%|            while j < half:      22|  32442720|      120.841|  3.72475e-06| 13.63%|                s[j] = 0      23|  32442720|      114.432|   3.5272e-06| 12.90%|                j += m      24|     19960|    0.0749919|  3.75711e-06|  0.01%|        i = i + 1      25|     19960|    0.0765574|  3.83554e-06|  0.01%|        m = 2 * i + 3      26|        40|  0.000222206|  5.55515e-06|  0.00%|    l = [2]      27|  20000000|      68.8031|  3.44016e-06|  7.76%|    for x in s:      28|  19999960|      67.9391|  3.39696e-06|  7.66%|        if x:      29|   3139880|      10.9989|  3.50295e-06|  1.24%|            l.append(x)      30|        40|  0.000155687|  3.89218e-06|  0.00%|    return l      31|         0|            0|            0|  0.00%|      32|         0|            0|            0|  0.00%|      33|         2|  8.10623e-06|  4.05312e-06|  0.00%|def benchmark():      34|         1|  5.00679e-06|  5.00679e-06|  0.00%|  start = time.time()      35|        41|   0.00101089|   2.4656e-05|  0.00%|  for _ in xrange(40):      36|        40|     0.232263|   0.00580657|  0.03%|          count = len(primes(1000000))  (call)|        40|      886.644|      22.1661| 99.97%|# 03.primes-v1.py:4 primes      37|         1|  5.96046e-06|  5.96046e-06|  0.00%|  end = time.time()      38|         1|  0.000678062|  0.000678062|  0.00%|  print "Benchmark duration: %r seconds" % (end-start)      39|         0|            0|            0|  0.00%|      40|         0|            0|            0|  0.00%|      41|         1|  5.79357e-05|  5.79357e-05|  0.00%|benchmark()  (call)|         1|      886.878|      886.878|100.00%|# 03.primes-v1.py:33 benchmark

我們現在可以看到更詳細的細節。讓我們稍微研究一下這個輸出。這是這個腳本的整個輸出,每一行你可以看到調用的次數、運行它所用的時間(秒)、每次調用的時間和佔全局時間的比例。此外,pprofile 還為我們的輸出增加了額外的行(比如 44 和 50 行,行前面寫着 (call)),這是累積指標。

同樣,我們可以看到有兩個循環在反覆調用 list.append,佔用了腳本的大部分時間。

vprof

地址:https://github.com/nvdv/vprof

vprof 是一個 Python 分析器,為各種 Python 程序特點提供了豐富的交互式可視化,比如運行時間和內存使用。這是一個圖形化工具,基於 Node.JS,可在網頁上展示結果。

使用這個工具,你可以針對相關 Python 腳本查看下面的一項或多項內容:

  • CPU flame graph
  • 代碼分析(code profiling)
  • 內存圖(memory graph)
  • 代碼熱圖(code heatmap)

要使用這個工具,你首先需要通過 pip 安裝:pip install vprof(CPython2)/ pypy -m pip install vprof(PyPy),然後像這樣調用:

在 CPython2 上,要顯示代碼熱圖(下面的第一行調用)和代碼分析(下面的第二行調用):

vprof -c h 03.primes-v1.py  vprof -c p 03.primes-v1.py

在 PyPy 上,要顯示代碼熱圖(下面的第一行調用)和代碼分析(下面的第二行調用):

pypy -m vprof -c h 03.primes-v1.py  pypy -m vprof -c p 03.primes-v1.py

在上面的兩個例子中,你都會看到如下的代碼熱圖:

以及如下的代碼分析:

結果是以圖形化的方式展示的,你可以將鼠標懸浮或點擊每一行,從而查看更多信息。同樣,我們可以看到有兩個循環在反覆調用 list.append 方法,佔用了腳本的大部分時間。

CPU 分析——Python 解釋器

在這一節,我將介紹一些可用於在運行 Python 腳本時對解釋器進行性能分析的工具和方法。

正如前幾節提到的,CPU 性能分析的意義是一樣的,但現在我們的目標不是 Python 腳本。我們現在想要知道 Python 解釋器的工作方式,以及 Python 腳本運行時在哪裡消耗的時間最多。

接下來我們將看到你可以怎樣跟蹤 CPU 使用情況以及找到解釋器中的熱點。

測量 CPU 使用情況

這一節所使用的腳本基本上和前面內存分析和腳本 CPU 使用情況分析時使用的腳本一樣,你也可以在這裡查閱代碼:https://gist.github.com/apatrascu/44f0c6427e2df96951034b759e16946f

import time      def primes(n):      if n == 2:          return [2]      elif n < 2:          return []      s = []      for i in range(3, n+1):          if i % 2 != 0:              s.append(i)      mroot = n ** 0.5      half = (n + 1) / 2 - 1      i = 0      m = 3      while m <= mroot:          if s[i]:              j = (m * m - 3) / 2              s[j] = 0              while j < half:                  s[j] = 0                  j += m          i = i + 1          m = 2 * i + 3      l = [2]      for x in s:          if x:              l.append(x)      return l      def benchmark():          start = time.time()          for _ in xrange(40):                  count = len(primes(1000000))          end = time.time()          print "Benchmark duration: %r seconds" % (end-start)      benchmark()

優化後的版本見下面或訪問:https://gist.github.com/apatrascu/ee660bf95469a55e5947a0066e930a69

import time      def primes(n):      if n==2:          return [2]      elif n<2:          return []      s=range(3,n+1,2)      mroot = n ** 0.5      half=(n+1)/2-1      i=0      m=3      while m <= mroot:          if s[i]:              j=(m*m-3)/2              s[j]=0              while j<half:                  s[j]=0                  j+=m          i=i+1          m=2*i+3      return [2]+[x for x in s if x]      def benchmark():          start = time.time()          for _ in xrange(40):                  count = len(primes(1000000))          end = time.time()          print "Benchmark duration: %r seconds" % (end-start)      benchmark()

CPython

CPython 的功能很多,這是完全用 C 語言寫的,因此在測量和/或性能分析上可以更加容易。你可以找到託管在 GitHub 上的 CPython 資源:https://github.com/python/cpython。默認情況下,你會看到最新的分支,在本文寫作時是 3.7+ 版本,但向前一直到 2.7 版本的分支都能找到。

在這篇文章中,我們的重點是 CPython 2,但最新的第 3 版也可成功應用同樣的步驟。

1. 代碼覆蓋工具(Code coverage tool)

要查看正在運行的 C 語言代碼是哪一部分,最簡單的方法是使用代碼覆蓋工具。

首先我們克隆這個代碼庫:

git clone https://github.com/python/cpython/  cd cpython  git checkout 2.7  ./configure

複製該目錄中的腳本並運行以下命令:

make coverage  ./python 04.primes-v1.py  make coverage-lcov

第一行代碼將會使用 GCOV 支持(https://gcc.gnu.org/onlinedocs/gcc/Gcov.html)編譯該解釋器,第二行將運行負載並收集在 .gcda 文件中的分析數據,第三行代碼將解析包含這些分析數據的文件並在名為 lcov-report 的文件夾中創建一些 HTML 文件。

如果我們在瀏覽器中打開 index.html,我們會看到為了運行我們的 Python 腳本而執行的解釋器源代碼的位置。你會看到類似下面的東西:

在上面一層,我們可以看到構成該源代碼的每個目錄以及被覆蓋的代碼的量。舉個例子,讓我們從 Objects 目錄打開 listobject.c.gcov.html 文件。儘管我們不會完全看完這些文件,但我們會分析其中一部分。看下面這部分。

怎麼讀懂其中的信息?在黃色一列,你可以看到 C 語言文件代碼的行數。接下來一列是特定一行代碼執行的次數。最右邊一列是實際的 C 語言源代碼。

在這個例子中,listiter_next 方法被調用了 6000 萬次。

我們怎麼找到這個函數?如果我們仔細看看我們的 Python 腳本,我們可以看到它使用了大量的列表迭代和 append。(這是另一個可以一開始就做腳本優化的地方。)

讓我們繼續看看其它一些專用工具。在 Linux 系統上,如果我們想要更多信息,我們可以使用 perf。官方文檔可參閱:https://perf.wiki.kernel.org/index.php/Main_Page

我們使用下面的代碼重建了 CPython 解釋器。你應該將這個 Python 腳本下載到同一個目錄。另外,要確保你的系統安裝了 perf。

make clean  ./configure --with-pydebug  make

如下運行 perf。使用 perf 的更多方式可以看 Brendan Gregg 寫的這個:http://www.brendangregg.com/perf.html

sudo perf record ./python 04.primes-v1.py

運行腳本後,你會看到下述內容:

Benchmark duration: 32.03910684585571 seconds  [21868 refs]   perf record: Woken up 20 times to write data ]  [ perf record: Captured and wrote 4.914 MB perf.data (125364 samples) ]

要查看結果,運行 sudo perf report 獲取指標。

只有最相關的調用會被保留。在上面的截圖中,我們可以看到佔用時間最多的是 PyEval_EvalFrameEx。這是其中的主解釋器循環,在這個例子中,我們對此並不關心。我們感興趣的是下一個耗時的函數 listiter_next,它佔用了 10.70% 的時間。

在運行了優化的版本之後,我們可以看到以下結果:

在我們優化之後,listiter_next 函數的時間佔用降至了 2.11%。讀者還可以探索對該解釋器進行進一步的優化。

2. Valgrind/Callgrind

另一個可用於尋找瓶頸的工具是 Valgrind,它有一個被稱為 callgrind 的插件。更多細節請參閱:http://valgrind.org/docs/manual/cl-manual.html

我們使用下面的代碼重建了 CPython 解釋器。你應該將這個 Python 腳本下載到同一個目錄。另外,確保你的系統安裝了 valgrind。

make clean  ./configure --with-pydebug  make

按下面方法運行 valgrind:

valgrind --tool=callgrind --dump-instr=yes  --collect-jumps=yes --collect-systime=yes  --callgrind-out-file=callgrind-%p.out -- ./python 04.primes-v1.py

結果如下:

Benchmark duration: 1109.4096319675446 seconds  [21868 refs]  ==24152==  ==24152== Events : Ir sysCount sysTime  ==24152== Collected : 115949791666 942 208  ==24152==  ==24152== I refs: 115,949,791,666

我們使用 KCacheGrind 進行了可視化:http://kcachegrind.sourceforge.net/html/Home.html

kcachegrind callgrind-2327.out

PyPy

在 PyPy 上,可以成功使用的分析器是非常有限的。PyPy 的開發者為此開發了工具 vmprof:https://vmprof.readthedocs.io/en/latest/

首先,你要下載 PyPy:https://pypy.org/download.html。在此之後,為其啟用 pip 支持。

bin/pypy -m ensurepip

安裝 vmprof 的方式很簡單,運行以下代碼即可:

bin/pypy -m pip install vmprof

按以下方式運行工作負載:

bin/pypy -m vmprof --web 04.primes-v1.py

然後在瀏覽器中打開顯示在控制台中的鏈接(以 http://vmprof.com/#/ 開頭的鏈接)。

原文鏈接:

https://pythonfiles.wordpress.com/2017/06/01/hunting-performance-in-python-code-part-3/ https://pythonfiles.wordpress.com/2017/08/24/hunting-performance-in-python-code-part-4/

* 凡來源非註明「機器學習算法與Python學習原創」的所有作品均為轉載稿件,其目的在於促進信息交流,並不代表本公眾號贊同其觀點或對其內容真實性負責。