Python
Cython

Cythonを使うときに気をつけること

公式のドキュメントを読んでもどうしても目から耳へすり抜けてしまうので、ここにまとめておきたいと思います。

def, cdef, cpdef

正直最初に見たときにはどう使い分けるのやらという感じでしたが、これらは言うなれば、スコープを制御しています:

スコープ def cdef cpdef
Pythonから見える △(.pyxファイル内で使える)
Cから見える ×

defでPython関数定義

まず、defはPythonの関数定義に使います。「Pythonの関数」ができないこととして、「Cの関数から呼び出す」ということが挙げられます。C言語側で処理しているときに(たとえばcdefされた関数の中で)Pythonの関数を呼び出すことはできません。Cのライブラリにコールバック関数として渡してやることもできません。

cdefでC関数定義

C言語側から呼び出せる関数を定義したい場合にはcdefを使います。Cythonはこのコードを一旦Cのコードに変換してから、コンパイルします。別の言い方をすると、「Pythonの文法でCのコードを書いている」ようなものです。

ですのでcdefされた関数の中では、以下の項目が必須になります:

  • 全ての変数の(C言語上での)型指定
  • 全ての関数呼び出しがcdef等価(cdefされているか、Cのライブラリ関数か)

cdefで変数定義

C言語の型を用いて変数を定義したいときにも、基本的にはcdefを用います。この場合はcdefされたCの関数だけでなく、defで作られたPythonの関数の中でも変数のcdefを行うことができます。

Python関数内でcdefで変数を定義するメリットは、その部分のコードがC言語のアルゴリズムに変換されることです。Python上でintの足し算a+bを行う場合、int.__add__(a, b)を呼び出しているのとほとんど変わらずオーバーヘッドがありますが、C言語上なら直接a+bと書かれます。whileループなどが入ってインクリメントや条件式がたくさん実行されるようになると、このオーバーヘッドはそれなりに効いてきます。

別のメリットとして、例えばPythonの変数pyobjint型であるとあらかじめわかる場合(後述)、Cythonはcdef int a = pyobjというコードを「C言語上でのint型への代入」に自動で変換してくれることが挙げられます。これによって、PythonとC言語とをCython上でシームレスに結合することができています。

cpdefを用いたハイブリッド関数定義

今までのところで、defcdefはそれぞれPythonとC言語の関数を定義するのに使えることがわかります。ただ状況によって、PythonのオブジェクトをどうこうできるC言語の関数を作りたいことがあります(コールバック関数など)。そういうときに使うのがcpdefです。cpdefされた関数はPython内での実体を持ちつつ、C言語側からも呼び出せる関数になります。ただPython実体という柔軟性の代償として多少のオーバーヘッドが出ます。

cdefクラス

関数と同様に、クラス定義でもcdefを使うことができます。これは構造体に毛が生えたようなもので、基本的にはCython側から使うことを意図して作られています。

  • Python側から、通常のクラスと同様にインスタンスを作成することができます。
  • インスタンスメソッドは.pyxファイル内でdefと書くと、基本的にcdefと等価になります。
  • インスタンスメソッドは、built-in functionとしてPython側からも呼び出せます。
  • メソッドもインスタンス変数も、基本的にはCython上でしか定義できません。インスタンス変数の初期化および解放は__cinit__および__dealloc__なる関数で行います(__init____del__は呼び出されない可能性が高い)。
    • メソッドはcpdefすることで、Python側でもオーバーライド可能なメソッドになります(が、多少遅くなります)。
    • インスタンス変数はpublic宣言することで、読み出しのみの変数としてPythonから見えるようになります。
    • インスタンス変数の読み書きをしたい場合は、プロパティーにします。@propertyあるいは@xxx.setterデコレータでメソッドを修飾します。このメソッドはcpdefである必要はなく、defでも問題ないようです。

Python本来のもろもろのチェック機構を外す

Pythonには様々なチェック機構があり、これらの機構がPythonの柔軟性を生んでいます。ただ処理速度を追求する際には、これが問題になることがあります。

型チェック

Pythonでは型はあって無きが如しで、オブジェクトの属性アクセスはもっぱらgetattr()call()からできていると言ってもよいでしょう。intfloatであってもこれは変わりません。できる限りCで定義された変数を使うべきです。

