TL;DR(要約)
- Cythonを使えば、(実装内容によっては)処理が遅い関数やクラスを高速化できる
- CtyhonとNumpyを組み合わせるには、ちょっとしたコツが必要
- notebookで実行する場合とスクリプトで実行する場合で、やり方が少し異なる
前置き
Python、便利ですよね。研究開発分野では言わずもがな、ちょっとした開発をする分にも申し分ない手軽さとライブラリの豊富さがあります。ただやはりせっかちな私にとってはもう少し早く実行が終わってほしいところ。
ということで、Cython+Numpyを使ってちょっとでも高速化できないか調べてみました。
Cythonとは?
Cython is an optimising static compiler for both the Python programming language and the extended Cython programming language (based on Pyrex). It makes writing C extensions for Python as easy as Python itself.
Cython: C-Extensions for Python
Pythonは処理速度は決して早くない、むしろ遅い部類である。そこで、C/C++に変換することにより高速化しようというのがCythonである。低級言語のC/C++(昔は高級言語だったが、現在は低級言語といって良いだろう)に変換してネイティブコンパイルするのだから、速いに決まっている。
深入りしないCython入門
Cythonとは、Python言語とその拡張言語に最適化された静的コンパイラのことのようです。つまり、Pythonチックに記述されたPyrexファイルからC/C++言語で記述されたコードを自動生成&コンパイルし、そのファイルをPythonスクリプトから呼び出すことで処理速度を高速化します。
notebookからCython
Cythonを使って高速化する場合、jupyter notebookを使う場合と、pythonスクリプトを使う場合でやり方が異なります。まずは、notebookから。
手順1. notebookのはじめのセルで以下を実行
%load_ext Cython
手順2. 高速化したい関数を定義(ここでは、フィボナッチ数列を計算する関数)
%%cython # cythonでコンパイルしてね、という指示
def cy_fib(n):
a, b = 0.0, 1.0
for i in range(n):
a, b = a + b, a
return a
手順3. 定義した関数を呼び出し
cy_fib(100)
スクリプトからCython
次にpythonスクリプトから、Cythonを実行する方法です。
手順1. pyxファイルに高速化したい関数を記述(ここでは、フィボナッチ数列を計算する関数)
def cy_fib(n):
a, b = 0.0, 1.0
for i in range(n):
a, b = a + b, a
return a
手順2. setup.pyファイルを作成
from distutils.core import setup, Extension
from Cython.Build import cythonize
from numpy import get_include # cimport numpyを使う場合に必要
# 「sample」はpyxファイルごとに修正
ext = Extension("sample", sources=["sample.pyx"], include_dirs=['.', get_include()])
setup(name="sample", ext_modules=cythonize([ext]))
手順3. 次のコマンドを実行し、cファイルとsoファイルを作成
python setup.py build_ext --inplace
手順4. 無事にsoファイルが作成されたら、関数を呼び出して実行してみる
from sample import cy_fib
cy_fib(100)
Option:手順2,3の簡略化
最新のCythonであれば、setup.pyを作らなくてもcファイルとsoファイルを作成することができます。
cythonize -i sample.pyx
numpy+Cython
次にnumpyを使用して、pythonスクリプトからCythonを実行する方法です。
手順1. pyxファイルにnumpyを使用してみる
import numpy as np
def func(n):
A = np.empty(n)
for i in range(n):
A[i] = i
sum = 0
for i in range(n):
sum += A[i]
return sum / n
手順2-4. スクリプトからCythonと同様に実行します。
高速化のコツ
numpyのデータタイプを指定すると、非常に速くなりました。これが重要そうです。
それでは何をすればよいかというと、配列要素へのアクセス方法(for文の書き方)を変えることです。要素の型指定なしでインデックスアクセスすると遅くなりますが、要素の型指定をしてインデックスアクセスにすると劇的に速くなります。
Cython関数の書き方による実行速度の違い
引用サイトを確認すると、高速化のコツは7つほどあるみたいです。それらを使った一例は次になります。内容としては、sample_with_numpy.pyxの高速化になります。
- コツ1. import文によるコンパイル
- コツ2. 関数で使用する変数の型を指定
- コツ3. numpyで使用する型を詳細に指定(重要)
- コツ4. 関数の返り値の型とcdefあるいはcpdefを指定
- コツ5. 関数のインライン化
- コツ6. ループ処理はインデックスを参照(重要)
- コツ7. 配列アクセスのチェック機能を省略する
import numpy as np
cimport numpy as cnp # コンパイル(コツ1)
cimport cython # コンパイル(コツ1)
from cython import boundscheck, wraparound # 配列チェック機能(コツ7)
ctypedef cnp.float64_t DTYPE_t # numpyの詳細な型指定(コツ3)
cpdef inline double func(int n): # cpdef&返り値の型指定&インライン化(コツ4,5)
cdef int i # 変数の型指定(コツ2)
cdef double sum # 変数の型指定(コツ2)
cdef cnp.ndarray[DTYPE_t, ndim=1] A # numpyの詳細な型指定(コツ3)
with boundscheck(False), wraparound(False): # 配列チェック機能を無効化(コツ7)
A = np.empty(n)
for i in range(n):
A[i] = i # 配列のインデックス参照(コツ6)
sum = 0
for i in range(n):
sum += A[i] # 配列のインデックス参照(コツ6)
return sum
速度計測してみた
27.4[us] → 1.22[us]という約22倍の高速が実現できました。pythonチックに書けてここまで高速化できるなら、使い倒したいですね。
pyxファイル | 実行コマンド | mean ± std | loops |
---|---|---|---|
sample_with_numpy.pyx | %%timeit func(100) | 27.4 µs ± 435 ns | 10,000 |
sample_with_numpy2.pyx | %%timeit func(100) | 1.22 µs ± 38 ns | 1,000,000 |
参考文献
ありがとうございました。