使用pybind11为Python编写C++扩展(一)配置篇:Build(编译和链接)


目录
  • Setuptools
  • CMake

最后决定选用pybind11,理由如下:

  1. 比python原生的C API看起来人性多了
  2. 我的C++代码不是现成的,需要一定的C++开发工作量,所以感觉cython不是很方便。如果C++接口已经给好了,只需要简单包装一下,Cython可能更好。
  3. pybind11声称只包含头文件,且能通过pip安装,感觉比boost_python轻量且最后这个扩展包容易分发。此外,感觉它的文档也比boost python友好不少……

Setuptools

参考官方的Setuptools构建文档

这种方式适合python包的构建、打包、分发、上传到PyPi一条龙服务。python使用C++扩展需要在setup.py里配置好Extension。以下是一个setup.py的样例:

import glob
import os.path
from distutils.core import setup

__version__ = "0.0.1"

# make sure the working directory is BASE_DIR
BASE_DIR = os.path.dirname(__file__)
os.chdir(BASE_DIR)

ext_modules = []

try:
    from pybind11.setup_helpers import Pybind11Extension, ParallelCompile, naive_recompile

    # `N` is to set the bumer of threads
    # `naive_recompile` makes it recompile only if the source file changes. It does not check header files!
    ParallelCompile("NPY_NUM_BUILD_JOBS", needs_recompile=naive_recompile, default=4).install()

    # could only be relative paths, otherwise the `build` command would fail if you use a MANIFEST.in to distribute your package
    # only source files (.cpp, .c, .cc) are needed
    source_files = glob.glob('source/path/*.cpp', recursive=True)

    # If any libraries are used, e.g. libabc.so
    include_dirs = ["INCLUDE_DIR"]
    library_dirs = ["LINK_DIR"]
    # (optional) if the library is not in the dir like `/usr/lib/`
    # either to add its dir to `runtime_library_dirs` or to the env variable "LD_LIBRARY_PATH"
    # MUST be absolute path
    runtime_library_dirs = [os.path.abspath("LINK_DIR")]
    libraries = ["abc"]

    ext_modules = [
        Pybind11Extension(
            "package.this_package", # depends on the structure of your package
            source_files,
            # Example: passing in the version to the compiled code
            define_macros=[('VERSION_INFO', __version__)],
            include_dirs=include_dirs,
            library_dirs=library_dirs,
            runtime_library_dirs=runtime_library_dirs,
            libraries=libraries,
            cxx_std=14,
            language='c++'
        ),
    ]
except ImportError:
    pass

setup(
    name='project_name',  # used by `pip install`
    version='0.0.1',
    description='xxx',
    ext_modules=ext_modules,
    packages=['package'], # the directory would be installed to site-packages
    setup_requires=["pybind11"],
    install_requires=["pybind11"],
    python_requires='>=3.8',
    include_package_data=True,
    zip_safe=False,
)

这样最后python包的文件结构是:

project_dir
    |-- package
    |   |-- __init__.py
    |   |-- this_package.xxxx.so
    |   |-- other.py
    |-- setup.py
  • 如果是在本地项目开发过程中需要构建.so库文件:

    python setup.py build_ext --inplace
    

    你的.so库会在package目录下,你可以直接像用python模块一样在python测试文件里引入:

    import package.this_package
    

    最好不要直接用python setup.py build或者python setup.py build_ext。它们会把动态库编译到一个单独的build目录下,前者会带上你包里其它的python文件,后者则只会有动态库。目录的路径比较复杂,导入比较麻烦,要么需要添加sys.path,要么要写比较丑陋的import。感觉只适合作为分发包前的一个中间步骤,对我们本地测试自己的包没啥用。

  • 如果最后需要分发你这个包:

    • 源码分发sdist:setuptools会直接打包源码,不需要先build。分发前记得删掉.so文件。 在安装的时候,setuptools会在要安装这个包的机器上当场build,这时候你安装进site-packages里的包也会长得跟目录package一样。
    • 二进制wheel分发:bdist_wheel:setuptools会在本机build好,然后一股脑塞进wheel里,最后分发的是那个.whl文件。这个文件跟操作系统和计算机的体系架构有关,这也是为啥很多我们熟悉的包,比如numpy,有很多个版本的.whl文件,我们需要找对应的下载。

    当然不管怎样,这些都是setuptools的自动操作,你只要配置好了setup.py,一切都好说。

