文章轉(zhuǎn)自:https://zhuanlan.zhihu.com/p/24311879
當(dāng)人們提到 Python 的時(shí)候,經(jīng)常會(huì)說到下面兩個(gè)優(yōu)點(diǎn):
- 寫起來方便
- 容易調(diào)用 C/C++ 的庫
然而實(shí)際上,第一點(diǎn)是以巨慢的執(zhí)行速度為代價(jià)的,而第二點(diǎn)也需要庫本身按照 Python 的規(guī)范使用 Python API、導(dǎo)出相應(yīng)的符號(hào)。
在天壤實(shí)習(xí)的時(shí)候,跟 Cython 打了不少交道,覺得這個(gè)工具雖然 Bug 多多,寫的時(shí)候也有些用戶體驗(yàn)不好的地方,但已經(jīng)能極大提高速度和方便調(diào)用 C/C++,還是非常不錯(cuò)的。這里就給大家簡單介紹一下 Cython(注意區(qū)別于 CPython)。Cython 可以讓我們方便地:
- 用 Python 的語法混合編寫 Python 和 C/C++ 代碼,提升 Python 速度
- 調(diào)用 C/C++ 代碼
例子:矩陣乘法
假設(shè)我們現(xiàn)在正在編寫一個(gè)很簡單的矩陣乘法代碼,其中矩陣是保存在 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 加速這個(gè)程序。我們先上 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)
可以看到這個(gè)程序和 Python 寫的幾乎差不多。我們來看看不一樣部分:
Cython 程序的擴(kuò)展名是 .pyx
cimport 是 Cython 中用來引入 .pxd 文件的命令。有關(guān) .pxd 文件,可以簡單理解成 C/C++ 中用來寫聲明的頭文件,更具體的我會(huì)在后面寫到。這里引入的兩個(gè)是 Cython 預(yù)置的。
@cython.boundscheck(False) 和 @cython.wraparound(False) 兩個(gè)修飾符用來關(guān)閉 Cython 的邊界檢查
-
Cython 的函數(shù)使用 cdef 定義,并且他可以給所有參數(shù)以及返回值指定類型。比方說,我們可以這么編寫整數(shù) min 函數(shù):
cdef int my_min(int x, int y): return x if x <= y else y這里 np.ndarray[np.float32_t, ndim=2] 就是一個(gè)類型名就像 int 一樣,只是它比較長而且信息量比較大而已。它的意思是,這是個(gè)類型為 np.float32_t 的2維 np.ndarray。
在函數(shù)體內(nèi)部,我們一樣可以使用 cdef typename varname 這樣的語法來聲明變量
在 Python 程序中,是看不到 cdef 的函數(shù)的,所以我們這里 def naive_dot(a, b) 來調(diào)用 cdef 過的 _naive_dot 函數(shù)。
另外,Cython 程序需要先編譯之后才能被 Python 調(diào)用,流程是:
- Cython 編譯器把 Cython 代碼編譯成調(diào)用了 Python 源碼的 C/C++ 代碼
- 把生成的代碼編譯成動(dòng)態(tài)鏈接庫
- Python 解釋器載入動(dòng)態(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=[]
)))
這段代碼對(duì)于我們這個(gè)簡單的例子來說有些太復(fù)雜了,不過實(shí)際上,再復(fù)雜也就這么復(fù)雜了,為了省得后面再貼一遍,所以索性就在這里把最復(fù)雜的列出來好了。這里順帶解釋一下好了:
- 'dot_cython' 是我們要生成的動(dòng)態(tài)鏈接庫的名字
- sources 里面可以包含 .pyx 文件,以及后面如果我們要調(diào)用 C/C++ 程序的話,還可以往里面加 .c / .cpp 文件
- language 其實(shí)默認(rèn)就是 c,如果要用 C++,就改成 c++ 就好了
- include_dirs 這個(gè)就是傳給 gcc 的 -I 參數(shù)
- library_dirs 這個(gè)就是傳給 gcc 的 -L 參數(shù)
- libraries 這個(gè)就是傳給 gcc 的 -l 參數(shù)
- extra_compile_args 就是傳給 gcc 的額外的編譯參數(shù),比方說你可以傳一個(gè) -std=c++11
- extra_link_args 就是傳給 gcc 的額外的鏈接參數(shù)(也就是生成動(dòng)態(tài)鏈接庫的時(shí)候用的)
- 如果你從來沒見過上面幾個(gè) gcc 參數(shù),說明你暫時(shí)還沒這些需求,等你遇到了你就懂了
然后我們只需要執(zhí)行下面命令就可以把 Cython 程序編譯成動(dòng)態(tài)鏈接庫了。
python setup.py build_ext --inplace
成功運(yùn)行完上面這句話,可以看到在當(dāng)前目錄多出來了 dot_cython.c 和 dot_cython.so。前者是生成的 C 程序,后者是編譯好了的動(dòng)態(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 倍的效率!而我們的代碼基本上就沒有改動(dòng)過!當(dāng)然啦,你要跟高度優(yōu)化過的 numpy 實(shí)現(xiàn)比,當(dāng)然還是慢了很多啦。不過掐指一算,這 0.982ms 其實(shí)跟直接寫 C++ 是差不多的,能實(shí)現(xiàn)這個(gè)這樣的效果已經(jīng)很令人滿意了。不信我們可以試試看手寫一次 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 甚至還更快了些,或許是因?yàn)?numpy 以及計(jì)量方式(取3次最好 vs 取平均)的緣故。
Cython 加速 Python 代碼的關(guān)鍵
如果我們把剛剛 Cython 代碼中的類型標(biāo)注都去掉(也就是函數(shù)參數(shù)和返回值類型以及函數(shù)體內(nèi)部的 cdef),再試試看運(yùn)行速度:
$ 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 實(shí)現(xiàn)幾乎和 Python 實(shí)現(xiàn)一樣慢了。所以說,在 Cython 中,類型標(biāo)注對(duì)于提升速度是至關(guān)重要的。
到了這里就可以吐槽動(dòng)態(tài)類型的不好了。單就性能方面來看,很多編譯期間就能確定下來的事情被推到了運(yùn)行時(shí);很多編譯期間能檢查出來的問題被推到了運(yùn)行時(shí);很多編譯期間能做的優(yōu)化也被推到了運(yùn)行時(shí)。再加上 CPython 又沒有帶 JIT 編譯器,這相當(dāng)于有相當(dāng)大的時(shí)間都浪費(fèi)在了類型相關(guān)的事情上,更不用說一大堆編譯器優(yōu)化都用不了。
分析 Cython 程序
前面說到,Cython 中類型聲明非常重要,但是我們不加類型標(biāo)注它依然是一個(gè)合法的 Cython 程序,所以自然而然地,我們會(huì)擔(dān)心漏加類型聲明。不過好在 Cython 提供了一個(gè)很好的工具,可以方便地檢查 Cython 程序中哪里可能可以進(jìn)一步優(yōu)化。下面命令既可以對(duì) dot_cython.pyx 進(jìn)行分析:
cython -a dot_cython.pyx
如果當(dāng)前 Cython 程序用到了 C++,那么還得加上 --cplus 參數(shù)。在成功運(yùn)行完 cython -a 之后,會(huì)產(chǎn)生同名的 .html 文件。我們可以打開看看不帶類型標(biāo)注的版本:

