程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式師世界 >> 編程語言 >  >> 更多編程語言 >> Python

在Python中調用C/C++:cython及pybind11

編輯:Python

在Python中調用C/C++:cython及pybind11

轉自:https://zhuanlan.zhihu.com/p/442935082

Python寫起來非常方便, 但面對大量for循環的時候, 執行速度有些捉急. 原因在於, python是一種動態類型語言, 在運行期間才去做數據類型檢查, 這樣效率就很低(尤其是大規模for循環的時候).

相比而言, C/C++每個變量的類型都是事先給定的, 通過編譯生成二進制可執行文件. 相比與python, C/C++效率比較高, 大規模for循環執行速度很快.

既然python的短板在於速度, 所以, 為了給python加速, 能否在Python中調用C/C++的代碼?

Python解釋器

當我們編寫Python代碼時,我們得到的是一個包含Python代碼的以.py為擴展名的文本文件。要運行代碼,就需要Python解釋器去執行.py文件。

(你給我翻譯翻譯, 什麼叫python代碼)

Cython

當我們從Python官方網站下載並安裝好Python後,我們就直接獲得了一個官方版本的解釋器:CPython。這個解釋器是用C語言開發的,所以叫CPython。在命令行下運行python就是啟動CPython解釋器。CPython是使用最廣的Python解釋器。

雖然CPython效率低, 但是如果用它去調用C/C++代碼, 效果還是挺好的. 像numpy之類的數學運算庫, 很多都是用C/C++寫的. 這樣既能利用python簡潔的語法, 又能利用C/C++高效的執行速度. 有些情況下numpy效率比自己寫C/C++還高, 因為numpy利用了CPU指令集優化和多核並行計算.

我們今天要講的Python調用C/C++, 都是基於CPython解釋器的.

IronPython

IronPythonJython類似,只不過IronPython是運行在微軟.Net平台上的Python解釋器,可以直接把Python代碼編譯成.Net的字節碼。缺點在於, 因為numpy等常用的庫都是用C/C++編譯的, 所以在IronPython中調用numpy等第三方庫非常不方便. (現在微軟已經放棄對IronPython的更新了)

Jython

Jython是運行在Java平台上的Python解釋器,可以直接把Python代碼編譯成Java字節碼執行。Jython的好處在於能夠調用Java相關的庫, 壞處跟IronPython一樣.

PyPy

PyPy一個基於Python的解釋器,也就是用python解釋.py. 它的目標是執行速度。PyPy采用JIT技術,對Python代碼進行動態編譯(注意不是解釋),所以可以顯著提高Python代碼的執行速度。

為什麼動態解釋慢

假設我們有一個簡單的python函數

 def add(x, y):
return x + y

然後CPython執行起來大概是這個樣子(偽代碼)

