Cython制作C++动态库的python binding

参考链接

https://docs.python.org/2/extending/building.html#building
python3官方文档: https://docs.python.org/3/extending/extending.html
https://www.tutorialspoint.com/python/python_further_extensions.htm
关键参考这个例子
https://github.com/JacekPierzchlewski/CppClass4Python3/blob/master/car/car2py.cpp
网上下载了Cython的书籍,已经上传nas
http://www.jyguagua.com/wp-content/uploads/2017/03/OReilly.Cython-A-Guide-for-Python-Programmers.pdf
直接看第八章吧,其他的说的都是用不上的。
https://notes-on-cython.readthedocs.io/en/latest/function_declarations.html

导言

首先先上结论,对于c++ 11,使用pybind11能够更加完美支持C++11的新特性,Cython更加适合纯C或者C++11之前的C++,包括对模板类以及MemoryView的支持。手工写这两者都需要大量的转换,最好是能够写一个自动wrap的小工具会好一些。Cython主要的问题在于对enum class的支持不足,其他的坑暂时还没有发现更多。

主要概念

需要认清以下几个主要的概念:

  • Extension
    Python大部分的解释器都是CPython,也就是实际上就是一个能够运行.py文件的C程序,python是支持通过so的方式对这个C程序进行扩展的,这个扩展是相当于直接加在了运行时的解释器上面,而不是类似于解析器读取py文件运行这样的机制,因此称之为Python的扩展Extension而不是package,

  • setup函数
    setup函数来自setup_tools, setup_tools主要是改写了原本distutils里面的setup函数,我们一般只用setup_tools的setup函数,能够支持一些新的特性。
    常见的开头长这样:

    1
    2
    3
    import setuptools
    from setuptools import setup, Extension
    from Cython.Build import cythonize

下面我们会具体说明使用到的cythonize函数,也就是Cython方案的核心之一。

Cython方案

Cython方案是诸多C++ wrapper中最接近python语法的一种,它能够比较好地用接近python的语法调用c++,不足之处就是对于enum class的支持非常差,导致无法在接口模块直接使用。为了使用一些C++支持而Cython不支持的特性,我们需要独立出来这部分代码,用纯C++来wrap一遍,去掉enum class这一类输入,用简单变量比如纯C的enum或者int来简单包装一下,再输入到最早需要接收enum class的函数中去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// shared_lib.h
enum class Flag{
FLAG1,
FLAG2,
...
};
void func(enum class Flag){}

// write a wrapper so that cython need not interact directly with shared_lib.h
// wrapper.h
void func_wrapper(int flag){
if(flag == 1) Flag cflag = Flag::FLAG1
...
return func(cflag);
}

Cython编译整体有两个阶段,第一阶段就是pyx转c/c++, 第二阶段就是这个c/c++转换为python的extension。第一阶段由cython提供,第二阶段就用的python官方的setuptools来实现了。第二阶段还能加入很多纯C/C++的代码,也最灵活。

pyx和pyd

类比于C++的.cpp和.h文件,pyx和pyd就是Cython的实现和头文件,注意声明来自C的变量和函数一定要在pyd里面进行,否则编译的时候会出现符号重定义的现象,这和C++是一样一样的。

wrap函数

返回bool值

返回bool值得函数在这里需要用bint来替代作为python object

wrap变量

接收str变量到char*

1
2
3
4
5
6
# void process_func(unsigned char*);
#
cpdef receive_char_from_python(self, pystr):
cstr = pystr.encode('utf-8')
# now cstr can be passed to c function as an unsigned char* pointer
process_func(cstr)

接收numpy变量

numpy的array在传到C++层面时候,其存储不一定是连续的,因此不能够直接取array.data作为buffer的起点,而应该先转换为continuous array之后,这个array.data才是能够直接作为c++指针的起点。比如我们用opencv在python层读了一个图片进来,这时候要怎样变成一个unsigned char* 的指针呢?注意,这个指针指向的内存由python所管理,因此千万别尝试接管这部分内存,否则很可能出现内存泄漏。

1
2
3
4
5
6
7
cpdef receive_numpy(self, image_data):
row, col = image_data.shape[0], image_data.shape[1]
cdef np.ndarray[np.uint8_t, ndim=3, mode = 'c'] np_buff = np.ascontiguousarray(image_data, dtype = np.uint8)
cdef unsigned char* im_buff = <unsigned char*> np_buff.data
# do whatever you want with im_buff, but do not free the memory it points to.
# ...
pass

接收bool变量

