Python C++ nanobind & scikit-build编译工作流搭建

Python C++ nanobind & scikit-build编译工作流搭建

Python C++ nanobind & scikit-build编译工作流搭建 - AmberFish的文章 - 知乎
https://zhuanlan.zhihu.com/p/2001276196558754001

C++编译对于初学者还是有点麻烦的。即使有CMake等现代构建工具,由于没有统一的依赖来源和版本限制,很多命令和参数需要手动设定,而且设定的方式不止一种。此外Python binding还要考虑开发中使用的Python依赖和Python控制下的构建(scikit-build, setup.py)。常见的问题如下:

  • 要写C++的情况下使用哪种Python 环境隔离和依赖工具?Conda, venv or uv?
  • 如何管理C++依赖?CPM, git submodule or path?
  • 能不能直接用pytorch里带的libtorch?
  • 怎么管理编译参数?命令参数,终端环境变量,or CMakeLists.txt?
  • 如何安装编译产物到python中,cmake install, pip 还是复制粘贴?
  • 如何从C++代码生成类型提示?

这几个问题AI都有答案,而且不止一种答案,麻烦在于有的解决方案是重复设置,有的并不符人类习惯——总不能每次在cmake命令后面都打一长串参数,或者把路径直接写CMakeLists.txt 里。实际上很多问题都有现代的解决方案,特别是近几年包管理器的发展提供了很多方便。本文结合过去踩坑经历,分享一个不反人类编译调试流程。这里不包括特定工具的教程,而是各个工具之间如何选择和联动的实践。具体的操作可以自行查阅文档。对应的示例如下:https://github.com/geyuuu/nanobind_examplegithub.com/geyuuu/nanobind_example此仓库在 wjakob/nanobind_example 的基础上删改而成,主要添加了引入libtorch等依赖的部分。

C++ binding总体流程

首先Python可以直接使用C编译好的二进制文件,无需任何配置,python的C接口可以直接将某个函数import使用。对于复杂的项目,CMake用来满足纯C++代码的编译,只要在CMake里进行设置,cmake build编译出来的.so文件可以被python直接import。你可以直接将其复制到python环境的site-packages文件夹当作一个package(当然这不是标准做法)。当涉及到复杂的python binding 时需要引入nanobind, pybind11这样的框架,这都可以在cmake配置文件中设置。真正编译并安装一个完整的package并安装到python环境里,需要引入python的构建系统,如setuptools sci-build. 这类构建系统由python主导,通过pip install命令就可以触发基于CMake等工具的编译,并安装package到当前环境。当你从pypi安装别人的package时,有时也可以看到这个编译的过程。按照完成的先后顺序,本文的目标如下:

  1. 编写正确的CMakeLists.txt,使用cmake命令可以编译出二进制文件;
  2. 使用pip install可以构建虚拟环境编译并安装;
  3. 在import时自动触发增量编译;

以上几步最大的问题是环境的配置,每一步所依赖的环境都是不同的。例如使用CMake命令时,环境可以来自bash和手动输入的参数;但如果你是通过VS code插件触发的cmake, 那么.bashrc中的设置是不起作用的,因为VS code启动内部终端执行命令并不会加载.bashrc;pip install 编译时会构建一个虚拟环境,外部设置和python环境不起作用;但是同样的命令,在增量编译中又不会构建虚拟环境而依赖当前的python环境。在下文会对这些行为做说明。

C++ 编译器

首先解决C++编译器。因为我需要使用CUDA,直接按照 https://developer.nvidia.com/cuda-downloads 安装Nvidia CUDA Toolkit. 这个软件包包括了较新的gcc编译器,CMake 和CUDA, 对于CUDA C++开发属于一劳永逸的选择。如果手动安装编译器,编译器版本管理和标准库版本又是一个坑,建议通过PPA的方式管理gcc和CMake版本,尤其是在apt安装的默认CMake 版本比较低,一些新项目无法使用。总体而言C++ 编译器的版本兼容问题不是很严重,只要对语言的大版本有支持(C++11, C++17)一般就不会有问题(当然出了问题也没法解决)。pip install 构建虚拟环境时会使用现有的编译器。

CMake 编译

我们通过nanobind绑定C++到python模块,pybind11的过程也类似。最简单的流程下,CMake会编译C++代码到一个.so文件,直接用python就可以import,不需要任何python文件。CMake的主要三个功能:

  1. CMake Config, 生成build目录(cmake命令);
  2. CMake Build, 编译(cmake build命令);
  3. CMake Install, 安装编译产物到某个位置。

这些命令的使用在此不做讨论。如果不熟悉,在VS code CMake tools插件的界面上可以手动触发挨个试一试。

CMake tools 插件
CMake插件对target的可视化排列还是很方便的。VS code中大部分功能的实现,实际上都是在某个终端执行某个命令。读者应该早已了解如何在各种终端中修改环境变量,但VS code插件启动的终端并不会主动加载这些配置,和其他终端环境是略有不同的。因此个人不建议在环境变量中存放类似库路径这样的内容。