if instance_has_method(x, '__add__') {

// x.__add__ 裡面又有一大堆針對不同類型的 y 的判斷
return call(x, '__add__', y);
} else if isinstance_has_method(super_class(x), '__add__' {

return call(super_class, '__add__', y);
} else if isinstance(x, str) and isinstance(y, str) {

return concat_str(x, y);
} else if isinstance(x, float) and isinstance(y, float) {

return add_float(x, y);
} else if isinstance(x, int) and isinstance(y, int) {

return add_int(x, y);
} else ...

因為Python的動態類型, 一個簡單的函數, 要做很多次類型判斷. 這還沒完,你以為裡面把兩個整數相加的函數,就是 C 語言裡面的 x + y 麼? No.

Python裡萬物皆為對象, 實際上Python裡的int大概是這樣一個結構體(偽代碼).

 struct {

prev_gc_obj *obj
next_gc_obj *obj
type IntType
value IntValue
... other fields
}

每個 int 都是這樣的結構體,還是動態分配出來放在 heap 上的,裡面的 value 還不能變,也就是說你算 1000 這個結構體加 1000 這個結構體,需要在heap裡malloc出來 2000 這個結構體. 計算結果用完以後, 還要進行內存回收. (執行這麼多操作, 速度肯定不行)

所以, 如果能夠靜態編譯執行+指定變量的類型, 將大幅提升執行速度.

Cython

什麼是Cython

cython是一種新的編程語言, 它的語法基於python, 但是融入了一些C/C++的語法. 比如說, cython裡可以指定變量類型, 或是使用一些C++裡的stl庫(比如使用std::vector), 或是調用你自己寫的C/C++函數.

注意: Cython不是CPython!

原生Python

我們有一個RawPython.py

from math import sqrt
import time
def func(n):
res = 0
for i in range(1, n):
res = res + 1.0 / sqrt(i)
return res
def main():
start = time.time()
res = func(30000000)
print(f"res = {
res}, use time {
time.time() - start:.5}")
if __name__ == '__main__':
main()

我們先使用Python原生方式來執行看一下需要多少時間, 在我電腦上要花4秒。

編譯運行Cython程序

首先, 把一個cython程序轉化成.c/.cpp文件, 然後用C/C++編譯器, 編譯生成二進制文件. 在Windows下, 我們需要安裝Visual Studio/mingw等編譯工具. 在Linux或是Mac下, 我們需要安裝gcc, clang 等編譯工具.

  • 通過pip安裝cython
pip install cython

把 RawPython.py 重命名為 RawPython1.pyx

  • 編譯的話, 有兩種辦法:

(1)用setup.py編譯

增加一個setup.py, 添加以下內容. 這裡language_level的意思是, 使用Python 3.

from distutils.core import setup
from Cython.Build import cythonize
setup(
ext_modules = cythonize('RawPython1.pyx', language_level=3)
)

把Python編譯為二進制代碼

python setup.py build_ext --inplace

然後, 我們發現當前目錄下多了RawPython1.c(由.pyx轉化生成), 和RawPython1.pyd(由.c編譯生成的二進制文件).

(2)直接在命令行編譯(以gcc為例)

cython RawPython1.pyx
gcc -shared -pthread -fPIC -fwrapv -O2 -Wall -fno-strict-aliasing -I/usr/include/python3.x -o RawPython1.so RawPython1.c

第一句是把.pyx轉化成.c, 第二句是用gcc編譯+鏈接.

  • 在當前目錄下, 運行
python -c "import RawPython1; RawPython1.main()"

我們可以導入編譯好的RawPython1模塊, 然後在Python中調用執行.

由以上的步驟的執行結果來看,並沒有提高太多,只大概提高了一倍的速度,這是因為Python的運行速度慢除了因為是解釋執行以外還有一個最重要的原因是Python是動態類型語言,每個變量在運行前是不知道類型是什麼的,所以即便編譯為二進制代碼同樣速度不會太快,這時候我們需要深度使用Cython來給Python提速了,就是使用Cython來指定Python的數據類型。

加速! 加速!

指定變量類型

cython的好處是, 可以像C語言一樣, 顯式地給變量指定類型. 所以, 我們在cython的函數中, 加入循環變量的類型.

然後, 用C語言中的sqrt實現開方操作.

 def func(int n):
cdef double res = 0
cdef int i, num = n
for i in range(1, num):
res = res + 1.0 / sqrt(i)
return res

但是, python中math.sqrt方法, 返回值是一個Pythonfloat對象, 這樣效率還是比較低.

為了, 我們能否使用C語言的sqrt函數? 當然可以~

Cython對一些常用的C函數/C++類做了包裝, 可以直接在Cython裡進行調用.

我們把開頭的

from math import sqrt

換成

from libc.math cimport sqrt

再按照上面的方式編譯運行, 發現速度提高了不少.

改造後的完整代碼如下:

import time
from libc.math cimport sqrt
def func(int n):
cdef double res = 0
cdef int i, num = n
for i in range(1, num):
res = res + 1.0 / sqrt(i)
return res
def main():
start = time.time()
res = func(30000000)
print(f"res = {
res}, use time {
time.time() - start:.5}")
if __name__ == '__main__':
main()

Cython調用C/C++

既然C/C++比較高效, 我們能否直接用cython調用C/C++呢? 就是用C語言重寫一遍這個函數, 然後在cython裡進行調用.

首先寫一段對應的C語言版本

usefunc.h

#pragma once
#include <math.h>
double c_func(int n)
{

int i;
double result = 0.0;
for(i=1; i<n; i++)
result = result + sqrt(i);
return result;
}

然後, 我們在Cython中, 引入這個頭文件, 然後調用這個函數

cdef extern from "usecfunc.h":
cdef double c_func(int n)
import time
def func(int n):
return c_func(n)
def main():
start = time.time()
res = func(30000000)
print(f"res = {
res}, use time {
time.time() - start:.5}")

在Cython中使用numpy

Cython中, 我們可以調用numpy. 但是, 如果直接按照數組下標訪問, 我們還需要動態判斷numpy數據的類型, 這樣效率就比較低.

 import numpy as np
cimport numpy as np
from libc.math cimport sqrt
import time
def func(int n):
cdef np.ndarray arr = np.empty(n, dtype=np.float64)
cdef int i, num = n
for i in range(1, num):
arr[i] = 1.0 / sqrt(i)
return arr
def main():
start = time.time()
res = func(30000000)
print(f"len(res) = {
len(res)}, use time {
time.time() - start:.5}")

解釋:

 cimport numpy as np

這一句的意思是, 我們可以使用numpy的C/C++接口(指定數據類型, 數組維度等).

這一句的意思是, 我們也可以使用numpy的Python接口(np.array, np.linspace等). Cython在內部處理這種模糊性,這樣用戶就不需要使用不同的名稱.

在編譯的時候, 我們還需要修改setup.py, 引入numpy的頭文件.

from distutils.core import setup, Extension
from Cython.Build import cythonize
import numpy as np
setup(ext_modules = cythonize(
Extension("RawPython4", ["RawPython4.pyx"],include_dirs=[np.get_include()],),
language_level=3)
)

加速!加速!

上面的代碼, 還是能夠進一步加速的

  1. 可以指定numpy數組的數據類型和維度, 這樣就不用動態判斷數據類型了. 實際生成的代碼, 就是按C語言裡按照數組下標來訪問.
  2. 在使用numpy數組時, 還要同時做數組越界檢查. 如果我們確定自己的程序不會越界, 可以關閉數組越界檢測.
  3. Python還支持負數下標訪問, 也就是從後往前的第i個. 為了做負數下標訪問, 也需要一個額外的if…else…來判斷. 如果我們用不到這個功能, 也可以關掉.
  4. Python還會做除以0的檢查, 我們並不會做除以0的事情, 關掉.
  5. 相關的檢查也關掉.

最終加速的程序如下:

import numpy as np
cimport numpy as np
from libc.math cimport sqrt
import time
cimport cython
@cython.boundscheck(False) # 關閉數組下標越界
@cython.wraparound(False) # 關閉負索引
@cython.cdivision(True) # 關閉除0檢查
@cython.initializedcheck(False) # 關閉檢查內存視圖是否初始化
def func(int n):
cdef np.ndarray[np.float64_t, ndim=1] arr = np.empty(n, dtype=np.float64)
cdef int i, num = n
for i in range(1, num):
arr[i] = 1.0 / sqrt(i)
return arr
def main():
start = time.time()
res = func(30000000)
print(f"len(res) = {
len(res)}, use time {
time.time() - start:.5}")
cdef np.ndarray[np.float64_t, ndim=1] arr = np.empty(n, dtype=np.float64)

這一句的意思是, 我們創建numpy數組時, 手動指定變量類型和數組維度.

上面是對這一個函數關閉數組下標越界, 負索引, 除0檢查, 內存視圖是否初始化等. 我們也可以在全局范圍內設置, 即在.pyx文件的頭部, 加上注釋

# cython: boundscheck=False
# cython: wraparound=False
# cython: cdivision=True
# cython: initializedcheck=False

也可以用這種寫法:

with cython.cdivision(True):
# do something here

其他

cython吸收了很多C/C++的語法, 也包括指針和引用. 也可以把一個struct/class從C++傳給Cython.

Cython總結

Cython的語法與Python類似, 同時引入了一些C/C++的特性, 比如指定變量類型等. 同時, Cython還可以調用C/C++的函數.

Cython的特點在於, 如果沒有指定變量類型, 執行效率跟Python差不多. 指定好類型後, 執行效率才會比較高.

更多文檔可以參考Cython官方文檔

Welcome to Cython’s Documentationdocs.cython.org/en/latest/index.html

pybind11

Cython是一種類Python的語言, 但是pybind11是基於C++的. 我們在.cpp文件中引入pybind11, 定義python程序入口, 然後編譯執行就好了.

從官網的說明中看到pybind11的幾個特點

  • 輕量級頭文件庫
  • 目標和語法類似於優秀的Boost.python庫
  • 用於為python綁定c++代碼

安裝

可以執行pip install pybind11安裝 pybind11 (萬能的pip)

也可以用Visual Studio + vcpkg+CMake來安裝.

簡單的例子

#include <pybind11/pybind11.h>
namespace py = pybind11;
int add_func(int i, int j) {

return i + j;
}
PYBIND11_MODULE(example, m) {

m.doc() = "pybind11 example plugin"; //可選,說明這個模塊是做什麼的
m.def("add_func", &add_func, "A function which adds two numbers");
}

首先引入pybind11的頭文件, 然後用PYBIND11_MODULE聲明.

  • example:模型名,切記不需要引號. 之後可以在python中執行import example
  • m:可以理解成模塊對象, 用於給Python提供接口
  • m.doc():help說明
  • m.def:用來注冊函數和Python打通界限
m.def( "給python調用方法名", &實際操作的函數, "函數功能說明" ). //其中函數功能說明為可選

編譯&運行

pybind11只有頭文件,所以只要在代碼中增加相應的頭文件, 就可以使用pybind11了.

#include <pybind11/pybind11.h>

在Linux下, 可以執行這樣的命令來編譯:

 c++ -O3 -Wall -shared -std=c++11 -fPIC $(python3 -m pybind11 --includes) example.cpp -o example$(python3-config --extension-suffix)

我們也可以用setup.py來編譯(在Windows下, 需要Visual Studio或mingw等編譯工具; 在Linux或是Mac下, 需要gcc或clang等編譯工具)

from setuptools import setup, Extension
import pybind11
functions_module = Extension(
name='example',
sources=['example.cpp'],
include_dirs=[pybind11.get_include()],
)
setup(ext_modules=[functions_module])

然後運行下面的命令, 就可以編譯了

python setup.py build_ext --inplace

在python中進行調用

python -c "import example; print(example.add_func(200, 33))"

在pybind11中指定函數參數

通過簡單的代碼修改,就可以通知Python參數名稱

m.def("add", &add, "A function which adds two numbers", py::arg("i"), py::arg("j"));

也可以指定默認參數

int add(int i = 1, int j = 2) {

return i + j;
}

PYBIND11_MODULE中指定默認參數

m.def("add", &add, "A function which adds two numbers",py::arg("i") = 1, py::arg("j") = 2);

為Python方法添加變量

PYBIND11_MODULE(example, m) {

m.attr("the_answer") = 23333;
py::object world = py::cast("World");
m.attr("what") = world;
}

對於字符串, 需要用py::cast將其轉化為Python對象.

然後在Python中, 可以訪問the_answerwhat對象

import example
>>>example.the_answer
42
>>>example.what
'World'

在cpp文件中調用python方法

因為python萬物皆為對象, 因此我們可以用py::object 來保存Python中的變量/方法/模塊等.

 py::object os = py::module_::import("os");
py::object makedirs = os.attr("makedirs");
makedirs("/tmp/path/to/somewhere");

這就相當於在Python裡執行了

 import os
makedirs = os.makedirs
makedirs("/tmp/path/to/somewhere")

用pybind11使用python list

我們可以直接傳入python的list

 void print_list(py::list my_list) {

for (auto item : my_list)
py::print(item);
}
PYBIND11_MODULE(example, m) {

m.def("print_list", &print_list, "function to print list", py::arg("my_list"));
}

在Python裡跑一下這個程序,

 >>>import example
>>>result = example.print_list([2, 23, 233])
2
23
233
>>>print(result)

這個函數也可以用std::vector<int>作為參數. 為什麼可以這樣做呢? pybind11可以自動將python list對象, 復制構造為std::vector<int>. 在返回的時候, 又自動地把std::vector轉化為Python中的list. 代碼如下:

 #include <pybind11/pybind11.h>
#include <pybind11/stl.h>
std::vector<int> print_list2(std::vector<int> & my_list) {

auto x = std::vector<int>();
for (auto item : my_list){

x.push_back(item + 233);
}
return x;
}
PYBIND11_MODULE(example, m) {

m.def("print_list2", &print_list2, "help message", py::arg("my_list"));
}

用pybind11使用numpy

因為numpy比較好用, 所以如果能夠把numpy數組作為參數傳給pybind11, 那就非常香了. 代碼如下(一大段)

 #include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
py::array_t<double> add_arrays(py::array_t<double> input1, py::array_t<double> input2) {

py::buffer_info buf1 = input1.request(), buf2 = input2.request();
if (buf1.ndim != 1 || buf2.ndim != 1)
throw std::runtime_error("Number of dimensions must be one");
if (buf1.size != buf2.size)
throw std::runtime_error("Input shapes must match");
/* No pointer is passed, so NumPy will allocate the buffer */
auto result = py::array_t<double>(buf1.size);
py::buffer_info buf3 = result.request();
double *ptr1 = (double *) buf1.ptr,
*ptr2 = (double *) buf2.ptr,
*ptr3 = (double *) buf3.ptr;
for (size_t idx = 0; idx < buf1.shape[0]; idx++)
ptr3[idx] = ptr1[idx] + ptr2[idx];
return result;
}
m.def("add_arrays", &add_arrays, "Add two NumPy arrays");

先把numpy的指針拿出來, 然後在指針上進行操作.

我們在Python裡測試如下:

 >>>import example
>>>import numpy as np
>>>x = np.ones(3)
>>>y = np.ones(3)
>>>z = example.add_arrays(x, y)
>>>print(type(z))
<class 'numpy.ndarray'>
>>>print(z)
array([2., 2., 2.])

來一段完整的代碼

#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <pybind11/numpy.h>
namespace py = pybind11;
int add_func(int i, int j) {

return i + j;
}
void print_list(py::list my_list) {

for (auto item : my_list)
py::print(item);
}
std::vector<int> print_list2(std::vector<int> & my_list) {

auto x = std::vector<int>();
for (auto item : my_list){

x.push_back(item + 233);
}
return x;
}
py::array_t<double> add_arrays(py::array_t<double> input1, py::array_t<double> input2) {

py::buffer_info buf1 = input1.request(), buf2 = input2.request();
if (buf1.ndim != 1 || buf2.ndim != 1)
throw std::runtime_error("Number of dimensions must be one");
if (buf1.size != buf2.size)
throw std::runtime_error("Input shapes must match");
/* No pointer is passed, so NumPy will allocate the buffer */
auto result = py::array_t<double>(buf1.size);
py::buffer_info buf3 = result.request();
double *ptr1 = (double *) buf1.ptr,
*ptr2 = (double *) buf2.ptr,
*ptr3 = (double *) buf3.ptr;
for (size_t idx = 0; idx < buf1.shape[0]; idx++)
ptr3[idx] = ptr1[idx] + ptr2[idx];
return result;
}
PYBIND11_MODULE(example, m) {

m.doc() = "pybind11 example plugin"; //可選,說明這個模塊是做什麼的
m.def("add_func", &add_func, "A function which adds two numbers");
m.attr("the_answer") = 23333;
py::object world = py::cast("World");
m.attr("what") = world;
m.def("print_list", &print_list, "function to print list", py::arg("my_list"));
m.def("print_list2", &print_list2, "help message", py::arg("my_list2"));
m.def("add_arrays", &add_arrays, "Add two NumPy arrays");
}

pybind11總結

pybind11在C++下使用, 可以為Python程序提供C++接口. 同時, pybind11也支持傳入python list, numpy等對象.

更多文檔可以參考pybind11官方文檔

https://pybind11.readthedocs.io/en/stable/pybind11.readthedocs.io/en/stable/

其他使用python調用C++的方式

  1. CPython會自帶一個Python.h, 我們可以在C/C++中引入這個頭文件, 然後編譯生成動態鏈接庫. 但是, 直接調用Python.h寫起來有一點點麻煩.
  2. boost是一個C++庫, 對Python.h做了封裝, 但整個boost庫比較龐大, 而且相關的文檔不太友好.
  3. swig(Simplified Wrapper and Interface Generator), 用特定的語法聲明C/C++函數/變量. (之前tensorlfow用的就是這個, 但現在改成pybind11了)

總結: 什麼時候應該加速呢

用Python開發比較簡潔, 用C++開發寫起來有些麻煩.

在寫python時, 我們可以通過Profile等耗時分析工具, 找出比較用時的代碼塊, 對這一塊用C++進行優化. 沒必要優化所有的部分.

Cython或是pybind11只做三件事: 加速, 加速, 還是加速. 在需要大量計算, 比較耗時的地方, 我們可以用C/C++來實現, 這樣有助於提升整個Python程序的執行速度.

加速python還有一些其他的方法, 比如用numpy的向量化操作代替for循環, 使用jit即時編譯等.


  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式師世界 All Rights Reserved