Cythonの浅漬け

Fri, May 1, 2020
python cython


Pythonアプリを高速化できるCythonについてざっくりと浅めにまとめた。

Cythonとは

CythonはPythonのスーパーセットなプログラミング言語、またはそのコンパイラ。 Cythonで書かれたコードは最適化されたC(またはC++)のコードにコンパイルできる。

最新版(2020年5月時点で0.29.6)のマニュアルはこれ

Cythonによる高速化

Pythonは動的型付けのインタプリタ型言語で、変数アクセスや関数呼び出しのたびに処理系が型を解決する必要があって遅い。 演算子の処理やプロパティアクセスがメソッド呼び出しになったりするのもオーバーヘッドになっている。 特にforループが非常に遅く、数値計算をforで回すようなコードはJavaの10倍、Cの数百倍くらい遅い。

Cythonで事前にコンパイルすることで、最適化されたりインタプリタ型という特性による遅さが改善されるので、普通のPythonコードをCythonでコンパイルするだけでも20%から50%くらい高速化できる。

さらに、Cython言語の機能によってCの型を静的に記述してやることで動的型付けの遅さを改善でき、しっかり書けば100倍以上の高速化を実現できる。 Cの関数や構造体をインポートして直接使ったり、GILを無視したマルチスレッディングなんかもできるので、処理内容によっては数百倍以上の高速化も可能。

Cythonコンパイラの使い方

Cythonコードの書き方は長めになるのであとで。

とりあえずコンパイル方法について。

前提条件

CythonのコンパイルにはC(C++の機能を使うときはC++)のコンパイル環境が必要。

Cythonインストール

pip install cythonでインストールできる。

Anacondaならデフォルトで入っている。

コンパイル実行

Cythonで書かれたコードはcythonパッケージに含まれるcythonizeというコマンドでコンパイルできる。

例:

cythonize -i -3 your_awesome_module.py

この例のように実行すると、your_awesome_module.pyをコンパイルして、Cのソースであるyour_awesome_module.cと、それをコンパイルした.soファイルができる。

.cファイルにはもとのPythonコードとCのコードが交互に書かれているので、いい感じに最適化されているかを確認できる。

加えて、cythonize-aオプションを付けるとhtmlファイルも吐く。 このhtmlでも、左の方の+をクリックすることでPythonコードとCのコードを見比べられる。 また、Python界とのやり取りが多い(i.e. 処理時間がかかる)部分が濃い黄色で表現されるので、最適化すべきところがわかりやすい。

Cythonモジュールのimport

Cythonで書いてコンパイルして作ったモジュールは、普通のPythonモジュールと同じようにimportできる。 つまり、Cythonコードを記述したファイルの名前から拡張を除いたやつがモジュール名になるので、Pythonコードからそれを普通にimportすればいい。

例えば前節でコンパイルしたyour_awesome_moduleは以下のようimportできる。

some_normal_module.py:

from path.to.your_awesome_module import fabulous_func

Cythonモジュールの書き方

CythonはPythonのスーパーセットなので、純粋なPythonのコードを徐々にCython化していくような感じで書ける。 Cython化とはつまり、型を付けたり、関数をC化したりすること。

Pythonの型とCythonの型

PythonとCythonの型(i.e. Cの型)は以下のように対応している。

Python C
bool bint
int
long
[unsigined] char
[unsigined] short
[unsigined] int
[unsigined] long
[unsigined] long long
float float
double
long double
str char *
dict struct
List[float] double[:]


Classも静的型にできる

Cythonモジュールの関数の種類

Cythonの関数定義方法は3種類ある

  • def: Pythonモジュールから呼べるけど遅い。
  • cdef: Pythonモジュールから見えず、Cythonモジュール内でしか使えないけど速い。
  • cpdef: Pythonモジュールから呼べるcdefのような感じ。cdefより数倍遅くなることもあるっぽい。

Cythonモジュールの形式

Cythonモジュールはいくつかの形式で書ける。

大きく分けてPure Python Modeとそうでないのがあり、Pure Python Modeが現在の推奨。

.pyxファイルに書く形式

旧来の非Pure Python Modeの形式。 拡張子がpyxのファイルにCythonコードを書くやり方。

your_awesome_module.pyx:

import math

