pip install 一个带 CUDA 算子的包,import 时报 undefined symbol: _ZN3c10...;自己写的 C++ 扩展编译通过,Python 里却说 dynamic module does not define module export function。这两个错误都和代码逻辑无关,都出在”源码变成可加载的二进制”这条流水线上。这一篇把流水线从头走一遍:一个 .cpp 怎么变成 .o,.o 怎么拼成程序或库,库怎么在运行时被找到,最后怎么变成 import xxx。
一行 clang++ 背后的四步
先看一个尽量小的程序。头文件只有一个声明,源文件里用宏定义了一个常数:
// square.h
#pragma once
int square_times(int x);
// square.cpp
#include "square.h"
#define FACTOR 3 // 预处理阶段就被文本替换,编译器看不到 FACTOR 这个名字
int square_times(int x) { return x * x * FACTOR; }
int main() { return square_times(2); } // 用退出码带出结果,避免引入 <iostream>clang++ square.cpp -o square 一条命令就能得到可执行文件,运行后退出码是 12。但这条命令其实依次做了四件事,每一步都可以单独停下来看产物:
clang++ -std=c++20 -E square.cpp -o build/square.ii # 预处理
clang++ -std=c++20 -O1 -S square.cpp -o build/square.s # 编译到汇编
clang++ -std=c++20 -O1 -c square.cpp -o build/square.o # 汇编成目标文件
clang++ build/square.o -o build/square # 链接预处理只做文本处理。去掉 # 开头的行号标记后,square.ii 的正文是:
int square_times(int x);
int square_times(int x) { return x * x * 3; }
int main() { return square_times(2); }#include 被替换成了头文件的全文,FACTOR 被替换成 3。预处理器不懂 C++,它不知道 int 是类型,只会粘贴和替换。所以”头文件”在语言层面并不存在,它只是被粘进每个 .cpp 的一段文本。粘的量可能很大:一个只写了 #include <iostream> 和空 main 的文件,预处理后有 83705 行。这就是大型 C++ 项目编译慢的一个直接原因,每个 .cpp 都要重新解析一遍它 include 的所有东西。
编译把一个预处理后的文件(一个”翻译单元”)变成汇编。-O1 下 square.s 里能看到两段:
__Z12square_timesi:
mul w8, w0, w0
add w0, w8, w8, lsl #1
ret
_main:
mov w0, #12
ret
x * x * 3 被写成”先算 x*x,再加上它左移 1 位”,省掉了一次乘法;main 里调用直接被常量折叠成 12。函数名变成了 __Z12square_timesi,这件事下一节解释。汇编本身怎么读属于 机器级程序。
汇编把文本形式的指令编码成机器码,产物 square.o 是一个目标文件(macOS 上 file 显示 Mach-O 64-bit object arm64)。它已经是机器码了,但还不能运行:里面的地址都是相对本文件的,调用别的文件里的函数时只留了一个”待填”的记号。
链接把一个或多个 .o 和需要的库合在一起,给每个函数和变量分配最终地址,把所有”待填”的记号填上,得到可执行文件。
这四步里,前三步对每个 .cpp 独立进行,互相看不见;只有链接这一步把它们放到一起。这个分工决定了后面所有错误的形态。
把 #define FACTOR 3 改成 #define FACTOR 4 后只重新执行了链接那一步(没有重新 -c),程序的退出码会是多少?
宏替换发生在预处理阶段,之后编译器只看到字面量 3,生成的机器码被保存在 square.o 里。只重跑链接,用的还是那个旧 .o。
链接器根本不读源文件,也不检查 .o 是否"过期",所以既不会变成 16,也不会报错。判断哪些文件需要重编,是构建系统的工作。
符号:目标文件之间的约定
把程序拆成两个文件,链接这一步才有活干:
// mathlib.cpp
static int helper(int x) { return x + 1; } // static:只在本文件可见
int counter = 0;
int add(int a, int b) { ++counter; return helper(a) + b - 1; }
// main.cpp
#include <cstdio>
int add(int a, int b); // 只有声明,定义要等链接器去别的 .o 里找
int main() { std::printf("add(2, 3) = %d\n", add(2, 3)); }分别 -c 成 .o,用 nm 看每个文件的符号表(省略了编译器生成的 ltmp* 局部标签):
$ nm build/mathlib.o
0000000000000000 T __Z3addii
0000000000000044 t __ZL6helperi
00000000000000a0 S _counter
$ nm build/main.o
U __Z3addii
0000000000000000 T _main
U _printf
符号就是”名字 → 本文件内的位置”。T 表示定义在代码段里的全局函数,S 是数据段里的变量,小写 t 表示本地符号(static 的 helper,别的文件引用不到它),U 表示 undefined:本文件用了,但定义不在这里。链接器的核心工作就是给每个 U 找到唯一一个对应的 T/S。main.o 里的 __Z3addii 在 mathlib.o 里找到了;_printf 在系统库 libSystem 里找到。
add 为什么叫 __Z3addii?C++ 允许重载,f(int) 和 f(double) 必须是两个不同的符号,所以编译器把参数类型编码进名字,这叫 name mangling:_Z 开头,3add 是长度加名字,ii 是两个 int 参数(macOS 还会在所有符号前多加一个 _)。c++filt 可以还原:
$ nm ov.o # f(int)、f(double)、extern "C" g(int)
0000000000000014 T __Z1fd
0000000000000000 T __Z1fi
0000000000000028 T _g
$ nm ov.o | c++filt
0000000000000014 T f(double)
0000000000000000 T f(int)
0000000000000028 T _g
extern "C" 告诉编译器按 C 的规则命名,不做 mangling,代价是不能重载。凡是要被别的语言(C、Python 的 ctypes、动态加载器)按名字找到的函数,通常都用 extern "C" 导出,因为 mangling 规则随编译器而不同,没人能可靠地猜出 __Z3addii 这种名字。
有了”每个文件独立编译、链接时按名字对账”这个模型,错误就能分成三类,而且一眼能看出是哪一类。编译错误在单个文件里就能发现,比如用字符串调用 add(int, int):
bad_compile.cpp:4:12: error: no matching function for call to 'add'
bad_compile.cpp:1:5: note: candidate function not viable: no known conversion from 'const char[4]' to 'int' for 2nd argument
编译器拿声明一对照就知道类型不对,不需要看到 add 的定义。链接错误是每个文件单独看都合法,但对账时缺了东西,比如链接时忘了带 mathlib.o:
Undefined symbols for architecture arm64:
"add(int, int)", referenced from:
_main in main.o
ld: symbol(s) not found for architecture arm64
报错里的名字已经被还原成 add(int, int),背后就是 nm 里那个 U __Z3addii 没人认领。反过来,两个 .o 都定义了同一个非 static 的全局符号,链接器会报 duplicate symbol。第三类是运行时装载错误,程序链接成功了,运行时却找不到它依赖的动态库,这要等下一节讲动态库才看得到。
main.cpp 只写了声明 int add(int a, int b);,没有提供定义。下面哪些说法正确?
编译只需要声明就能检查调用是否合法,所以 -c 成功,并在符号表里留下一个 undefined 引用 U,等链接器去找。
在 -c 阶段报错是把编译和链接混为一谈了,"找不到定义"只能在链接时发现。static 会把符号变成本地的小写 t,别的 .o 看不到它,链接时 add(int, int) 依然 undefined。
静态库与动态库
项目一大,.o 文件会有几十上百个,自然想把一组相关的 .o 打包起来,作为”库”交给别人用。打包有两种方式,区别在于库的代码什么时候进入你的程序。
静态库(.a)只是 .o 的归档文件,和 zip 差不多:
$ ar rcs build/lib/libvec.a build/vec.o
$ ar t build/lib/libvec.a
__.SYMDEF SORTED
vec.o
$ clang++ main.cpp build/lib/libvec.a -o build/app_static && ./build/app_static
dot = 32.0
$ otool -L build/app_static
/usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 2100.43.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)
__.SYMDEF 是 ar 加的符号索引,方便链接器快速查找。链接时,链接器从 .a 里挑出解决了某个 U 的那些 .o,把它们的代码复制进可执行文件。所以 otool -L(列出运行时依赖的动态库,Linux 上对应 ldd)里看不到 libvec,程序和它已经没有关系了,.a 删掉也照样能跑。代价是每个用到这个库的程序都有一份副本,库修了 bug,所有程序都得重新链接。
动态库(macOS 上 .dylib,Linux 上 .so)把这一步推迟到运行时:链接器只记下”运行时需要某个库、需要它的哪些符号”,代码不复制进来。程序启动时,动态加载器(macOS 是 dyld,Linux 是 ld.so)把库映射进进程地址空间,再把符号的地址填好。多个进程可以共享同一份库的物理内存,这部分机制属于 虚拟内存。
$ clang++ -dynamiclib vec.cpp -install_name @rpath/libvec.dylib -o build/lib/libvec.dylib
$ clang++ main.cpp -Lbuild/lib -lvec -Wl,-rpath,@executable_path/lib -o build/app_dyn
$ ./build/app_dyn
dot = 32.0
$ otool -L build/app_dyn
@rpath/libvec.dylib (compatibility version 0.0.0, current version 0.0.0)
/usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 2100.43.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)
-L 和 -l 是给链接器用的:去 build/lib 里找 libvec。但链接时找到了,不代表运行时也能找到,程序可能被拷到别的机器上。所以可执行文件里只记了 @rpath/libvec.dylib,@rpath 是一个占位符,具体展开成哪些目录由 -rpath 写进文件的 LC_RPATH 决定,这里是 @executable_path/lib,即”可执行文件旁边的 lib 目录”。用相对位置而不是绝对路径,整个目录搬走也能用。
现在可以看到第三类错误了。把 dylib 挪到别处再运行:
$ ./build/app_dyn
dyld[4646]: Library not loaded: @rpath/libvec.dylib
Referenced from: <E47E7DA9-...> /private/tmp/kb08/08-3-static-vs-dynamic/build/app_dyn
Reason: tried: '/private/tmp/kb08/08-3-static-vs-dynamic/build/lib/libvec.dylib' (no such file), ...
exit=134
编译和链接都成功过,程序在 main 之前就被 dyld 终止了。Reason: tried: 列出了它按 rpath 展开后实际找过的路径,排查时先看这一行。临时救急可以设 DYLD_LIBRARY_PATH(Linux 是 LD_LIBRARY_PATH):
$ DYLD_LIBRARY_PATH=$PWD/build/elsewhere ./build/app_dyn
dot = 32.0
$ DYLD_LIBRARY_PATH=$PWD/build/elsewhere /bin/sh -c 'echo "[$DYLD_LIBRARY_PATH]"; ./build/app_dyn'
[]
dyld[4650]: Library not loaded: @rpath/libvec.dylib
直接运行有效,经过 /bin/sh 就失效了:macOS 的 SIP 会在启动受保护的系统程序时清掉所有 DYLD_* 变量,而 /bin/sh、/usr/bin/env 都受保护,很多脚本恰好经过它们。这也说明环境变量只是补丁,正经做法是把 rpath 写对。
动态库还带来一个新问题。静态链接时,代码在构建那一刻就定了;动态链接时,运行时加载的可能是另一个版本的库。只要新版本的符号名和内存布局与旧版本一致,程序就能继续工作,这种二进制层面的约定叫 ABI。源码兼容不等于 ABI 兼容:给一个结构体加个字段,调用方重新编译就没事,但用旧头文件编译的程序按旧大小访问它,就会读错内存;改了函数参数类型,mangled 名字变了,加载时直接报 undefined symbol。PyTorch 的 C++ 扩展必须和 torch 的版本精确对上,开头那个 undefined symbol: _ZN3c10... 就是这一类问题。
app_dyn 链接成功,拷到另一台机器的同一目录结构下(可执行文件旁边有 lib/libvec.dylib)运行。为什么它能找到库?
运行时的查找只依赖可执行文件里记录的 install name 和 LC_RPATH,这里都是相对可执行文件自身位置的,所以整个目录搬走也成立。
-L 只在链接时告诉链接器去哪找,不会写进文件。代码被复制进来是静态库的行为,otool -L 里还列着 @rpath/libvec.dylib 就说明它是动态依赖。dyld 不会默认在当前工作目录里找 @rpath 库,换个目录运行就会失败。
从手写命令到 CMake
到这里我们已经手写了七八条命令,-I、-L、-l、-rpath 一个都不能错,改了头文件还得自己记住哪些 .cpp 要重编。两个文件还能应付,几百个文件、Debug/Release 两套配置、Linux 和 macOS 两种平台就不行了。构建系统要解决两件事:根据依赖关系只重编改动过的部分;根据”谁依赖谁”自动推导出编译和链接参数。
CMake 本身不编译,它读 CMakeLists.txt,生成 Makefile 或 Ninja 文件,再由它们调用编译器。一个最小项目:库 vecmath 的公开头文件在 include/,内部头文件在 src/,可执行文件 demo 在 app/。
cmake_minimum_required(VERSION 3.20)
project(vecmath_demo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(vecmath STATIC src/vecmath.cpp)
# include/ 是对外接口,用 vecmath 的人也要看到 -> PUBLIC
# src/ 是内部细节,只有 vecmath 自己编译时需要 -> PRIVATE
target_include_directories(vecmath
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
add_executable(demo app/main.cpp)
# 链接 vecmath 的同时继承它的 PUBLIC 属性,demo 不用再手写 -I
target_link_libraries(demo PRIVATE vecmath)核心概念是 target:add_library 和 add_executable 各声明一个目标,编译参数挂在目标上,target_link_libraries 声明依赖。PUBLIC 表示”我自己用,依赖我的人也用”,PRIVATE 表示”只有我自己用”(还有 INTERFACE:自己不用,只传给依赖方,常见于纯头文件库)。
构建分两步,配置和编译,产物放在单独的 build/ 目录里(out-of-source build,删掉 build/ 就回到干净状态):
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -v # -v 打印实际执行的命令-v 的输出(路径缩写成 .,去掉了依赖跟踪用的 -MD -MT -MF 参数)正好验证了 PUBLIC/PRIVATE 的含义:
clang++ -I./include -I./src -O3 -DNDEBUG -std=gnu++20 -arch arm64 ... -c ./src/vecmath.cpp
ar qc libvecmath.a CMakeFiles/vecmath.dir/src/vecmath.cpp.o
clang++ -I./include -O3 -DNDEBUG -std=gnu++20 -arch arm64 ... -c ./app/main.cpp
clang++ -O3 -DNDEBUG -arch arm64 ... CMakeFiles/demo.dir/app/main.cpp.o -o demo libvecmath.a
编 vecmath.cpp 时两个 -I 都有;编 main.cpp 时只继承到了 -I./include。如果 main.cpp 去 include src/detail.h,会直接编译失败,这正是想要的:内部头文件被构建系统挡在了外面。CMake 生成的就是我们前面手写的那几条命令,ar 打包、链接时带上 .a,一条不少。
CMAKE_BUILD_TYPE 决定优化级别。Release 带来 -O3 -DNDEBUG,Debug 是 -g 且不优化。NDEBUG 这个宏会让 assert() 在预处理阶段被删掉,同一份代码两种配置的行为不同:
$ ./build/demo
NDEBUG defined: assert() is compiled out
dot sum = 209715200, time = 137 ms
$ ./build-debug/demo
NDEBUG not defined: assert() is active
dot sum = 209715200, time = 687 ms
同一个点积循环,Debug 版慢了 5 倍。拿没加 -DCMAKE_BUILD_TYPE 的构建测性能,是最常见的测量失误之一;在多配置生成器(Xcode、Visual Studio)里,这个选项要换成 cmake --build build --config Release。
用装在别处的第三方库时,CMake 用 find_package(Foo) 去找。库自己安装了 FooConfig.cmake 的叫 Config 模式,CMake 自带 FindFoo.cmake 的叫 Module 模式。找到后得到一个像 Foo::Foo 这样的 imported target,链接它就能自动带上头文件路径和库文件,和链接自己项目里的 target 没有区别。下一节的 pybind11 就是这样被找到的。
vecmath 的 target_include_directories 里,如果把 include/ 从 PUBLIC 改成 PRIVATE,会发生什么?
PRIVATE 的属性只作用于 vecmath 自己,不会通过 target_link_libraries 传给 demo,所以编译 main.cpp 的命令里不再有 -I./include,在预处理 #include 时就失败了。
"链接会带上头文件"混淆了两件事:头文件只在预处理阶段需要,链接器只处理 .o 和库。PUBLIC/PRIVATE 管的是编译参数的传递,不改变符号的可见性,所以也不会产生 undefined symbol。
Python 扩展就是一个动态库
回到开头的问题:让 Python import 一个 C++ 模块。CPython 的 import foo 在 sys.path 里找到 foo.cpython-312-darwin.so 这样的文件后,用 dlopen 把它当动态库加载,然后按名字查找一个符号 PyInit_foo 并调用它,这个函数负责创建模块对象、注册里面的函数。所以一个扩展模块需要满足三条:
- 是一个动态库,文件名后缀和解释器匹配。后缀可以问解释器:
sysconfig.get_config_var('EXT_SUFFIX')在这台机器上是.cpython-312-darwin.so,换 3.13 就不认了。 - 导出一个
extern "C"的PyInit_<模块名>,模块名必须和文件名前缀一致。 - 里面调用的 Python C API(
PyLong_FromLong、PyBaseObject_Type等)不能是 undefined 到底,它们由加载它的解释器进程提供。
手写 PyInit 和 C API 的类型转换很繁琐:每个参数都要从 PyObject* 拆出来,检查类型,处理引用计数,出错时设置异常。pybind11 是一个纯头文件的 C++ 库,用模板在编译期生成这些胶水代码。绑定一个函数和一个类:
#include <pybind11/pybind11.h>
#include <pybind11/stl.h> // 让 std::vector <-> list 自动转换;不 include 就会报类型无法转换
namespace py = pybind11;
int add(int a, int b) { return a + b; }
class Counter {
public:
explicit Counter(std::string name) : name_(std::move(name)) {}
void add(int k) { total_ += k; history_.push_back(k); }
int total() const { return total_; }
const std::vector<int>& history() const { return history_; }
std::string repr() const { return "<Counter " + name_ + " total=" + std::to_string(total_) + ">"; }
private:
std::string name_;
int total_ = 0;
std::vector<int> history_;
};
// 第一个参数是模块名,展开后定义 PyInit_basics,必须和文件名前缀一致
PYBIND11_MODULE(basics, m) {
m.def("add", &add, py::arg("a"), py::arg("b") = 1);
py::class_<Counter>(m, "Counter")
.def(py::init<std::string>())
.def("add", &Counter::add)
.def_property_readonly("total", &Counter::total)
.def("history", &Counter::history) // 返回时把 vector 拷贝成新的 list
.def("__repr__", &Counter::repr);
}最朴素的构建方式是一条 clang++ 命令,加上 -shared -fPIC 生成动态库,用 python -m pybind11 --includes 拿到 Python 和 pybind11 的头文件路径:
PY=.venv/bin/python
EXT=$($PY -c "import sysconfig; print(sysconfig.get_config_var('EXT_SUFFIX'))")
clang++ -std=c++20 -O2 -shared -fPIC $($PY -m pybind11 --includes) basics.cpp -o build/basics$EXT在 macOS 上这条命令会坏掉,链接器报一长串:
Undefined symbols for architecture arm64:
"_PyBaseObject_Type", referenced from:
"_PyBool_Type", referenced from:
"_PyBuffer_IsContiguous", referenced from:
...
这正是第三条要求:这些符号的定义在 Python 解释器里,链接时不存在。一种做法是链接 libpython,但那样扩展就绑死了某个具体的 Python 安装,而且进程里可能出现两份解释器状态。正确的做法是告诉链接器”这些符号先别管,加载时去宿主进程里找”,macOS 上是 -undefined dynamic_lookup(Linux 的 .so 默认就允许未定义符号):
clang++ -std=c++20 -O2 -shared -fPIC $($PY -m pybind11 --includes) \
-undefined dynamic_lookup basics.cpp -o build/basics$EXT这次链接成功。用上一节的工具检查产物:nm -gU 能看到导出的 T _PyInit_basics;nm -u 列出 137 个 _Py 开头的 undefined 符号,等 import 时由解释器填;otool -L 里只有 libc++ 和 libSystem,没有 libpython。在 Python 里使用:
import basics
print(basics.add(2, 3), basics.add(a=10))
c = basics.Counter("clicks"); c.add(3); c.add(4)
print(c, c.total, c.history())
try:
basics.add("2", 3)
except TypeError as e:
print("TypeError:", str(e).splitlines()[0])
d = basics.Counter("copy"); d.add(1)
h = d.history(); h.append(99)
print(h, d.history())5 11
<Counter clicks total=7> 7 [3, 4]
TypeError: add(): incompatible function arguments. The following argument types are supported:
[1, 99] [1]
默认参数、关键字参数、__repr__、只读属性都按 Python 的习惯工作,类型不匹配时抛的是 Python 的 TypeError 而不是段错误。最后一行值得看清楚:history() 返回的 list 是从 std::vector 拷贝出来的新对象,往里 append 不影响 C++ 对象。STL 容器跨语言边界时默认都是拷贝,因为 Python 的 list 和 std::vector 内存布局完全不同。
如果把 PYBIND11_MODULE(basics, ...) 写成 basix,文件还叫 basics...so,编译链接都成功,import 时才失败:
ImportError: dynamic module does not define module export function (PyInit_basics)
Python 按文件名去找 PyInit_basics,库里只有 PyInit_basix,又是一个按名字对账失败的符号问题。
关于用 -undefined dynamic_lookup 构建出来的 basics.cpython-312-darwin.so,下面哪些说法正确?
dynamic_lookup 让 _Py* 符号保持 undefined,dlopen 时在宿主进程(解释器)里解析。3.13 的解释器既不认 cpython-312 后缀,C API 的 ABI 也不同。
它是动态库,没有 main,更不能脱离解释器运行。PYBIND11_MODULE 的第一个参数决定导出符号 PyInit_xxx 的名字,和文件名对不上时 import 直接报 ImportError。
跨过边界的数据:拷贝还是共享
list 和 vector 之间只能拷贝,这在 AI Infra 场景里不可接受:一个几百 MB 的 NumPy 数组或张量,传给 C++ 算子之前先拷一遍,加速就没了意义。NumPy 数组底层是一块连续或带步长(stride)的原始内存,加上 dtype、shape、strides 这些描述信息;只要 C++ 侧能按同样的描述去读这块内存,就可以不拷贝。pybind11 的 py::array_t<float> 做的就是这件事,但它有一个”贴心”的默认行为,会悄悄破坏零拷贝:
#include <pybind11/numpy.h>
// 默认 flags = forcecast:dtype 不对时 pybind11 会悄悄转换成一个新数组
void scale_default(py::array_t<float> a, float k) {
auto r = a.mutable_unchecked(); // 跳过每次访问的边界检查,热循环里才快
for (py::ssize_t i = 0; i < r.size(); ++i) r(i) *= k;
}
// c_style:要求行优先连续;不满足时同样生成连续的副本
void scale_c(py::array_t<float, py::array::c_style> a, float k) {
float* p = a.mutable_data(); // 连续才能放心当一维指针用
for (py::ssize_t i = 0; i < a.size(); ++i) p[i] *= k;
}
// noconvert:宁可报 TypeError,也不要偷偷拷贝
m.def("scale_strict", &scale_c, py::arg("a").noconvert(), py::arg("k"));另外导出两个函数返回 C++ 看到的数据地址,和 a.ctypes.data 比较,就能判断是不是同一块内存。四种输入的结果:
== 1. float32 且连续:零拷贝,原地修改可见
same buffer: True
a = [ 0. 10. 20. 30.]
== 2. dtype 不匹配(float64):默认 forcecast 会拷贝,修改丢失
same buffer: False
b = [0. 1. 2. 3.]
== 3. 非连续(步长切片)+ c_style:拷贝成连续副本,修改丢失
v.flags.c_contiguous: False same buffer: False
base = [0. 1. 2. 3. 4. 5. 6. 7.]
== 4. 非连续 + 默认 flags:不拷贝,按 stride 访问,修改可见
same buffer: True
base = [ 0. 1. 20. 3. 40. 5. 60. 7.]
第 2、3 种情况最危险:函数正常返回,没有任何报错,原地修改却丢了,因为改的是一个临时副本。np.arange(4) 默认是 float64、x[::2] 和 x.T 都不连续,这些都是日常写法。对于语义上要”原地修改”的接口,应该加 noconvert(),让不满足条件的输入直接失败:
== 5. noconvert:不满足条件直接报错
float64 -> TypeError
strided -> TypeError
list -> TypeError
float32 contiguous -> ok [ 0. 10. 20. 30.]
== 6. 只读数组:写之前 pybind11 会检查 writeable
ValueError: array is not writeable
PyTorch 的 C++ 扩展面对同样的问题,所以算子里常见 TORCH_CHECK(x.is_contiguous()) 和 dtype 检查,道理相同。连续内存与 stride 的细节见 C++ 内存与所有权。数据零拷贝之后,计算本身如果很长,还可以在 C++ 里用 py::gil_scoped_release 释放 GIL,让其他 Python 线程同时运行,GIL 本身见 Python 并发与性能。
Python 里写 x = np.arange(8, dtype=np.float32).reshape(2, 4).T,再调用 zc.scale_c(x, 10)(参数类型是 py::array_t<float, py::array::c_style>,没有 noconvert)。结果是什么?
.T 只交换 strides,不移动数据,结果不是 C 连续的。c_style 要求连续,没有 noconvert 时 pybind11 的做法是拷贝出一个连续副本传进去,函数改完副本就丢了。
抛 TypeError 是加了 noconvert() 才有的行为。dtype 对了只满足一半条件,连续性不满足照样会拷贝。
用 CMake 构建扩展
手写命令能工作,但 EXT_SUFFIX、-undefined dynamic_lookup、Linux 和 macOS 的差异都得自己处理。pybind11 作为 pip 包自带了 pybind11Config.cmake,提供一个 pybind11_add_module 命令把这些都包起来。同一份 basics.cpp,只换构建方式:
cmake_minimum_required(VERSION 3.20)
project(pybind_cmake_demo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
# Development.Module 只要头文件,不要求 libpython:扩展本来就不该链接它
find_package(Python 3.12 COMPONENTS Interpreter Development.Module REQUIRED)
find_package(pybind11 CONFIG REQUIRED)
pybind11_add_module(basics ../10-2-pybind-basics/basics.cpp)直接配置会失败,CMake 不知道 pip 把 pybind11 装到了 venv 的哪里:
CMake Error at CMakeLists.txt:5 (find_package):
Could not find a package configuration file provided by "pybind11" with any
of the following names:
pybind11.cps
pybind11Config.cmake
pybind11-config.cmake
这是上一节 Config 模式的典型报错。解决办法是让 pybind11 自己报出位置,同时明确指定用哪个 Python(不指定时 FindPython 可能挑中 PATH 里的另一个解释器,造出后缀不对的扩展):
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
-DPython_EXECUTABLE=$PY -Dpybind11_DIR=$($PY -m pybind11 --cmakedir)
cmake --build build -v-v 打印出的命令(路径有缩写,省略了依赖跟踪参数):
clang++ -Dbasics_EXPORTS -isystem .../include/python3.12 -isystem .../site-packages/pybind11/include
-O3 -DNDEBUG -std=gnu++20 -arch arm64 -fPIC -fvisibility=hidden -flto ... -c basics.cpp
clang++ -O3 -DNDEBUG -arch arm64 -bundle ... -Xlinker -undefined -Xlinker dynamic_lookup -flto
-o basics.cpython-312-darwin.so basics.cpp.o
-fPIC、-undefined dynamic_lookup、正确的后缀,前面手动踩过的点都在里面。另外多了两个参数:-fvisibility=hidden 默认隐藏所有符号,只有 PyInit_basics 被显式导出,避免两个扩展里同名的内部符号在一个进程中互相干扰;-flto 是链接期优化。产物 basics.cpython-312-darwin.so 在 Python 里 import basics; basics.add(2, 3) 返回 5。
PyTorch 的 torch.utils.cpp_extension(CppExtension、CUDAExtension、load())是同一套东西的另一层封装:用 pybind11 绑定,替你找好 torch 的头文件和库,写好 rpath。理解了从 .cpp 到 import 的这条流水线,它报的错就都能对上号,算子本身怎么写见 GPU 与 Kernel。
同一台机器上,用 CMake 构建扩展时没有传 -DPython_EXECUTABLE,FindPython 选中了系统里的 Python 3.9,而你在 3.12 的 venv 里 import。最可能出现什么情况?
扩展的后缀和编译时用的 Python 头文件都来自 CMake 找到的那个解释器。3.12 只认 cpython-312 后缀,即使强行改名,3.9 和 3.12 的 C API 也不兼容。
pybind11 是纯头文件,但它生成的代码调用的是具体版本的 Python C API。扩展用 dynamic_lookup 构建,从不在链接时解析 Python 符号,所以不会在链接阶段报错,问题要到 import 时才暴露。
记住这三件事
- 每个
.cpp独立地预处理、编译、汇编成.o,只有链接把它们放在一起,所以错误分三类:编译错误看单个文件,链接错误是符号对账失败,装载错误是运行时找不到动态库。 - 符号是名字到位置的映射,C++ 会把类型 mangle 进名字;
nm看T/U,otool -L/ldd看动态依赖,rpath 决定运行时去哪找库。 - Python 扩展是一个导出
PyInit_<名字>、Python 符号留到 import 时解析的动态库;pybind11 生成胶水代码,CMake 的pybind11_add_module处理平台细节,跨边界传数组时要盯住 dtype 和连续性,否则会悄悄拷贝。
延伸阅读
- Randal E. Bryant, David R. O’Hallaron, Computer Systems: A Programmer’s Perspective, Chapter 7 Linking
- Ian Lance Taylor, Linkers 系列博客(20 篇):https://www.airs.com/blog/archives/38
- CMake 官方教程:https://cmake.org/cmake/help/latest/guide/tutorial/index.html
- pybind11 文档,First steps 与 NumPy 章节:https://pybind11.readthedocs.io/en/stable/
- Python 官方文档,Extending and Embedding the Python Interpreter:https://docs.python.org/3/extending/index.html
- PyTorch 教程,Custom C++ and CUDA Extensions:https://pytorch.org/tutorials/advanced/cpp_extension.html