graphics.hatenablog.com

技術系テクニカルアーティストのあれこれ

C#とPythonからC++を叩いてみる。

たとえば Unity と Maya の両方で同じロジックを使いたい場合、C#Python で同じものを実装するのはとてもだるいので、C++ で書いてから共有できるようにしてみた。
GitHub - hal1932/DllExportTest

この手のことをやるときは基本的には SWIG で全然問題ないんだけど、あれはあれで面倒*1なので手動でやる方法を整理しておくのも悪くないとは思う。

仕組み

f:id:hal1932:20190901232138p:plain

まぁなんてことはない。C++ で書いたロジックを dllexport して、C# からは DllImport で、Python からは ctypes で叩くだけ。

C++

以下のクラスを DLL 化することにする。

class TestClass
{
public:
	int func1(int i);
	int func2(int (*callback)(int), int i);
};

DLLエクスポート宣言

#ifdef _WINDLL
#	define DLL_API extern "C" __declspec(dllexport)
#else
#	define DLL_API extern "C"
#endif

Visual Studio の場合、DLL 作成プロジェクトをビルドすると _WINDLL というマクロが定義される。このマクロがあるときに extern "C" __declspec(dllexport) を宣言するようにしておく。そうでない場合、つまり普通に C++ から叩くときはこの宣言は不要なので、extern "C" だけが残るようにする。

関数の呼び出し規約

他言語から自分の関数を呼び出させるにあたり、「呼び出され方」をあらかじめ考慮しておく必要がある。

さて、コンピュータ科学において「関数を呼び出す」とは具体的にどういうことか? これが以外と難しい。
詳細な説明は以下のサイトに譲るとして、とりあえず __cdecl と __stdcall だけ覚えておくといい。

ざっくり言って、C++ での関数宣言時に extern "C" を付けると、__cdecl でビルドされる。そうすると、C# からは [DllImport("filename.dll", CallingConvention = CallingConvention.Cdecl)] を使えば読み込めるし、Python からは dll = ctypes.CDLL('filename.dll') みたいにすれば DLL をロードできる。

extern "C" をつけないと __stdcall になるから、C# では CallingConvention.StdCall、Python では WinDLL を使う必要がある。

