Python による C の関数の呼び出し

2016年2月14日

はじめに

Python による C の関数の呼び出しについて試してみた。元ネタは Micha Gorelick, Ian Ozsvald 著 "ハイパフォーマンス Python"。

環境

  • Intel Core i5 M450 2.4 GHz
  • Windows 7 64 bit
  • MSYS2 64 bit (MinGW w64)
  • Anaconda for Windows (Python 2.7)

※MinGW にも Python が入っているので、Anaconda のほうを使うようにパスを設定している。

コード

サンプルコードは以下の通り。

test.c

#include <stdio.h>
#include <math.h>

#include "test.h"


void test(int n, double a[][2])
{
    double dx = 2*M_PI/(n - 1);
    int i;

    for(i = 0; i < n; i++)
    {
        double x = i*dx;
        double f = sin(x);
        a[i][0] = x;
        a[i][1] = f;
    }
}

test.h

#ifndef TEST_H
#define TEST_H

void test(int n, double a[][2]);

#endif /* TEST_H */

Makefile

all:
	gcc -Wall -O2 -c test.c
	gcc -shared -o test.so test.o

x = [0, 2π] の範囲で sin 関数を計算した結果をリストで返すものである。

比較のため、同じ処理を行う Python コードを以下に示す。

call_func.py

from math import pi, sin


def test(n):
    dx = 2*pi/(n - 1)
    r = []
    for i in xrange(n):
        x = i*dx
        f = sin(x)
        r.append((x, f))
    return r

Python 関数の実行

比較のため、まず Python で書かれた関数の実行速度を計測する。生のコードの結果は以下の通りである。

$ python -m timeit -n 5 -r 5 -s "import call_func" "call_func.test(1000000)"
5 loops, best of 5: 505 msec per loop

Numba 版

Numba を使ってみる。コードは以下の通り。

from math import pi, sin
from numba import jit


@jit
def test(n):
    dx = 2*pi/(n - 1)
    r = []
    for i in xrange(n):
        x = i*dx
        f = sin(x)
        r.append((x, f))
    return r

結果は以下の通り。

$ python -m timeit -n 5 -r 5 -s "import call_func" "call_func.test(1000000)"
5 loops, best of 5: 221 msec per loop

Cython 版

Cython も試してみる。コードは以下の通り。

call_func.pyx

from math import pi, sin


def test(int n):
    cdef double dx
    cdef int i
    cdef double x
    cdef double f
    
    dx = 2*pi/(n - 1)
    r = []
    for i in xrange(n):
        x = i*dx
        f = sin(x)
        r.append((x, f))
    return r

setup.py

from distutils.core import setup
from distutils.extension import Extension
from Cython.Distutils import build_ext

setup(
    cmdclass={'build_ext': build_ext},
    ext_modules=[Extension("calculate", ["call_func.pyx"])]
)

コンパイル

$ python setup.py build_ext --inplace

結果は以下の通り。

$ python -m timeit -n 5 -r 5 -s "import calculate" "calculate.test(1000000)"
5 loops, best of 5: 278 msec per loop

Numba のほうが速い。Cython よくわかっていないので、もう少しなんとかなるのかもしれないが、ここでの目的からずれるので、これでよしとする。

Python から C の関数を呼び出す

Python から C の関数を呼び出す仕組みとして、ctypes や CFFI がある。

C のライブラリの準備

C の関数を呼び出すために、まずライブラリをコンパイルする。GCC だと、次のようにする。

$ gcc -Wall -O2 -c test.c
$ gcc -shared -o test.so test.o

ctypes

ctypes は Python の標準モジュールである。次のように用いる。

call_c_func_ctypes.py

import ctypes


_test = ctypes.CDLL('test.so')

TYPE_INT = ctypes.c_int
TYPE_DOUBLE_2 = ctypes.c_double*2
TYPE_DOUBLE_P_2 = ctypes.POINTER(TYPE_DOUBLE_2)

# void test(int n, double a[][2]);
_test.test.argtypes = [
    TYPE_INT,
    TYPE_DOUBLE_P_2
]
_test.test.restype = None


def test(n):
    _n = TYPE_INT(n)

    TYPE_DOUBLE_N_2 = TYPE_DOUBLE_2*n

    _r = [TYPE_DOUBLE_2(0, 0) for i in xrange(n)]
    _r = TYPE_DOUBLE_N_2(*_r)
    
    _test.test(_n, _r)

    r = [[elem[i] for i in (0, 1)] for elem in _r]

    return r

ctypes.CDLL() でライブラリを読み込んでいる。ctypes.c_int などは、ctypes で定義されている C のデータ型である。"ctypes.c_double*2" は要素を 2 つ持つ配列、ctypes.POINTER(x) は x のポインタを表す。

C の関数を呼び出すために、引数と返り値の型を指定する。_test.test.argtypes に引数の型の配列を、_test.test.restype に返り値の型を入れる。

関数の引数は Python のデータから ctypes のデータに変換する必要がある。ふつうの変数は単純である。

    _n = TYPE_INT(n)

配列がややこしい。

    _r = [TYPE_DOUBLE_2(0, 0) for i in xrange(n)]
    _r = TYPE_DOUBLE_N_2(*_r)

