LoginSignup
64
52

More than 3 years have passed since last update.

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

Last updated at Posted at 2018-02-14

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

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でも問題ないようです。

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: 構造体について

  1. アクセスするメンバだけ定義しておけばよいので、ブラックボックスとして使用するような構造体の場合はstruct Handle: passとして(メンバ関数の定義なしで)宣言して構いません。
  2. 対応ヘッダファイルにシンボルが定義されていればよいので、typdef struct foo { ... } Foo;で定義されている場合、以下のいずれでもコンパイルは通ります:
    1. struct foo: ...を定義してからctypdef foo Fooとする。
    2. 直接struct Foo: ...として定義する。

注3: ビルド引数の記述

標準ライブラリでないライブラリの場合、setuptoolsExtensionのオプションを明示的に指定しないと、コンパイルやリンクが通らないでしょう。

以下が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()からできていると言ってもよいでしょう。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ヘッダファイルの読み込みとか)あるけれど、とりあえず基本的な部分の理解ができればよしとしましょう。

64
52
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
64
52