這里用黃色部分標(biāo)出了和 Python 發(fā)生交互的地方,簡單地理解,就是拖累性能的地方。點(diǎn)擊每一行可以查看相應(yīng)的生成的 C/C++ 代碼??梢钥吹轿覀冞@里幾乎每一行都被標(biāo)了出來(汗……)
這里我們點(diǎn)開了第16行,也就是 for k in xrange(p),可以發(fā)現(xiàn)這么一句簡單的話,卻被展開成了如此復(fù)雜的語句,從這一系列 Python API 的名稱來看,我們至少額外地做了:創(chuàng)建和銷毀 Python Object、增加和減少 Python Object 的引用計(jì)數(shù)、類型檢查、列表長度檢查等等……然而在不知道類型的情況下,為保證運(yùn)行正確,這些事情又是不得不做的。
我們把類型標(biāo)注加回來,再看看 cython -a 的結(jié)果:

這里同樣展開了 for k in xrange(p) 這一行,可以看到,它很直接地就翻譯成了 C 里面的 for 循環(huán)。其他地方同樣也簡化了很多,剩下的只有進(jìn)出函數(shù)調(diào)用、raise ValueError 和 np.zeros 這些確實(shí)是要和 Python 發(fā)生交互的地方被標(biāo)了出來。一般來說,我們把一個(gè) Cython 程序優(yōu)化到這個(gè)地步就行了。
根據(jù) Amdahl’s Law 我們知道(其實(shí)根據(jù)直覺我們也知道),只要最核心的代碼足夠快就行了。所以說,我們完全可以放心地編寫 Python 代碼,享受 Python 帶來的好處,同時(shí)把核心代碼用 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 簡單包裝一下就可以使用了。
本來我想膠水這一部分也像前面性能提升部分一樣詳細(xì)地寫出來。后來想想,其實(shí)這一部分主要涉及的就是 Cython 語法本身,沒有什么特別值得注意的,所以看看 Cython 文檔就好了。我這里把一些特性不完全地列出來:
- 函數(shù)簽名基本上可以原樣從 C/C++ 復(fù)制到 Cython 中
- C 中的 _Bool 類型和 C++ 中的 bool 類型在 Cython 中都用 bint 取代(因?yàn)?Python 沒有布爾類型)
- struct / enum / union 是支持的
- const 限定和引用都是支持的
- 命名空間是支持的
- C++ 類是支持的
- 部分操作符重載是支持的,部分操作符需要改名
- 內(nèi)嵌類是支持的
- 模板是支持的
- 異常是支持的
- 構(gòu)造函數(shù)、析構(gòu)函數(shù)是支持的
- 靜態(tài)成員是支持的
- libc / libcpp / STL 是支持的
- 聲明寫在 .pxd 中可以在 .pyx 中 cimport 進(jìn)來
- 你可能需要注意 Python 字符串到各式各樣的 C/C++ 字符串的轉(zhuǎn)換
也就是說在 Cython 里面調(diào)用 C/C++ 代碼應(yīng)該是沒有任何問題的,你想在 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 多核并行計(jì)算