使用 CMake 统一管理并编译 C++/Python/R 算法包

在数据分析领域,Python 和 R 都是比较常用的语言。这两种语言在使用上有很多的相似处,也有很多的不同。 一方面,这两个语言对于代码的执行效率都远远不如静态语言(如C++),尤其是循环的效率、矩阵运算的效率等。 另一方面,这两种语言使用起来都要更为方便,而且有许多其他的软件包可以使用,很容易就可以和其他算法一起使用,这点又是 C++ 这种静态语言不能比的。 所以长久以来形成了“用 Python 和 R 调用 C++ 计算”的模式,以发挥两类语言各自的特点。 Python 中可以使用 pybind 或者 Cython 调用 C++ 代码,而 R 可以用 Rcpp 调用 C++ 代码。 目前很多算法库都有 Python 和 R 版本,但是往往都是单独开发,甚至 Python 版本和 R 版本不是同一个作者。 为了解决这个问题,笔者尝试了使用 CMake 将算法计算部分的 C++ 代码和调用部分的 Python 与 R 代码统一管理,使开发者可以同时提供两种语言的版本。

项目结构

本项目主要采用这样一种结构:

  • / 根目录
    • CMakeLists.txt 主 CMake 配置文件
    • include C++ 头文件
    • src C++ 源文件
    • test C++ 单元测试
    • python Python 模块代码
      • mypypackage Python 代码,主要包含用于调用 C++ 的 Cython 代码
      • test
      • CMakeLists.txt Python 模块的 CMake 配置文件
      • setup.py 用于构建和发布的 scikit-build 脚本
    • R R 包代码
      • data 用于存放包提供的数据文件
      • man 用于存放其他文档
      • R 用于调用的 R 代码
      • src 用于调用库的 C++ 代码
      • CMakeLists.txt R 包的 CMake 配置文件
      • DESCRIPTION.in R 包 DESCRIPTION 模板,在 CMake 项目配置时自动填入版本号等信息
      • NAMESPACE.in R 包 NAMESPACE 模板,在 CMake 项目配置时自动填入版本号等信息

根目录中可以添加一些持续集成配置文件、文档源文件等其他文件。

总体上,该项目结构是一个 C++ 项目的格式,在开发时也是先开发 C++ 代码,在 C++ 代码的基础上再开发 Python 或 R 代码,甚至其他语言的代码。

设计思路

在这个包中,根目录中的 C++ 代码主要负责实现算法内核的部分,即与所有调用语言无关的东西。在这里面,不能使用 Python 中的 DataFrame 或者 R 中的任何类型,只能使用纯 C++ 支持的类型。也就是说,需要调用者在 C++ 程序中可以直接调用这个库。这个算法核心部分通过 /test 目录下的代码进行单元测试,只要测试通过就说明算法核心没有问题。

目录 PythonR 中的代码主要是提供这些语言对于调用 C++ 代码的支持。一般情况下,都是这样一个顺序:Python 或 R 函数调用中间件、中间件调用 C++ 库。所以这两个目录中就需要包含 Python 或 R 函数(简称包函数)以及中间件这两个部分。在 Python 中,中间件往往是用 Cython 或者 Pybind 编写的,为包函数提供了 Python 对象和 C++ 对象进行对接的能力;在 R 中,中间件往往是用 C++ 编写的,依靠 Rcpp 包提供的能力,将 R 语言对象转换为 C++ 对象,并调用 C++ 库函数。

具体实现

为了描述方便,我们将 CMakeLists.txt 文件统称为配置文件。

根配置文件的编写比较简单,主要就是设置一些变量,例如是否有 Python 模块的 WITH_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
# /CMakeLists.txt
cmake_minimum_required(VERSION 3.12.0)
project(myproject VERSION 0.1.0)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/cmake)

option(WITH_R "Whether to build R extension" OFF)

set(TEST_DATA_DIR ${CMAKE_SOURCE_DIR}/test/data)

add_subdirectory(src)

include(CTest)
enable_testing()

add_subdirectory(test)

set(CPACK_PROJECT_NAME ${PROJECT_NAME})
set(CPACK_PROJECT_VERSION ${PROJECT_VERSION})
include(CPack)

if(WITH_R)
add_subdirectory(R)
endif()

