Cythonの問題点と活用法

Cythonの問題点

エディタのサポートが壊滅的

Hover, tokenization, syntax highlightその他もろもろ、あったらいいな機能が皆無。

自分で作ればいいじゃん!→技術的な壁

ちょっとしたもの(keywordのハイライトとか)なら作れるけど、定義済みの変数とか使用されていない変数、存在するモジュールにトークナイズとなるとうーん。労力に見合わないかなと。

したがって、若干色付けされた白地のコードをlinter無しで書くことになる。

コンパイル処理が入るためミスがあれば大体そこで気付けるが、大規模なコードには向かない。

Cythonの活用法

Cythonで実装を書くのではなく、C/C++で実装したものをPythonから呼び出すためのインターフェイスを提供するツールとして用いるのがベストだと思う。

Cythonを書く量を最小限にして、ヒューマンエラーを抑えていこうという作戦。

具体的な手順

目標:高速なモジュールの作成
アウトライン

  • (Cythonパッケージやビルドツールのインストール)
  • C/C++で実装
  • Pythonから呼びたいものだけCythonでラップ
  • 使いやすいようにPythonでラップ

例はC++で行うがCでも同様。

パッケージの作成

$ mkdir my_module
$ cd my_module

今後出てくる.はこのディレクトリを指すことにする。

ヘッダの作成

実装するものをまとめたヘッダファイルを作成

// my_module_c.h
#ifndef MY_MODULE_C_H
#define MY_MODULE_C_H

#include <vector>
#include <string>

// Goに倣って、外部に公開する関数はキャピタライズ
double Sum_c(std::vector<double>& data);

class MyClass_c {
    public:
        MyClass_c(std::string name);
        void PrintCount();
    private:
    // Pythonに倣って_を付けることで明示的にprivateと宣言
    // 引数との名前衝突も避けられる
        std::string _name;
        int _count;
        void addCount();
};

#endif // MY_MODULE_C_H

実装

C++で処理を実装する

// my_module_c.cpp
#include "my_module_c.h"
#include <iostream>

double Sum_c(std::vector<double>& data) {
    double result = 0;
    for (double v : data) {
        result += v;
    }
    return result;
}

MyClass_c::MyClass_c(std::string name) {
    _name = name;
    _count = 0;
}

void MyClass_c::PrintCount() {
    std::cout << "I'm " << _name << ".\n"; 
    std::cout << "My count is " << _count << ".\n";
    addCount();
}

void MyClass_c::addCount() {
    _count++;
}
コラム::テストしてみる

Cythonに行く前に、C/C++で書いたコードが確かに動くか確認してみる。

// my_module_c_test.cpp
#include "my_module_c.h"
#include <iostream>
using namespace std;

int main() {
    const int n = 3;
    vector<double> data = {2.4, 4.6, 5.0};
    double total = Sum_c(data);
    double expected = 12.0;
    cout << "Sum_c test "
         << "result: " << total 
         << ", expected: " << expected
         << ", " << ((total == expected)? "ok" : "err")
         << endl;
    
    cout << "MyClass test" << endl;
    MyClass* ptr = new MyClass("Bob");
    if (ptr == nullptr) {
        cout << "instance not created" << endl;
        exit(1);
    }
    cout << "MyClass::PrintCount" << endl;
    for (int i = 0; i < 3; i++) {
        ptr->PrintCount();
    }
    delete ptr;
}

コンパイル&実行

g++ -c my_module_c.cpp -o my_module_c.o
g++ my_module_c_test.cpp  my_module_c.o -o my_module_c_test
Remove-Item my_module_c.o
./my_module_c_test
Remove-Item my_module_c_test.exe

実行結果

$ ./test_c
Sum_c test
result: 12, expected: 12, ok

MyClass test
MyClass::PrintCount
I'm Bob.
My count is 0.
I'm Bob.
My count is 1.
I'm Bob.
My count is 2.

Cythonで実装するとデバッグが困難だが、C/C++単体で動くように実装することでC/C++のサポート溢れるデバッグツールでデバッグが可能

お片付け

ファイルが増えてきたのでお片付け

$ mkdir src
$ mv my_module_c.cpp ./src/my_module_c.cpp
$ mkdir include
$ mv my_module_c.h ./include/my_module_c.h

