Cython基本用法

文章轉自:https://zhuanlan.zhihu.com/p/24311879

當人們提到 Python 的時候侈离,經常會說到下面兩個優(yōu)點:

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

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

以上部分的參考資料:

作為膠水

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

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

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末躺盛,一起剝皮案震驚了整個濱河市项戴,隨后出現的幾起案子,更是在濱河造成了極大的恐慌槽惫,老刑警劉巖周叮,帶你破解...
    沈念sama閱讀 218,941評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件辩撑,死亡現場離奇詭異,居然都是意外死亡仿耽,警方通過查閱死者的電腦和手機合冀,發(fā)現死者居然都...
    沈念sama閱讀 93,397評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來项贺,“玉大人君躺,你說我怎么就攤上這事【纯福” “怎么了晰洒?”我有些...
    開封第一講書人閱讀 165,345評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長啥箭。 經常有香客問我谍珊,道長,這世上最難降的妖魔是什么急侥? 我笑而不...
    開封第一講書人閱讀 58,851評論 1 295
  • 正文 為了忘掉前任砌滞,我火速辦了婚禮,結果婚禮上坏怪,老公的妹妹穿的比我還像新娘贝润。我一直安慰自己,他們只是感情好铝宵,可當我...
    茶點故事閱讀 67,868評論 6 392
  • 文/花漫 我一把揭開白布打掘。 她就那樣靜靜地躺著,像睡著了一般鹏秋。 火紅的嫁衣襯著肌膚如雪尊蚁。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,688評論 1 305
  • 那天侣夷,我揣著相機與錄音横朋,去河邊找鬼。 笑死百拓,一個胖子當著我的面吹牛琴锭,可吹牛的內容都是我干的。 我是一名探鬼主播衙传,決...
    沈念sama閱讀 40,414評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼决帖,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了蓖捶?” 一聲冷哼從身側響起地回,我...
    開封第一講書人閱讀 39,319評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后落君,有當地人在樹林里發(fā)現了一具尸體穿香,經...
    沈念sama閱讀 45,775評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年绎速,在試婚紗的時候發(fā)現自己被綠了皮获。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,096評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡纹冤,死狀恐怖洒宝,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情萌京,我是刑警寧澤雁歌,帶...
    沈念sama閱讀 35,789評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站知残,受9級特大地震影響靠瞎,放射性物質發(fā)生泄漏。R本人自食惡果不足惜求妹,卻給世界環(huán)境...
    茶點故事閱讀 41,437評論 3 331
  • 文/蒙蒙 一乏盐、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧制恍,春花似錦父能、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至鹃唯,卻和暖如春爱榕,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背俯渤。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評論 1 271
  • 我被黑心中介騙來泰國打工呆细, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留型宝,地道東北人八匠。 一個月前我還...
    沈念sama閱讀 48,308評論 3 372
  • 正文 我出身青樓,卻偏偏與公主長得像趴酣,于是被迫代替她去往敵國和親梨树。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,037評論 2 355

推薦閱讀更多精彩內容