Python/C/C++混合项目

Python/C/C++混合项目

引言

Python是动态语言,不需要复杂的编译链接等过程,因此开发调试的速度非常快,适合快速搭建起一个软件的原型。当程序耗时的主要瓶颈在IO的时候,动态解释器产生的开销是微不足道的,因此Python非常适合爬虫这类的工作。但是对于一些计算密集型程序,Python的执行速度就不是太能接受了。因此,一个常见的思路就是将计算密集的部分用C/C++来实现,之后把库包装出一个Python接口,嵌入到整个项目中。

cython和pybind11是常用的两种解决方案。cython是一种集成了C的Python编译器,它的大致思路是将已有的Python代码编译成C的扩展模块。cython的语法类似于加上了类型注解的Python,因此原本用Python编写的代码可以比较容易修改并通过cython编译成C语言的扩展;pybind11则是一个轻量级的头文件库,它的大致思路是将C++代码封装后暴露给Python模块,和cython是反过来的过程。pybind11允许直接用C++编写单元,之后通过接口将这些暴露给Python。

当然,这两种方案也可以同时集成在同一个项目中。接下来通过一个简单的示例,梳理一下构建一个同时包含Python模块、cython模块和pybind11模块混合项目的大致思路。

项目示例

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
phono/
├── __init__.py
├── scripts.py
├── py/
│ ├── __init__.py
│ └── mymath.py
├── cyth/
│ ├── __init__.py
│ ├── mymath.pyx
├── cpp/
│ ├── __init__.py
│ ├── include/
│ │ └── mymath.h
│ ├── src/
│ │ └── mymath.cpp
│ └── CMakeLists.txt
├── CMakeLists.txt
└── setup.py

编写一个非常简单的示例,整个包的名字叫作phono,包含三个子包,cythpycpp。在Python中,包就是一个有__init__.py文件的目录,模块就是一个.py文件,一个包可以包含多个模块。如果目录中没有__init__.py文件,解释器就无法识别这个目录是一个包,就没法用import ...命令导入。在这里,每个子包均包含一个mymath模块,每个模块实现各自的函数。cppmymath模块实现一个乘法函数my_multiplycythmymath模块实现一个加法函数my_add,py中的模块调用这两个函数,实现一个加乘法函数my_add_multiply

phono/:

  • __init__.py: 标识phono为一个Python包。
  • setup.py: Python项目的安装脚本,定义了如何构建和安装整个项目。
  • CMakeLists.txt: 主目录的CMake文件,做一些项目的基本配置,引导向各子目录的CMakeLists.txt
  • scripts.py: 可以定义一些脚本函数,在setup.py中进行注册,可以直接在命令行调用某些命令。

py/:

  • __init__.py: 标识py为一个子包。

  • mymath.pyx: 一个Python文件,包含用Python编写的函数。

cyth/:

  • __init__.py: 标识cython为一个子包。

  • mymath.pyx: 一个cython文件,包含用cython编写的函数。

cpp/:

  • include/: 包含C++头文件的目录,放置函数或类的声明。
    • mymath.h: C++头文件,声明了在mymath.cpp中实现的函数。
  • src/: 包含C++源文件的目录。这里实现通过pybind11暴露给Python的C++函数。
    • mymath.cpp: C++源文件,包含C++的函数实现。
    • math_wrapper.cpp: 通过pybind11将C++实现封装为Python模块。将C++函数实现与封装隔离,方便后期可能将全部Python代码移植为C++版本。
  • CMakeLists.txt: CMake构建脚本,用于配置和编译C++代码,生成可供Python调用的共享库(.so.pyd 文件)。

cython包

__init__.py文件留空,仅用于标识这是一个包。mymath.pyx中导入cython库,定义一个简单的加法函数。cdef的函数只能在.pyx文件内部使用,cpdef的函数可以同时在.pyx文件和Python环境中调用,cython会对结果做一层包装。

1
2
3
4
5
6
7
8
# mymath.pyx

cimport cython

# 定义一个加法函数
cpdef int my_add(int a, int b):
return a + b

pybind11包

三个文件的内容分别如下所示。在mymath_wrapper.cpp中,调用PYBIND11_MODULE,将my_multiply函数注册到mymath模块中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src/math.cpp

#include "mymath.h"

int my_multiply(int a, int b) {
return a * b;
}

// src/mymath_wrapper.cpp

#include <pybind11/pybind11.h>
#include "mymath.h"

PYBIND11_MODULE(mymath, m) {
m.def("my_multiply", &my_multiply, "A function that multiplies two numbers");
}

// include/mymath.h

#ifndef CPP_MATH_H
#define CPP_MATH_H

int my_multiply(int a, int b);

#endif // CPP_MATH_H

cpp目录下的CMakeLists.txt如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# cpp/CMakeLists.txt

