當人們提到 Python 的時候侈离,經常會說到下面兩個優(yōu)點:
- 寫起來方便
- 容易調用 C/C++ 的庫
然而實際上,第一點是以巨慢的執(zhí)行速度為代價的滥壕,而第二點也需要庫本身按照 Python 的規(guī)范使用 Python API而账、導出相應的符號性雄。
在天壤實習的時候,跟 Cython 打了不少交道涕侈,覺得這個工具雖然 Bug 多多沪停,寫的時候也有些用戶體驗不好的地方,但已經能極大提高速度和方便調用 C/C++裳涛,還是非常不錯的牙甫。這里就給大家簡單介紹一下 Cython(注意區(qū)別于 CPython)。Cython 可以讓我們方便地:
- 用 Python 的語法混合編寫 Python 和 C/C++ 代碼调违,提升 Python 速度
- 調用 C/C++ 代碼
例子:矩陣乘法
假設我們現在正在編寫一個很簡單的矩陣乘法代碼窟哺,其中矩陣是保存在 numpy.ndarray 中。Python 代碼可以這么寫:
# dot_python.py
import numpy as np
def naive_dot(a, b):
if a.shape[1] != b.shape[0]:
raise ValueError('shape not matched')
n, p, m = a.shape[0], a.shape[1], b.shape[1]
c = np.zeros((n, m), dtype=np.float32)
for i in xrange(n):
for j in xrange(m):
s = 0
for k in xrange(p):
s += a[i, k] * b[k, j]
c[i, j] = s
return c
不用猜也知道這比起 C/C++ 寫的要慢的不少技肩。我們感興趣的是且轨,怎么用 Cython 加速這個程序。我們先上 Cython 程序代碼:
# dot_cython.pyx
import numpy as np
cimport numpy as np
cimport cython
@cython.boundscheck(False)
@cython.wraparound(False)
cdef np.ndarray[np.float32_t, ndim=2] _naive_dot(np.ndarray[np.float32_t, ndim=2] a, np.ndarray[np.float32_t, ndim=2] b):
cdef np.ndarray[np.float32_t, ndim=2] c
cdef int n, p, m
cdef np.float32_t s
if a.shape[1] != b.shape[0]:
raise ValueError('shape not matched')
n, p, m = a.shape[0], a.shape[1], b.shape[1]
c = np.zeros((n, m), dtype=np.float32)
for i in xrange(n):
for j in xrange(m):
s = 0
for k in xrange(p):
s += a[i, k] * b[k, j]
c[i, j] = s
return c
def naive_dot(a, b):
return _naive_dot(a, b)
可以看到這個程序和 Python 寫的幾乎差不多虚婿。我們來看看不一樣部分:
Cython 程序的擴展名是 .pyx
cimport 是 Cython 中用來引入 .pxd 文件的命令旋奢。有關 .pxd 文件,可以簡單理解成 C/C++ 中用來寫聲明的頭文件然痊,更具體的我會在后面寫到至朗。這里引入的兩個是 Cython 預置的。
@cython.boundscheck(False) 和 @cython.wraparound(False) 兩個修飾符用來關閉 Cython 的邊界檢查
-
Cython 的函數使用 cdef 定義剧浸,并且他可以給所有參數以及返回值指定類型锹引。比方說矗钟,我們可以這么編寫整數 min 函數:
cdef int my_min(int x, int y): return x if x <= y else y
這里 np.ndarray[np.float32_t, ndim=2] 就是一個類型名就像 int 一樣,只是它比較長而且信息量比較大而已嫌变。它的意思是吨艇,這是個類型為 np.float32_t 的2維 np.ndarray。
在函數體內部腾啥,我們一樣可以使用 cdef typename varname 這樣的語法來聲明變量
在 Python 程序中东涡,是看不到 cdef 的函數的,所以我們這里 def naive_dot(a, b) 來調用 cdef 過的 _naive_dot 函數倘待。
另外疮跑,Cython 程序需要先編譯之后才能被 Python 調用,流程是:
- Cython 編譯器把 Cython 代碼編譯成調用了 Python 源碼的 C/C++ 代碼
- 把生成的代碼編譯成動態(tài)鏈接庫
- Python 解釋器載入動態(tài)鏈接庫
要完成前兩步凸舵,我們要寫如下代碼:
# setup.py
from distutils.core import setup, Extension
from Cython.Build import cythonize
import numpy
setup(ext_modules = cythonize(Extension(
'dot_cython',
sources=['dot_cython.pyx'],
language='c',
include_dirs=[numpy.get_include()],
library_dirs=[],
libraries=[],
extra_compile_args=[],
extra_link_args=[]
)))
這段代碼對于我們這個簡單的例子來說有些太復雜了祖娘,不過實際上,再復雜也就這么復雜了贞间,為了省得后面再貼一遍,所以索性就在這里把最復雜的列出來好了雹仿。這里順帶解釋一下好了:
- 'dot_cython' 是我們要生成的動態(tài)鏈接庫的名字
- sources 里面可以包含 .pyx 文件增热,以及后面如果我們要調用 C/C++ 程序的話,還可以往里面加 .c / .cpp 文件
- language 其實默認就是 c胧辽,如果要用 C++峻仇,就改成 c++ 就好了
- include_dirs 這個就是傳給 gcc 的 -I 參數
- library_dirs 這個就是傳給 gcc 的 -L 參數
- libraries 這個就是傳給 gcc 的 -l 參數
- extra_compile_args 就是傳給 gcc 的額外的編譯參數,比方說你可以傳一個 -std=c++11
- extra_link_args 就是傳給 gcc 的額外的鏈接參數(也就是生成動態(tài)鏈接庫的時候用的)
- 如果你從來沒見過上面幾個 gcc 參數邑商,說明你暫時還沒這些需求摄咆,等你遇到了你就懂了
然后我們只需要執(zhí)行下面命令就可以把 Cython 程序編譯成動態(tài)鏈接庫了。
python setup.py build_ext --inplace
成功運行完上面這句話人断,可以看到在當前目錄多出來了 dot_cython.c 和 dot_cython.so吭从。前者是生成的 C 程序,后者是編譯好了的動態(tài)鏈接庫恶迈。
下面讓我們來試試看效果:
$ ipython 15:07:43
Python 2.7.12 (default, Oct 11 2016, 05:20:59)
Type "copyright", "credits" or "license" for more information.
IPython 4.0.1 -- An enhanced Interactive Python.
? -> Introduction and overview of IPython's features.
%quickref -> Quick reference.
help -> Python's own help system.
object? -> Details about 'object', use 'object??' for extra details.
In [1]: import numpy as np
In [2]: import dot_python
In [3]: import dot_cython
In [4]: a = np.random.randn(100, 200).astype(np.float32)
In [5]: b = np.random.randn(200, 50).astype(np.float32)
In [6]: %timeit -n 100 -r 3 dot_python.naive_dot(a, b)
100 loops, best of 3: 560 ms per loop
In [7]: %timeit -n 100 -r 3 dot_cython.naive_dot(a, b)
100 loops, best of 3: 982 μs per loop
In [8]: %timeit -n 100 -r 3 np.dot(a, b)
100 loops, best of 3: 49.2 μs per loop
所以說涩金,提升了大概 570 倍的效率!而我們的代碼基本上就沒有改動過暇仲!當然啦步做,你要跟高度優(yōu)化過的 numpy 實現比,當然還是慢了很多啦奈附。不過掐指一算全度,這 0.982ms 其實跟直接寫 C++ 是差不多的,能實現這個這樣的效果已經很令人滿意了斥滤。不信我們可以試試看手寫一次 C++ 版本:
// dot.cpp
#include <ctime>
#include <cstdlib>
#include <chrono>
#include <iostream>
class Matrix {
float *data;
public:
size_t n, m;
Matrix(size_t r, size_t c): data(new float[r*c]), n(r), m(c) {}
~Matrix() { delete[] data; }
float& operator() (size_t x, size_t y) { return data[x*m+y]; }
float operator() (size_t x, size_t y) const { return data[x*m+y]; }
};
float dot(const Matrix &a, const Matrix& b) {
Matrix c(a.n, b.m);
for (size_t i = 0; i < a.n; ++i)
for (size_t j = 0; j < b.m; ++j) {
float s = 0;
for (size_t k = 0; k < a.m; ++k)
s += a(i, k) * b(k, j);
c(i, j) = s;
}
return c(0, 0); // to comfort -O2 optimization
}
void fill_rand(Matrix &a) {
for (size_t i = 0; i < a.n; ++i)
for (size_t j = 0; j < a.m; ++j)
a(i, j) = rand() / static_cast<float>(RAND_MAX) * 2 - 1;
}
int main() {
srand((unsigned)time(NULL));
const int n = 100, p = 200, m = 50, T = 100;
Matrix a(n, p), b(p, m);
fill_rand(a);
fill_rand(b);
auto st = std::chrono::system_clock::now();
float s = 0;
for (int i = 0; i < T; ++i) {
s += dot(a, b);
}
auto ed = std::chrono::system_clock::now();
std::chrono::duration<double> diff = ed-st;
std::cerr << s << std::endl;
std::cout << T << " loops. average " << diff.count() * 1e6 / T << "us" << std::endl;
}
$ g++ -O2 -std=c++11 -o dot dot.cpp
$ ./dot 2>/dev/null
100 loops. average 1112.11us
可以看到相比起隨手寫的 C++ 程序将鸵,Cython 甚至還更快了些勉盅,或許是因為 numpy 以及計量方式(取3次最好 vs 取平均)的緣故。
Cython 加速 Python 代碼的關鍵
如果我們把剛剛 Cython 代碼中的類型標注都去掉(也就是函數參數和返回值類型以及函數體內部的 cdef)咨堤,再試試看運行速度:
$ python setup.py build_ext --inplace
$ ipython
In [1]: import numpy as np
In [2]: import dot_python
In [3]: import dot_cython
In [4]: a = np.random.randn(100, 200).astype(np.float32)
In [5]: b = np.random.randn(200, 50).astype(np.float32)
In [6]: %timeit -n 100 -r 3 dot_cython.naive_dot(a, b)
100 loops, best of 3: 416 ms per loop
In [7]: %timeit -n 100 -r 3 dot_python.naive_dot(a, b)
100 loops, best of 3: 537 ms per loop
可以看到菇篡,這下 Cython 實現幾乎和 Python 實現一樣慢了。所以說一喘,在 Cython 中驱还,類型標注對于提升速度是至關重要的。
到了這里就可以吐槽動態(tài)類型的不好了凸克。單就性能方面來看议蟆,很多編譯期間就能確定下來的事情被推到了運行時;很多編譯期間能檢查出來的問題被推到了運行時萎战;很多編譯期間能做的優(yōu)化也被推到了運行時咐容。再加上 CPython 又沒有帶 JIT 編譯器,這相當于有相當大的時間都浪費在了類型相關的事情上蚂维,更不用說一大堆編譯器優(yōu)化都用不了戳粒。
分析 Cython 程序
前面說到,Cython 中類型聲明非常重要虫啥,但是我們不加類型標注它依然是一個合法的 Cython 程序蔚约,所以自然而然地,我們會擔心漏加類型聲明涂籽。不過好在 Cython 提供了一個很好的工具苹祟,可以方便地檢查 Cython 程序中哪里可能可以進一步優(yōu)化。下面命令既可以對 dot_cython.pyx 進行分析:
cython -a dot_cython.pyx
如果當前 Cython 程序用到了 C++评雌,那么還得加上 --cplus 參數树枫。在成功運行完 cython -a 之后,會產生同名的 .html 文件景东。我們可以打開看看不帶類型標注的版本:
這里用黃色部分標出了和 Python 發(fā)生交互的地方砂轻,簡單地理解,就是拖累性能的地方斤吐。點擊每一行可以查看相應的生成的 C/C++ 代碼舔清。可以看到我們這里幾乎每一行都被標了出來(汗……)
這里我們點開了第16行曲初,也就是 for k in xrange(p)体谒,可以發(fā)現這么一句簡單的話,卻被展開成了如此復雜的語句臼婆,從這一系列 Python API 的名稱來看抒痒,我們至少額外地做了:創(chuàng)建和銷毀 Python Object、增加和減少 Python Object 的引用計數颁褂、類型檢查故响、列表長度檢查等等……然而在不知道類型的情況下傀广,為保證運行正確,這些事情又是不得不做的彩届。
我們把類型標注加回來伪冰,再看看 cython -a 的結果:
這里同樣展開了 for k in xrange(p) 這一行,可以看到樟蠕,它很直接地就翻譯成了 C 里面的 for 循環(huán)贮聂。其他地方同樣也簡化了很多,剩下的只有進出函數調用寨辩、raise ValueError 和 np.zeros 這些確實是要和 Python 發(fā)生交互的地方被標了出來吓懈。一般來說,我們把一個 Cython 程序優(yōu)化到這個地步就行了靡狞。
根據 Amdahl’s Law 我們知道(其實根據直覺我們也知道)耻警,只要最核心的代碼足夠快就行了。所以說甸怕,我們完全可以放心地編寫 Python 代碼甘穿,享受 Python 帶來的好處,同時把核心代碼用 C/C++ 或者 Cython 重寫梢杭,這樣就能兼顧開發(fā)效率和執(zhí)行效率了温兼。
以上部分的參考資料:
- Cython for NumPy users
- Faster code via static typing
- Dynamic type languages versus static type languages
- Language Basics
作為膠水
Python 是很好的膠水語言,但是前提是庫本身要使用 Python API 來和 Python 交互式曲。有了 Cython 之后妨托,我們可以照常編寫 C/C++ 程序缸榛,或者是直接拿來一份已有的 C/C++ 源碼吝羞,然后用 Cython 簡單包裝一下就可以使用了。
本來我想膠水這一部分也像前面性能提升部分一樣詳細地寫出來内颗。后來想想钧排,其實這一部分主要涉及的就是 Cython 語法本身,沒有什么特別值得注意的均澳,所以看看 Cython 文檔就好了恨溜。我這里把一些特性不完全地列出來:
- 函數簽名基本上可以原樣從 C/C++ 復制到 Cython 中
- C 中的 _Bool 類型和 C++ 中的 bool 類型在 Cython 中都用 bint 取代(因為 Python 沒有布爾類型)
- struct / enum / union 是支持的
- const 限定和引用都是支持的
- 命名空間是支持的
- C++ 類是支持的
- 部分操作符重載是支持的,部分操作符需要改名
- 內嵌類是支持的
- 模板是支持的
- 異常是支持的
- 構造函數找前、析構函數是支持的
- 靜態(tài)成員是支持的
- libc / libcpp / STL 是支持的
- 聲明寫在 .pxd 中可以在 .pyx 中 cimport 進來
- 你可能需要注意 Python 字符串到各式各樣的 C/C++ 字符串的轉換
也就是說在 Cython 里面調用 C/C++ 代碼應該是沒有任何問題的糟袁,你想在 Cython 里面用 Python 的語法寫 C/C++ 程序基本上也是沒有問題的。具體的可以查閱以下資料:
- Using C++ in Cython
- Extension Types
- Special Methods of Extension Types
- Sharing Declarations Between Cython Modules
- Unicode and passing strings
- cython/Cython/Includes
- wrapping struct with nested enum - reference in vector template
?首發(fā)于博客 Python 多核并行計算