公式のドキュメントを読んでもどうしても目から耳へすり抜けてしまうので、ここにまとめておきたいと思います。
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の変数pyobj
がint
型であるとあらかじめわかる場合(後述)、Cythonはcdef int a = pyobj
というコードを「C言語上でのint型への代入」に自動で変換してくれることが挙げられます。これによって、PythonとC言語とをCython上でシームレスに結合することができています。
cpdefを用いたハイブリッド関数定義
今までのところで、def
とcdef
はそれぞれ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
でも問題ないようです。
- メソッドは
C言語のライブラリを呼び出す
外部のライブラリを使うには、そのヘッダーファイルに何が定義されているか(関数プロトタイプ、構造体etc.)をCythonにわかる方式で記述しておく必要があります。大まかに2種類の方法がありますが、記法はどちらも同じです。
.pyx
ファイル内で記述する方法
こちらがより手軽な方法です。イメージとしては「.pyx
ファイルに対応する名前空間に、ライブラリのシンボルがそのまま取り込まれる」という感じです。
標準ライブラリの場合は、以下のような感じになります:
cdef extern from "<math.h>": # 標準ライブラリの場合
DEF HUGE_VAL = 1e500 # #defineマクロ ('='に注意)
double exp(double x) # 関数プロトタイプ
# 使う関数、マクロだけ定義しておけばよい
# この状態で、同じ.pxdファイル内で HUGE_VALやexpを使うことができる
そうでない(普通の)ライブラリの場合は以下:
cdef extern from "spam.h":
DEF ARTIFICIAL = 1 # #defineマクロ
ctypedef int fattype_t # typedef
int spam_counter # グローバル変数
void order_spam(int tons) # 関数プロトタイプ
struct spam: # 構造体定義
fattype_t fat_type
double fat_content
# アクセスする必要があるメンバだけ定義しておけばよい
.pxd
ファイルに宣言する方法
Cythonでは、Cライブラリのextern宣言に特化したファイルを.pxd
(pyrex definitions) という拡張子で作っておくことができます。やることとしては上記と同じで、.pyx
ファイルに直書きする代わりに.pxd
ファイルに書き込むだけです。
こちらはどちらかというと、モジュール的な名前空間にCライブラリのシンボルを定義する感じです。こうすることで、どの.pyx
ファイルからもcimport ...
の形で読み込めるようになって便利になります。
たとえばlibspam.pxd
を以下のように定義しておきます:
# in: libspam.pxd
cdef extern from "spam.h":
void order_spam(int tons)
struct spam:
...
こうしておくと、同じディレクトリにある.pyx
ファイルから、以下のようにCライブラリのシンボルを参照できます:
# in: spamconsumer.pyx
cimport libspam # libspam.pxdの読み込み
libspam.order_spam(20) # spam.hの関数order_spamの呼び出し
cdef libspam.spam *STOCK # spam.hの構造体spamの参照
色々な.pyx
ファイルから参照されるようなライブラリの場合、.pxd
ファイルを作っておいた方が便利です。実際のところ、cimport numpy
では暗黙的に、Cython側で用意されたnumpy用の.pxd
を読み込んでいると思います。
注1: マクロについて
マクロは、Cythonで解釈可能な表現(expression)を使って定義する必要があるようです。なので、場合によってはヘッダーファイルを参照して即値をコピペしなければならない…。マクロ定義された関数をプロトタイプとして表現してよいのか?とか、NAN
みたいな値はどう定義すればよい?などありますが、試したことがないのでよくわかりません。
注2: 構造体について
-
アクセスするメンバだけ定義しておけばよいので、ブラックボックスとして使用するような構造体の場合は
struct Handle: pass
として(メンバ関数の定義なしで)宣言して構いません。 -
対応ヘッダファイルにシンボルが定義されていればよいので、
typdef struct foo { ... } Foo;
で定義されている場合、以下のいずれでもコンパイルは通ります:-
struct foo: ...
を定義してからctypdef foo Foo
とする。 - 直接
struct Foo: ...
として定義する。
-
注3: ビルド引数の記述
標準ライブラリでないライブラリの場合、setuptools
のExtension
のオプションを明示的に指定しないと、コンパイルやリンクが通らないでしょう。
以下がCythonのドキュメンテーションに書いてある雛形です:
from setuptools import Extension, setup
from Cython.Build import cythonize
extensions = [
# 第1引数がモジュール名、
# 第2引数がモジュールに必要なコードファイル(.pyx, .c, ...)の配列
Extension("primes", ["primes.pyx"],
include_dirs=[...], # インクルードディレクトリの配列
libraries=[...], # ライブラリ名の配列(gccの'-l'オプションに渡す名前)
library_dirs=[...]),# ライブラリディレクトリの配列(gccの'-L'オプション)
# Everything but primes.pyx is included here.
Extension("*", ["*.pyx"],
include_dirs=[...],
libraries=[...],
library_dirs=[...]),
]
setup(
name="My hello app",
ext_modules=cythonize(extensions),
)
実際にはExtension
はレシピを書いたコンテナでしかありません。ビルド作業そのものは、setup()
中に出てくるcythonize
関数が行ないます。
他で書いた通り、numpy.get_include()
あるいはpkg-config <lib> --cflags
のようなコマンドを使うことで、ここの入力をある程度自動化することはできます。ユースケースによっては、最悪ユーザに環境変数を設定させて、ビルトスクリプトではそれを見に行く、という方式でもよいのかもしれない…。
Python本来のもろもろのチェック機構を外す
Pythonには様々なチェック機構があり、これらの機構がPythonの柔軟性を生んでいます。ただ処理速度を追求する際には、これが問題になることがあります。
型チェック
Pythonでは型はあって無きが如しで、オブジェクトの属性アクセスはもっぱらgetattr()
とcall()
からできていると言ってもよいでしょう。int
やfloat
であってもこれは変わりません。できる限り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ヘッダファイルの読み込みとか)あるけれど、とりあえず基本的な部分の理解ができればよしとしましょう。