Cython : C との融合による高速化

Last Change: 15-Jan-2016.
author : qh73xe

このページでは cython に関するまとめを行います. 一般に python や perl, Ruby 等のスクリプト言語は気軽にスクリプトを組める代わり に,実行速度に関してはコンパイル言語である C や java よりも遅いと言われています.

このページで紹介する cython は python の実行速度の遅さを,C 言語と融合することで解決しようというアプローチです. Cython は独自に python の記法を拡張し,一度 C にコンパイルすることで,python とほぼ同程度の使い勝手で C 並の実行速度を実現します. また,C 及び C++ ライブラリのラッパーを記述することも可能です.

注釈

このページに関して

まず,申し訳ないことですが,私は何も Cython を熟知しているわけではありません. むしろ,熟知しようと思い,その勉強のためのノートとしてこのページを作成しています.

Cython の基礎

この章ではとりあえず Cython を実行し,通常の python コードとの比較を行うことを目的にしています. 例題として n 番目の フィボナッチ数列 を計算する python 関数を作成してみます.

fib.py
def fib(n):
    a, b = 0.0, 1.0
    for i in range(n):
        a, b = a + b, a
    return a

上記のコードを C で記述すると以下のようになります.

fib in C
double cfib(int n){
    int i;
    double a=0.0, b=1.0, tmp;
    for (i=0, i<n; ++i){
        tmp = a; a = a + b; b = tmp;
    }
    return a;
}

で,上記2つのコードを融合したものが Cython のコードになります. 具体的には型の宣言を行っていく感じです.

fib in cython
def fib(int n):
    cdef int i
    cdef double a = 0.0, b = 1.0
    for i in range(n):
        a, b = a + b, a
    return a

Cython コードのコンパイルと実行

python はインタープリタ言語であるのに対し, C 言語はコンパイル言語です.

python がスクリプトを修正したらば すぐに実行できるのに対し,C はビルドという処理を挟む必要が出てきます.

で, Cython ですが,これは C と同様にビルド処理を噛ませる必要があります. このコンパイルステップは明示的に実行することも暗黙のうちに実行することも可能です. 以下しばらくは cython の実行方法について説明をしていきます.

cython の実行方法は大きく以下の4種類の方法があります.

  • Ipython インタープリタで対話的にコンパイルして実行する
  • インポート時に自動コンパイルする
  • Python の distutils のようなビルドツールを使って個別にビルドする
  • make, CMake, SCons などの標準ビルドシステムに統合する

これらの方法があるために Cython は対話的な使用から, 何年も使用するコードをビルド することまでできます.

どの場合でも Cython のソースコードは二段階のコンパイルステージで処理を行い, python がインポートできるモジュールを生成します.

Ipython を使用した対話的 Cython

まずは,対話的に Cython を使用する方法をメモしておきます. これは python を使用する人間(というかインタープリタ言語を使用する人間)にとって 最も重要な使用方法であると考えているからです.

Cython は上記の通りコンパイル処理が必要になるわけですが, ipython を使用すると,コンパイルに関しては裏で自動実行をさせることができ, 実質的にはほとんどコンパイルを気にする必要がなくなります.

注釈

Ipython に関して

この方法で Cython を使用するためには Ipython が必要になります. Ipython に関しての詳細は別途記述するつもりです. Ipython とはようは python のインタラクティブシェルを機能拡張したものであり, 通常の python shell にはない機能(マジックコマンド)を使用することが可能です. Cython を動的にコンパイルする機能もこのマジックコマンドとして実装されています.

とりあえず,導入に関しては pip を使用すればよいです.

Ipython で Cthon コードの動的なコンパイルを行うためには,まずそれらの機能を Ipython 側でロードする必要があります. Ipython 側でマジックコマンドのロードを行うにはマジック関数 ‘%load_ext’ を使用します.

%load_ext Cython

読み込みが成功された場合には何も出力がなされません. 一方何か問題がある場合(ようは Cython 関連のマジックコマンドが見つからない場合) にはエラーが起きます.

警告

load_ext に関して

この関数で読み込むべきモジュール名が最近変更されたようです. 上記の例は最新のものになっております. 少し古い資料では load_ext cythonmagic となっているので注意してください.

pyximport による動的コンパイル

Ipython による動的コンパイルは対話的に使用するには便利ですが,スクリプトから Cython で書いた関数を実行したい場合には,向いていません(ipython 依存なので).

そのため,ここでは Cython コードを通常の python モジュール(ようはライブラリでもいいですが) のように import して使用する方法を説明します. タイトルにもあるように,この実行方法でもコンパイルは動的になされるため 実質上気にすることはありません.

cython は究極的には python ではない言語であるため, 通常の import 文で cython コードを読み込むことはできません. そのため, Cython ライブラリでは cython コードを import するための関数を用意して います.