TYPE_DOUBLE_N_2 型 (実体は (c_types.c_double*2)*n) には TYPE_DOUBLE_2 型 (実体は ctypes.c_double*2) の引数を N 個渡す必要があり、TYPE_DOUBLE_2 型には double 型の引数を 2 個渡す必要があるので、まず TYPE_DOUBLE_2 型の要素を N 個持つリストを作り、それを展開して TYPE_DOUBLE_N_2 に渡している。

型変換した引数を用いて関数を呼び出す。

    _test.test(_n, _r)

実行結果は以下の通り。

$ python -m timeit -n 5 -r 5 -s "import call_c_func_ctypes" "call_c_func_ctypes.test(1000000)"
5 loops, best of 5: 3.5 sec per loop

Python 版の結果の単位は msec だったが、今回の結果は単位が sec になっている。つまり、実行速度がものすごく遅い。C で書けば速度が速くなるかというと、そうでもないということである。この場合は、関数が遅いというより、関数を呼び出す前後のデータの準備・後処理に時間がかかっている。

上記のコードを NumPy を用いて書き直す。

call_c_func_ctypes2.py

import ctypes
import numpy as np


_test = ctypes.CDLL('test.so')

TYPE_INT = ctypes.c_int
TYPE_DOUBLE_2 = ctypes.c_double*2
TYPE_DOUBLE_PP = ctypes.POINTER(ctypes.POINTER(ctypes.c_double))

# void test(int n, double a[][2]);
_test.test.argtypes = [
    TYPE_INT,
    TYPE_DOUBLE_PP
]
_test.test.restype = None


def test(n):
    _n = TYPE_INT(n)

    r = np.zeros((n, 2))
    _r = r.ctypes.data_as(TYPE_DOUBLE_PP)
    
    _test.test(_n, _r)

    return r

今回は NumPy のメモリをそのまま用いているため、データの受け渡しの手間が要らなくなっている。結果は以下の通り。

$ python -m timeit -n 5 -r 5 -s "import call_c_func_ctypes2" "call_c_func_ctypes2.test(1000000)"
5 loops, best of 5: 54.6 msec per loop

Python 版より 10 倍ほど速くなった。よかった。返り値の型が変わってしまっているが、使い方はさほど変わらないので、よしとする。

CFFI

CFFI (C Foreign Function Interface for Python) というモジュールでも、ctypes と同様のことができる。

call_c_func_cffi.py

from cffi import FFI


ffi = FFI()

ffi.cdef(r'''
void test(int n, double a[][2]);
''')

lib = ffi.dlopen('test.so')

def test(n):
    _r = ffi.new('double[%d][2]' % n)

    lib.test(n, _r)

    r = [[elem[i] for i in (0, 1)] for elem in _r]

    return r

ctypes よりだいぶスッキリしたコードになっている。関数のシグネチャの情報は、ffi.cdef() で C の関数宣言をそのまま書くことで表現されている。

実行結果は以下の通り。

$ python -m timeit -n 5 -r 5 -s "import call_c_func_cffi" "call_c_func_cffi.test(1000000)"
5 loops, best of 5: 683 msec per loop

ctypes ほどではないが、やはり遅い。

NumPy 版は以下の通り。

call_c_func_cffi2.py

from cffi import FFI
import numpy as np


ffi = FFI()

ffi.cdef(r'''
void test(int n, double a[][2]);
''')

lib = ffi.dlopen('test.so')

def test(n):
    r = np.zeros((n, 2))
    _r = ffi.cast('double(*)[2]', r.ctypes.data)

    lib.test(n, _r)

    return r

ffi.new() でデータを作る代わりに、NumPy でデータを作り、それをキャストして渡している。返り値のデータコピーがいらなくなっている。結果は以下の通り。

$ python -m timeit -n 5 -r 5 -s "import call_c_func_cffi2" "call_c_func_cffi2.test(1000000)"
5 loops, best of 5: 54.1 msec per loop

Python コードの高速化

そもそも比較した Python コードは効率が悪いのではないかという気がしてきたので、コードの高速化を図った。

リスト内包表記

call_func2.py

from math import pi, sin


def test(n):
    dx = 2*pi/(n - 1)
    r = [(i*dx, sin(i*dx)) for i in xrange(n)]
    return r

結果

$ python -m timeit -n 5 -r 5 -s "import call_func2" "call_func2.test(1000000)"
5 loops, best of 5: 448 msec per loop

少し速くなった。

NumPy の利用

call_func_numpy.py

from math import pi, sin
import numpy as np


def test(n):
    dx = 2*pi/(n - 1)
    r = np.c_[np.arange(n)*dx, np.sin(np.arange(n)*dx)]
    return r

結果

$ python -m timeit -n 5 -r 5 -s "import call_func_numpy" "call_func_numpy.test(1000000)"
5 loops, best of 5: 54.1 msec per loop

C に匹敵する速度。

まとめ

  • Python から C の関数を呼び出すのは、ctypes や CFFI で可能。
  • 前処理の簡便さから、CFFI のほうが使いやすいかもしれない。
  • NumPy を使うべし (むしろ C いらない…?)。

参考文献