Posted at
PythonDay 12

Python3でCのAPI

More than 1 year has passed since last update.


なぜイマサラAPI?

Pythonを高速化させる方法は色々あると思います。


  • Pythonのリストの処理をNumpyの配列にする

  • Numbaを使う

  • Cythonを使う

  • Boost.Pythonを使う

  • SWIGを使う

他にもあるかもしれませんが、CをAPI化する理由としては。


  • 既に存在しているCのコードを利用したい

  • 部分的な処理がボトルネックでCで実行すると改善することが見込まれる

  • 再配布したい

特に最後の項目は、最近のsetuptoolsが進化して、buildとwheel化が容易になってきたため、API化も再考する余地がありそうです。

筆者のようにCを全く書けない人間が、有識者にもらった小規模のCのプログラムを有効活用したい場合等、ニッチなところで需要があるような気がします。


題材

サンプルとして、エラトステネスの篩というアルゴリズムを使ってみます。


詳しくは寡聞にして知らないのでリンク先を参考にしていただくとして、素数判定法のアルゴリズムの一つで、指定された整数以下の素数を探索をします。


例えば、与えられた整数が10であるならば、[2, 3, 5, 7]が探索されます。

Cのコードはこちらを参考にしました。

#include <stdio.h>

#include <time.h>
#include <sys/time.h>

#define MAX 1000000

double gettimeofday_sec()
{
struct timeval tv;
gettimeofday(&tv, NULL);
return tv.tv_sec + (double)tv.tv_usec*1e-6;
}

double prim(int n)
{
double start,end;
start = gettimeofday_sec();

int i, j;
int p[n];

for(i=0 ; i<n ; i++) p[i] = 0;
p[0] = 1;

for(i=2 ; i<=n/2 ; i++){
for(j=2 ; i*j<=n ; j++){
if(p[i*j-1] == 0)
p[i*j-1] = 1;
}
}

end = gettimeofday_sec();
return end - start;
}

探索結果を全て出力すると多すぎるため、処理時間を返す関数としました。


準備


virtualenv

予め仮想環境の作成を強く推奨します。


今回はvirtualenvwrapperがインストールされていることを前提に進めていきます。


仮想環境名はapi_testとします。


mkvirtualenvのキーワード引数--python=にビルドしたいpythonのバージョンを指定します。


これは後述するパッケージのビルド(wheelの作成)に大きく影響するため、パッケージを配布する対象のPythonを指定します。


筆者の場合、Pathは/usr/bin/python3.5となりましたが、ご自身の環境に合わせてください。

mkvirtualenv api_test --python=/usr/bin/python3.5


ディレクトリ構成

任意のディレクトリを作成し、最終的には下記の構成になります。

.

├── api_sample
│   ├── __init__.py
│   └── py
│   └── __init__.py
├── prim.c
└── setup.py

ファイルやディレクトリの作成がメンドウなかたは、筆者のgithubのリポジトリからcloneする方法もあります。

git clone https://github.com/drillan/python3_c_api_sample.git


CのコードをPython API化

ディレクトリ直下のprim.cを作成して編集します。


ヘッダ

Python API を取り込みます。

#include <Python.h>

Python.hで定義されているユーザから可視のシンボルは、全て接頭辞PyまたはPYが付いているようです。

システムによっては標準ヘッダの定義に影響するようなプリプロセッサ定義を行っているので、 Python.hをいずれの標準ヘッダよりも前にインクルードしないといけないようです。


Cの関数をPythonのオブジェクト化

今回は上記Cのサンプルコードのprim()関数をPythonのモジュールとして扱うことにします。

static PyObject *

prim(PyObject *self, PyObject *args)
{
int n;
if(!PyArg_ParseTuple(args, "i", &n))
return NULL;

double start,end;
start = gettimeofday_sec();

int i, j;
int p[n];

for(i=0 ; i<n ; i++) p[i] = 0;
p[0] = 1;

for(i=2 ; i<=n/2 ; i++){
for(j=2 ; i*j<=n ; j++){
if(p[i*j-1] == 0)
p[i*j-1] = 1;
}
}

end = gettimeofday_sec();
return Py_BuildValue("d", end - start);
}

self引数には、モジュールレベルの関数であればモジュールが、メソッドにはオブジェクトインスタンスが渡されます。


args引数は、引数の入った Pythonタプルオブジェクトへのポインタになります。タプル内の各要素は、呼び出しの際の引数リストにおける各引数に対応します。


引数がPythonから与えられるため、このようなハンドリングが必要となります。


また、与えられた引数はCの型に変換する必要があるため、PyArg_ParseTuple()を使用しています。


第二引数の"i"はint型を指し、"d"ならdouble型、"s"ならchar型といった感じになります。

最後にPy_BuildValue()によって再度、Pythonで受け取れる型に返します。

このように、Cの関数をPythonのAPI化するには、Python -> C -> Pythonという流れを意識する必要があってメンドウですね。


メソッドテーブルの登録

上記で作成したprim()関数をPythonのメソッドとして呼び出すことができるよう、メソッドテーブルに登録します。

static PyMethodDef methods[] = {

{"prim", prim, METH_VARARGS},
{NULL, NULL}
};

3つのエントリは順番にメソッド名、C実装へのポインタ、呼び出しをどのように行うかを示すフラグビットになります。


METH_VARARGSはPyCFunction型のメソッドで典型的に使われる呼び出し規約で、関数への引数がタプル形式で与えられます。


キーワード引数を与えたい場合にはMETH_KEYWORDSを指定します。 


docstring

通常はPyDoc_STRVAR()を使って生成するようです。


後述のモジュールの登録時にdocstringとして登録できます。

PyDoc_STRVAR(api_doc, "Python3 API sample.\n");


