Cython 基本用法

我一直非常喜歡 Python。當人們提到 Python 的時候,經常會說到下面兩個優點:

  1. 寫起來方便
  2. 容易調用 C/C++ 的庫

然而實際上,第一點是以巨慢的執行速度為代價的,而第二點也需要庫本身按照 Python 的規範使用 Python API、導出相應的符號。

在天壤實習的時候,跟 Cython 打了不少交道,覺得這個工具雖然 Bug 多多,寫的時候也有些用戶體驗不好的地方,但已經能極大提高速度和方便調用 C/C++,還是非常不錯的。這裡就給大家簡單介紹一下 Cython(注意區別於 CPython)。Cython 可以讓我們方便地:

  • 用 Python 的語法混合編寫 Python 和 C/C++ 代碼,提升 Python 速度
  • 調用 C/C++ 代碼

例子:矩陣乘法

假設我們現在正在編寫一個很簡單的矩陣乘法代碼,其中矩陣是保存在 numpy.ndarray 中。Python 代碼可以這麼寫:

# dot_python.pyimport numpy as npdef 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.pyximport numpy as npcimport numpy as npcimport 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 cdef 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. 把生成的代碼編譯成動態鏈接庫
  3. Python 解釋器載入動態鏈接庫

要完成前兩步,我們要寫如下代碼:

# setup.pyfrom distutils.core import setup, Extensionfrom Cython.Build import cythonizeimport numpysetup(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" 是我們要生成的動態鏈接庫的名字
  • 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 的額外的鏈接參數(也就是生成動態鏈接庫的時候用的)
  • 如果你從來沒見過上面幾個 gcc 參數,說明你暫時還沒這些需求,等你遇到了你就懂了

然後我們只需要執行下面命令就可以把 Cython 程序編譯成動態鏈接庫了。

python setup.py build_ext --inplace

成功運行完上面這句話,可以看到在當前目錄多出來了 dot_cython.c 和 dot_cython.so。前者是生成的 C 程序,後者是編譯好了的動態鏈接庫。

下面讓我們來試試看效果:

$ ipython 15:07:43Python 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 npIn [2]: import dot_pythonIn [3]: import dot_cythonIn [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 loopIn [7]: %timeit -n 100 -r 3 dot_cython.naive_dot(a, b)100 loops, best of 3: 982 μs per loopIn [8]: %timeit -n 100 -r 3 np.dot(a, b)100 loops, best of 3: 49.2 μs per loop

所以說,提升了大概 570 倍的效率!而我們的代碼基本上就沒有改動過!當然啦,你要跟高度優化過的 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/null100 loops. average 1112.11us

可以看到相比起隨手寫的 C++ 程序,Cython 甚至還更快了些,或許是因為 numpy 以及計量方式(取3次最好 vs 取平均)的緣故。

Cython 加速 Python 代碼的關鍵

如果我們把剛剛 Cython 代碼中的類型標註都去掉(也就是函數參數和返回值類型以及函數體內部的 cdef),再試試看運行速度:

$ python setup.py build_ext --inplace$ ipythonIn [1]: import numpy as npIn [2]: import dot_pythonIn [3]: import dot_cythonIn [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 loopIn [7]: %timeit -n 100 -r 3 dot_python.naive_dot(a, b)100 loops, best of 3: 537 ms per loop

可以看到,這下 Cython 實現幾乎和 Python 實現一樣慢了。所以說,在 Cython 中,類型標註對於提升速度是至關重要的。

到了這裡就可以吐槽動態類型的不好了。單就性能方面來看,很多編譯期間就能確定下來的事情被推到了運行時;很多編譯期間能檢查出來的問題被推到了運行時;很多編譯期間能做的優化也被推到了運行時。再加上 CPython 又沒有帶 JIT 編譯器,這相當於有相當大的時間都浪費在了類型相關的事情上,更不用說一大堆編譯器優化都用不了。

分析 Cython 程序

前面說到,Cython 中類型聲明非常重要,但是我們不加類型標註它依然是一個合法的 Cython 程序,所以自然而然地,我們會擔心漏加類型聲明。不過好在 Cython 提供了一個很好的工具,可以方便地檢查 Cython 程序中哪裡可能可以進一步優化。下面命令既可以對 dot_cython.pyx 進行分析:

cython -a dot_cython.pyx

如果當前 Cython 程序用到了 C++,那麼還得加上 --cplus 參數。在成功運行完 cython -a 之後,會產生同名的 .html 文件。我們可以打開看看不帶類型標註的版本:

這裡用黃色部分標出了和 Python 發生交互的地方,簡單地理解,就是拖累性能的地方。點擊每一行可以查看相應的生成的 C/C++ 代碼。可以看到我們這裡幾乎每一行都被標了出來(汗……)

這裡我們點開了第16行,也就是 for k in xrange(p),可以發現這麼一句簡單的話,卻被展開成了如此複雜的語句,從這一系列 Python API 的名稱來看,我們至少額外地做了:創建和銷毀 Python Object、增加和減少 Python Object 的引用計數、類型檢查、列表長度檢查等等……然而在不知道類型的情況下,為保證運行正確,這些事情又是不得不做的。

我們把類型標註加回來,再看看 cython -a 的結果:

這裡同樣展開了 for k in xrange(p) 這一行,可以看到,它很直接地就翻譯成了 C 裡面的 for 循環。其他地方同樣也簡化了很多,剩下的只有進出函數調用、raise ValueError 和 np.zeros 這些確實是要和 Python 發生交互的地方被標了出來。一般來說,我們把一個 Cython 程序優化到這個地步就行了。

根據 Amdahl』s Law 我們知道(其實根據直覺我們也知道),只要最核心的代碼足夠快就行了。所以說,我們完全可以放心地編寫 Python 代碼,享受 Python 帶來的好處,同時把核心代碼用 C/C++ 或者 Cython 重寫,這樣就能兼顧開發效率和執行效率了。

以上部分的參考資料:

  • 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++ 類是支持的
  • 部分操作符重載是支持的,部分操作符需要改名
  • 內嵌類是支持的
  • 模板是支持的
  • 異常是支持的
  • 構造函數、析構函數是支持的
  • 靜態成員是支持的
  • 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

首發於博客 Python 多核並行計算
推薦閱讀:

Python 家族有多龐大
Python數據分析及可視化實例之CentOS7.2+Python3x+Flask部署標準化配置流程
Flask 實現小說網站 (二)

TAG:Python | Cython | 性能优化 |