ここでは,先ほどから何回か登場している fib 関数を用意しましょう. これを任意のディレクトリに記述し, fib.pyx として保存します. 通常の import 文と同様,この fib.pyx と同じ階層のディレクトリ内で python のイン タラクティブシェルを実行します(別に Ipython でもいいですし,スクリプトを用意してもよいです).

import pyximport
pyximport.install()
from fib import fib
fib(100)

今回は python が実行可能なモジュールとして cython コードを読み込んでいます. そのため通常のライブラリの使用と同様, from , import 文を使用して fib モジュール の fib 関数をロードし,実行しています.

Cython のプロファイリングツール

さて, cython を使用すると python の中で C 言語を使用できることは分かりました. しかし,肝心なのは,我々は結局 「早い python」 を使いたいのであって C を使いたいわけではないということです. つまり,重要なのは最低限の C を使って我々のコードを最速化したいというのが目的であるはずだということです.

で,ここで必要になる作業がどこは C を使うべきかという判断です. 幸いなことに Cython ではこの場所を見つけるためのツールを有しています.

この章で使用する関数

この章では以下の関数を高速化していくことを考えます.

integrate.py
def integrate(a, b, f, N=20000):
    dx = (b - a) / N
    s = 0.0
    for i in range(N):
        s += f(a + i * dx)
    return s * dx

Cython の実行時間のプロファイリング

さて,そもそもの話ですが python にはプロファイリング環境が結構充実しています. 組み込みの profile ライブラリ(および,より高速な cProfile) を使えば, 実行時プロファイリングは簡単にできます. また Ipython の %timeit, %run を使用すれば対話的なプロファイリングも容易でしょう. しかしこれらの機能は cython を使用する場合少し,不都合が生じます. プロファイリングツール自体が言語の壁を超えることができず, C レベルの処理のプロファイリング情報が消えてしまうのです.

そのため, Cython ではこれらの実行時プロファイラとうまく連携をする C コードを作 成し,C レベルの処理が python ネイティブの処理であるようにプロファイラを騙します.

では早速,integrate.py を作成しておきましょう. その上でプロファイリング用のスクリプト(main.py)を作成します.

main.py
from integrate import integrate
from math import sin, pi


def sin2(x):
    return sin(x) ** 2


def main():
    a, b = 0.0, 2.0 * pi
    return integrate(a, b, sin2, N=400000)


if __name__ == "__main__":
    import cProfile
    cProfile.run('main()', sort='time')

まだ,ここで使用している integrate は純粋な python であるため, 通常どおりの cProfile を使っています.

さて,この出力は以下の通りです.

$ python main.py
      800005 function calls in 1.053 seconds

Ordered by: internal time

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1    0.522    0.522    1.053    1.053 integrate.py:1(integrate)
400000    0.394    0.000    0.530    0.000 main.py:5(sin2)
400000    0.137    0.000    0.137    0.000 {built-in method sin}
     1    0.000    0.000    1.053    1.053 {built-in method exec}
     1    0.000    0.000    1.053    1.053 main.py:9(main)
     1    0.000    0.000    1.053    1.053 <string>:1(<module>)
     1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

プロファイリング結果の見方を簡単に説明すると以下の通りです.

  • ncalls : 関数が読み出された回数
  • tottime: ある関数から呼び出された関数の実行時間を含まない,その関数単独の実行時間
  • percall: tottime を ncalls で割った値
  • その他:関数名とか

今回は main.py 内で tottime でソートをするように設定しています. で,結果を見ればわかるように一番時間のかかっている関数は integrate であると分かります. そのため,これを高速化してみましょう.

まずは内部の実装は変更せず単純にファイル名を .py から .pyx にしてみます. これでも有効な cython コードではあるため,ある程度実行が高速化されるかと思います. cython モジュールの読み込みであるため main.py を少し変更します.

main.py
import pyximport
pyximport.install()
from integrate import integrate
from math import sin, pi


def sin2(x):
    return sin(x) ** 2


def main():
    a, b = 0.0, 2.0 * pi
    return integrate(a, b, sin2, N=400000)


if __name__ == "__main__":
    import cProfile
    cProfile.run('main()', sort='time')

さて,これを実行すると以下のようになります.

$ python main.py
      800005 function calls in 1.011 seconds

Ordered by: internal time

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1    0.484    0.484    1.011    1.011 {integrate.integrate}
400000    0.389    0.000    0.527    0.000 main.py:7(sin2)
400000    0.138    0.000    0.138    0.000 {built-in method sin}
     1    0.000    0.000    1.011    1.011 {built-in method exec}
     1    0.000    0.000    1.011    1.011 main.py:11(main)
     1    0.000    0.000    1.011    1.011 <string>:1(<module>)
     1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

先ほどの結果と比較して integrate 関数の実行時間が短くなっています. では更に高速化するために integrate 関数に静的な型付を行います.