主要工作就是向根配置文件中,引入 C++ 配置文件和测试用例,以及当 WITH_R=ON 时引入 R 配置文件。

C++ 配置文件

这部分的配置文件写法和一般 CMake 管理的 C++ 库没有什么区别,所做的就是查找一些库、构建库或可执行程序。 可以无需考虑 Python 或 R 的部分。

R 配置文件

R 配置文件相对比较简单一些。由于 R 有自己的包结构,如果要发布到 CRAN 中的话,就需要按照这种结构来提交。 而且 R 包的安装是通过 R CMD INSTALL 命令进行安装的,不太适合用文件拷贝的方式进行安装。 那么我们可以根据现有的代码结构,单独使用一个文件夹,程序化构造 R 包的结构,并调用 R 相关命令进行包的构建。 于是我们可以充分利用 CMake 提供的文件操作命令以及 add_custom_target() 方法实现这一目的。

在 CMake 配置和生成阶段,我们可以使用以下命令来生成一个 R 包的标准结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# /R/CMakeLists.txt
set(PROJECT_RBUILD_DIR ${CMAKE_BINARY_DIR}/${PROJECT_NAME})
make_directory(${PROJECT_RBUILD_DIR})
configure_file(DESCRIPTION.in ${PROJECT_RBUILD_DIR}/DESCRIPTION)
configure_file(NAMESPACE.in ${PROJECT_RBUILD_DIR}/NAMESPACE)
file(COPY cleanup DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY configure DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY configure.ac DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY ${CMAKE_SOURCE_DIR}/R/R DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY ${CMAKE_SOURCE_DIR}/R/src DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY ${CMAKE_SOURCE_DIR}/R/man DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY ${CMAKE_SOURCE_DIR}/R/data DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY ${CMAKE_SOURCE_DIR}/include/header.h DESTINATION ${PROJECT_RBUILD_DIR}/src)
file(COPY ${CMAKE_SOURCE_DIR}/src/sources.cpp DESTINATION ${PROJECT_RBUILD_DIR}/src)

这样在 CMake 构建目录下,会出现一个 ${PROJECT_RBUILD_DIR} 的目录,里面就是一个标准结构的 R 包。 接下来,所有与 R 相关的操作,就都可以针对这个文件夹进行。下面是一个对 R 包进行编译、生成文档、打包的示例。

1
2
3
4
5
6
7
8
9
10
11
# /R/CMakeLists.txt
add_custom_target(mypackage_rbuild
VERBATIM
WORKING_DIRECTORY ${PROJECT_RBUILD_DIR}/..
COMMAND_EXPAND_LISTS
COMMAND ${CMAKE_COMMAND} -E
make_directory "${PROJECT_NAME}.library"
COMMAND ${R_EXECUTABLE} CMD INSTALL --preclean --clean --library=${PROJECT_NAME}.library ${PROJECT_NAME}
COMMAND ${R_EXECUTABLE} -e "roxygen2::roxygenize('${PROJECT_NAME}', load_code = 'source')"
COMMAND ${R_EXECUTABLE} CMD build ${PROJECT_NAME}
)

基于这种思路,我们同样可以编写一个测试,就用来执行 R CMD check 命令。

1
2
3
4
5
6
# /R/CMakeLists.txt
add_test(
NAME Test_R_mypackage
COMMAND ${R_EXECUTABLE} CMD check ${PROJECT_NAME}_${PROJECT_VERSION_R}.tar.gz --as-cran --no-manual
WORKING_DIRECTORY ${PROJECT_RBUILD_DIR}/..
)

此外,如果使用 VSCode 进行开发,且使用 CMake 作为配置提供工具, 使用这种方式会导致自动类型提示无法在 RcppExports.cpp 等 R 包所需的 C++ 文件中工作。 解决方法也非常简单,就是写一个正常的 CMake 生成目标即可,但是将这个生成目标排除在 ALL 目标之外。

1
2
3
4
5
6
7
# /R/CMakeLists.txt
find_package(R REQUIRED)
include_directories(${R_INCLUDE_DIRS} ${RCPP_ARMADILLO_INCLUDE_DIR} ${RCPP_INCLUDE_DIR})
include_directories(../include)
add_library(mypackage_rcpp_export SHARED src/RcppExports.cpp)
target_link_libraries(mypackage_rcpp_export mylib)
set_target_properties(mypackage_rcpp_export PROPERTIES EXCLUDE_FROM_ALL TRUE)