# Headers and Libs of: Python, pybind11
execute_process(
COMMAND cmd.exe /C "echo %COMPUTERNAME%"
OUTPUT_VARIABLE HOSTNAME
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if(HOSTNAME STREQUAL "DESKTOP-VGTMA36")
set(PYBIND11_ROOT "C:/softs/anaconda3/envs/PhononMC/Lib/site-packages/pybind11/share/cmake/pybind11")
elseif(HOSTNAME STREQUAL "DESKTOP-OMFGUTF")
set(PYBIND11_ROOT "D:/Anaconda3/envs/PhononMC/Lib/site-packages/pybind11/share/cmake/pybind11")
else()
endif()

set(SRC_DIR "${CMAKE_SOURCE_DIR}/${PROJECT_NAME}/cpp/src")
include_directories(${CMAKE_SOURCE_DIR}/${PROJECT_NAME}/cpp/include)

find_package(pybind11 REQUIRED PATHS ${PYBIND11_ROOT})

pybind11_add_module(mymath "${SRC_DIR}/mymath_wrapper.cpp" "${SRC_DIR}/mymath.cpp")

在这里,为了方便在不同主机上进行调试,可以在cmake中查询电脑的名字,根据不同的名字,指向不同的Pybind目录。注意这里的目录不是pybind11的根目录,而是包含了cmake模块文件(Find<PackageName>.cmake)或配置文件(<PackageName>Config.cmake)的目录。根据这些文件,find_package会进行相应的配置,设置一些变量、获得一些函数等等。比如pybind11_add_module函数可以帮助创建Python模块。它会设置所有必要的编译选项、链接库和目标属性,以确保生成的模块可以正确地导入到Python中。

Python包

__init__.py文件内容留空,mymath.py中采用相对导入同时调用pybind的包和cython的包:

1
2
3
4
5
6
7
from ..cyth import mymath as cyth_mymath
from ..cpp import mymath as cpp_mymath

def my_add_multiply(a: int, b: int, c: int) -> int:
temp: int = cyth_mymath.my_add(a, b)
return cpp_mymath.my_multiply(temp, c)

phono根目录的setup.py中,可以把py包中的所有模块都导入进来。这样实际上就没有py的这一层命名空间了,py包的划分对外可以变成不可见的:

1
2
from .py.mymath import my_add_multiply
# or from .py import mymath

集成与调用

phono根目录下的CMakeLists.txt中,设置cmake的入口:

1
2
3
4
5
6
7
8
9
10
11
# CMakeLists.txt

cmake_minimum_required(VERSION 3.10)

project(phono)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)

add_subdirectory("${PROJECT_NAME}/cpp")

add_subdirectory指定了cmake的子目录,cmake会递归地执行子目录中的CMakeLists.txt。之后,设置setup.py,以便于在Python环境中构建并安装相应的包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# setup.py

import os
import subprocess
import glob
import shutil
from setuptools import setup, Extension
from distutils.sysconfig import get_python_lib
from setuptools.command.build_ext import build_ext as _build_ext

PACKAGE_NAME = "phono"
VERSION = "0.1"

SUB_PACKAGES = [
"{}.py".format(PACKAGE_NAME),
"{}.cyth".format(PACKAGE_NAME),
"{}.cpp".format(PACKAGE_NAME)
]


# 自定义 build_ext 类,整合 CMake 和 Cython 编译
class CustomBuildExt(_build_ext):
def run(self):

if '--inplace' in self.distribution.script_args:
self.inplace = True
else:
self.inplace = False

if os.path.exists(self.build_temp):
shutil.rmtree(self.build_temp)
os.makedirs(self.build_temp)
else:
os.makedirs(self.build_temp)

self.project_root = os.path.abspath(os.path.dirname(__file__))
self.build_cmake()
self.build_cython()

def build_cmake(self):

subprocess.check_call(
['cmake', '-G', 'Ninja', self.project_root],
cwd = self.build_temp
)

subprocess.check_call(
['ninja'],
cwd = self.build_temp
)

pyd_files = glob.glob(os.path.join(self.build_temp, PACKAGE_NAME, 'cpp', "*.pyd"))

if not pyd_files:
raise RuntimeError("No .pyd files found in the build directory.")

for pyd_file in pyd_files:
target_dir = os.path.join(self.project_root, PACKAGE_NAME, 'cpp')

if not os.path.exists(target_dir):
os.makedirs(target_dir)

shutil.copy(pyd_file, target_dir)


def build_cython(self):
source_dir = os.path.join(self.project_root, PACKAGE_NAME, 'cyth')

pyx_files = glob.glob(os.path.join(source_dir, "*.pyx"))
for pyx_file in pyx_files:
subprocess.check_call(
['cythonize', '-i', pyx_file],
cwd = self.build_temp
)


setup(
name=PACKAGE_NAME,
version=VERSION,
packages=[PACKAGE_NAME] + SUB_PACKAGES,
package_data={
"phono.cpp": ["*.pyd"],
"phono.cyth": ["*.pyd"]
},
cmdclass={
'build_ext': CustomBuildExt,
},
entry_points={
'console_scripts': [
'say_hello = phono.scripts:say_hello'
]
}
)

