首頁 >後端開發 >Python教學 >99%的人都不知道! Python、C、C 擴充、Cython 差異對比!

99%的人都不知道! Python、C、C 擴充、Cython 差異對比!

WBOY
WBOY轉載
2023-04-14 17:40:032004瀏覽

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 為什麼能加速,我們後面會慢慢聊。

效率差異

那麼它們之間的效率差異是什麼樣的呢?我們用一個表格來比較一下:

99%的人都不知道! Python、C、C 擴充、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 進行重寫,編譯成擴充模組,然後呼叫擴充模組裡面的功能。

以上是99%的人都不知道! Python、C、C 擴充、Cython 差異對比!的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:51cto.com。如有侵權,請聯絡admin@php.cn刪除