def int your_fancy_func(int x, int y):
    cdef int z

    z = _neet_helper(x) + _neet_helper(y)
    return math.fabs(z)

cdef int _neet_helper(int x):
    return x * x


この形式だと以下のような問題があって扱いづらい。

  • コンパイルしないと実行できない。
  • フォーマッタとかリンタを適用できない。
  • エディタのサポートが微妙。
  • ユニットテストを書き辛い。(Pythonで書くテストコードからcdef関数をimportもモックもできない。)

.pxdファイルに書く形式

Pure Python Modeのひとつ。 純粋なPythonコードを普通の.pyファイルに書いておいて、同名の.pxdファイルに型情報を書く形式。

your_awesome_module.py:

import math

def your_fancy_func(x, y):
    z = _neet_helper(x) + _neet_helper(y)
    return math.fabs(z)

def _neet_helper(x):
    return x * x

your_awesome_module.pxd:

cpdef int your_fancy_func(int x, int y)
cdef int _neet_helper(int x)


この形式だと、your_awesome_moduleの中身は純粋なPythonコードなのでそのまま実行できるし、フォーマッタやリンタも普通に使える。 コンパイルしない限りユニットテストも普通にできる。

ただし以下の問題がある。

  • .pyファイルと.pxdファイルという別ファイルの内容の同期を保つ必要があり、やや保守しにくい。
  • .pxdにはdef関数が書けないので、その型付けをするためにはcpdef関数にする必要がある。書き手の意図と異なることをしないといけないので微妙。
  • .pxdには(下記マジック属性を使わないと)ローカル変数の型を書けない。

マジック属性を使う形式

Pure Python Modeのひとつ。 通常の.pyファイルのPythonコードにcythonモジュールで型を記述する形式。

your_awesome_module.py:

import cython
import math

@cython.locals(x=cython.int, y=cython.int, z=cython.int)
@cython.returns(cython.int)
def your_fancy_func(x, y):
    z = _neet_helper(x) + _neet_helper(y)
    return math.fabs(z)

@cython.cfunc
@cython.locals(x=cython.int)
@cython.returns(cython.int)
def _neet_helper(x):
    return x * x


これなら.pxdファイルを保守しなくてよくて楽だし、コンパイルしない限り普通のPythonモジュールとして扱える。

けど結構読み辛い。

型アノテーションを使う形式

Pure Python Modeのひとつ。 実はCythonはPythonの型アノテーションも解釈してくれるので、それで書くのが一番よさそう。 Cython 0.27でローカル変数の型アノテーションも解釈してくれるようになった。

your_awesome_module.py:

import cython
import math

def your_fancy_func(x: cython.int, y: cython.int) -> cython.int:
    z: cython.int

    z = _neet_helper(x) + _neet_helper(y)
    return math.fabs(z)

@cython.cfunc
def _neet_helper(x: cython.int) -> cython.int:
    return x * x


これもコンパイルしない限り普通のPythonモジュールとして扱えるし、読みやすい。

ただ、割と新しめの機能だからか、ちょこちょこバグがある模様。

Tips

  • cdef関数では自作のデコレータが使えない。ジェネレータも使えない。
  • Cのコードで、Cの型として扱えないものはPyObjectという型になっている。このPyObjectをなるべく減らすのが高速化におおきく寄与する気がする。
  • strはPyObjectになるけどstrのままにしておくのが推奨されている。Cの文字列(i.e. char*)は遅かったりunicodeのサポートが微妙だったりするので。
  • 戻り値がPyObjectじゃないcdef関数で発生した例外はデフォルトでは握りつぶされる。(Cに例外が無いから?)
    • 例外を呼び出し元に伝えたいならexceptを使う。
  • 以下の条件を満たす関数を書くと、コンパイル時に「 Exception clause not allowed for function returning Python object 」という奇妙なエラーになる。@cython.returnsを付けると回避できる。
    • @cython.cfunc@cython.ccallが付いている。
    • Pythonオブジェクトを返す。
    • 戻り値の型が型アノテーションで書かれている。
  • ループは変にPythonのAPIを呼んでPythonicなコードを書くより、愚直にforで回したほうがいい。例えば、

    all([True, True, True, False])

    より、

    for cond in [True, True, True, False]:
        if not cond:
            break

    の方が5倍以上速い。