呼び出し規約 __cdecl __stdcall
宣言(C++) extern "C" -
DllImport時の指定(C#) CallingConvention.Cdecl CallingConvention.StdCall
ctypesのDLLローダー(Python) CDLL WinDLL

__cdecl と __stdcall のどちらを使うかはまぁ好きにすればいいんだけど、__cdecl のほうが汎用的で無難だと思う。

クラス定義

クラスをそのままの形で公開する*2こともできるんだけど、ものすごくめんどくさいのでやめといたほうがいい。どうしてもやりたいなら以下の記事が参考*3*4になる。

というわけで、今回は以下のとおり new/delete もバラバラにして C 言語っぽい API を公開することにする。C 言語でオブジェクト指向っぽいことをやるときと同じイメージで構わない。
これを、C#Python 側でクラスにラップすることにしよう。

DLL_API TestClass* TestClass_New() { return new TestClass(); }
DLL_API void TestClass_Delete(TestClass* self) { delete self; }
DLL_API int TestClass_func1(TestClass* self, int i) { return self->func1(i); }
DLL_API int TestClass_func2(TestClass* self, int (*callback)(int), int i)
    { return self->func2(callback, i); }

DLL_API void test3(void* dst, int size, int offset) {
	auto ptr = static_cast<int*>(dst) + offset;
	*ptr = 1;
	*(ptr + 1) = 2;
}

なお test3() に関して、これは「dst の領域に任意のデータを C++ から書き込む」ことを想定してる。dst のメモリ上に具体的に展開されるデータ形式(型定義)に関しては、あらかじめ C++ 側で定義しつつ中身を埋めてやってもいいし、あるいは何らかの方法で C++ 側に読み込まれたデータを、size と offset にしたがって dst に memcpy するような実装でも構わない。

ここまでのソースコードは以下。

C#

DLL のロードとアンロード

面倒だから今回は試してないけど、ふつうの C# ランタイムなら AppDomain を使えばいい。

関数呼び出し

おおよそのポイントは以下のとおり。

  • 公開された関数を static extern で宣言して、それらをしかるべき場所で呼び出す。
  • インスタンス生成時に _New() を呼ぶ。
  • インスタンス破棄時に _Delete() を呼ぶ。
  • _Delete() の呼び忘れ防止に IDisposable を使う。

上記の項目にさえ気をつければここでの嵌りどころはないと思う。

class CsTestClass : IDisposable
{
    [DllImport("CppDll.dll")]
    static extern IntPtr TestClass_New();

    [DllImport("CppDll.dll")]
    static extern void TestClass_Delete(IntPtr self);

    [DllImport("CppDll.dll")]
    static extern int TestClass_func1(IntPtr self, int i);

    public CsTestClass() { _self = TestClass_New(); }

    ~CsTestClass() => Dispose(false);
    public void Dispose() => Dispose(true);

    private void Dispose(bool disposing) {
        ...
        TestClass_Delete(_self);
        ...
    }

    public int Func1(int i) => TestClass_func1(_self, i);
}

引数と戻り値

C++C# で使える型定義は違うんだけど、まぁだいたいそれっぽいやつを指定しておけばいい。ただ文字列に関してはちょっと面倒なので注意。

ユーザー定義型のポインタは IntPtr にしておけばいい。C# 側から型の内部にアクセスできなくなるけど、C++ 側で過不足なく関数を公開しておけば問題ないはず。どうしても内部にアクセスしたければ、以下の記事を参考にしつつ構造体のかたちで dllexport してやればいい。

コールバック関数

型に応じた delegate を使えばいい。

DLL_API int TestClass_func2(TestClass* self, int (*callback)(int), int i);
delegate int func2_Callback(int i);

[DllImport("CppDll.dll")]
static extern int TestClass_func2(IntPtr self, func2_Callback callback, int i);

public int Func2(Func<int, int> callback, int i)
    => TestClass_func2(_self, new func2_Callback(callback), i);

C# 側の公開メソッド(今回の場合は Func2)の引数として delegate を要求しても別にいいっちゃあいいんだけど、DLL 側の都合で必要な定義をユーザーに見せる必要はないし、Action や Func でそのまま使えるようにしてあげたほうが C# っぽくていいかなーという気はする。
なので、ここでは private な delegate を定義した上で、C# 側の public インターフェイスとしては Func を使うようにしてみた。

呼び出し側はこんなかんじ。

using (var test = new CsTestClass()) {
    var result = test.Func2(i => i + 2, 1);
}

メモリ領域のやりとり

まず C++ の void* は C# では IntPtr に対応させればいい。構造体を定義するなら StructureLayout でメモリレイアウトを C++ 側と揃えてやること。

[DllImport("CppDll.dll")]
static extern void test(IntPtr dst, int size, int offset);

[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct Test
{
    public int A;
    public int B;
}

呼び出し側はちょっと面倒で、まず C++ にわたすメモリ領域は AllocHGlobal() を使ってアンマネージド領域で動的に確保する必要がある。で、C++ 側で領域を埋めたら、C# 側では BitConverter で読み取ってもいいし、構造体を使うなら PtrToStructure() を使ってマネージド領域のメモリに書き込んでもらう。AllocHGlobal() で確保した領域は GC に乗らないので、FreeHGlobal() を使って確実に解放されるように気をつけること。

var dstBuffer = Marshal.AllocHGlobal(Marshal.SizeOf<Test>());
test(dstBuffer, Marshal.SizeOf<Test>(), 0);
var obj = Marshal.PtrToStructure<Test>(dstBuffer);
Marshal.FreeHGlobal(dstBuffer);

C# 側はここまで。ソースコードはこちら。

Python

DLL のロードとアンロード

CDLL や WinDLL のコンストラクタ内で LoadLibrary が呼ばれてるっぽいので、ふつうに C++ と同じノリで FreeLibrary してあげればいい。ただし Windows の場合、x64 の場合に FreeLibrary の引数定義がバグってるので、そこだけ手動で直してやる必要がある。

# load
dll = ctypes.CDLL('CppDll.dll')

# unload
ctypes.windll.kernel32.FreeLibrary.argtypes = [ctypes.wintypes.HMODULE]
ctypes.windll.kernel32.FreeLibrary(dll._handle)

関数呼び出し

C# の場合と、そう大きくは変わらない。

  • ctypes.CDLL() で DLL をロードしたら、あとはダックタイピングで公開関数を呼び出せる。
  • インスタンス生成時に _New() を呼ぶ。
  • インスタンス破棄時に _Delete() を呼ぶ。
dll = ctypes.CDLL('CppDll.dll')

class PyTestClass(object):
    def __init__(self):
        self.__instance = dll.TestClass_New()

    def __del__(self):
        try:
            dll.TestClass_Delete(self.__instance)
        except OSError as e:  # AccessViolation!!
            pass

    def func1(self, i):
        return dll.TestClass_func1(self.__instance, i)

ただし一点だけ注意点。
Python の場合は __del__() がデストラクタに相当するわけだけど、これが実際に呼び出されるのは「GCインスタンスを回収するとき」であって、「インスタンスの参照カウンタが 0 になったとき」ではない。そして、それは「インタプリタの終了処理中」に行われることも多い。
つまり、上記のコードでは dll インスタンスが破棄されたあとに PyTestClass.__del__ の呼び出しが行われる可能性がある。それが起きると、OSError としてアクセス違反例外が投げられてしまう。

対応に関して。
ちょっとした小さなスタンドアロンスクリプトであれば、インタプリタプロセス終了時にどうせまっさらに戻るので、上記コードのように try-except で握りつぶしてもおそらく実害はない。ある程度の大きさだったり、それこそ Maya みたいにインタプリタプロセスの終了に頼れない場合は、たとえばデストラクタに相当するメソッドを別途用意した上で、ユーザーに明示的に呼び出してもらうなり、ライブラリ側でインスタンスへの weakref を暗黙に保管して適宜それを呼びだすなり、そういった対応が必要になってくる。

引数と戻り値

C# のときと同じ。適当にそれっぽい型を使ってやればいい。ユーザー定義型のポインタは int として扱われる。

構造体を使いたい場合は以下を参考に。

コールバック関数の定義

こちらも C# のときとあまり変わらない。delegate のかわりに ctypes.PYFUNCTYPE(ctypes.c_int, ctypes.c_int) を使って定義してやる。適当にぐぐってると ctypes.CFUNCTYPE を使ってる人が多いけど、ドキュメントによると CFUNCTYPE はどうやら関数呼び出し中に GIL を解放するらしい。あんまりちゃんとは調べてないけど、てことはコールバックを使う目的なら PFUNCTYPE が正解なのかな?

PYFUNCTYPE が扱うのは「Python 呼び出し規約」というものらしいんだけど、軽くぐぐった範囲ではよくわからなかった。とりあえず __cdecl で定義された関数なら問題なさげ。

_func2_callback = ctypes.PYFUNCTYPE(ctypes.c_int, ctypes.c_int)
def func2(self, callback, i):
    return dll.TestClass_func2(self.__instance, _func2_callback(callback), i)

呼び出し側はこんなかんじ。

obj = PyTestClass()
result = obj.func2(lambda x: x + 2, 1)

メモリ領域のやりとり

C# と比べると若干シンプルかも。まずクラスを定義するときは ctypes.Structure を継承するかたちで行うこと。生メモリ領域のやりとりでよければ bytearray で構わない。

class TestClass(ctypes.Structure):
    _fields_ = [
        ('x', ctypes.c_int32),
        ('y', ctypes.c_int32),
        ]

まずは argtypes で引数の型を指定する必要がある。関数が返り値を持つ場合は restype も同様に。
渡す領域は bytearray で確保する。ただ、これを引数に渡すときはサイズも一緒に含めてやる必要があるので、ctypes.c_char を使ってサイズ指定済み配列のかたちで渡してやる。from_buffer() は、任意の領域から指定したサイズだけ取り出すような機能だと思えばいい。
C++ 側で中身を書き込んでもらったら、io.BytesIO なり、構造体の ctypes.Structure.from_buffer で読み出すことができる。

dll.test.argtypes = (ctypes.c_void_p, ctypes.c_int32, ctypes.c_int32)

dst_buffer = bytearray(ctypes.sizeof(TestBase))
dst_type = ctypes.c_char * ctypes.sizeof(TestBase)
dll.test(dst_type.from_buffer(dst_buffer), 0, ctypes.sizeof(TestBase))

dst = TestBase.from_buffer(dst_buffer)

スレッド周りの扱い

ctypes 経由だと GIL が解放された状態で C++ 側の関数を呼び出してくれるので、C++ 側でスレッドを立ててやればちゃんと並列処理ができる。便利で良い。

ちなみに関連する話題として、PySide の関数なんかも C++ 部分は GIL 解放状態で呼び出されるので、たとえば QImage のコンストラクタ呼び出しを ThreadPool 経由で呼び出すと、実はちゃんと並列で処理してくれる。

このあたり、状況に応じて積極的に使っていくと、場合によってはとても Python とは思えないパフォーマンスを叩き出せるようになる。「エンジニアリングが得意なTA」として、ちょっとした差別化ポイントにもなるんじゃなかろうか。

以上。ソースコードは以下。

*1:例えば生成した Python モジュールを Maya で使いたい場合、mayapy を使って pyd を作ってやる必要があったりして無駄に汎用性を落とすことになる。ctypes 直叩きだと依存先に Maya が含まれないはずだからちょっとだけ嬉しい。

*2:「DLL経由で他言語から呼び出せるようにする」ことを「公開する」と呼ぶことにする。

*3:ただ個人的には、"プログラマー"ならともかく、"TA" として理解すべきレベルは超えてると思う。もちろん知っといて損はないけど……。

*4:記事中でCppSharpも紹介されてるけど、C#以外で使えないのと、C#側でunsafeが使われてしまうので、あまり気安くおすすめはできそうにない。