楔子
Python 和 C、C++ 之間一個最重要的差異就是 Python 是解釋型語言,而 C、C++ 是編譯型語言。如果開發 Python 程序,那么在修改代碼之后可以立刻運行,而 C、C++ 則需要一個編譯步驟。編譯一個規模比較大的 C、C++ 程序,那么可能會花費我們幾個小時甚至幾天的時間;而使用Python則可以讓我們進行更敏捷的開發,從而更具有生產效率。
而 Cython 同 C、C++ 類似,在源代碼運行之前也需要一個編譯的步驟,不過這個編譯可以是隱式的,也可以是顯式的。而自動編譯 Cython 的一個很棒的特性就是它使用起來和純 Python 是差不多的,無論是顯式還是隱式,我們都可以將 Python 的一部分(計算密集)使用 Cython 重寫,因此 Cython 的編譯需求可以達到最小化。因為沒有必要將所有的代碼都用 Cython 編寫,而是將那些需要優化的代碼使用 Cython 編寫即可。
在這一篇博客中,我們將會介紹編譯 Cython 代碼的幾種方式,並結合 Python 使用。因為我們說 Cython 是為 Python 提供擴展模塊,最終還是要通過 Python 解釋器來調用的。
而編譯Cython有以下幾個選擇:
Cython 代碼可以在 IPython 解釋器中進行編譯,並交互式運行。
Cython 代碼可以在導入的時候自動編譯。
Cython 代碼可以通過類似於 Python 的 disutils 模塊的編譯工具進行獨立編譯。
Cython代碼可以被繼承到標准的編譯系統,例如:make、CMake、SCons。
這些選擇可以讓我們在幾個特定的場景應用 Cython,從一端的快速交互式探索到另一端的快速構建。
但無論是哪一種編譯方式,從傳遞 Cython 代碼到生成 Python 可以導入和使用的擴展模塊都需要經歷兩個步驟。在我們討論每種編譯方式的細節之前,了解一下這兩個步驟到底在做些什么是很有幫助的。
Cython編譯 Pipeline
因為 Cython 是 Python 的超集,所以 Python 解釋器無法直接運行 Cython 的代碼,那么如何才能將 Cython 代碼變成 Python 解釋器可以識別的有效代碼呢?答案是通過 Cython 編譯 Pipeline。
Pipeline 的職責就是將 Cython 代碼轉換成 Python 解釋器可以直接導入並使用的 Python 擴展模塊,這個 Pipeline 可以在不受用戶干預的情況下自動運行(使 Cython 感覺像 Python 一樣),也可以在需要更多控制時由用戶顯式的運行。
Pipeline 由兩步組成:第一步是由 cython 編譯器負責將 Cython 轉換成經過優化並且依賴當前平台的 C、C++ 代碼;第二步是使用標准的 C、C++ 編譯器將第一步得到的 C、C++ 代碼進行編譯並生成標准的擴展模塊,並且這個擴展模塊是依賴特定的平台的。如果是在 Linux 或者 Mac OS,那么得到的擴展模塊的后綴名為 .so,如果是在 Windows 平台,那么得到的擴展模塊的后綴名為 .pyd(擴展模塊 .pyd 本質上是一個 DLL 文件)。不管是什么平台,最終得到的都會是一個成熟的 Python 擴展模塊,它是可以直接被 Python 解釋器進行 import 的。
而工具在管理這幾個步驟所面臨的復雜性,我們都會在這一篇博客的結尾進行描述。盡管在編譯 Pipeline 運行的時候我們很少去關注究竟發生了什么,但是將這些過程記在腦海總歸是好的。
Cython編譯器是一種 源到源 的編譯器,並且生成的擴展模塊也是經過高度優化的,因此 Cython 生成的 C 代碼編譯得到的擴展模塊 比 手寫的 C 代碼編譯得到的擴展模塊 運行的要快並不是一件稀奇的事情。因為 Cython 生成的 C 代碼是經過高度精煉,所以大部分情況下比手寫所使用的算法更優,而且 Cython 生成的 C 代碼支持所有的通用 C 編譯器,生成的擴展模塊同時支持許多不同的 Python 版本。
所以 Cython 和 C 擴展本質上干的事情是一樣的,都是將符合 Python/C API 的 C 代碼編譯成 Python 擴展模塊,只不過寫 Cython 的話我們不需要直接面對 C,cython 編譯器會自動將 Cython 代碼翻譯成 C 代碼,然后我們再將其編譯成擴展模塊。所以兩者本質是一樣的,只不過 C 比較復雜,而且難編程;但是 Cython 簡單,語法本來就和 Python 很相似,所以我們選擇編寫 Cython,然后讓 cython 編譯器幫我們把 Cython 代碼翻譯成 C 的代碼。而且重點是,cython 編譯器是經過優化的,如果我們能寫出很棒的 Cython 代碼,那么 cython 編譯器在編譯之后就會得到同樣高質量的 C 代碼。
安裝
現在我們知道在編譯 Pipeline 中有兩個步驟,而實現這兩個步驟需要我們確保機器上有 C、C++ 編譯器以及 Cython 編譯器,不同的平台有不同的選擇。
C、C++編譯器
Linux 和 Mac OS 無需多說,因為它們都自帶 gcc,但是注意:如果是 Linux 的話,我們還需要 yum install python3-devel(以 CentOS 為例)。至於 Windows,可以下載一個 Visual Studio,但是那個玩意會比較大,如果不想下載 vs 的話,那么可以選擇安裝一個 MinGW 並設置到環境變量中,至於下載方式可以去 https://sourceforge.net/projects/mingw/files/
進行下載。
當然環境什么的,這里就不細說了,如果這一點都無法解決的話,根本就入不了 Cython 的大門。
安裝 cython 編譯器
安裝 cython 編譯器的話,可以直接通過 pip install cython 即可。因此我們看到 cython 編譯器只是 Python 的一個第三方包,因此運行 Cython 代碼同樣要借助 Python 解釋器。
在終端中輸入 cython -V,看看是否會提示 cython 的版本,如果正常顯示,那么證明安裝成功。
或者寫代碼查看:
from Cython import __version__
print(__version__) # 0.29.14
如果代碼正常執行,那么證明安裝成功。
disutils
Python 有一個標准庫 disutils,可以用來構建、打包、分發 Python 工程。而其中一個對我們有用的特性就是它可以借助 C 編譯器將 C 源碼編譯成擴展模塊,並且這個模塊是自帶的、考慮了平台、架構、Python 版本等因素,因此我們在任意地方使用disutils都可以得到擴展模塊。
注意:上面 disutils 只是幫我們完成了 Pipeline 的第二步,那第一步呢?第一步則是需要 cython 來完成。
以我們之前說的斐波那契數列為栗:
# fib.pyx
def fib(n):
"""這是一個擴展模塊"""
cdef int i
cdef double a=0.0, b=1.0
for i in range(n):
a, b = a + b, a
return a
然后我們對其進行編譯:
from distutils.core import setup
from Cython.Build import cythonize
# 我們說構建擴展模塊的過程分為兩步: 1. 將 Cython 代碼翻譯成 C 代碼; 2. 根據 C 代碼生成擴展模塊
# 而第一步要由 cython 編譯器完成, 通過 cythonize; 第二步要由 distutils 完成, 通過 distutils.core 下的 setup
setup(ext_modules=cythonize("fib.pyx", language=3))
# 里面的 language=3 表示只需要兼容 python3 即可, 而默認是 2 和 3 都兼容
# 強烈建議加上這個參數, 因為目前為止我們只需要考慮 python3 即可
# cythonize 負責將 Cython 代碼轉成 C 代碼, 這里我們可以傳入單個文件, 也可以是多個文件組成的列表
# 或者一個glob模式, 會匹配滿足模式的所有 Cython 文件; 然后 setup 根據 C 代碼生成擴展模塊
這個文件叫做 1.py,這里只是做了准備,但是還沒有進行編譯。我們需要終端執行 python 1.py build 進行編譯。
在我們執行命令之后,當前目錄會多出一個 build 目錄,里面的結構如下。重點是那個 fib.cp38-win_amd64.pyd 文件,該文件就是根據 fib.pyx 生成的擴展模塊,至於其它的可以直接刪掉了。我們把這個文件單獨拿出來測試一下:
import fib
# 我們看到該 pyd 文件直接就被導入了, 至於中間的 cp38-win_amd64 指的是對應的解釋器版本、操作系統等信息
print(fib) # <module 'fib' from 'D:\\satori\\fib.cp38-win_amd64.pyd'>
try:
# 我們在里面定義了一個 fib 函數, 在 fib.pyx 里面定義的函數在編譯成擴展模塊之后可以直接使用
print(fib.fib("xx"))
except Exception:
import traceback
print(traceback.format_exc())
"""
Traceback (most recent call last):
File "D:/satori/1.py", line 7, in <module>
print(fib.fib("xx"))
File "fib.pyx", line 6, in fib.fib
for i in range(n):
TypeError: an integer is required
"""
# 因為我們定義的是fib(int n), 而傳入的不是整型, 所以直接報錯
print(fib.fib(20)) # 6765.0
# 我們的注釋
print(fib.fib.__doc__) # 這是一個擴展模塊
我們在 Linux 上再測試一下,代碼以及編譯方式都不需要改變,並且生成的動態庫的位置也不變。
>>> import fib
>>> fib
<module 'fib' from '/root/fib.cpython-36m-x86_64-linux-gnu.so'>
>>> exit()
我們看到依舊是可以導入的,只不過 Linux 上是 .so 的形式,Windows 上是 .pyd。因此我們可以看出,所謂 Python 的擴展模塊,本質上就是當前操作系統上一個動態庫,只不過生成該動態庫對應的 C 源文件遵循標准的 Python/C API,所以它是可以被 Python 解釋器識別、直接通過 import 語句進行導入的,就像導入普通的 py 文件一樣。而對於其它的動態庫,比如 Linux 中存在大量的動態庫(.so文件),而它們則不一定是由遵循標准 Python/C API 的 C 文件生成的,所以此時再通過 import 進行導入的話解釋器是無法識別的,如果 Python 想調用這樣的動態庫就需要使用 ctypes 模塊了。
引入 C 源文件
除此之外我們還可以嵌入 C、C++ 的代碼,我們來看一下。
// cfib.h
double cfib(int n); // 定義一個函數聲明
//cfib.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;
} // 函數體的實現
然后是 pyx 文件:
# 通過 cdef extern from 導入頭文件, 寫上里面的函數
cdef extern from "cfib.h":
double cfib(int n)
# 然后 Cython 可以直接調用
def fib_with_c(n):
"""調用 C 編寫的斐波那契數列"""
return cfib(n)
最后是編譯:
from distutils.core import setup, Extension
from Cython.Build import cythonize
# 我們看到之前是直接往 cythonize 里面傳入一個文件名即可
# 但是現在我們傳入了一個 Extension 對象, 通過 Extension 對象的方式可以實現更多功能
# 這里指定的 name 表示編譯之后的文件名, 顯然編譯之后會得到 wrapper_cfib.cp38-win_amd64.pyd
# 如果是之前的方式, 那么得到的就是 fib.cp38-win_amd64.pyd, 默認會和 .pyx 文件名保持一致, 這里我們可以自己指定
# sources 則是代表源文件, 這里我們只需要指定 pyx 和 c 源文件即可, 因為頭文件也在同一個目錄中
# 如果不在, 那么還需要通過 include_dirs 指定頭文件的所在目錄, 不然 extern from "cfib.h" 就報錯了
ext = Extension(name="wrapper_cfib", sources=["fib.pyx", "cfib.c"])
setup(ext_modules=cythonize(ext))
然后我們來調用一下:
import wrapper_cfib
print(wrapper_cfib.fib_with_c(20)) # 6765.0
print(wrapper_cfib.fib_with_c.__doc__) # 調用 C 編寫的斐波那契數列
我們看到成功調用 C 編寫的斐波那契數列,這里我們使用了一種新的創建擴展模塊的方法,我們來總結一下。
如果是單個 pyx 文件的話, 那么直接通過 cythonize("xxx.pyx") 即可
如果 pyx 文件還引入了 C 文件, 那么通過 cythonize(Extension(name="xx", sources=["", ""])) 的方式即可; name 是編譯之后的擴展模塊的名字, sources 是你要編譯的源文件, 我們這里是一個 pyx 文件一個 C 文件;
建議后續都使用第二種方式,可定制性更強,而且我們之前使用的 cythonize("fib.pyx")
完全可以用 cythonize(Extension("fib", ["fib.pyx"]))
進行替代。
關於使用 Cython 包裝 C、C++ 代碼的更詳細細節,我們會在后續系列中詳細介紹,總之我們編譯的時候相應的源文件是不能少的。
通過 IPython 動態交互 Cython
使用 distutils 編譯 Cython 代碼可以讓我們控制每一步的執行過程,當時也意味着我們在使用之前必須要先經過獨立的編譯,不涉及到交互式。而 Python 的一大特性就是交互式,比如 IPython,所以需要想個法子讓 Cython 也支持交互式,而實現的辦法就是使用魔法命令。
我們打開 IPython,在上面演示一下。
# 我們在 IPython 上運行,執行 %load_ext cython 便會加載 Cython 的一些魔法函數
In [1]: %load_ext cython
# 然后神奇的一幕出現了,加上一個魔法命令,就可以直接寫 Cython 代碼
In [2]: %%cython
...: def fib(int n):
...: """這是一個 Cython 函數,在 IPython 上編寫"""
...: cdef int i
...: cdef double a = 0.0, b = 1.0
...: for i in range(n):
...: a, b = a + b, a
...: return a
# 測試用時,平均花費82.6ns
In [6]: %timeit fib(50)
82.6 ns ± 0.677 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
注意:以上同樣涉及到編譯成擴展模塊的過程。
首先 IPython 中存在一些魔法命令,這些命令以一個或兩個百分號開頭,它們提供了普通 Python 解釋器所不能提供的功能。%load_ext cython 會加載 cython 的一些魔法函數,如果執行成功將不會有任何的輸出。然后重點來了,%%cython 允許我們在 IPython 解釋器中直接編寫 Cython 代碼,當我們按下兩次回車時,顯然這個代碼塊就結束了。但是里面的 Cython 代碼會被 copy 到名字唯一的 .pyx 文件中,並將其編譯成擴展模塊,編譯成功之后 IPython 會再將該模塊內的所有內容導入到當前的環境中,以便我們使用。
因此上述的編譯過程、編譯完成之后的導入過程,都是我們在按下兩次回車鍵之后自動發生的。但是不管怎么樣,它都涉及到編譯成擴展模塊的過程,包括后面要說的即時編譯,只不過這一步不需要你手動做了。
當然相比 IPython,我們更常用 jupyter notbook,既然 Cython 在前者中可以使用,那么后者肯定也是可以的。
jupyter notebook 底層也是使用了 IPython,所以它的原理和 IPython 是等價的,會先將代碼塊 copy 到名字唯一的 .pyx 文件中,然后進行編譯。編譯完畢之后再將里面的內容導入進來,而第二次編譯的時候由於單元格里面的內容沒有變化,所以不再進行編譯了。
另外在編譯的時候如果指定了 --annotate 選項,那么還可以看到對應的代碼分析。
可以看到還是非常強大的,尤其是在和 jupyter 結合之后,真的非常方便。
使用 pximport 即時編譯
因為 Cython 是以 Python 為中心的,所以我們希望 Python 解釋器在導包的時候能夠自動識別 Cython 文件,導入 Cython 就像導入常規、動態的 Python 文件一樣。但是不好意思,Python 在導包的時候並不會自動識別以 .pyx 結尾的文件,但是我們可以通過 pyximport 來改變這一點。
# fib.pyx
def foo(int a, int b):
return a + b
# 2.py
import pyximport
# 這里同樣指定 language_level=3, 則表示針對的是py3, 因為這種方式也是要編譯的
pyximport.install(language_level=3)
# 執行完之后, Python 解釋器在導包的時候就會識別 Cython 文件了, 當然會先進行編譯
import fib
print(fib.foo(11, 22)) # 33
正如我們上面演示的那樣,使用 pyximport 可以讓我們省去 cythonize 和 distutils 這兩個步驟(注意:這兩個步驟還是存在的,只是不用我們做了)。另外, Cython 源文件不會立刻編譯,只有當被導入的時候才會編譯,並且即便后續 Cython 源文件被修改了,pyximport 也會自動檢測,當重新導入的時候也會再度重新編譯,機制就和 Python 的 pyc 文件是一個道理。
自動編譯之后的 pyd 文件位於 ~/.pyxbld/lib.xxx
中。
但是這樣有一個弊端,我們說 pyx 文件並不是直接導入的,而是在導入之前先有一個編譯成擴展模塊的步驟,然后導入的是這個擴展模塊,只不過這一步驟不需要我們手動來做了。所以它要求你的當前環境中有一個 cython 編譯器以及合適的 C 編譯器,而這些環境是不受控制的,沒准哪天就編譯失敗了。因此最保險的方式還是使用我們之前說的 distutils,先編譯成擴展模塊(.pyd 或者 .so),然后再放在生產模式中使用。
但是問題來了,如果包含 Cython 文件中還引入了其它的 C 文件該怎么辦呢?還以我們之前的斐波那契數列數列為例:
// cfib.h
double cfib(int n); // 定義一個函數聲明
//cfib.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.pyx 中導入它:
cdef extern from "cfib.h":
double cfib(int n)
def fib_with_c(n):
return cfib(n)
那么問題來了,這個時候如果導入 fib 會發生什么后果呢?答案是報錯,因為它不知道該去哪里尋找這些外部文件,而顯然這些文件應該是要鏈接在一起的。那么要如何做呢?就是我們下面要說的問題了。
控制 pyximport 並管理依賴
我們說手動編譯的時候,我們可以直接指定依賴的 C 文件的位置,但是直接導入 .pyx 文件的時候是並不知道這些依賴在哪里的。所以我們應該還要定義一個 .pyxbld 文件,.pyxbld 文件要和 .pyx 文件具有相同的基名,比如我們是為了指定 fib.pyx 文件的依賴,那么 .pyxbld 文件就應該叫做 fib.pyxbld,並且它們要位於同一目錄中。
那么這個 .pyxbld 文件里面應該寫什么內容呢?
# fib.pyxbld
from distutils.extension import Extension
def make_ext(modname, pyxfilename):
"""
如果 .pyxbld 文件中定義了這個函數, 那么在編譯之前會進行調用, 並自動往里面進行傳參
modname 是編譯之后的擴展模塊名, 顯然這里就是 fib
pyxfilename 是編譯的 .pyx 文件, 顯然是 fib.pyx, 注意: .pyx 和 .pyxbld 要具有相同的基名稱
然后它要返回一個我們之前說的 Extension 對象
:param modname:
:param pyxfilename:
:return:
"""
return Extension(modname,
sources=[pyxfilename, "cfib.c"],
# include_dir 表示在當前目錄中尋找頭文件
include_dirs=["."])
# 我們看到整體還是類似的邏輯, 因為編譯這一步是怎么也繞不過去的
# 區別就是手動編譯還是自動編譯, 如果是自動編譯, 顯然限制會比較多
# 如果想解除限制, 那么我們看到這和手動編譯沒啥區別了, 甚至還要更麻煩一些
此時我們再來直接導入看看,會不會得到正確的結果。
import pyximport
pyximport.install(language_level=3)
import fib
print(fib.fib_with_c(50)) # 12586269025.0
.pyxbld 文件中除了通過定義 make_ext 函數的方式外,還可以定義 make_setup_args 函數。對於 make_ext 函數,我們說在編譯的時候會自動傳遞兩個參數:modname、pyxfilename。但如果定義的是 make_setup_args 函數,那么在編譯時就不會傳遞任何參數,一些都由你自己決定。
但這里還有一個問題,首先 Cython 源文件一旦改變了,那么再導入的時候就會重新編譯;但如果 Cython 源文件(.pyx)依賴的 C 文件改變了呢?這個時候導入的話還會自動重新編譯嗎?答案是會的(沒想到吧),cython 編譯器不僅會檢測 Cython 文件的變化,還會檢測它依賴的 C 文件的變化。
我們將 fib.c 中的函數 cfib 的返回值加上 1.1,然后其它條件不變,看看結果如何。
import pyximport
pyximport.install(language_level=3)
import fib
print(fib.fib_with_c(50)) # 12586269026.1
可以看到結果變了,之前的話還需要定義一個具有相同基名的 .pyxdeps 文件,來指定 .pyx 文件具有哪些依賴,但是目前不需要了,也會自動檢測依賴文件的變化。
但是說實話,像這種依賴 C 文件的情況,建議還是事先編譯好,這樣才能百分百穩定運行。當然如果你部署服務的環境具備編譯條件,那么也可以不用提前編譯。
小結
目前我們介紹了如何將 pyx 文件編譯成擴展模塊,對於一個簡單的 pyx 文件來說,方法如下:
from distutils.core import setup, Extension
from Cython.Build import cythonize
# 推薦以后就使用這種方法
ext = Extension(
name="wrapper_fib", # 生成的擴展模塊的名字
sources=["fib.pyx"], # 源文件
)
setup(ext_modules=cythonize(ext, language_level=3)) # 指定Python3
如果還有其它需求,那么就通過其它參數指定,這些上面都說過了。
此外還可以通過 pyximport 自動編譯,我們后面在學習 Cython 語法的時候,就采用這種自動編譯的方式了。因為方便,不需要我們每次都來手動編譯,但是要將服務放在生產環境中,建議還是提前編譯好。
那么下一篇博客將來學習 Cython 的語法知識,看看如何才能寫出健壯的 Cython 代碼。