实际处理python binding时通常还会有__init__.py 等python代码文件,这部分是CMake无法处理的。如python目录里有__init__.py 等文件,修改C++代码需要重新编译更新.so文件。具体流程在下文。

CMake 依赖问题

现在详细解释CMakeLists.txt编写和C++依赖问题,以下代码均在示例仓库中。示例中的CMakeLists.txt主要基于nanobind示例,参见nanobind文档的解释。 这里主要解释一下外部依赖问题,也就是find_package时可能会遇到的错误。

find_package(nanobind CONFIG REQUIRED)

find_package(CUDAToolkit REQUIRED)
find_package(Torch REQUIRED)

对于网上能下载到的公共资源,比如github上的开源库,建议使用包管理工具。例如一个简单的依赖方式是CMake Package

Manager(CPM):# An example of using CPM to manage dependencies
# Uncomment the following lines to enable CPM and add stdexec as a dependency.
# For more information on how to add CPM to your project, see: https://github.com/cpm-cmake/CPM.cmake#adding-cpm
# include(CPM.cmake)

# CPMAddPackage(
#   NAME stdexec
#   GITHUB_REPOSITORY NVIDIA/stdexec
#   GIT_TAG main # This will always pull the latest code from the `main` branch. You may also use a specific release version or tag
  1. CMake 变量与缓存,如<PackageName>_ROOT,<PackageName>_DIR 从命令参数或者CMakeLists.txt直接定义的变量;
  2. 环境变量;
  3. 系统标准路径,如CMAKE_SYSTEM_PREFIX_PATH, CMAKE_INSTALL_PREFIX

前文已经提到,为了方便管理不建议在环境变量里面设置这些内容。如果你使用VScode cmake插件并希望通过插件启动编译过程,可以在vscode项目设置中设置cmake config的环境

添加图片注释,不超过 140 字(可选)

上面的设置等价于项目的.vscode 目录下的settings.json。此时通过插件启动cmake,终端将添加对应的参数和环境。

{
"cmake.configureOnEdit": true,
    "cmake.configureOnOpen": false,
    "cmake.configureArgs": [
        "-DPython_ROOT_DIR=.venv"
    ],
    "cmake.configureEnvironment": {
        "Torch_DIR": "${workspaceFolder}/.venv/lib/python3.12/site-packages/torch/share/cmake"
    },
    "cmake.installPrefix": "${workspaceFolder}/.venv/lib/python3.12/site-packages/",
    "github.copilot.enable": {
    "*": false
    }
}

使用Pytorch中的libtorch

上一节指定了相对的Python路径,Torch路径和CMAKE_INSTALL_PREFIX,这样做的目的是使用虚拟环境中的python和python包里的库,包括nanobind 和pytorch.在libtorch官方的页面上提供了libtorch的下载,但由于pytorch中本身就包含了libtorch, 专门下载一个并没有必要。更重要的是,这使得pytorch和libtorch的版本一致,把C++ 包管理和分发的工作抛给了Python虚拟环境和pypi,在重新搭建环境时无需手动下载。nanobind的标准用法也是如此,我认为是个很聪明的选择做法。

虚拟环境和Python 依赖:uv

在科学计算领域最常用的Python包管理器是conda. conda 设计理念很先进,不仅python,在虚拟环境里面gcc编译器、环境变量都可以设置。问题在于不同项目的依赖可能有潜在的冲突,在不同项目中通用同一个环境是有潜在问题的。此外conda本身的安装和pip并存,在管理上比较麻烦。Python自带了venv,可以在项目中生成.venv目录,完整包括了独立的Python解释器,激活虚拟环境之后可以随意折腾,缺点在于依赖解析慢,安装新包费存储费时间。对于复杂的python项目,推荐使用uv, 同样是生成.venv, uv利用符号链接节省空间和安装时间,而且解析依赖速度相当快。此外也支持了现代的Python项目配置文件pyproject.toml. 本文也将使用这个配置文件。关于依赖管理,除了uv文档也可参阅PEP 507 和PEP 735. 对于Python环境,参考教程安装uv, 通过 uv sync 可以直接安装pypoject.toml 中要求的包,当前搭建的环境信息会被写入uv.lock 文件。也可以手动uv pip, 不过安装的包不会记录在pypoject.toml 里面。uv add的包会添加到pypoject.toml.由于PyPI没有收录GPU版本的pytorch, 可以参考uv的文档设置pytorch依赖。

DevContainer我认为是vscode继romote和language server之后的第三大发明,最彻底的隔离。只要一个.devcontainer.json配置文件就能定义容器环境,把文件系统接到容器里面,不需要再配置磁盘挂载网络等等问题。可惜的是官方镜像更新不勤,如默认pytorch镜像还停留在一年多之前的版本,没有CUDA 13。选择自己搭建镜像又会有各种小问题,我尝试用Nvidia NGC Catalog中的镜像就会默认用root登录,这导致了一些文件权限问题,git credential传递也遇Bug. 不过一些大项目都有人维护devcontainer文件,因此也是一种方便的选择。