integrate.pyx
def integrate(double a, double b, f, int N=20000):
    cdef:
        int i
        double dx = (b - a) / N
        double s = 0.0
    for i in range(N):
        s += f(a + i * dx)
    return s * dx

これで main.py を実行すると以下のようになります.

$ python3 main.py
      800005 function calls in 1.056 seconds

Ordered by: internal time

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
400000    0.528    0.000    0.679    0.000 main.py:7(sin2)
     1    0.376    0.376    1.056    1.056 {integrate.integrate}
400000    0.152    0.000    0.152    0.000 {built-in method sin}
     1    0.000    0.000    1.056    1.056 {built-in method exec}
     1    0.000    0.000    1.056    1.056 main.py:11(main)
     1    0.000    0.000    1.056    1.056 <string>:1(<module>)
     1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

ついに sin2 よりも interate が高速化されました. では今度は sin2 を高速化していきます. ここで sin2 は main.py で実装をしていました. これを integrate.pyx に移動します.

$ python main.py
      5 function calls in 0.646 seconds

Ordered by: internal time

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1    0.645    0.645    0.645    0.645 {integrate.integrate}
     1    0.000    0.000    0.646    0.646 {built-in method exec}
     1    0.000    0.000    0.645    0.645 main.py:7(main)
     1    0.000    0.000    0.645    0.645 <string>:1(<module>)
     1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

これだけでも結構高速化されているのがわかるかと思います. ところで,プロファイリングの結果から sin2 が消えてしまっていることにお気づきでしょうか? また, ncalls の回数の大きく減っているように見えます.

これは,別に実際の実行回数が減っているわけではなく, sin2 とその内容をコンパイルするようにしたためにプロファイラが検知をしていないためです. これが冒頭に言っていた問題です.

これを解決するためには Cython に実行時プロファイリングを行うように明示する必要が あります.

interate.pyx の冒頭に profile コンパイラディレクティブを追加しグローバルに有効にします.

$ python main.py
      400006 function calls in 0.149 seconds

Ordered by: internal time

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
400000    0.082    0.000    0.082    0.000 integrate.pyx:5(sin2)
     1    0.067    0.067    0.149    0.149 integrate.pyx:9(integrate)
     1    0.000    0.000    0.149    0.149 {built-in method exec}
     1    0.000    0.000    0.149    0.149 main.py:7(main)
     1    0.000    0.000    0.149    0.149 <string>:1(<module>)
     1    0.000    0.000    0.149    0.149 {integrate.integrate}
     1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

今度はトータルの実行時間が大きく増加してしまいます. これはプロファイラの実行によって,オーバーヘッドが起き,計測対象のコードの実行時間が歪められてしまったためです.

さて,合計の実行時間が最も長いのは依然として sin2 です. これをどうにか高速化することを考えます. 幸いにして, C の標準ライブラリにも sin はありますのでこれを使用してみましょう. これは cimport を利用すればよいです.

integrate.pyx
# cython: profile=True
from libc.math cimport sin
#from math import sin


def sin2(x):
    return sin(x) ** 2


def integrate(double a, double b, f, int N=20000):
    cdef:
        int i
        double dx = (b - a) / N
        double s = 0.0
    for i in range(N):
        s += f(a + i * dx)
    return s * dx
  • 上記のコードでは比較のため,コメントアウトを行っています.

結果は以下のようになります.

$ python main.py
      400006 function calls in 0.100 seconds

Ordered by: internal time

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1    0.066    0.066    0.100    0.100 integrate.pyx:10(integrate)
400000    0.034    0.000    0.034    0.000 integrate.pyx:6(sin2)
     1    0.000    0.000    0.100    0.100 {built-in method exec}
     1    0.000    0.000    0.100    0.100 main.py:7(main)
     1    0.000    0.000    0.100    0.100 {integrate.integrate}
     1    0.000    0.000    0.100    0.100 <string>:1(<module>)
     1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

さて,高速化はこんな所にして最後に cython の プロファイラをオフにして実行してみます.

$ python main.py
      5 function calls in 0.029 seconds

Ordered by: internal time

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1    0.029    0.029    0.029    0.029 {integrate.integrate}
     1    0.000    0.000    0.029    0.029 {built-in method exec}
     1    0.000    0.000    0.029    0.029 main.py:7(main)
     1    0.000    0.000    0.029    0.029 <string>:1(<module>)
     1    0.000    0.000    0.000    0.000 {method 'disable' of '_lsprof.Profiler' objects}

最初の計測と比較して格段に早くなっていることがわかるかと思います. この章では, cython による高速化をプロファイリングと併用して行ってきました. これだけでも結構簡単に cython が使用できること,かなりの高速化を見込めることがお分かりになるかと思います.

次に何をするか

ここまでで基本的な cython の使い方は説明できたと思います. 以下に,より詳しい説明を書いていきます.