Cythonでラップ

Pythonに公開したい関数だけCythonでラップ。
これがインターフェースとなる。

# my_module_cy.pyx
from libcpp.vector cimport vector
from libcpp.string cimport string

cdef extern from "my_module_c.h": # extern fromでヘッダ読み込み
# namespace有り: cdef extern from "some.h" namespace "somename":
    double Sum_c(vector[double]& data) 

    cdef cppclass MyClass_c:
        MyClass_c(const string name)
        void PrintCount()


def sum_cy(vector[double] data: list[float]) -> float:
    return Sum_c(data)


cdef class MyClass_cy:
    # インスタンス変数の列挙
    cdef MyClass_c* _ptr
    def __cinit__(self, *args, **kwargs) -> None:
        # cレベルの初期化
        # __init__関数と同じ引数で呼ばれる
        pass


    def __init__(self, name: str) -> None:
        cdef string _name = bytes(name, encoding='utf-8')
        self._ptr = new MyClass_c(_name)


    def __dealloc__(self) -> None:
        # cレベルのメモリ開放
        del self._ptr


    def print_count(self) -> None:
        self._ptr.PrintCount()

コンパイル

  1. Cythonコード→C/C++コード
  2. コンパイル & ビルド

という2段階で行う。

環境によって操作が異なる部分をsetuptoolsモジュールで覆うことで隠してしまおう、という理由でsetup.pyを書く。

from setuptools import setup, Extension
from Cython.Build import cythonize

name = 'my_module_compiled' # 実際に利用するモジュールの名前
sources = [  # コンパイルするソースファイルの列挙
    'my_module_cy.pyx',
    './src/my_module_c.cpp'
]
extra_compile_args = [] # コンパイラオプション


ext = Extension(
     name, 
     sources=sources, 
     language='c++', # 変数不可(?)
     extra_compile_args=extra_compile_args
)

setup(
    ext_modules = cythonize(ext, language_level="3"), # python3用にコンパイル
    include_dirs = ['./include/'] # ヘッダの在りかを指定
)

実行

# -i オプションでカレントディレクトリにバイナリがコピーされる
$ python setup.py build_ext -i

実行結果

...
  ライブラリ build\temp.win-amd64-3.9\Release\./src\my_module_compiled.cp39-win_amd64.lib とオブジェクト build\temp.win-amd64-3.9\Release\./src\my_module_compiled.cp39-win_amd64.exp を作成中
コード生成しています。
コード生成が終了しました。
copying build\lib.win-amd64-3.9\my_module_compiled.cp39-win_amd64.pyd -> 

Pythonでラップ

アノテーション書いたり、DocString書いたり。
なくても良い。
保守性を考えるとない方が良いが、利便性を考えるとあった方が良い。

# my_module_py.py
import my_module_compiled as mmcy

def sum_py(data: list[float]) -> float:
    return mmcy.sum_cy(data)


class MyClass_py(mmcy.MyClass_cy): # 継承可能
    def __init__(self, name: str) -> None:
        super().__init__(name)


    def print_count(self) -> None:
        super().print_count()

Cythonで書いた__cinit__も当然継承される。
引数を*args, **kwargsとしたのはこのため。

簡単なテスト

# my_module_py_test.py
import my_module_py as mmpy

def main():
    data = [3.3, 4.7, 5]
    total = mmpy.sum_py(data)
    print(' + '.join(list(map(str, data))), '=', total)

    obj = mmpy.MyClass_py('Bob')
    for _ in range(3):
        obj.print_count()


if __name__ == '__main__':
    main()

実行結果

3.3 + 4.7 + 5 = 13.0
I'm Bob.
My count is 0.
I'm Bob.
My count is 1.
I'm Bob.
My count is 2.

Cython Tips

わずかばかりのCythonの知見のメモ。

  • 特殊メソッド__XXX__cdefではなくdefで定義
  • list[T] ↔ vector[T]で交換可能
  • ポインタ、アドレス演算子NULLは使用可能
  • ただし、ポインタはcdefで定義する
  • np.ndarray[T] ↔ vector[T]は交換可能
  • 三項演算子?は使用不可
  • インクリメント・デクリメント不可
  • inline不可