Cython基本用法

文章轉(zhuǎn)自:https://zhuanlan.zhihu.com/p/24311879

當(dāng)人們提到 Python 的時(shí)候,經(jīng)常會(huì)說到下面兩個(gè)優(yōu)點(diǎn):

  1. 寫起來方便
  2. 容易調(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)用,流程是:

  1. Cython 編譯器把 Cython 代碼編譯成調(diào)用了 Python 源碼的 C/C++ 代碼
  2. 把生成的代碼編譯成動(dòng)態(tài)鏈接庫
  3. 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í)行效率了。

以上部分的參考資料:

作為膠水

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++ 程序基本上也是沒有問題的。具體的可以查閱以下資料:

?首發(fā)于博客 Python 多核并行計(jì)算

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容