Python による C の関数の呼び出し2016年2月14日 | |
はじめにPython による C の関数の呼び出しについて試してみた。元ネタは Micha Gorelick, Ian Ozsvald 著 "ハイパフォーマンス Python"。 環境
※MinGW にも Python が入っているので、Anaconda のほうを使うようにパスを設定している。 コードサンプルコードは以下の通り。 test.c #include <stdio.h> #include <math.h> #include "test.h" void test(int n, double a[][2]) { double dx = 2*M_PI/(n - 1); int i; for(i = 0; i < n; i++) { double x = i*dx; double f = sin(x); a[i][0] = x; a[i][1] = f; } } test.h #ifndef TEST_H #define TEST_H void test(int n, double a[][2]); #endif /* TEST_H */ Makefile all: gcc -Wall -O2 -c test.c gcc -shared -o test.so test.o x = [0, 2π] の範囲で sin 関数を計算した結果をリストで返すものである。 比較のため、同じ処理を行う Python コードを以下に示す。 call_func.py from math import pi, sin def test(n): dx = 2*pi/(n - 1) r = [] for i in xrange(n): x = i*dx f = sin(x) r.append((x, f)) return r Python 関数の実行比較のため、まず Python で書かれた関数の実行速度を計測する。生のコードの結果は以下の通りである。 $ python -m timeit -n 5 -r 5 -s "import call_func" "call_func.test(1000000)" 5 loops, best of 5: 505 msec per loop Numba 版Numba を使ってみる。コードは以下の通り。 from math import pi, sin from numba import jit @jit def test(n): dx = 2*pi/(n - 1) r = [] for i in xrange(n): x = i*dx f = sin(x) r.append((x, f)) return r 結果は以下の通り。 $ python -m timeit -n 5 -r 5 -s "import call_func" "call_func.test(1000000)" 5 loops, best of 5: 221 msec per loop Cython 版Cython も試してみる。コードは以下の通り。 call_func.pyx from math import pi, sin def test(int n): cdef double dx cdef int i cdef double x cdef double f dx = 2*pi/(n - 1) r = [] for i in xrange(n): x = i*dx f = sin(x) r.append((x, f)) return r setup.py from distutils.core import setup from distutils.extension import Extension from Cython.Distutils import build_ext setup( cmdclass={'build_ext': build_ext}, ext_modules=[Extension("calculate", ["call_func.pyx"])] ) コンパイル $ python setup.py build_ext --inplace 結果は以下の通り。 $ python -m timeit -n 5 -r 5 -s "import calculate" "calculate.test(1000000)" 5 loops, best of 5: 278 msec per loop Numba のほうが速い。Cython よくわかっていないので、もう少しなんとかなるのかもしれないが、ここでの目的からずれるので、これでよしとする。 Python から C の関数を呼び出すPython から C の関数を呼び出す仕組みとして、ctypes や CFFI がある。 C のライブラリの準備C の関数を呼び出すために、まずライブラリをコンパイルする。GCC だと、次のようにする。 $ gcc -Wall -O2 -c test.c $ gcc -shared -o test.so test.o ctypesctypes は Python の標準モジュールである。次のように用いる。 call_c_func_ctypes.py import ctypes _test = ctypes.CDLL('test.so') TYPE_INT = ctypes.c_int TYPE_DOUBLE_2 = ctypes.c_double*2 TYPE_DOUBLE_P_2 = ctypes.POINTER(TYPE_DOUBLE_2) # void test(int n, double a[][2]); _test.test.argtypes = [ TYPE_INT, TYPE_DOUBLE_P_2 ] _test.test.restype = None def test(n): _n = TYPE_INT(n) TYPE_DOUBLE_N_2 = TYPE_DOUBLE_2*n _r = [TYPE_DOUBLE_2(0, 0) for i in xrange(n)] _r = TYPE_DOUBLE_N_2(*_r) _test.test(_n, _r) r = [[elem[i] for i in (0, 1)] for elem in _r] return r ctypes.CDLL() でライブラリを読み込んでいる。ctypes.c_int などは、ctypes で定義されている C のデータ型である。"ctypes.c_double*2" は要素を 2 つ持つ配列、ctypes.POINTER(x) は x のポインタを表す。 C の関数を呼び出すために、引数と返り値の型を指定する。_test.test.argtypes に引数の型の配列を、_test.test.restype に返り値の型を入れる。 関数の引数は Python のデータから ctypes のデータに変換する必要がある。ふつうの変数は単純である。 _n = TYPE_INT(n) 配列がややこしい。 _r = [TYPE_DOUBLE_2(0, 0) for i in xrange(n)] _r = TYPE_DOUBLE_N_2(*_r) TYPE_DOUBLE_N_2 型 (実体は (c_types.c_double*2)*n) には TYPE_DOUBLE_2 型 (実体は ctypes.c_double*2) の引数を N 個渡す必要があり、TYPE_DOUBLE_2 型には double 型の引数を 2 個渡す必要があるので、まず TYPE_DOUBLE_2 型の要素を N 個持つリストを作り、それを展開して TYPE_DOUBLE_N_2 に渡している。 型変換した引数を用いて関数を呼び出す。 _test.test(_n, _r) 実行結果は以下の通り。 $ python -m timeit -n 5 -r 5 -s "import call_c_func_ctypes" "call_c_func_ctypes.test(1000000)" 5 loops, best of 5: 3.5 sec per loop Python 版の結果の単位は msec だったが、今回の結果は単位が sec になっている。つまり、実行速度がものすごく遅い。C で書けば速度が速くなるかというと、そうでもないということである。この場合は、関数が遅いというより、関数を呼び出す前後のデータの準備・後処理に時間がかかっている。 上記のコードを NumPy を用いて書き直す。 call_c_func_ctypes2.py import ctypes import numpy as np _test = ctypes.CDLL('test.so') TYPE_INT = ctypes.c_int TYPE_DOUBLE_2 = ctypes.c_double*2 TYPE_DOUBLE_PP = ctypes.POINTER(ctypes.POINTER(ctypes.c_double)) # void test(int n, double a[][2]); _test.test.argtypes = [ TYPE_INT, TYPE_DOUBLE_PP ] _test.test.restype = None def test(n): _n = TYPE_INT(n) r = np.zeros((n, 2)) _r = r.ctypes.data_as(TYPE_DOUBLE_PP) _test.test(_n, _r) return r 今回は NumPy のメモリをそのまま用いているため、データの受け渡しの手間が要らなくなっている。結果は以下の通り。 $ python -m timeit -n 5 -r 5 -s "import call_c_func_ctypes2" "call_c_func_ctypes2.test(1000000)" 5 loops, best of 5: 54.6 msec per loop Python 版より 10 倍ほど速くなった。よかった。返り値の型が変わってしまっているが、使い方はさほど変わらないので、よしとする。 CFFICFFI (C Foreign Function Interface for Python) というモジュールでも、ctypes と同様のことができる。 call_c_func_cffi.py from cffi import FFI ffi = FFI() ffi.cdef(r''' void test(int n, double a[][2]); ''') lib = ffi.dlopen('test.so') def test(n): _r = ffi.new('double[%d][2]' % n) lib.test(n, _r) r = [[elem[i] for i in (0, 1)] for elem in _r] return r ctypes よりだいぶスッキリしたコードになっている。関数のシグネチャの情報は、ffi.cdef() で C の関数宣言をそのまま書くことで表現されている。 実行結果は以下の通り。 $ python -m timeit -n 5 -r 5 -s "import call_c_func_cffi" "call_c_func_cffi.test(1000000)" 5 loops, best of 5: 683 msec per loop ctypes ほどではないが、やはり遅い。 NumPy 版は以下の通り。 call_c_func_cffi2.py from cffi import FFI import numpy as np ffi = FFI() ffi.cdef(r''' void test(int n, double a[][2]); ''') lib = ffi.dlopen('test.so') def test(n): r = np.zeros((n, 2)) _r = ffi.cast('double(*)[2]', r.ctypes.data) lib.test(n, _r) return r ffi.new() でデータを作る代わりに、NumPy でデータを作り、それをキャストして渡している。返り値のデータコピーがいらなくなっている。結果は以下の通り。 $ python -m timeit -n 5 -r 5 -s "import call_c_func_cffi2" "call_c_func_cffi2.test(1000000)" 5 loops, best of 5: 54.1 msec per loop Python コードの高速化そもそも比較した Python コードは効率が悪いのではないかという気がしてきたので、コードの高速化を図った。 リスト内包表記call_func2.py from math import pi, sin def test(n): dx = 2*pi/(n - 1) r = [(i*dx, sin(i*dx)) for i in xrange(n)] return r 結果 $ python -m timeit -n 5 -r 5 -s "import call_func2" "call_func2.test(1000000)" 5 loops, best of 5: 448 msec per loop 少し速くなった。 NumPy の利用call_func_numpy.py from math import pi, sin import numpy as np def test(n): dx = 2*pi/(n - 1) r = np.c_[np.arange(n)*dx, np.sin(np.arange(n)*dx)] return r 結果 $ python -m timeit -n 5 -r 5 -s "import call_func_numpy" "call_func_numpy.test(1000000)" 5 loops, best of 5: 54.1 msec per loop C に匹敵する速度。 まとめ
参考文献 | |
PENGUINITIS |