这样,我们就可以完全依靠 CMake 的指令操作所有的流程。例如

1
2
3
4
mkdir build && cd build
cmake .. -DWITH_R=ON
cmake --build . --config Release --target mypackage_rbuild
ctest -R Test_R_mypackage --output-on-failure

在持续集成中也可以采用这样的操作,避免因为 R 解释器以及操作系统的问题,造成很多不必要的麻烦。

Python 配置文件

Python 的配置文件会相对来说更复杂一点,因为涉及到 Cython 语言的编译问题。 好在 scikit-build 库已经提供了使用 CMake 编译 Cython 文件的方法,而且有打包的功能。 那么我们可以直接使用 scikit-build 编译,也可以像 R 包一样,构建一个 scikit-build 所需要的结构, 并将这个包作为提交 Pypi 的包。

使用 CMake 直接编译

根据 scikit-build 的文档,我们可以用这样的配置直接编译一个 Python 模块(pyd 文件)

1
2
3
4
5
# /python/mypackage/CMakeLists.txt
add_cython_target(pymypackage.pyx CXX)
add_library(pymypackage MODULE ${pymypackage})
target_link_libraries(pymypackage mylib ${ARMADILLO_LIBRARIES} ${Python3_LIBRARIES} Python3::NumPy)
python_extension_module(pymypackage)

然后在 Python 模块代码的配置文件中引入即可

1
2
3
4
# /python/CMakeLists.txt
include_directories(${MYLIB_INCLUDE_DIR})
add_subdirectory("mypypackage")
add_subdirectory("test")

编译好的 Python 模块就可以直接通过 import 关键字引入。 同样我们可以写一些 install 脚本,这样就可以直接将编译好的包安装在本地。

使用 Scikit-Build 编译

与 R 包类似,构建 Pypi 包无非就是拷贝一些文件到一个目录,形成对应的结构。例如我们可以这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# /python/CMakeLists.txt
set(PYMYPACKAGE_SKBUILD_DIR ${CMAKE_BINARY_DIR}/pymypackage)
add_custom_target(pymypackage_skbuild
VERBATIM
COMMAND_EXPAND_LISTS
COMMAND ${CMAKE_COMMAND} -E
make_directory
${PYMYPACKAGE_SKBUILD_DIR}
COMMAND ${CMAKE_COMMAND} -E
copy_directory ${CMAKE_SOURCE_DIR}/python ${PYMYPACKAGE_SKBUILD_DIR}
COMMAND ${CMAKE_COMMAND} -E
copy_directory ${CMAKE_SOURCE_DIR}/cmake ${PYMYPACKAGE_SKBUILD_DIR}/cmake
COMMAND ${CMAKE_COMMAND} -E
copy_directory ${CMAKE_SOURCE_DIR}/include ${PYMYPACKAGE_SKBUILD_DIR}/include
COMMAND ${CMAKE_COMMAND} -E
copy_directory ${CMAKE_SOURCE_DIR}/src ${PYMYPACKAGE_SKBUILD_DIR}/src
)

包结构设置好了,下面就是使用 Scikit-Build 进行编译。 虽然 Scikit-Build 最终还是通过 CMake 构建的,但是这种方式支持 python 命令编译安装。 我们需要编写 setup.pypyproject.toml 文件。

1
2
3
4
5
6
7
8
9
# setup.py
from skbuild import setup
setup(
name="pymypackage",
version="0.2.0",
author="myname",
packages=["pymypackage"],
install_requires=['cython']
)
1
2
3
4
5
6
7
8
9
10
11
12
13
[build-system]
requires = [
"setuptools>=42",
"wheel",
"scikit-build>=0.12",
"cmake>=3.18",
"ninja",
"cython",
"numpy",
"pandas",
"geopandas"
]
build-backend = "setuptools.build_meta"

此时如果使用 python setup.py 的方式安装,到此为止只是告诉 Python 要使用 Scikit-Build 安装,以及如何使用这个工具安装。 但是还没有告诉 Scikit-Build 怎么去安装。 这一步还是通过写 CMake 配置文件进行实现的,通过这个配置文件就告诉 Scikit-Build 使用什么样的步骤构建并编译包。