一些需要注意的点(坑):

  • 如果需要通过sdist(即.tar.gz的源码方式)发布包的话,Extensionsource_files字段必须是相对路径。否则build的时候会因为egg-info里的SOURCE.txt里有绝对路径而报错。但由此带来的问题是我们不能确定跑setup.py的时候工作目录是啥,为了保险起见,需要把它设置成setup.py所在的目录

  • 在安装包之前,为了获取一些metadata,setuptools会先跑一次setup.py,这个时候如果没有装pybind11,会报错。为了解决这个问题:

    • 为了能正常执行到setup函数,我们需要先保证没有pybind11的情况下执行这个文件也不会报错。所以我们需要把所有依赖pybind11.setup_helpers的部分都放到try里。

      也有其它的方法,比如直接复制一个setup_helpers啥的,具体可以看官方的Setuptools构建文档。

    • 根据setuptools的文档,setup_requires并不会安装包,所以pybind11也需要加到install_requires里。
    • 最后,在setup.py install安装本包的时候,setuptools会先安装依赖项,这时就可以成功build和安装了。
  • 如果你的外接库不在系统查找动态库的指定路径里,那么指定link_dirs之后,编译和链接不会出错。但执行的时候还是会因为找不到动态库而报错。可以通过添加runtime_library_dirs(等价于-Wl,-rpath),或者给LD_LIBRARY_PATH环境变量里添加这个路径。

  • 编译后的.so的位置,以及你的C++ module在python里的名字,取决于你给Extension写的名字。如果它叫package.a.b.c.this_package,那么最后这个.so文件就会在project_dir/package/a/b/c下。

    此外,哪怕你这个project只想导出一个.so里的模块,把它放到一个文件夹里包装起来也会更好。因为如果你只想导出一个this_package,把setup函数里的配置改成了packages=['this_package'],这个.so文件会直接被加到site-packages,感觉不是很优雅。

    但是,要保证执行.so不出错,在C++里通过PYBIND11_MODULE把这个扩展expose到python里的时候,名字也要对应

    PYBIND11_MODULE(this_package, m) {}
    

CMake

参考官方的CMake构建文档。

如果是编译嵌入python的C++程序,可以用CMake,比较方便。

虽然python extension似乎也可以用CMake,但是还是setuptools比较方便。

我这里主要是用CMake编译C++部分的测试。CMakeLists.txt大概长这样:

# the CMakeList to test the C++ part from a C entry point
cmake_minimum_required(VERSION 3.21)
project(project_name)

set(CMAKE_CXX_STANDARD 14)

# Find pybind11
find_package(pybind11 REQUIRED)

# If any library (e.g. libabc.so) is needed
include_directories(INCLUDE_DIR)
link_directories(LINK_DIR)

# Add source file
file(GLOB A_NAME_FOR_SOURCE CONFIGURE_DEPENDS "source/path/*.cpp")
file(GLOB A_NAME_FOR_TEST CONFIGURE_DEPENDS "test/path/*.cpp")
# Exclude the file that define the python module to avoid segment fault in CLion debugger
list(REMOVE_ITEM A_NAME_FOR_SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/path/to/exposure.cpp")

add_executable(TARGET_NAME ${A_NAME_FOR_SOURCE} ${A_NAME_FOR_TEST})
target_link_libraries(TARGET_NAME abc pybind11::embed)
  • project_name随便写
  • TARGET_NAME随便写,只要add_executabletarget_link_libraries对应就行,是最后的可执行文件的名字
  • A_NAME_FOR_SOURCEA_NAME_FOR_TEST是一个CMake的中间变量名,随便写,它们分别代表了GLOB找到的一堆源文件,和用于测试的一堆文件
  • INCLUDE_DIR里是库abc的头文件,LINK_DIR里必须包含库文件,动态库类似libabc.so,静态库类似libabc.a
  • Link到pybind11::embed的原因是防止带python对象的那部分C++代码编译失败。
  • 如果是为了在C++里测试,源文件部分要去掉把C++模块注入python的那个源文件,即写了PYBIND11_MODULE的那个(用REMOVE_ITEM命令),不然LLDB debugger可能segment fault(我也不知道为啥……)。