有一个比较奇怪的现象,就是容器里面的bool不能用bint替换,因为没有默认的强制类型转换函数,必须使用原生的c++ bool

1
2
3
4
from libcpp cimport bool
cdef extern from "wrapper.h":
cdef struct Foo:
vector[bool] boolFlags

shared library的依赖管理

我们给shared library添加wrapper, 在这样的使用场景下,so库是预编译的,拿到的时候应该就是so库及其相应的头文件,我们知道,python作为C程序的一种,他要想要使用到这个so库,加载的方式和传统的C程序也是没有什么两样的,比如搜索/usr/lib, /lib, ${LD_LIBRARY_PATH}, RPATH等等,因为我们希望在pip install 和uninstall的时候,pip能够管理我们全部生成的文件,在卸载的时候也能够卸载干净,因此我们不可能将so库放在/usr/lib或者/lib这样的全局位置,并且这样的操作setuptools默认是不支持的,且需要sudo权限。而设置LD_IBRARY_PATH则需要在启动python之前就完成设置,一旦python启动,后续不能够再设置,当然也有办法是python设置完之后,重启python,这种骚操作我觉得还是少用为妙。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import sys, os
mymodule_path = "/home/me/python/mymodule"
try:
sys.path.append(mymodule_path)
import mymodule
except ImportError:
if sys.platform == 'win32':
os.environ['PATH'] = mymodule_path
elif sys.platform == 'darwin':
os.environ['DYLD_LIBRARY_PATH'] = mymodule_path
else:
os.environ['LD_LIBRARY_PATH'] = mymodule_path
args = [sys.executable]
args.extend(sys.argv)
os.execv(sys.executable, args)

那么就只剩下一个关键的途径了,那就是设置RPATH,也就是runtime_library_dirs了,这是写在so库中的一个路径,我们可以用readelf -d xxx.so来看到,也可以用ldd xxx.so来看到如果在ldd当前目录下运行程序,有什么样的xxx.so依赖的其他so库能够被找到。RPATH有一个特殊的符号,$ORIGIN,注意这个不是一个变量,而是一个特定的符号,他表示,无论这个so被扔到那里了,$ORIGIN就是so所在的目录位置,于是,相对路径就变得可行起来了,比如我们添加这样的一行编译:

1
2
3
set_target_properties(xxx PROPERTIES LINK_FLAGS "-Wl,-rpath,$ORIGIN")
或者
set_target_properties(xxx PROPERTIES INSTALL_RPATH "$ORIGIN")

而在setuptools里面,则是在Extension加上这个property:

1
ext = Extension(runtime_library_dirs=["$ORIGIN"], ... , )

再次提醒,这个$ORIGIN并不会被替换成其他的变量,因为这是一个特殊符号。但是,这个只解决了由wrapper直接依赖的so的问题,假设我们的模块是mylib, mylib依赖的A.so可以由上述方式找到,但是A.so可能依赖另外一些shared lib, 比如B.so, 这就要求A.so在编译的时候就已经将RPATH设置正确,否则也是没有办法的,不过一般A.so, B.so这些也都是我们能够接触到源码的,所以改一下编译也不是难题,并且实在不行还可以用patchelf这个工具做后处理修改掉rpath.

Cython打包过程

像我们这种打包shared lib的,不可避免需要将被wrap的so安装到sitepackage里面去,那么怎么实现这件事呢?那就是用package_data啦,注意,package_data只能打包在package所在的文件夹之内的数据文件,不在这个路径下的,是不能被打包的,也不会报错。并且,某个文件要想被打包,其顶层一定有一个 \_init__.py 作为当前文件夹是package的声明。比如:

1
2
3
4
5
|--data
|--model.bin
|--model.lib
|--__init__.py
|--setup.py

在setup.py里面,要想打包data里面的内容,那么:
只需要setup(package_data={'data': ["model.*"]}, ..., )即可,当然目录还能更加复杂一些。

python setup.py install/sdist/bdist/bdist_wheel

install为直接安装,一般就是安装到本机
sdist作用基本相当于直接将当前源码打包,然后放到其他机器同样运行python setup.py install来安装,就是所谓的源码发布
bdist作用相当于将pyx, pyd等编译之后只取二进制文件,实际上并不好用
bdist_wheel是我觉得最好用的,它切掉了全部的源码,只留下必要的文件。

所有需要被打包的文件,都通过MANIFEST.in来进行指定。

static问题

由于python的extension在不同的系统,动态库其symbol的可见性是不一样的,比如windows就全局共享一个可见性,因此建议所有的extension的函数都带上static,只有模块的初始化函数XXX_Init除外。

0%