モジュールの登録

Pythonからimportできるよう、モジュール名等を登録します。


モジュール名、docstring、モジュールのメモリ領域、メソッドを指定します。

static struct PyModuleDef cmodule = {

PyModuleDef_HEAD_INIT,
"c", /* name of module */
api_doc, /* module documentation, may be NULL */
-1, /* size of per-interpreter state of the module,
or -1 if the module keeps state in global variables. */

methods
};

今回、モジュール名はcとしました。


3つめの-1を指定すると、そのモジュールはグローバルな状態を持つためにサブ・インタープリターをサポートしていないということになるようです。筆者自身もわからないので、詳しく知りたいかたはPEP 3121を参照してください。


初期化関数

cモジュールがimportされた際にPyInit_c()関数が呼ばれます。


PyModule_Create()関数にて上記で定義したモジュールを生成し、初期化関数に返します。(__init__.pyみたいなもの?)


Pyinit_name()関数はモジュール名によって名前が異なるので注意してください。


最終的なprim.cの内容


prim.c

#include <Python.h>

#include <stdio.h>
#include <time.h>
#include <sys/time.h>

#define MAX 1000000

double gettimeofday_sec()
{
struct timeval tv;
gettimeofday(&tv, NULL);
return tv.tv_sec + (double)tv.tv_usec*1e-6;
}

static PyObject *
prim(PyObject *self, PyObject *args)
{
int n;
if(!PyArg_ParseTuple(args, "i", &n))
return NULL;

double start,end;
start = gettimeofday_sec();

int i, j;
int p[n];

for(i=0 ; i<n ; i++) p[i] = 0;
p[0] = 1;

for(i=2 ; i<=n/2 ; i++){
for(j=2 ; i*j<=n ; j++){
if(p[i*j-1] == 0)
p[i*j-1] = 1;
}
}

end = gettimeofday_sec();
return Py_BuildValue("d", end - start);
}

static PyMethodDef methods[] = {
{"prim", prim, METH_VARARGS},
{NULL, NULL}
};

PyDoc_STRVAR(api_doc, "Python3 API sample.\n");

static struct PyModuleDef cmodule = {
PyModuleDef_HEAD_INIT,
"c", /* name of module */
api_doc, /* module documentation, may be NULL */
-1, /* size of per-interpreter state of the module,
or -1 if the module keeps state in global variables. */

methods
};

PyInit_c(void)
{
return PyModule_Create(&cmodule);
}



__init__.pyに登録

そのままビルドしてimportすることもできますが、パッケージを意識してapi_sample/__init__.pyに登録しておきます。


api_sample/__init__.py

import c



おまけ、Pythonコードの作成

せっかくなのでPythonで同じコードを書いて速度を比較してみます。


api_sample/py/__init__.pyprim.cと同等のコードを書いて保存します。


上記とファイル名は同じですが階層が異なるので注意してください。


api_sample/py/__init__.py

import time

MaxNum = 1000000

def prim(n):
start = time.time()
prime_box = [0 for i in range(n)]
prime_box[0], prime_box[1] = 1, 1
for i in range(n)[2:]:
j = 1
while i * (j + 1) < n:
prime_box[i * (j + 1)] = 1
j += 1
end = time.time()
return end - start

if __name__ == '__main__':
print(prim(MaxNum))



ビルド

いよいよビルドですが、なんとsetup.pyに書いておくだけでsetuptoolsがビルドしてくれるようです。便利な時代になりました。


ということでsetup.pyを作成します。


setup.py

from setuptools import setup, Extension

c = Extension('c', sources=['prim.c'])
setup(name="api_sample", version="0.0.0",
description="Python3 API Sample",
packages=['api_sample'], ext_modules=[c])


setuptools.Extension()でCのソースコードの場所を指定することで、Pythonの拡張としてビルドされることがポイントでしょうか。


setuptools.setup()のキーワード引数ext_modulesに上記で指定したモジュール名を指定することで、Pythonのモジュールとして呼び出すことが可能となります。

python setup.py build

上記を実行することで必要なモジュールがビルドされ、buildディレクトリに保存されます。


インストール

早速インストールして使ってみましょう。

python setup.py install

pip freeze

問題なくインストールできれば、下記のように出力されます。

api-sample==0.0.0


実行

まずはPythonのコードから実行してみます。

python -c "from api_sample import py; print(py.prim(1000000))"

筆者の環境ではこれくらいでした

5.6855926513671875

続いてCのコード。

python -c "from api_sample import c; print(c.prim(1000000))"

0.0776968002319336

大分速くなりました!


配布

wheelを作成します。

python setup.py bdist_wheel

筆者の環境ではapi_sample-0.0.0-cp35-cp35m-linux_x86_64.whlというファイルができました。

上記を実行するとdistディレクトリに実行環境に合わせたwheelファイルが作成されます。


virtualenvを推奨した最大の理由がこれで、仮想環境を切り替えてwheelを再作成することにより、環境に合わせたパッケージが作成されるため、配布が大変に便利です。


Windows環境

ビルドが必要なパッケージでよくネックとなるのがWindows環境です。


通常はVisual Studioが必要なのですが、Visual C++ Build Toolsをインストールすることでビルドが可能になります。


これをインストールした状態で、python setup.py bdist_wheelを実行することで、Windows用のwheelがビルドされ、ビルド環境がないユーザに対しても配布することが可能となります。


また、32bit、64bitそれぞれのPythonをインストールし、それぞれのvirtualenv環境でビルドすることにより、32bit/64bit両方に対応したwheelを作成することができます。

PyPIに各プラットフォームに対応したwheelを登録しておけば、ユーザはpip installで簡単にインストールすることができます。


参考リンク