这里改写了自带的build_ext类,完全由命令行来生成对应的库,这需要我们对setup()的运行逻辑有大致的了解。总的来说,setup() 函数运行的流程就是根据用户输入的配置参数,构建和初始化一系列内部对象,并调用这些对象的方法来完成打包、构建和安装等操作,大体上包含以下几个步骤:

  1. 读取参数: 调用 setup() 时,接收用户提供的关键字参数,这些参数用于描述包的元数据、包的结构、依赖关系、扩展模块配置等。
  2. 构建配置对象setup() 用这些参数,创建并初始化一个 Distribution 对象。self.distribution负责存储所有与打包和分发相关的信息。
  3. 初始化和配置:其他类(尤其是 Command 子类)会通过 self.distribution 访问和使用这些信息,进行一些其他的初始化,并保存这些配置信息。每个 Command 子类对应一个 setup.py 支持的命令(例如 buildinstallsdist 等)。
  4. 调用方法执行操作: 配置对象初始化后,setup() 会根据命令行输入(如 python setup.py installpython setup.py build_ext),选择并调用相应的 Command 对象的方法。这些方法负责执行具体的操作,如构建二进制文件、生成分发包、安装包等。
  5. 完成构建/安装流程: 在整个过程中,这些对象的方法会处理文件、编译代码、生成元数据文件、执行钩子函数等。setup() 的主要工作就是协调这些对象之间的调用,确保每个步骤都按顺序正确执行。

在这里,具体指定的参数说明如下:

  • name:项目的名称,这个名称只在安装或分发时会用到,与包的名称没有关系。比如在使用 pip install 安装包时,name 参数就是安装的包名。在生成的 dist-info 目录中,这个名称也会作为目录的一部分。
  • version:指定项目的版本号。在 dist-info 目录中,这个版本号同样会出现在目录名称中,并且在安装时 pip 会根据版本号来决定是否需要更新或替换现有版本。

比如对于在我的电脑的Anaconda的PhononMC环境中安装的0.1版本的phono包,就会在D:\Anaconda3\envs\PhononMC\Lib\site-packages下生成一个phono-0.1.dist-info目录,里面储存了项目信息。pip实际上就是根据这些信息,进行包的依赖管理、升级和删除等等。

  • packages: 一个包含包名的列表,用来告诉 setuptools 哪些包(即哪些目录)需要被打包并包含在最终的分发包中。setup.py 不会自动包含任何子包或模块,导致安装时只会包含那些显式指定的模块,而其他模块则不会被打包和安装。setuptools模块提供了一个find_packages()函数,它会则会自动扫描和包含所有符合条件的包(包含__init__.py的目录)。

  • package_datapackages默认只会打包和复制包目录中的Python文件,其他类型的文件,如文本文件、配置文件、图片等,可以通过packages_data参数来指定。它的格式是一个字典,键是包的名称,值是一个文件模式列表。在这里,包含了编译出来的*.pyd库文件。采用package_data的好处首先是setup会帮我们打包和复制,另外是它会注册到dist-info目录中的RECORD记录文件中,这样pip uninstall phono的时候可以正确删除这些文件。

  • ext_modules:指定 setuptools 如何编译 C 或 C++ 编写的扩展模块,并将其包含在最终的 Python 包中。ext_modules 参数接受一个包含 Extension 对象的列表。每个 Extension 对象代表一个扩展模块,包含模块的名称、源文件、依赖库和其他构建信息。在这里,我们为了统一处理pybind和cython的包,采用cmake自行编译,因此这里就不需要这个参数了,只需要把编译出来的包放到package_data中。

  • cmdclass :用于指定自定义的命令类,上面我们说的,setup()中会生成一系列Command类对象来执行构建、安装等操作。我们可以重写这些类,以扩展或替代 setuptools 默认的命令行为。比如我们这里自定义了一个 CustomBuildExt 类,来替换原有的build_ext类行为。此时,我们运行 python setup.py build_ext 时就会执行我们自定义的 CustomBuildExt 类中的逻辑。

    在这里,我们在CustomBuildExt中定义了两个函数,build_cmake调用cmakeninja生成pybind的库文件,并把生成目录中的.pyd文件复制到源文件目录中。build_cython调用cythonize,生成cython的库文件。

  • entry_points:用于定义项目的可执行入口点、插件机制和其他扩展点。它允许我们将 Python 函数或类注册为命令行工具、插件或其他可由外部调用的入口。比如在这里,我们把script.py中定义的say_hello函数设置为入口点。在安装phono包后,在命令行中输入say_hello,即可调用该函数。

1
2
3
4
# phono/scripts.py

def say_hello():
print('hello, world!')

phono根目录执行pip install .成功安装phono包后,即可成功导入该项目。