CMake Install

经过如上的设置,理论上你可以通过cmake成功编译了。这里产生的.so 文件会被放在CMAKE_INSTALL_PREFIX/nanobind_example目录中,也就是.venv/lib/python3.12/site-packages\nanobind_example. 对应的CMakeLists.txt的设置:install(TARGETS _nanobind_example_impl LIBRARY DESTINATION nanobind_example)如果再加上__init__.py 这些文件,这就是一个可以被使用的包了。完全为python编写的包会通过下文的python build后端去干这些事情,手动用cmake命令编译二进制文件一方面是为了验证C++部分的正确性,另一方面生成的文件可以给IDE/编辑器类型提示,否则代码一片红也很糟心(下文的提到的sci-build等后端编译时隔离不会留下痕迹,所以编辑器没法获取这些信息)。


通过 scikit-build 编译和安装

python C++ binding的事实标准是使用pip和scikit-build工具。根据pyproject.toml,它们会创建一个临时的隔离环境,自动安装所需python依赖并调用CMake 编译,最后安装在当前python环境当中。直接pip install . 可以实现编译C++代码并安装到当前环境。这里需要注意该环境是完全隔离的,依赖包会自动安装在pyproject.toml文件build-system.requires中的依赖,这是编译所需要的包。pyproject.toml中还有一个dependencies指用户使用包时会用到的python依赖(类似其他包管理方式中的requirements.txt),和编译依赖requires 并不等价。例如在我们在使用中并不需要nanobind,所以nanobind只出现在requires 里就好。

[build-system]
requires = ["scikit-build-core >=0.10", "nanobind >=1.3.2","torch"]
build-backend = "scikit_build_core.build"
使用uv sync 命令时向venv虚拟环境安装的是dependencies. 也可以定义自己的另一组dependencies,用参数的方式指定uv sync 安装哪一组依赖。

安装完成之后,可以在venv中python的site-package里发现你的.so文件和python代码。

渐进式编译

scikit-build编译安装,每次需要从零开始构建一个一次性的环境,这一步比较耗费时间。可以参考scikit-build的文档 做渐进式的编译:$ pip install --no-build-isolation -Ceditable.rebuild=true -ve .该命令只需要编译一次,在编译中依赖的是当前虚拟环境(venv目录中的,而不是隔离的),因此需要保证在当前的虚拟环境也安装build-system.requires里面的东西。编译一次之后,每次在import阶段都会检测C++代码的修改,如果有修改自动触发增量编译。这样就不用手动编译之后再运行了。其中-ve指输出详细安装信息(verbose)和可编辑模式(editable )。在editable模式下,Python代码还在原地,只有.so文件被安装到了虚拟环境对应包位置。

关于setup.py:之前C++编译流程通常通过setuptools模块和setup.py实现,​但目前的标准下setup.py 不是必须的,理论上所有设置都可以在pyproject.toml配置文件中搞定。

自动生成Python类型提示 Stub generation

以上内容已经完整地演示了如何编译一个项目。如果你开启了编辑器的类型检查,也就是pylance之类的东西,大概率在import 之后会有划红线的地方,因为Pylance无法从.so文件中获得Python类型信息。像nanobind这类工具,提供从C++代码生成类型提示的选项。这需要在CMakeLists.txt文件中进行设置。请参考实例repo中的nanobind_add_stub 部分。参考Typing - nanobind documentation.

nanobind_add_stub(
  nanobind_example_stub
  MODULE _nanobind_example_impl
  OUTPUT _nanobind_example_impl.pyi
  PYTHON_PATH $<TARGET_FILE_DIR:_nanobind_example_impl>
  DEPENDS _nanobind_example_impl
  MARKER_FILE py.typed
)

# Install directive for scikit-build-core
install(TARGETS _nanobind_example_impl LIBRARY DESTINATION nanobind_example)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/py.typed ${CMAKE_CURRENT_BINARY_DIR}/_nanobind_example_impl.pyi DESTINATION nanobind_example)

这里的一个坑点在于,在editable模式下包含了类型信息的py.typed和.pyi 文件被安装在了包目录而python源码还在原地,pylance无法识别。当然可以在CMakeLists.txt中进行一些复杂的设置来实现,我倾向于转而使用nanobind的命令行工具,这样生成的类型提示文件默认在源码旁边:$ python -m nanobind.stubgen -m my_ext -M py.typed

如果使用VS code, 我习惯把类似渐进式编译和stubgen命令设置成task,这样用快捷键就可以出发。详情请询AI。

总结

其实没有什么总结,这篇文章本身更像是一个踩坑目录而非完整的教程。这是写的第一篇长教程,好久没写过长的文章我写的好累,我感觉其他人应该也看不太下去。纯手写几乎没有AI,现在的孩子们太幸福了这些坑可以问AI而我们当年只能自己尝试。希望能对后来的小资历们有所帮助。我终于成为了能发二次元封面技术文章的人。