转载自知乎Python调用C++最优解
前言
编程语言的选择
不可否认的是,人工智能慢慢成为我们日常生活中不可或缺的一部分,同时,也有越来越多的技术从业者(码农)想要选择或者转行到这个行业,那么AI
行业应该选择哪一门编程语言呢?
如何选择一门语言,主要看这门语言在行业内的生态何如。在AI
行业,Python
有着它不可取代的重要地位。目前世界上最流行的深度学习框架,如谷歌的TensorFlow
、FaceBook
的PyTorch
以及开源社区的Keras
神经网络库等,都是用Python
实现的,Microsoft
的CNTK
也完全支持Python
。并且Python
语言本身也擅长进行科学计算和数据分析,支持各种数学运算。目前在AI
行业,任何语言都不能够撼动Python
的地位。
是否掌握了Python
就能够畅游AI
的海洋了呢?当然不够!深度学习往往需要规模密度较大的计算,通常还需要一些硬件的支持,比如GPU
。由于语言特性的限制,Python
(解释型语言)对比C++
(编译型语言)在执行性能上有着数量级的劣势,与此同时,对于硬件接口(比如GPU
)的支持,Python
也显得力不从心,但这些却是C++
的特长。在要求高效执行的程序架构中,我们都会看到C++
一展身手,比如智能机器人的路径规划、机械手臂运动控制以及目前最流行的计算机视觉库OpenCV
的底层实现,都会使用到C++
语言。在机器学习、深度学习算法方面,C++
才是核心,而Python
通常是核心之上的一层封装。
在AI
行业,Python
和C++
各自有各自的应用场景,相辅相成,缺一不可。即使抛开行业不论,Python
与C++
本身也是当前最火的编程语言,以下是TIOBE
公布的2022
年12
月编程语言排行榜,Python
、C/C++
稳居前三甲。
混合编程
在AI
领域的实际的开发工作中,综合考虑代码开发效率以及执行效率,程序架构通常是由C++
完成核心算法模块,而程序逻辑部分则由Python
编写。那么,Python
模块与C++
模块如何通信呢?这就不得不提到一个概念“混合编程”,所谓混合编程,实际上就是不同编程语言之前的相互调用,在这里,我们主要讨论Python
调用C++
。
通常C++
编写的模块会被封装成库文件供其他模块调用,对于Linux
系统是.so
或者.a
,对于Windows
系统则是.dll
或者.lib
。而Python
(专指CPython
)调用C/C++
库的主要手段有:
ctypes
,ctypes
为Python
的内置模块,其原理是将C
语言中的基础数据类型封装成Python
对象以供Python
调用。其缺点是只支持C
语言基础类型,不支持C++
类对象,并且对于嵌套层数较深的结构体,封装起来也很是繁琐。SWIG
,SWIG
用于将C/C++
代码暴露给其它语言的工具,在使用时,需要编写一个复杂的SWIG
接口声明文件,并使用SWIG
自动生成使用Python-C-API
的C
代码,可读性很差。Cython
,Cython
是Python
语言的扩展,支持原生Python
,但是引入了自己额外的语法。使用Cython
编译器可以将Cython
代码自动转化为C
代码,并编译成动态链接库供Python
调用。相比于SWIG
来说更加方便,生成的代码更加易读,但是总的来说学习、使用成本仍然过于高昂。pybind11
,pybind11
是一个轻量级的只包含一组头文件的C++
库,可实现C++11
和Python
之间的无缝操作,虽然也可用于C++
调用Python
,但主要还是聚焦于Python
调用C++
。相对于其他混合编程方式pybind11
有着轻量级、使用简单、支持面广等众多优势,本文也将着重介绍pybind11
的基本使用。
pybind11
pybind11
源码开放在github
:pybind11,license
为BSD
,截止2022
年底已发布17
个release
版本,当前最新版本为Version 2.10.2
,github
上star
数量超过12k
。NVIDIA
的视频硬解码库VideoProcessingFramework
就是基于pybind11
实现的C++
到Python
的封装。
pybind11 is a lightweight header-only library that exposes C++ types in Python and vice versa, mainly to create Python bindings of existing C++ code. Its goals and syntax are similar to the excellent Boost.Python library by David Abrahams: to minimize boilerplate code in traditional extension modules by inferring type information using compile-time introspection.
使用
限于篇幅原因,pybind11
的使用细节本文不作赘述,官方文档上有详细说明:https://pybind11.readthedocs.io/en/latest/,若有朋友觉得看文档太过麻烦,也可以给我留言,后续可以出一个系列来详细介绍pybind11
的各种使用细节。
下面通过一个简单的使用demo
,用来介绍pybind11
的基本使用,demo
的作用是验证numpy
图像矩阵在Python
与C++
之间的相互传输,其主要逻辑分为两步:
- 在
Python
侧通过opencv-python
读入一张图片,并传给C++
侧,然后保存至本地。 - 在
C++
侧通过opencv
读入一张图片,并传给Python
侧,然后保存至本地。
环境如下:
- 操作系统:
Ubuntu-20.04
。 Cmake
版本:3.16.3
。Python
版本:3.8
。
话不多说,直接上代码。
下载pybind11
在github
上下载最新的release
源码即可,Version 2.10.2
的下载地址为:https://github.com/pybind/pybind11/releases/tag/v2.10.2
下载完之后解压,其层级结构如下:
将解压完之后的pybind11-2.10.2
目录直接置于C++
项目中即可。
C++模块
C++
代码层级结构如下:
C++
部分定义了两个供Python
调用的函数:
- 函数:
void NumpyUint83CToCvMat(py::array_t<unsigned char> &array);
,接收一个Python
侧的3
通道numpy
图像矩阵,并将图像保存至img2.jpg
。 - 函数:
py::array_t<unsigned char> CvMatUint83CToNumpy();
,读取本地图片:img2.jpg
,并返回一个Python
侧的3
通道numpy
图像矩阵。
CMakeLists.txt
:
cmake_minimum_required(VERSION 3.4...3.18)
project(demo)
set(CMAKE_CXX_STANDARD 11)
if (NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif ()
message(STATUS "Build Type: ${CMAKE_BUILD_TYPE}")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall")
set(CMAKE_CXX_FLAGS_DEBUG "-O0 -g")
set(CMAKE_CXX_FLAGS_RELWITHDEBINFO "-O2 -g")
set(CMAKE_CXX_FLAGS_RELEASE "-O3")
# 指定PYTHON_EXECUTABLE
if (NOT DEFINED PYTHON_EXECUTABLE)
set(PYTHON_EXECUTABLE "/home/csy/opt/miniconda3/envs/py38/bin/python3.8" CACHE PATH "Path to PYTHON_EXECUTABLE")
endif ()
# 指定PYTHON_INCLUDE_DIR
if (NOT DEFINED PYTHON_INCLUDE_DIR)
set(PYTHON_INCLUDE_DIR "/home/csy/opt/miniconda3/envs/py38/include/python3.8" CACHE PATH "Path to PYTHON_INCLUDE_DIR")
endif ()
# 指定PYTHON_LIBRARY
if (NOT DEFINED PYTHON_LIBRARY)
set(PYTHON_LIBRARY "/home/csy/opt/miniconda3/envs/py38/lib/libpython3.8.so" CACHE PATH "Path to PYTHON_LIBRARY")
endif ()
# 指定pybind11路径
add_subdirectory(pybind11-2.10.2)
# 指定源码
set(DEMO_SOURCES ${CMAKE_SOURCE_DIR}/src/demo.cc)
# 生成动态库
pybind11_add_module(demo SHARED ${DEMO_SOURCES})
# 添加头文件目录
target_include_directories(demo PRIVATE ${CMAKE_SOURCE_DIR}/src)
# opencv依赖
find_package(OpenCV REQUIRED)
if (OpenCV_FOUND)
message(OpenCV_INCLUDE_DIRS: ${OpenCV_INCLUDE_DIRS})
target_include_directories(demo PRIVATE ${OpenCV_INCLUDE_DIRS})
message(OpenCV_LIBRARIES: ${OpenCV_LIBRARIES})
target_link_libraries(demo PRIVATE ${OpenCV_LIBRARIES})
else (OpenCV_FOUND)
message(FATAL_ERROR "OpenCV library not found")
endif (OpenCV_FOUND)
# 设置动态库保存路径
set_target_properties(demo PROPERTIES LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/lib)
target_compile_definitions(demo PRIVATE VERSION_INFO=${EXAMPLE_VERSION_INFO})
demo.h
:
#ifndef DEMO_PYBIND11_SRC_DEMO_H_
#define DEMO_PYBIND11_SRC_DEMO_H_
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
namespace py = pybind11;
void NumpyUint83CToCvMat(py::array_t<unsigned char> &array);
py::array_t<unsigned char> CvMatUint83CToNumpy();
#endif //DEMO_PYBIND11_SRC_DEMO_H_
demo.cc
:
#include <pybind11/functional.h>
#include <opencv2/opencv.hpp>
#include "demo.h"
void NumpyUint83CToCvMat(py::array_t<unsigned char> &array) {
if (array.ndim() != 3) {
// throw std::runtime_error("3-channel image must be 3 dims");
std::cout << "3-channel image must be 3 dims" << std::endl;
return;
}
py::buffer_info buf = array.request();
cv::Mat img_mat(buf.shape[0], buf.shape[1], CV_8UC3, (unsigned char *) buf.ptr);
cv::imwrite("img2.jpg", img_mat);
}
py::array_t<unsigned char> CvMatUint83CToNumpy() {
cv::Mat img_mat = cv::imread("img2.jpg");
py::array_t<unsigned char> array = py::array_t<unsigned char>({img_mat.rows, img_mat.cols, 3}, img_mat.data);
return array;
}
PYBIND11_MODULE(demo, m) {
m.doc() = "Pybind11 demo";
m.def("NumpyUint83CToCvMat", &NumpyUint83CToCvMat);
m.def("CvMatUint83CToNumpy", &CvMatUint83CToNumpy, py::return_value_policy::move);
}
以上代码编译完成之后会生成一个动态库:demo.cpython-38-x86_64-linux-gnu.so
,该动态库可以供Python
模块直接import
。
Python模块
Python
代码层级结构如下:
Python
部分代码逻辑如下:
- 导入
C++
动态库:import demo
。 - 读取本地图片:
img1.jpg
,并调用C++
函数:demo.NumpyUint83CToCvMat(img1)
,将图片矩阵传入到函数中。 - 调用
C++
函数demo.CvMatUint83CToNumpy()
,并将返回的图片矩阵保存至:img3.jpg
。
test.py
:
import cv2
import demo
if __name__ == '__main__':
img1 = cv2.imread('img1.jpg')
demo.NumpyUint83CToCvMat(img1)
img3 = demo.CvMatUint83CToNumpy()
cv2.imwrite('img3.jpg', img3)
程序执行后会生成:img2.jpg
、img3.jpg
。
需要注意的地方
GIL问题
我们知道Python
(仅限CPython
)因为GIL
的存在,无法通过多线程利用到操作系统的多核资源(关于GIL
的问题,此处不作赘述,感兴趣的朋友可自行百度)。Python
与C++
混合编程的一个很大的优势就是能充分利用多核资源,在程序设计中,将计算密集型的模块放到C++
程序中,利用C++
多线程的优势能极大地提高程序的执行性能。
在pybind11
中,想要达到以上效果,需要程序员做一些额外的工作。在程序中当执行流从Python
侧进入C++
侧时,GIL
总是持有的,如果C++
侧代码长时间运行,且不释放GIL
,则Python
侧会长时间阻塞。因此,通过Python
调用C++
时,若C++
侧代码执行时间较长,且存在Python
侧多线程需求,建议在C++
代码入口处释放GIL
。
pybind11
提供了两种释放GIL
的方式:
- 在功能代码的执行处加上:
py::gil_scoped_release release
。
m.def("call_go", [](Animal *animal) -> std::string {
py::gil_scoped_release release;
return call_go(animal);
});
2. 在模块接口定义处加上:py::call_guard<py::gil_scoped_release>()
。
m.def("call_go", &call_go, py::call_guard<py::gil_scoped_release>());