原文:https://zhuanlan.zhihu.com/p/60992622
圖 | 《側耳傾聽》劇照
背景這篇文章的背景是在最近的工作中涉及到了一些計算密集型任務,這些計算密集型任務或多或少觸發了一些之前幾乎沒有關心過的Python性能問題,所以寫下這篇文章分析Python的性能問題,並調研了一些對應的改善方案(Numba、Cython)。
坦白地說,在過往用Objective-C寫iOS應用的經歷中,除了一些面試和工作中常見的關於一些UI組件渲染的性能問題外,幾乎沒有關心過代碼執行的效率(性能)問題。這次也正好是一個契機使我有機會複習一些本科學過的知識。
二維數組求和首先讓我們看一段簡單的Python代碼,這段代碼定義了一個函數,其功能是對一個np.ndarray類型的二維數組求和,並返回結果:
def arr_sum(src_arr):
res = 0
shape = src_arr.shape
for r in range(0, shape[0]):
for c in range(0, shape[1]):
res += src_arr[r][c]
return res
這段代碼沒什麼特別的,如果我們的src_arr.shape是128x128,上面這個代碼片段執行1000次,在我的機器上(i7 6700K、32G),大概需要3.7857s,同樣的,沒有對比就沒有傷害,同樣的代碼,如果我們用C++重寫一下,大概會是這個樣子:
double sum(double arr[][128], int row) {
double res = 0;
for (int i = 0; i < row; ++i) {
for (int j = 0; j < 128; ++j) {
res += arr[i][j];
}
}
return res;
}
這段代碼的執行時間將會是0.035s,即大約35ms,可以看出,在這個場景下(當然,在實際的項目或者研究中,根據問題規模的不同,某個代碼片段的實現也會不盡相同),還是可以有一個粗糙的結論:大概Python比C++慢了100倍。
雖然這個結論不是非常嚴謹,例如,我們出於某種面向對象的考慮,希望這段代碼不是非常的膠水,可能會用std::vector或者某些容器替換一個C風格的二維數組:
template<typename T>
T sum(vector<vector<T>>& arr, int row, int col) {
T res = 0;
for (int i = 0; i < row; ++i) {
for (int j = 0; j < col; ++j) {
res += arr[row][col];
}
}
return res;
}
而上面這段粗糙的實現對應的執行時間將會是0.060s,即60ms,但是仍然要比Python快出兩個數量級,依然沒有問題。
甚至如果你不幸將:
T sum(vector<vector<T>>& arr, int row, int col)
寫成了:
T sum(vector<vector<T>> arr, int row, int col)
而導致函數調用時複製整個二維數組,也只需要4.3s,而對比Python的3.75s,反而會感覺還沒慢多少。
為什麼Python慢?那麼為什麼Python會顯得慢呢?首先,Python通常被稱作解釋型語言,是相對於像C++這樣的編譯型語言來說的。
事實上py文件也會被編譯,但是並不像C++,或者是其他靜態強類型編譯型語言那樣,通過預處理、編譯、彙編、連結這樣的過程最終得到機器碼。py文件,即Python的原始碼通常會在運行時被解釋器先解釋為字節碼,然後交由虛擬機將字節碼翻譯成機器碼執行,而這一步就很尬了。
事實上,也正是因為這樣,我們往往才因此獲得Python在運行時一些非常強大的特性,例如generator,利用generator我們可以做一些非常神奇的事情,例如協程等。
但是另一方面,Python的解釋器和虛擬機翻譯並執行字節碼的過程帶來了很大的性能開銷,一個直覺的解釋是:由於沒有原生的編譯時類型檢查,所有的類型的檢查都被移交給了運行時,執行一行Python代碼很可能需要做不只一行的類型檢查、邊界檢查等等。
這裡其實並不打算詳細探究Python字節碼的編譯與執行,只是簡單的通過一個例子大致說明一行Python代碼是如何被解釋和執行的,
考慮源文件test.py,他們的實現很簡單,其中test.py的實現大概是這樣的:
def add(x, y):
res = x + y
print('Res: ' + res)
如你所見的,計算兩個值的和,然後將結果列印到標準輸出。
我們通過dis模塊將test.py翻譯成可讀的字節碼指令,將會是這樣:
Disassembly of add:
2 0 LOAD_FAST 0 (x)
3 LOAD_FAST 1 (y)
6 BINARY_ADD
7 STORE_FAST 2 (res)
3 10 LOAD_GLOBAL 0 (print)
13 LOAD_CONST 1 ('Res: ')
16 LOAD_FAST 2 (res)
19 BINARY_ADD
20 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
23 POP_TOP
24 LOAD_CONST 0 (None)
27 RETURN_VALUE
即,首先Python的原始碼py文件將會被解釋器翻譯成上類似上面的東西,但是具體可能會更加複雜一點,但是我想到這一步應該已經能說明一些問題了,然後這個字節碼會被Python虛擬機嘗試執行,而在執行時則會有很多運行時的檢查,然後才是轉化為真正的機器碼交由CPU執行。
而C++就不同了,因為他沒有這一步。
其實,字節碼也不是每次都是從磁碟讀py文件在運行時編譯的,事實上,每個在運行時被編譯的py文件將會產生PyCodeObject對象,這一步操作是在解釋import語句時執行的,PyCodeObject對象創建後,一方面將會根據需要被虛擬機繼續轉化為PyFrameObject對象進行後續的機器碼翻譯和執行工作,另一方面會帶著一個「最後修改日期」的欄位被緩存在磁碟上,通常,這個位置是在原始碼同級目錄下__pycache__文件夾中,這些被緩存的PyCodeObject對象將與源文件同名,只是擴展名為pyc,以便下次運行時直接讀取緩存,從而節約編譯字節碼的時間。
所以,一個粗糙的結論可能是這樣的,Python由於要在運行時編譯和解釋執行字節碼,而且這個過程中參與了很多類似運行時類型檢查的操作等一系列其他操作,從而產生了很多額外開銷,降低了性能。
如何提速?那麼如何提速?在本文我們調研了兩種方案,分別是Numba和Cython,接下來我們將分別簡述它們的加速原理,並給出一些示例代碼,並做一些簡單的性能對比實驗。
Numba首先我們介紹Numba,先引一段官網文檔的介紹:
Numba is a just-in-time compiler for Python that works best on code that uses NumPy arrays and functions, and loops. The most common way to use Numba is through its collection of decorators that can be applied to your functions to instruct Numba to compile them. When a call is made to a Numba decorated function it is compiled to machine code 「just-in-time」 for execution and all or part of your code can subsequently run at native machine code speed!
其中有兩句話比較重要:
Numba is a just-in-time compiler for Python that works best on code that uses NumPy arrays and functions, and loops.
Numba是一個JIT編譯器,它和Numpy的數組和函數以及循環一起用時,效果最佳。
另一句是:
When a call is made to a Numba decorated function it is compiled to machine code 「just-in-time」 for execution and all or part of your code can subsequently run at native machine code speed!
如果一個調用被Numba裝飾器修飾,那麼它將被JIT機制編譯成機器碼執行,性能堪比本地機器碼的速度。
同樣,它的原理簡介也能在官網文檔中找到:
Numba reads the Python bytecode for a decorated function and combines this with information about the types of the input arguments to the function. It analyzes and optimizes your code, and finally uses the LLVM compiler library to generate a machine code version of your function, tailored to your CPU capabilities. This compiled version is then used every time your function is called.
簡要的概括即是,Numba通過一個裝飾器讀某些調用的字節碼,並為它們的參數等添加類型信息,嘗試優化代碼後,通過LLVM編譯器直接生成對應的機器碼。
思想其實很明確,一方面是在字節碼被執行前添加類型信息,然後省去原始調用的字節碼被執行的過程,通過LLVM直接編譯機器碼,從而節省了性能開銷,事實也確實如此。
通過引入numba模塊,原始的Python代碼將會被改寫成這樣:
import numba as nb
@nb.njit()
def arr_sum(src_arr):
res = 0
shape = src_arr.shape
for r in range(0, shape[0]):
for c in range(0, shape[1]):
res += src_arr[r][c]
return res
可以發現其實沒什麼變化,除了加了一個@nb.njit()的裝飾器外,沒有對原函數做任何改動,這其實也是numba的方便之處(與後續的Cython方案對比),事實上@nb.njit()會盡其所能去尋找能被numba的JIT機制添加類型信息並翻譯成機器碼的對象,如果失敗了,運行起來的效果甚至會比原始的Python代碼還慢。
在它的文檔的開頭也就提到,它和Numpy的數組和函數以及循環一起用時,效果最佳,同時文檔也給出了一個暫時不支持pandas類型的例子。
詳細的numba實現原理已經超出了本文做簡單調研的範圍,可能我們會有後續幾篇文章討論這個問題。
一個快速的入門文檔可以參見:
https://numba.pydata.org/numba-doc/latest/user/5minguide.html
最後,添加numba裝飾器後,代碼片段對大小為128x128的二維數組求和,運行1000次時間為0.017122s,即17ms,比CXX還要快。
而JIT首次嘗試編譯求和函數代碼的約為0.151606s,即150ms,所以,一個粗糙的結論是,如果這段代碼確實性能開銷較大,且被調用頻率相對較高,那麼一個短暫的編譯時間還是可以被接受的。
Cython接下來我們介紹Cython,
Cython是在Python中實現C-Extensions的一種方案,簡單的理解是,Python提供了一些與CXX的Lib相互調用的機制,而能通過import作為模塊來進行調用的C或者C++的Lib,就是C-Extensions,有很多方案可以用來實現C-Extensions,例如Swig等,而Cython就是其中一種。
同樣引一段官方文檔中關於Cython的介紹:
Cython is an optimising static compiler for both the Python programming language and the extended Cython programming language (based on Pyrex). It makes writing C extensions for Python as easy as Python itself.
它的核心精神是,Cython將Cython語言(一種基於Python的擴展語言)寫的pyx文件直接編譯成C extensions,從而獲得近乎於寫CXX語言的性能。
我們直接看一個Cython改寫的二維數組求和代碼片段,這個代碼片段的文件名將會是func.pyx,可以注意到到與*.py結尾的文件不同,Cython的代碼將是以pyx結尾。
cimport cython
@cython.boundscheck(False)
@cython.wraparound(False)
def arr_sum(double[:, :] src_arr):
cdef double res = 0
cdef Py_ssize_t[:] shape = src_arr.shape
cdef Py_ssize_t r = 0
cdef Py_ssize_t c = 0
for r in range(0, shape[0]):
for c in range(0, shape[1]):
res += src_arr[r][c]
return res
我們用了一些cdef關鍵字,來在定義變量時指明它們的類型,同時,我們使用了形如double[:, :]這樣的關鍵字,它代表了Python中的MemoryView,即內存視圖。簡而言之,內存視圖可以快速索引值,通過內存視圖,我們可以避開繁瑣的Python對象引用流程,直接訪問一個二維數組某個下標值,如果不經轉置,它在內存上應該是連續的,永遠是通過一個基地址加上一個偏移量。
Cython文檔在Typed Memory Views一節詳細的介紹了這個機制,這裡就不在贅述了。
https://cython.readthedocs.io/en/latest/src/userguide/memoryviews.html
看上去我們在寫帶類型標識符的Python,實際上這些代碼都會被Cython先解釋稱CXX,然後編譯成.so(Linux),通過編寫對應的setup.py:
from distutils.core import setup
from Cython.Build import cythonize
setup(
ext_modules=cythonize(["playground/cyfunc/func.pyx"], annotate=True)
)
打開annotate=True,Cython會替我們生成一份源碼分析,如下圖,詳細的展示了pyx文件是如何生成CXX代碼的,同時,黃色對應的行說明這行有Python調用,可能會影響能:
img通過這種方式,代碼片段對大小為128x128的二維數組求和,運行1000次時間為0.0181s,約18ms。
性能對比最後我們給出了四組實驗的結果,代碼片段對大小為128x128的二維數組求和,運行1000次時間如下:
Total cost time for func: py_func, call 1000 times: 3.803216s.
Total cost time for func: np_func, call 1000 times: 0.343562s.
Total cost time for func: nb_func, call 1000 times: 0.017122s.
Total cost time for func: cy_func, call 1000 times: 0.018159s.
它們分別代表了原始Python、Numpy、Numba、Cython對應的性能。
需要注意的是,numba的編譯時間約為150ms:
Time cost for numba first call: 0.151606s.
希望在之後的幾篇文章中,討論Numba與Cython的實現細節。