99%的人都不知道!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
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;
}
上面便是 C 實現的一個斐波那契數列,可能有人好奇為什么我們使用浮點型,而不是整型呢?答案是 C 的整型是有范圍的,所以我們使用 double,而且 Python 的 float 在底層對應的是 PyFloatObject、其內部也是通過 double 來存儲的。
C 擴展:
然后是 C 擴展,注意:C 擴展不是我們的重點,寫 C 擴展和寫 Cython 本質是一樣的,都是為 Python 編寫擴展模塊,但是寫 Cython 絕對要比寫 C 擴展簡單的多。
#include "Python.h"
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;
}
static PyObject *fib(PyObject *self, PyObject *n) {
if (!PyLong_CheckExact(n)) {
wchar_t *error = L"函數 fib 需要接收一個整數";
PyErr_SetObject(PyExc_ValueError,
PyUnicode_FromWideChar(error, wcslen(error)));
return NULL;
}
double result = cfib(PyLong_AsLong(n));
return PyFloat_FromDouble(result);
}
static PyMethodDef methods[] = {
{"fib",
(PyCFunction) fib,
METH_O,
"這是 fib 函數"},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"c_extension",
"這是模塊 c_extension",
-1,
methods,
NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC PyInit_c_extension(void) {
return PyModule_Create(&module);
}
可以看到,如果是寫 C 擴展,即便一個簡單的斐波那契,都是非常復雜的事情。
Cython 代碼:
最后看看如何使用 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
怎么樣,Cython 代碼和 Python 代碼是不是很相似呢?雖然我們現在還沒有正式學習 Cython 的語法,但你也應該能夠猜到上面代碼的含義是什么。我們使用 cdef 關鍵字定義了一個 C 級別的變量,并聲明了它們的類型。
Cython 代碼也是要編譯成擴展模塊之后,才能被解釋器識別,所以它需要先被翻譯成 C 的代碼,然后再編譯成擴展模塊。再次說明,寫 C 擴展和寫 Cython 本質上沒有什么區別,Cython 代碼也是要被翻譯成 C 代碼的。
但很明顯,寫 Cython 比寫 C 擴展要簡單很多,如果編寫的 Cython 代碼質量很高,那么翻譯出來的 C 代碼的質量同樣很高,而且在翻譯的過程中還會自動進行最大程度的優化。但如果是手寫 C 擴展,那么一切優化都要開發者手動去處理,更何況在功能復雜的時候,寫 C 擴展本身就是一件讓人頭疼的事情。
Cython 為什么能夠加速?
觀察一下 Cython 代碼,和純 Python 的斐波那契相比,我們看到區別貌似只是事先規定好了變量 i、a、b 的類型而已,關鍵是為什么這樣就可以起到加速的效果呢(雖然還沒有測試,但速度肯定會提升的,否則就沒必要學 Cython 了)。
但是原因就在這里,因為 Python 中所有的變量都是一個泛型指針 PyObject *。PyObject(C 的一個結構體)內部有兩個成員,分別是 ob_refcnt:保存對象的引用計數、ob_type *:保存對象類型的指針。
不管是整數、浮點數、字符串、元組、字典,亦或是其它的什么,所有指向它們的變量都是一個 PyObject *。當進行操作的時候,首先要通過 -> ob_type 來獲取對應類型的指針,再進行轉化。
比如 Python 代碼中的 a 和 b,我們知道無論進行哪一層循環,結果指向的都是浮點數,但是解釋器不會做這種推斷。每一次相加都要進行檢測,判斷到底是什么類型并進行轉化;然后執行加法的時候,再去找內部的 __add__ 方法,將兩個對象相加,創建一個新的對象;執行結束后再將這個新對象的指針轉成 PyObject *,然后返回。
并且 Python 的對象都是在堆上分配空間,再加上 a 和 b 不可變,所以每一次循環都會創建新的對象,并將之前的對象給回收掉。
以上種種都導致了 Python 代碼的執行效率不可能高,雖然 Python 也提供了內存池以及相應的緩存機制,但顯然還是架不住效率低。
至于 Cython 為什么能加速,我們后面會慢慢聊。
效率差異
那么它們之間的效率差異是什么樣的呢?我們用一個表格來對比一下:
提升的倍數,指的是相對于純 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 語言,只不過在編寫的時候還需要遵循 CPython 提供的 API 規范,這樣就可以將 C 代碼編譯成 pyd 文件,直接讓 Python 來調用。從結果上看,和 Cython 做的事情是一樣的。但是還是那句話,用 C 寫擴展,本質上還是寫 C,而且還要熟悉底層的 Python/C API,難度是比較大的。
Cython
單獨看循環體耗時的話,純 C 、C 擴展、Cython 都是差不多的,但是編寫 Cython 顯然是最方便的。而我們說 Cython 做的事情和 C 擴展本質是類似的,都是為 Python 提供擴展模塊,區別就在于:一個是手動寫 C 代碼,另一個是編寫 Cython 代碼、然后再自動翻譯成 C 代碼。所以對于 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__ 方法,有的話則以 a 和 b 為參數進行調用,將 a 和 b 指向的對象相加。計算出結果之后,再將其指針轉成 PyObject * 返回。
而對于 C 和 Cython 來說,在創建變量的時候就事先規定了類型為 double,不是其它的,因此編譯之后的 a + b 只是一條簡單的機器指令。這對比下來,Python 尼瑪能不慢嗎。
3. Python 對象的內存分配
Python 的對象是分配在堆上面的,因為 Python 對象本質上就是 C 的 malloc 函數為結構體在堆區申請的一塊內存。在堆區進行內存的分配和釋放需要付出很大的代價,而棧則要小很多,并且它是由操作系統維護的,會自動回收,效率極高,棧上內存的分配和釋放只是動一動寄存器而已。
但堆顯然沒有此待遇,而恰恰 Python 的對象都分配在堆上,盡管 Python 引入了內存池機制使得其在一定程度上避免了和操作系統的頻繁交互,并且還引入了小整數對象池、字符串的intern機制,以及緩存池等。
但事實上,當涉及到對象(任意對象、包括標量)的創建和銷毀時,都會增加動態分配內存、以及 Python 內存子系統的開銷。而 float 對象又是不可變的,因此每循環一次都會創建和銷毀一次,所以效率依舊是不高的。
而 Cython 分配的變量(當類型是 C 里面的類型時),它們就不再是指針了(Python 的變量都是指針),對于當前的 a 和 b 而言就是分配在棧上的雙精度浮點數。而棧上分配的效率遠遠高于堆,因此非常適合 for 循環,所以效率要比 Python 高很多。另外不光是分配,在尋址的時候,棧也要比堆更高效。
所以在 for 循環方面,C 和 Cython 要比純 Python 快了幾個數量級,這并不是奇怪的事情,因為 Python 每次迭代都要做很多的工作。
什么時候使用 Cython?
我們看到在 Cython 代碼中,只是添加了幾個 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 的整數是受到限制的,這意味著它們不能正確地表示無限精度的整數。
不過 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,并且主要討論了它的定位,以及和 Python、C 之間的差異。至于如何使用 Cython 加速 Python,如何編寫 Cython 代碼、以及它的詳細語法,我們將會后續介紹。
總之,Cython 是一門成熟的語言,它是為 Python 而服務的。Cython 代碼不能夠直接拿來執行,因為它不符合 Python 的語法規則。
我們使用 Cython 的方式是:先將 Cython 代碼翻譯成 C 代碼,再將 C 代碼編譯成擴展模塊(pyd 文件),然后在 Python 代碼中導入它、調用里面的功能方法,這是我們使用 Cython 的正確途徑、當然也是唯一的途徑。
比如我們上面用 Cython 編寫的斐波那契,如果直接執行的話是會報錯的,因為 cdef 明顯不符合 Python 的語法規則。所以 Cython 代碼需要編譯成擴展模塊,然后在普通的 py 文件中被導入,而這么做的意義就在于可以提升運行速度。因此 Cython 代碼應該都是一些 CPU 密集型的代碼,不然效率很難得到大幅度提升。
所以在使用 Cython 之前,最好先仔細分析一下業務邏輯,或者暫時先不用 Cython,直接完全使用 Python 編寫。編寫完成之后開始測試、分析程序的性能,看看有哪些地方耗時比較嚴重,但同時又是可以通過靜態類型的方式進行優化的。找出它們,使用 Cython 進行重寫,編譯成擴展模塊,然后調用擴展模塊里面的功能。