1
2
3
4
5
6
7
8
9
10
11
12
13
# /python/CMakeLists.txt
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake)
find_package(Armadillo REQUIRED)
if(MSVC)
add_definitions(-D_CRT_SECURE_NO_WARNINGS)
endif(MSVC)
set(MYLIB_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/include)
include_directories(${MYLIB_INCLUDE_DIR})
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src ${pygwmodel_BINARY_DIR}/mylib)
add_subdirectory("pygwmodel")
enable_testing()
add_subdirectory("test")

那么问题来了,这段 CMake 配置应该写在哪里呢? 应该是 /python/CMakeLists.txt ,因为这个文件是我们 Pypi 包结构的根配置文件。 但是构建这个包的 CMake 配置写在哪里呢? 还是这个文件,因为 /python 目录是 Python 包代码的根目录。 这样就产生了一个冲突,这个文件该如何包含两种配置?

为了解决这个问题,我们可以使用 Scikit-Build 提供的一个 CMake 宏 SKBUILD 。 如果定义了这个宏,那就说明是 Scikit-Build 在使用这个配置文件; 如果没有,那就说明不是 Scikit-Build 在使用。 但是如果不是 Scikit-Build 在使用,我们依然也需要分成两种情况:直接用 CMake 编译和构建 Pypi 包。 因此我们需要再定义一个 USE_SKBUILD 的宏,来区分这两种情况。 综合起来,配置文件 /python/CMakeLists.txt 就需要写成下面这个形式

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
# /python/CMakeLists.txt
if(SKBUILD)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake)
find_package(Armadillo REQUIRED)
if(MSVC)
add_definitions(-D_CRT_SECURE_NO_WARNINGS)
endif(MSVC)
set(MYLIB_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/include)
include_directories(${MYLIB_INCLUDE_DIR})
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src ${pygwmodel_BINARY_DIR}/mylib)
add_subdirectory("pygwmodel")
enable_testing()
add_subdirectory("test")
elseif(USE_SKBUILD)
set(PYMYPACKAGE_SKBUILD_DIR ${CMAKE_BINARY_DIR}/pymypackage)
add_custom_target(pymypackage_skbuild
VERBATIM
COMMAND_EXPAND_LISTS
COMMAND ${CMAKE_COMMAND} -E
make_directory
${PYMYPACKAGE_SKBUILD_DIR}
COMMAND ${CMAKE_COMMAND} -E
copy_directory ${CMAKE_SOURCE_DIR}/python ${PYMYPACKAGE_SKBUILD_DIR}
COMMAND ${CMAKE_COMMAND} -E
copy_directory ${CMAKE_SOURCE_DIR}/cmake ${PYMYPACKAGE_SKBUILD_DIR}/cmake
COMMAND ${CMAKE_COMMAND} -E
copy_directory ${CMAKE_SOURCE_DIR}/include ${PYMYPACKAGE_SKBUILD_DIR}/include
COMMAND ${CMAKE_COMMAND} -E
copy_directory ${CMAKE_SOURCE_DIR}/src ${PYMYPACKAGE_SKBUILD_DIR}/src
)
else()
include_directories(${MYLIB_INCLUDE_DIR})
add_subdirectory("mypypackage")
add_subdirectory("test")
endif()

这样,就可以用这一个配置文件,实现三种不同的构建。 如果只需要本地构建,配置 CMake 的时候带上 -DWITH_PYTHON=ON 参数; 如果需要构建包结构,带上 -DWITH_PYTHON=ON -DUSE_SKBUILD=ON 参数, 然后使用 Python 解释器运行 setup.py 脚本就可以构建了。

参考仓库

关于 Python 部分,可以参考仓库 hpdell/libgwmodel,该仓库是按照本文所描述的方式编写的。 关于 R 部分,上述仓库中虽然有,但是方法比较陈旧了,与本文描述也有一定的出入。 使用本文方法编写的仓库暂时还不适合开源,但会尽快开源。 届时将补充在本文中。

感谢您的阅读,本文由 HPDell 的个人博客 版权所有。如若转载,请注明出处:HPDell 的个人博客(http://hpdell.github.io/编程/cmake-cpp-pypi-cran/
原神中为什么暴击暴伤比为1:2最好?