9

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

 2 years ago
source link: https://hpdell.github.io/%E7%BC%96%E7%A8%8B/cmake-cpp-pypi-cran/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

使用 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 等,然后添加一些目录。下面是一个示例

# /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 包的标准结构。

# /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 包进行编译、生成文档、打包的示例。

# /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 命令。

# /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 目标之外。

# /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 的指令操作所有的流程。例如

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 文件)

# /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 模块代码的配置文件中引入即可

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

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

使用 Scikit-Build 编译

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

# /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 文件。

# setup.py
from skbuild import setup
setup(
name="pymypackage",
version="0.2.0",
author="myname",
packages=["pymypackage"],
install_requires=['cython']
)
[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 使用什么样的步骤构建并编译包。

# /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 就需要写成下面这个形式

# /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 部分,上述仓库中虽然有,但是方法比较陈旧了,与本文描述也有一定的出入。 使用本文方法编写的仓库暂时还不适合开源,但会尽快开源。 届时将补充在本文中。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK