pybind11 返回 torch::Tensor 后为何在 Python 中出现 seg fault?
优先检查 torch::from_blob 引用的缓冲区
生命周期问题值得怀疑,尤其要检查这一步:
torch::from_blob(data_array, ...);
torch::from_blob 默认直接使用已有内存,不复制数据,也不会自动接管这块内存的所有权。如果 data_array 是局部数组、局部 std::vector 的缓冲区,或在函数返回前被释放,返回的 tensor 就会引用失效内存。
示例没有给出 data_array 的分配和释放代码,目前还不能确定这是不是唯一原因。后处理是否越界写入、元素类型是否正确、缓冲区长度是否足够,也需要检查。
通常有两种修复方式:
- 在原缓冲区仍然有效时调用
.clone(),让返回的 tensor 拥有独立的数据。 - 用
torch::empty分配输出 tensor,再向它的数据区写入处理结果。
py::return_value_policy::copy 不能替代 tensor 的数据复制。
为什么 shape 和 dtype 正常,访问数据却崩溃?
shape、dtype、stride 等元数据保存在 tensor 对象内部,与它引用的数据缓冲区有不同的生命周期。即使底层数据地址已经失效,这些信息仍可能正常读取。打印 tensor 或读取某个元素时,程序才会访问底层数据,从而触发非法内存访问。
例如:
torch::Tensor make_bad_tensor() {
std::vector<std::uint8_t> buffer(1024, 7);
return torch::from_blob(
buffer.data(),
{1024},
torch::TensorOptions().dtype(torch::kUInt8)
);
} // buffer 被销毁,返回的 tensor 仍引用原地址
这里通常出现的是悬空指针,不一定是空指针。原地址可能暂时还能读取,也可能已经被释放、复用,或变得不可访问。
正常情况下,按值返回 torch::Tensor 没有问题。tensor 句柄通过引用计数管理内部对象,函数中的局部 tensor 析构,并不意味着 Python 持有的 tensor 数据也会被销毁。需要检查的是,from_blob 创建的 tensor 是否持有外部缓冲区的所有者。
即使在 C++ 函数内部检查时数据正确,也不能排除生命周期问题,因为局部缓冲区当时可能还没有被销毁。
为什么返回策略和 reshape 没有解决问题?
py::return_value_policy::copy 不等于调用:
tensor.clone();
复制 C++ 的 torch::Tensor 句柄后,两个 tensor 仍可共享同一份底层存储。返回策略无法自动把外部缓冲区变成 PyTorch 拥有的数据。
下面的操作同样不能保证解决生命周期问题:
auto final_output = output.reshape(output_shape);
对于 from_blob 创建的连续 tensor,reshape 通常只改变视图,仍然引用原来的数据,不能依赖它复制数据。
.contiguous() 也不适合用来修复这个问题:输入已经连续时,它可以直接返回共享原存储的 tensor。
需要独立存储时,应明确调用 .clone(),并在源缓冲区仍然有效时完成复制。等 Python 收到结果后再调用 output.clone(),可能已经太晚,因为复制本身也要读取那块失效内存。
为什么只有某些 tensor 大小会崩溃?
访问失效内存属于未定义行为,不能保证所有大小都会以同样的方式失败。
tensor 大小变化后,内存分配器可能采用不同的分配路径。释放后的地址是否仍可访问、何时被后续分配复用,也可能随之变化。如果代码中还有越界写入,大小变化还会影响它破坏哪个对象。
因此,某些大小能运行、另一些大小崩溃,符合内存错误的表现。即使某个大小能打印出正确结果,也不能据此认定它是安全的。
仅凭 num_neurons 的区间,无法确定具体的分配机制,也不能推导出“从 36 开始的所有大小都会失败”。这些区间是当前运行条件下的复现线索,不是 PyTorch 的 tensor 尺寸限制。
修复方式一:返回前复制数据
假设最终输出应当是 uint8,处理结果保存在 std::vector<std::uint8_t> 中,可以把函数末尾改成:
// data_array: 已填充完成的 std::vector<std::uint8_t>
// output_shape: 已校验的 std::vector<std::int64_t>
auto options = torch::TensorOptions()
.dtype(torch::kUInt8)
.device(torch::kCPU);
auto borrowed = torch::from_blob(
data_array.data(),
{static_cast<std::int64_t>(data_array.size())},
options
);
// 此时 data_array 必须仍然有效,且没有其他线程修改它。
auto owned = borrowed.clone();
return owned.reshape(output_shape);
这段代码先按缓冲区的实际元素数量创建一维 tensor,复制数据后再调整形状。output_shape 的元素总数必须与 data_array.size() 一致,否则 reshape 应当报错。
如果缓冲区此前已经失效,或实际分配长度小于声明长度,.clone() 仍可能崩溃。它只能解决复制之后的所有权问题,无法修复此前的非法访问。
修复方式二:直接向 tensor 写入结果
如果可以修改后处理代码,让 PyTorch 直接分配最终缓冲区,就能省去一次完整的数据复制:
auto options = torch::TensorOptions()
.dtype(torch::kUInt8)
.device(torch::kCPU);
auto result = torch::empty(output_shape, options);
auto* dst = result.data_ptr<std::uint8_t>();
const auto count = result.numel();
// 将原后处理逻辑改为写入 dst。
// 所有写入下标必须满足 0 <= index < count。
// 返回前应完成所有需要的元素写入。
return result;
底层存储由 tensor 管理,返回到 Python 后,会随 tensor 的引用计数继续存活。
torch::empty 不会初始化元素。如果处理逻辑只写入一部分,应补齐其余元素,或根据需要使用 torch::zeros。
确实需要零拷贝时,再考虑给 from_blob 提供自定义 deleter 或保存外部所有者。deleter 必须与实际分配方式匹配,也无法延长栈数组的生命周期。
示例里还有几处需要单独修正
uint16_t 与 torch::kUInt8 不一致
注释中 data_array 的元素类型是 uint16_t,代码却指定了:
.dtype(torch::kUInt8)
from_blob 不执行数值类型转换,只会按指定 dtype 解释指针处的字节。将 uint16_t* 与 kUInt8 配合使用,会逐字节解释数据,不会把每个 16 位整数转换成一个 8 位整数。
应根据输出用途选择类型和长度:
- 要保留 16 位无符号数,缓冲区类型和 tensor dtype 必须对应,例如
std::uint16_t配合torch::kUInt16;还需确认后续算子支持该 dtype。 - 要输出 8 位数,先按业务规则转换数值,处理超过 255 的值,再使用
std::uint8_t配合torch::kUInt8。 - 要暴露原始字节,则按字节数量确定长度和 shape。
元素数量与字节数量不能混用。对于连续 tensor,声明的数据范围应满足:
所需字节数 = 元素数量 × 每个元素的字节数
from_blob 调用需要显式提供尺寸
题目中的写法:
torch::from_blob(data_array, options);
缺少 sizes 参数。通常应写成:
torch::from_blob(data_array, {element_count}, options);
如果实际扩展已经成功编译,说明运行代码与这段简化示例有差异。排查时应以实际调用为准,核对传入的指针、sizes 和 dtype。
Python 中的 np.ndarray(...) 没有创建维度向量
这行代码:
output_shape = np.ndarray(
[batch_size, num_neurons, data_len, data_wid]
)
创建的是具有这些维度的未初始化数组,不是保存四个维度值的一维数组。
如果 C++ 接口仍接收 py::array dim,应改为:
import numpy as np
output_shape = np.array(
[batch_size, num_neurons, data_len, data_wid],
dtype=np.int64,
)
C++ 端还应验证它是一维整数数组,并检查维度值。如果接口改为接收 std::vector<std::int64_t>,可以通过 pybind11/stl.h 支持 Python 列表,调用时直接传:
output_shape = [
batch_size, num_neurons, data_len, data_wid
]
修正绑定声明,并核对实际头文件版本
命名空间别名的正确语法是:
namespace py = pybind11;
绑定 PyTorch tensor 并使用 py::array 时,通常需要:
#include <torch/extension.h>
#include <pybind11/numpy.h>
namespace py = pybind11;
普通绑定可以写为:
PYBIND11_MODULE(processing_ext, m) {
m.def(
"processing",
&processing,
py::arg("batchOutputs"),
py::arg("dim")
);
}
题目中的 py::mod_gil_not_used() 与声明的 pybind11 2.11.2 不一致,该版本没有这个接口。这个选项也无法修复内存生命周期问题。如果实际代码能够编译,应检查编译器使用的究竟是哪一套 pybind11 头文件,不能只看 Python 环境中安装的包版本。
建议按什么顺序排查?
- 追踪
data_array的所有者,找到分配和释放位置,检查是否存在局部std::vector析构、重新分配或设备缓冲区复用。确认缓冲区至少存活到.clone()完成。 - 核对实际分配字节数、dtype、shape 的元素总数,以及处理循环的读写边界。计算维度乘积时也要防止整数溢出。
- 检查 NumPy 输入的读取方式。未经 dtype 和 strides 检查,不能把任意
py::array的指针当成连续 C 数组读取,尤其要留意切片和转置后的数组。 - 用已知值替换后处理,暂时直接返回
torch::zeros(output_shape, options)。如果它运行稳定,而原实现崩溃,就可以把排查范围进一步集中到后处理和外部缓冲区;这一步仍不能确定唯一根因。 - 使用带调试符号的扩展,通过调试器查看崩溃栈,定位原生代码中的错误。必要时用 AddressSanitizer 检查越界访问和 use-after-free。
例如,可以通过 GDB 运行复现脚本:
gdb --args python3 test.py
在 GDB 中执行:
run
bt
重点查看首次非法访问的位置。Python 打印 tensor 时崩溃,可能只是此前的内存损坏在此时暴露出来。
如果在缓冲区仍有效时调用 .clone() 后问题消失,就支持“返回值引用了短生命周期缓冲区”的判断。但仍应检查后处理是否越界,因为复制改变了内存布局,也可能暂时掩盖其他错误。
备注:内容仅供参考。
