楔子
Cython:估計很多人都聽說過,是用來對 Python 進行加速的。如果你在使用 Python 編程時,有過如下想法,那么 Cython 非常適合你。
1. 因為某些需求導致不得不編寫一些多重嵌套的循環,而這些循環如果用 C 語言來實現會快幾百倍,但是不熟悉 C 或者不知道 Python 如何與 C 進行交互。
2. 因為 Python 解釋器的性能原因,如果將 CPython 解釋器換成 PyPy,或者干脆換一門語言,比如 Julia,將會得到明顯的性能提升,可是換不得。因為你的項目組規定只能使用 Python 語言,解釋器只能 CPython。
3. Python 是一門動態語言,但你希望至少在數字計算方面,能夠加入可選的靜態類型,這樣可以極大的加速運算效果。因為單純的數字相加不太需要所謂的動態性,尤其是當你的程序中出現了大量的計算邏輯時。
4. 對於一些計算密集型的部分,你希望能夠寫出一些超越 Numpy、Scipy、Pandas 的算法。
5. 你有一些已經用 C、C++ 實現的庫,你想直接在 Python 內部更好地調用它們,並且不使用 ctypes、cffi 等模塊。
6. 也許你聽說過 Python 和 C 可以無縫結合,通過 C 來為 Python 編寫擴展模塊,將 Python 代碼中性能關鍵的部分使用 C 進行重寫,來達到提升性能的效果。但是這需要你對 Python 解釋器有很深的了解,熟悉底層的 Python/C API,而這是一件非常痛苦的事情。
如果你有過上面的一些想法,那證明你的 Python 水平是很優秀的,然而這些問題總歸是要解決的,於是 Cython 便閃亮登場了。注意:Cython 並不是一個什么實驗性的項目,它出現的時間已經不短了,並且在生產環境中久經考驗,我們完全是有理由學習它的。
下面讓我們開始 Cython 的學習之旅吧,最終不管效果如何,逼格要到位。至少我覺得用 Cython 還是很酷的,它的語法我特別特別喜歡,當然它也確實起到了加速的效果。
關於 Cython
關於 Cython,你必須要清楚兩件事:
1. Cython 是一門編程語言,它將 C、C++ 的靜態類型系統融合在了 Python 身上。
補充:沒錯,Cython 是一門編程語言,文件的后綴是 .pyx,它是 Python 的一個超集;語法是 Python 語法和 C 語法的混血,當然我們說它是 Python 的一個超集,因此你寫純 Python 代碼也是可以的。
2. cython 是一個編譯器,負責將 Cython 源代碼翻譯成高效的 C 或者 C++ 源代碼;Cython 源文件被編譯之后的最終形式可以是 Python 的擴展模塊(.pyd),也可以是一個獨立的可執行文件。
因此 Cython 的強大之處就在於它將 Python 和 C 結合了起來,可以讓你像寫 Python 代碼一樣的同時還可以獲得 C 的高效率;所以我們看到 Cython 相當於是高級語言 Python 和低級語言 C 之間的一個融合,因此有人也稱 Cython 是 "克里奧爾編程語言"(creole programming language)。
補充:克里奧爾人是居住在西印度群島的歐洲人和非洲人的混血兒,以此來形容 Cython 也類似於是一個(Python 和 C 的)混血兒。
但是 Python 和 C 系語言大相徑庭,為什么要將它們融合在一起呢?答案是:因為這兩者並不是對立的,而是互補的。Python 是高階語言、動態、易於學習,並且靈活。但是這些優秀的特性是需要付出代價的,因為Python的動態性、以及它是解釋型語言,導致其運行效率比靜態編譯型語言慢了好幾個數量級。
而 C 語言是最古老的靜態編譯型語言之一,並且至今也被廣泛使用。從時間來算的話,其編譯器已有將近半個世紀的歷史,在性能上做了足夠的優化,因此 C 語言是非常低級、同時又非常強大的。然而不同於 Python 的是,C 語言沒有提供保護措施(沒有 GC、容易內存泄露),以及使用起來很不方便。
所以兩個語言都是主流語言,只是特性不同使得它們被應用在了不同的領域。而Cython 的美麗之處就在於:它將 Python 語言豐富的表達能力、動態機制和 C 語言的高性能匯聚在了一起,並且代碼寫起來仍然像寫 Python 一樣。
注意:除了極少數的例外,Python代碼(2.x和3.x版本)已經是有效的Cython 代碼,因為 Cython 可以看成是 Python 的超集。並且 Cython 在 Python 語言的基礎上添加了一些少量的關鍵字來更好的開發 C 的類型系統,從而允許 cython 編譯器生成高效的 C 代碼。如果你已經知道 Python 並且對 C 或 C++ 有一定的基礎了解,那么你可以直接學習 Cython,無需再學習其它的接口語言。
另外,我們其實可以將 Cython 當成兩個身份來看待:如果將 Python 編譯成 C,那么可以看成 Cython 的 '陰';如果將 Python 作為膠水連接 C 或者 C++,那么可以看成是 Cython 的 '陽'。我們可以從需要高性能的 Python 代碼開始,也可以從需要一個優化的 Python 接口的 C、C++ 開始,而我們這里是為了學習 Cython,因此顯然是選擇前者。為了加速 Python 代碼,Cython 將使用可選的靜態類型聲明並通過算法來實現大量的性能提升,尤其是靜態類型系統,這是實現高性能的關鍵。
Cython 和 CPython 的區別
關於 Cython,最讓人困惑的就是它和 CPython 之間的關系(尤其是少了的那個字母 P),但是需要強調的是這兩者是完全不同的。
首先 Python 是一門語言,它有自己的語法規則。我們按照 Python 語言規定的語法規則編寫的代碼就是 Python 源代碼,但是源代碼只是一個或多個普通的文本文件,我們需要使用 Python 語言對應的解釋器來執行它。
而 Python 解釋器也會按照同樣的語法規則來對我們編寫的 Python 源代碼進行分詞、語法解析等等,如果我們編寫的代碼不符合 Python 的語法規則,那么會報出語法錯誤,也就是 SyntaxError。如果符合語法規范的話,那么會順利的生成抽象語法樹(Abstract Syntax Tree,簡稱AST),然后將 AST 編譯成指令集合,也就是所謂的字節碼(bytes code),最后再執行字節碼。
所以我們看到 Python 源代碼是需要 Python 解釋器來操作的,我們想做一些事情的話,如果光寫成源代碼是不行的,必須要由 Python 解釋器將我們的代碼解釋成機器可以識別的指令進行執行才可以。而 CPython 正是 Python 語言對應的解釋器,並且它也是官方實現的標准解釋器,同時還是是使用最廣泛的一種解釋器。基本上我們使用的解釋器都是 CPython,也就是從官網下載、然后安裝之后所得到的。
標准解釋器 CPython 是由 C 語言實現的,除了 CPython 之外還有 Jython(java實現的 Python 解釋器)、PyPy(Python 語言實現的 Python 解釋器)等等。總之設計出一門語言,還要有相應的解釋器才可以;至於編譯型語言,則是對應的編譯器。
最后重點來了,我們說 CPython 解釋器是由 C 實現的,它給 Python 語言提供了 C 級別的接口,也就是熟知的 Python/C API。比如:Python 中的列表,底層對應的是 PyListObject;字典則對應 PyDictObject,等等等等。所以當我們在Python中創建一個列表,那么 CPython 在執行的時候,就會在底層創建一個 PyListObject。因為 CPython 是用C來實現的,最終肯定是將 Python 代碼翻譯成 C 級別的代碼,然后再變成機器碼交給 CPU 執行。而 Cython 也是如此,Cython 代碼也是要被翻譯成 C 代碼的,而實現這一點的就是 cython 編譯器(本質上是一個第三方模塊,所以同樣依賴於 CPython)。因此 Cython 是一門語言,它並不是Python 解釋器的另一種實現,它的地位和 CPython 不是等價的,不過和 Python 是平級的。
因此 Cython 是一門語言,可以通過 Cython 源代碼生成高效的擴展模塊,同樣需要 CPython 來進行調用。
比較一下 Python、C、C 擴展、Cython
我們以簡單的斐波那契數列為例,來測試一下它們執行效率的差異。
先是 Python 代碼:
def fib(n):
a, b = 0.0, 1.0
for i in range(n):
a, b = a + b, a
return a
正如上面提到的那樣,Python 函數是一個合法的 Cython 函數,上面的這個函數在 Python 和 Cython 中的表現是完全一致的,我們后面會看看如何使用 Cython 來提升性能。
double cfib(int n) {
int i;
double a=0.0, b=1.0, tmp;
for (i=0; i<n; ++i) {
tmp = a; a = a + b; b = tmp;
}
return a;
}
上面便是 C 實現的一個斐波那契數列,可能有人好奇為什么我們使用浮點型,而不是整型呢?答案是 C 中的整型是有范圍的,所以我們使用 double,而且 Python 中 float 在底層對應的是PyFloatObject、其內部也是通過 double 來存儲的。
然后是 C 擴展,注意:C 擴展不是我們的重點,寫 C 擴展和寫 Cython 本質是一樣的,都是為 Python 編寫擴展模塊,但是寫 Cython 絕對要比寫 C 擴展簡單的多。
static PyObject *
fib(PyObject *self, PyObject *n) {
if (!PyLong_CheckExact(n)) {
PyErr_Format(PyExc_ValueError, "function fib excepted int, not %s", Py_TYPE(n) -> tp_name);
return NULL;
}
PyObject *z;
double a = 0.0, b = 1.0, tmp;
int i;
for (i = 0; i < PyLong_AsLong(n); i++){
tmp = a; a = a + b; b = tmp;
}
z = PyFloat_FromDouble(a);
return z;
}
最后來看看如何使用 Cython 來編寫斐波那契,你覺得使用 Cython 編寫的代碼應該是一個什么樣子的呢?
def fib(int n):
cdef int i
cdef double a = 0.0, b = 1.0
for i in range(n):
a, b = a + b, a
return a
怎么樣,代碼和 Python 是不是很相似呢?雖然我們現在還沒有正式學習 Cython 的語法,但你也應該能夠猜到上面代碼的含義是什么。我們使用 cdef 關鍵字定義了一個 C 級別的變量,並聲明了它們的類型。
關鍵是為什么這樣就可以起到加速的效果呢(雖然還沒有測試,但速度肯定會提升的,否則就沒必要學 Cython 了),和純 Python 的斐波那契相比,我們看到區別貌似只是事先規定好了變量 i、a、b 的類型而已。但是原因就在這里,因為 Python 中所有的變量都是一個 PyObject *,在底層中就是 C 的一個指針。PyObject(C 的一個結構體)內部有兩個成員,分別是 ob_refcnt:保存對象的引用計數、ob_type *:保存對象類型的指針。不管是整型、字符串、元組、字典,亦或是其它的什么,所有指向它們的變量都是一個 PyObject*,當進行操作的時候,首先要通過
-> ob_type
來獲取對應的類型的指針,再進行轉化。比如:這里的 a 和 b,我們雖然知道無論進行哪一層循環,結果指向的都是浮點數,但是 Python 解釋器不會做這種推斷。每一次相加都要進行檢測,判斷到底是什么類型並進行轉化,然后執行加法的時候,再去找內部的 __add__ 方法,將兩個對象相加,創建一個新的對象,執行結束后再將這個新對象的指針轉成 PyObject *,然后返回。並且 Python 中的對象都是在堆上分配空間,再加上 a 和 b 不可變,所以每一次循環都會創建新的對象,並將之前的對象給回收掉。
以上種種都導致了 Python 代碼的執行效率不可能高,雖然 Python 也提供了內存池以及相應的緩存機制,但顯然還是架不住效率低。
關於 Cython 為什么能加速,Python為什么慢,我們后面會繼續嘮叨。
效率差異
那么它們之間的效率差異是什么樣的呢?我們用一個表格來對比一下:
提升的倍數,指的是相對於純 Python 來說在效率上提升了多少倍;第二列是fib(0),顯然它沒有真正進行循環,fib(0) 測量的是調用一個函數所需要花費的開銷;而倒數第二列 "循環體耗時" 指的是執行 fib(90) 的時候,排除函數調用本身的開銷,也就是執行內部循環體所花費的時間。
整體來看純 C 語言編寫的斐波那契,毫無疑問是最快的,但是這里面有很多值得思考的地方,我們來分析一下。
純 Python
眾望所歸,各方面都是表現最差的那一個。從 fib(0) 來看,調用一個函數要花 590 納秒。和 C 相比慢了這么多,原因就在於 Python 調用一個函數的時候需要創建一個棧幀,而這個棧幀是分配在堆上的,而且結束之后還要涉及棧幀的銷毀等等。至於 fib(90),顯然無需分析了。
純 C
顯然此時沒有和 Python 運行時的交互,因此消耗的性能最小。fib(0) 表明了,C調用一個函數,開銷只需要 2 納秒;fib(90) 則說明執行一個循環,C 比 Python 快了將近80倍。
C 擴展
C 擴展應該都聽說過,使用 C 來為 Python 編寫擴展模塊。我們看一下循環體耗時,發現 C 擴展和純 C 是差不多的,區別就是函數調用上花的時間比較多。原因就在於我們在調用擴展模塊的函數時,需要先將 Python 中的數據轉成 C 中的數據,然后在 C 計算斐波那契數列,計算完了再將 C 中的數據轉成 Python 中的數據。
所以 C 擴展本質也是 C 語言,只不過在編寫的時候遵循 Python 提供的 API 規范,可以將 C 代碼編譯成 pyd 文件,直接讓 Python 來調用。從結果上看,和 Cython 做的事情是比較類似的。但是還是那句話,用 C 寫擴展,本質上還是寫 C,而且還要熟悉底層的 Python/C API,難度是比較大的。
Cython
單獨看循環體耗時的話,我們看到純 C、C 擴展、Cython 都是差不多的,但是編寫 Cython 顯然是最方便的。而我們說 Cython 做的事情和 C 擴展本質是類似的,都是為 Python 提供擴展模塊,所以對於 Cython 來說,將 Python 的數據轉成 C 的數據、進行計算、然后再轉成 Python 中的數據返回,這一過程是無可避免的。但是我們看到 Cython 在函數調用時的耗時相比 C 擴展卻要少很多,主要是 Cython 生成的C代碼是經過高度優化的。不過說實話,函數的調用花的時間根本不需要關心,內部代碼塊執行所花的時間才是我們應該需要注意的。
Python 的 for 循環為什么這么慢?
通過循環體耗時我們看到,Python 的 for 循環真的是出了名的慢,那么原因是什么呢?我們來分析一下。
1. Python 的 for 循環機制
Python 在遍歷一個可迭代對象的時候,會先調用這個可迭代對象內部的__iter__ 方法返回其對應的迭代器,然后再不斷地調用這個迭代器的 __next__ 方法,將值一個一個的迭代出來,直到迭代器拋出 StopIteration 異常,for循環捕捉,終止循環。而迭代器是有狀態的,Python 解釋器需要時刻記錄迭代器的迭代狀態。
2. Python 的算數操作
這一點我們上面其實一定提到過了,Python 由於其動態特性,使得其無法做任何基於類型的優化。比如:循環體中的 a + b,這個 a、b 指向的可以是整數、浮點數、字符串、元組、列表,甚至是我們實現了魔法方法 __add__ 的類的實例對象,等等等等。盡管我們知道是浮點數,但是 Python 不會做這種假設,所以每一次執行 a + b 的時候,都會檢測其類型到底是什么?然后判斷內部是否有 __add__ 方法,以及兩者能不能相加,然后條件滿足的話再調用對應的 __add__ 方法,將 a 和 b 作為參數,將 a 和 b 指向的對象進行相加。計算出結果之后,再返回其指針轉成 PyObject * 返回。
而對於 C 和 Cython 來說,在創建變量的時候就實現規定了類型。就是這個類型,不是其它的,因此編譯之后的 a + b 只是一條簡單的機器指令。這對比下來,Python 尼瑪能不慢嗎。
3. Python中對象的內存分配
我們說 Python 中的對象是分配在堆上面的,因為 Python 中的對象本質上就是 C 中的 malloc 函數為結構體在堆區申請的一塊內存。我們知道在堆區進行內存的分配和釋放是需要付出很大的代價的,而棧則要小很多,並且它是由操作系統維護的,會自動回收,效率極高。而堆顯然沒有此待遇,而恰恰 Python 的對象都是分配在堆上的,盡管 Python 引入了內存池機制使得其在一定程度上避免了和操作系統的頻繁交互,並且還引入了小整數對象池以及針對字符串的intern機制。但事實上,當涉及到對象(任意對象、包括標量)的創建和銷毀時,都會增加動態分配內存、以及 Python 內存子系統的開銷。而 float 對象又是不可變的,因此每循環一次都會創建和銷毀一次,所以效率依舊是不高的。
而 Cython 分配的變量,這里是 a 和 b,它們就不再是指針了(我們說 Python 中的變量本質上都是一個指針),而是分配在棧上的雙精度浮點數。而棧上分配的效率遠遠高於堆,因此非常適合 for 循環,所以效率要比 Python 高很多。
所以在 for 循環方面,C 和 Cython 要比純 Python 快了一個數量級以上,這並不是奇怪的事情,因為 Python 每次迭代都要做很多的工作。
需要注意的點
我們看到只是在代碼中添加了幾個 cdef 就能獲得如此大的性能改進,顯然是非常讓人振奮的。但是,並非所有的 Python 代碼在使用 Cython 時,都能獲得巨大的性能改進。我們這里的斐波那契數列示例是刻意的,因為里面的數據是綁定在 CPU 上的,運行時都花費在處理 CPU 寄存器的一些變量上,而不需要進行數據的移動。如果此函數是內存密集(例如,給兩個大數組添加元素)、I/O 密集(例如,從磁盤讀取大文件)或網絡密集(例如,從 FTP 服務器下載文件),則 Python,C,Cython 之間的差異可能會顯著減少(對於存儲密集操作)或完全消失(對於 I/O 密集或網絡密集操作)。
當提升 Python 程序性能是我們的目標時,Pareto 原則對對我們幫助很大,即:程序百分之 80 的運行耗時是由百分之 20 的代碼引起的。但如果不進行仔細的分析,那么是很難找到這百分之 20 的代碼的。因此我們在使用 Cython 提升性能之前,分析整體業務邏輯是第一步。
如果我們通過分析之后,確定程序中的瓶頸是由網絡 IO 所導致的,那么我們就不能期望 Cython 可以帶來顯著的性能提升;因此在你使用 Cython 之前,是有必要先確定到底哪種原因導致程序中出現了瓶頸。因此,盡管 Cython 是一個強大的工具,但前提是它必須應用在正確的道路上。
因為 Cython 將 C 的類型系統引入進了 Python,所以 C 的數據類型的限制是我們需要關注的。我們知道,Python 的整數不受長度的限制,但是 C 中 int 和 long 是受到限制的,這意味着它們不能正確地表示無限精度的整數。但幸運的是,Cython 的一些特性可以幫助我們捕獲這些溢出。總之最重要的是:C 數據類型的速度比 Python 數據類型快,但是會受到限制導致其不夠靈活和通用。
從這里我們也能看出,在速度以及靈活性、通用性上面,Python 選擇了后者。
此外,思考一下 Cython 的另一個特性:連接外部代碼。假設,我們的起點不是Python 而是 C 或者 C++,我們希望使用 Python 將多個 C 或者 C++ 進行連接。而 Cython 理解 C 和 C++ 的聲明,並且它能生成高度優化的代碼,因此更適合作為連接的橋梁。
由於我本人是主 Python 的,如果涉及到 C、C++,我們都是介紹如何在 Cython 中引入 C、C++,直接調用已經寫好的 C 庫;而不會介紹如何在 C、C++ 中引入 Cython,來作為連接多個 C、C++ 模塊的橋梁。這一點望理解,因為本人不用 C、C++ 編寫服務,只會用它們來輔助 Python 提高效率。
使用 Cython 來包裝 C 代碼
我們之前用 C 實現了一個斐波那契數列,那么我們可以直接在 Cython 中進行調用。下面看看怎么做?
// fib.h
double cfib(int n);
// fib.c
double cfib(int n) {
int i;
double a=0.0, b=1.0, tmp;
for (i=0; i<n; ++i) {
tmp = a; a = a + b; b = tmp;
}
return a;
}
一個頭文件 fib.h,里面是函數聲明;一個源文件 fib.c,里面是函數實現,那么我們看看如何在 Cython 中訪問它們。
cdef extern from "cfib.h":
double cfib(int n)
def fib(n):
# 調用 C 實現的斐波那契函數
return cfib(n)
cdef extern from 可以看成是導入頭文件,內部相當於定義要使用的 C 函數。整體還是比較簡單的,當然我們目前只是簡單介紹了一下。至於 Cython 更詳細的語法,以及 Cython 代碼如何編譯成 pyd 文件、引入了 C 代碼的 Cython 代碼又如何編譯成 pyd 文件,我們會后續系列介紹。
小結
我們當前只是介紹了一下 Cython,並且主要討論了它的定位,以及和 Python、C 之間的差異。至於如何使用 Cython 加速 Python,以及如何編寫 Cython 代碼,我們將會后續介紹。
總之,Cython 是一門成熟的語言,它是為 Python 而服務的。Cython 代碼不能夠直接拿來執行,我們使用 Cython 的方式是:先將 Cython 代碼編譯成擴展模塊(pyd 文件),然后在 Python 代碼中導入它、調用里面的功能方法,這是我們使用 Cython 的正確途徑、當然也是唯一的途徑。我們上面用 Cython 編寫的斐波那契,如果直接執行的話是會報錯的,因為 cdef 明顯不符合 Python 的語法規則。所以 Cython 代碼需要編譯成擴展模塊,然后在普通的 py 文件中被導入,而這么做的意義就在於可以提升運行速度。因此 Cython 代碼應該都是一些 CPU 密集型的代碼,不然效率很難得到大幅度提升。
因此在我們使用 Cython 之前,最好先仔細分析一下業務邏輯,或者你暫時先不用 Cython,直接完全使用 Python 編寫。編寫完成之后開始測試、分析程序的性能,看看有哪些地方耗時比較嚴重,但同時又是可以通過靜態類型的方式進行優化的。找出它們,使用 Cython 進行重寫,編譯成擴展模塊,然后調用擴展模塊里面的功能。
后續系列我們來介紹更多關於 Cython 的內容。