関数定義の際に引数の型を指定することで、cdef等価の型(Cネイティブ型、あるいはcdefクラス)であることを保障してコンパイルすることができます:

def pydef_add(int a, int b):
    return a + b

cdef int cdef_add(int a, int b):
    return a + b

たとえばnumpyの場合も、Cの型情報をCython側で用意してくれている(?)のでそれを使うことができます。型情報の読み込みにはcimportキーワードを使います:

cimport numpy as cnumpy

...

cdef cnumpy.ndarray arr # 配列ポインタの定義
cdef cnumpy.float64_t value = 0.0 # numpy.float64型の変数の定義

Noneチェック

Pythonは暗黙的に、呼び出している名前がNoneを指しているかを毎回チェックしています。@cython.nonecheck(False)デコレータを使用することで、このチェックを切ることができます。ただ万が一ここにNoneが渡されるとプログラムがクラッシュするので注意が必要です。

これとは別に、「引数がNoneでない」ということを保障するためにnot Noneという修飾子があります:

def complicated(cnumpy.ndarray array not None):
   pass

配列チェック

Pythonは配列アクセスをする場合、自動的に添え字がオーバーフローしたりしないかをチェックしています。もし自分のコードで「オーバーフロー・アンダーフローが起こらない」ことが保障されるなら、@cython.boundscheck(False)デコレータを使用することでこのチェックを切ることができます。

このほかNumPyの配列を使う場合は、配列の形について一定の宣言をあらかじめすることができ、これによって処理を効率化することができます(コンパイル時にCythonがしてくれます):

def calc_max(cnumpy.ndarray[cnumpy.float64_t, ndim=1] vec):
    ...

上の例のように、配列で用いているデータ型やデータの次元数を指定しておけます。ここにさらに上記@cython.boundscheck(False)をつけることで、より効率的な(Cネイティブに近い)コードになります

GILに注意

感覚的なGILの理解

Pythonにはglobal interpreter lock (GIL)なる機構があり、Pythonの動作が遅くなる理由の半分くらいはこれだと思います。これは以下の原理に対する要請です:

Pythonスレッドがいくらあろうと、ひとつのPythonプロセスにはPythonインタプリタはひとつしか存在しない。

これはある意味では、プロセス内でのPython環境の一意性を保つのには重要な機構です。それで、たくさんのスレッドがひとつのインタプリタにアクセスする際にしっちゃかめっちゃかにならないように、実行中のスレッドだけがインタプリタのロックを取得して、その他のスレッドはロックの順番待ちをしているわけです。これがGILです。

Python環境での処理を減らす

そういうわけなので、Pythonのコードを実行する際には(たとえCythonコードからであっても)確実にGILが取得されます。たとえネイティブスレッドを使っていてもここでGILの取り合いがおこるので、処理速度は確実に落ちます。

なので、処理を早くするには「できるだけCネイティブの処理に置き換える(cdef等価なものだけを使う)」「Cネイティブ処理の間はGILを取得しない」ことが重要になります。Cython内では、with nogil:でコンテキストを始めることで、そのコンテキスト内ではGILなしで実行が行われます。逆にwith gil:というコンテキスト指定もあります。

GIL迷子にならない(コールバック関数には注意)

上記のポイントは逆に言うと、「Pythonオブジェクトを使うときにはいつでもGILが必要」ということです。実際問題、コールバック関数内からPythonオブジェクトを使いたい場合には注意が必要です。

ハードウェア関連のコールバック関数によっては、Pythonプロセスが生成したスレッドではないことがあります。そうするとコールバックの環境からインタプリタのアドレスがわからないので、その中でPythonオブジェクトを使おうとすると「GILが見つからない!」的なエラーが出てプログラムがクラッシュします。

こういう場合は、Pythonから見えるスレッド(Pythonスレッドでも、PyQtのスレッドでも)を自前で立ち上げて、そこでCondition.wait()などでコールバックの発生を待つしかなさそうです。

結語

Cythonは便利だけれど、裏で何が起こっているのか、プログラミングモデルを理解するのが大変です。機能自体はまだまだ(ctypedefとかDEFとか、Cヘッダファイルの読み込みとか)あるけれど、とりあえず基本的な部分の理解ができればよしとしましょう。