首发于jwxie.cn
Cython 的一些小实验
开头语
这篇博客本质上是对Cython探索的一些记录,当然仅供周末空闲时间的一些娱乐。
基础介绍
这一段主要讲一下为什么我要做这么一个测试。主要原因是最近在工作中做了一些检测方面的内容,因为生产环境没有GPU,因此把所有模型都往CPU上挪了,但是除了模型部分的网络加速外,检测还有一些后处理比较费是时间,因此就想尝试一下是否可以对这一部分纯Python实现的内容进行加速。
这里主要针对的部分是PriorBox的生成(对,你没有看错!不是NMS
就是PriorBox
!😂)
实验设计
-
先对一下四个部分进行实验:
- 纯Python实现的
PriorBox
。 - 使用Cython直接对
PriorBox
类进行封装,重命名类名为PriorBoxCython
。 - 对2部分的类里的一些变量进行预定义,重命名类名为
PriorBoxCythonOptimized
。 - 使用Cpp对
PriorBox
重新构造,并使用cython进行编译,生成PriorBoxCpp
共享库。
- 纯Python实现的
-
然后对四个部分的内容的耗时和内存消耗进行比对
-
做一些分析和讨论
实验代码实现
-
纯Python的实现来自Pytorch_Retinaface仓库下的实现,具体如下:
# coding=utf-8 # file: priorbox.py # author: jwxie/jwxie.cn from itertools import product as product from math import ceil import torch class PriorBox(object): def __init__(self, cfg, image_size=None, phase='train'): super(PriorBox, self).__init__() self.min_sizes = cfg['min_sizes'] self.steps = cfg['steps'] self.clip = cfg['clip'] self.image_size = image_size self.feature_maps = [[ ceil(self.image_size[0] / step), ceil(self.image_size[1] / step)] for step in self.steps] self.name = "s" def forward(self): anchors = [] for k, f in enumerate(self.feature_maps): min_sizes = self.min_sizes[k] for i, j in product(range(f[0]), range(f[1])): for min_size in min_sizes: s_kx = min_size / self.image_size[1] s_ky = min_size / self.image_size[0] dense_cx = [x * self.steps[k] / self.image_size[1] for x in [j + 0.5]] dense_cy = [y * self.steps[k] / self.image_size[0] for y in [i + 0.5]] for cy, cx in product(dense_cy, dense_cx): anchors += [cx, cy, s_kx, s_ky] # back to torch land output = torch.Tensor(anchors).view(-1, 4) if self.clip: output.clamp_(max=1, min=0) return output
这里面有一个
cfg
变量我这里没提供,本质就是一个参数文件,要跑这部分代码的可以看这里,复制一下测试。 -
使用Cython对上面这个模块的编译很简单,用pip装好Cython之后写一个如下的
setup.py
:# coding=utf-8 # file: setup.py # author: jwxie/jwxie.cn from distutils.core import setup from Cython.Build import cythonize setup( ext_modules=cythonize("priorbox.py") )
然后用
python3 setup.py build_ext
执行编译并生成最终的共享库文件(会在当前目录下自动创建一个build目录,其中包含两个子目录分别是temp和lib,共享文件在lib目录下)。 -
对变量进行预定义本质上只是一些心理作用,我也只是猜测会对结果产生真正的影响。修改很简单,只需要对纯python实现的
PriorBox
类的forwad
函数后面跟一些cdef来进行预定义。# .... 上面和1一模一样 def forward(self): anchors = [] # >>>>>>>>>>>>>>> 添加的部分开始 cdef int i, j, k, min_size cdef double cx, cy, s_kx, s_ky, dense_cx, dense_cy cdef list f, anchors # <<<<<<<<<<<<<<< 添加的部分结束 for k, f in enumerate(self.feature_maps): # .... 下面和1一模一样
然后走一遍2的流程,生成共享库。
-
这部分纯自己照着1的实现自己翻译了一下,个人cpp只能达到大学毕业考试及格水平😂。
分成三个部分,如下表所示:
三个部分 简介 内容 第一部分 cpp实现 头文件、类实现、CmakeList.txt 第二部分 pxd文件 实际上是cython在实现交叉的时候的一个头文件,完成一些声明 第三部分 pyx文件 实际上是对cpp的实现进行一些封装,利用cpp结果实现功能并返回
-
第一部分 cpp实现
首先第一步分是是cpp的实现,具体的又分成三个小部分,第一部分是头文件
PriorBox.h
// // Created by JiaweiXie on 2021/3/20. // #ifndef MODEL_PRIORBOX_H #define MODEL_PRIORBOX_H #include "vector" #include "iostream" #include "math.h" class PriorBox { public: PriorBox() = default; PriorBox(std::vector<std::vector<int>> &min_size, std::vector<int> &steps, std::vector<int> &image_size); std::vector<std::vector<double>> forward(); private: std::vector<std::vector<int>> min_size; std::vector<int> steps; bool clip = false; std::vector<int> image_size; std::vector<std::vector<int>> feature_maps; std::vector<std::vector<double>> anchors; void GetFeatureMaps(); }; #endif //MODEL_PRIORBOX_H
第二步写一个对应的实现文件
PriorBox.cpp
```cpp // // Created by JiaweiXie on 2021/3/20. // #include "PriorBox.h" PriorBox::PriorBox(std::vector<std::vector<int>> &min_size, std::vector<int> &steps, std::vector<int> &image_size) { this->min_size = min_size; this->steps = steps; this->image_size = image_size; this->GetFeatureMaps(); } void PriorBox::GetFeatureMaps() { for (int &step : steps) { std::vector<int> s; s.push_back(ceil(1. * image_size[0] / step)); s.push_back(ceil(1. * image_size[1] / step)); // std::cout << ceil(1. * image_size[0] / step) << std::endl; feature_maps.push_back(s); } } std::vector<std::vector<double>> PriorBox::forward() { int anchor_size = 0; for (auto f : feature_maps) { anchor_size += f[0] * f[1]; } anchors.reserve(anchor_size * 2); // std::cout << anchor_size * 2 << std::endl; for (long unsigned int k = 0; k < feature_maps.size(); ++k) { auto f = feature_maps[k]; auto ms = min_size[k]; for (int f0 = 0; f0 < f[0]; ++f0) { for (int f1 = 0; f1 < f[1]; ++f1) { for (auto elem_ms : ms) { double s_kx = 1. * elem_ms / image_size[1]; double s_ky = 1. * elem_ms / image_size[0]; double dense_cx = (f1 + 0.5) * steps[k] / image_size[1]; double dense_cy = (f0 + 0.5) * steps[k] / image_size[1]; std::vector<double> s = {dense_cx, dense_cy, s_kx, s_ky}; anchors.push_back(s); } } } } // std::cout << anchors.size() << " x " << anchors[0].size() << std::endl; return anchors; } // 做一些测试,看一下结果对不对 //int main() { // std::vector<int> steps = {8, 16, 32}; // std::vector<std::vector<int>> min_size = {{16, 32}, // {64, 128}, // {256, 512}}; // std::vector<int> image_size = {1024, 1024}; // // PriorBox p(min_size, steps, image_size); // // p.forward(); // // return 0; //} ```
最后写一个
CMakeLists.txt
用于编译测试:cmake_minimum_required(VERSION 3.16) project(cython) set(CMAKE_CXX_STANDARD 14) find_package(OpenCV) include_directories(.) add_executable(cython PriorBox.cpp PriorBox.h)
-
第二部分 pxd文件
引用Cython的官方文档一句话,
We now need to declare the attributes and methods for use on Cython.
We put those declarations in a file called
Rectangle.pxd
.You can see it as a header file which is readable by Cython
这部分内容可以看作是一个Cython可读取的一个头文件。
# coding=utf-8 # file: PriorBoxCpp.pxd # author: jwxie/jwxie.cn # libcpp是cython自带的一个库,里面实现一些cpp的STL和内建数据结构 # 类似的也有libc是c的一些数据结构 from libcpp.vector cimport vector cdef extern from "PriorBox.cpp": pass # Declare the class with cdef cdef extern from "PriorBox.h": cdef cppclass PriorBox: # 这里实际就是把PriorBox.h的内容用python写一下 PriorBox() except + # except + 的意义是为了保证cython能捕捉到错误 # cython并不能捕捉cpp的错误 详见官方文档里面的 # "#add-public-attributes"和"#Exceptions"章节 PriorBox(vector[vector[int]], vector[int], vector[int]) except + vector[vector[double]] forward() except + """ python和cpp的错误捕捉还有一个对应表这里简单列一下,详细的可以看官方文件。 C++ Python bad_alloc MemoryError bad_cast TypeError bad_typeid TypeError domain_error ValueError invalid_argument ValueError ios_base::failure IOError out_of_range IndexError overflow_error OverflowError range_error ArithmeticError underflow_error ArithmeticError (all others) RuntimeError """
-
第三部分 pyx文件
这里的东西就是一些把cpp实现里面留下的接口再封装一下,实现与python的对接
这里有一个比较重要的东西就是,实际上cpp的一部分STL和python的数据结构是匹配的,简单列一下:
Python type => C++ type => Python type bytes std::string bytes iterable std::vector list iterable std::list list iterable std::set set iterable (len 2) std::pair tuple (len 2) 具体的代码如下:
# coding=utf-8 # file: PriorBoxCpp.pyx # author: jwxie/jwxie.cn from PriorBoxCpp cimport PriorBox def PriorBoxCpp(py_min_size, py_steps, py_image_size): cdef PriorBox cpp_prior_box cpp_prior_box = PriorBox.PriorBox(py_min_size, py_steps, py_image_size) result = cpp_prior_box.forward() return result
最后同样写一个setup.py对pyx文件进行编译:
# coding=utf-8
# file: setup.py
# author: jwxie/jwxie.cn
from Cython.Build import cythonize
from setuptools import setup, Extension
ext_modules = Extension(
"PriorBoxCpp",
sources=["PriorBoxCpp.pyx"],
language='c++' # 这里不能少,默认用gcc,会报找不到ios头文件
)
setup(
name='PriorBoxCpp',
ext_modules=cythonize(ext_modules)
)
最终生成的共享库也保存在build/lib.linux-x86_64-3.7目录下。
这里其实cimport也可以import numpy 之类的c包,读者感兴趣的可以自己玩玩 0.0,但要注意理论上不是所有的包都可以的奥。
一个要注意的是我是在环境 python3.7 + linux + cpython解释器 下进行的编译的,注意你自己的环境,最终生成的文件名可能会不一样
实验结果
先说感想,这个结果很气人💢,让我感觉到了我的cpp写的有多么垃圾 T。T
按照上一章的描述对所有代码处理完成之后,在写一点东西用来调用,如下:
import time # 计时
from memory_profiler import profile # 内存消耗监视
# 我这里把2编译出来的包位置挪到当前位置了, 所以这里直接import就可以
from priorbox import PriorBox as PriBoxPyCython
# 剩下俩都在build/lib.linux-x86_64-3.7目录下
# 要加一下PYTHONPATH才能正常import
import sys
sys.path.insert(0, 'build/lib.linux-x86_64-3.7/')
from PriorBoxCpp import PriorBoxCpp
from priorbox import PriorBox as PriorBoxCythonOptimized
# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> #
# 这里把纯python的实现复制一下,我这里写的话就太长了,而且重复嘞 #
# <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< #
if __name__ == '__main__':
cfg = {
'name': 'mobilenet0.25',
'min_sizes': [[16, 32], [64, 128], [256, 512]],
'steps': [8, 16, 32],
'clip': False, }
@profile
def A(): # 纯python实现
p = PriorBox(cfg, (1024, 1024)).forward()
@profile
def B(): # 用cython编译直接为.c,用gcc生成共享库
p = PriBoxPyCython(cfg, (1024, 1024)).forward()
@profile
def C(): # 对变量进行预定义后,用cython编译为.c,用gcc生成共享库
p = PriorBoxCythonOptimized(cfg, (1024, 1024)).forward()
@profile
def D(): # 自己的垃圾cpp实现
p = PriorBoxCpp([[16, 32], [64, 128], [256, 512]], [8, 16, 32], [1024, 1024])
# 先测试内存消耗
A()
B()
C()
D()
# 然后测时间,跑200次,看总时间
def T(cmd):
s = time.time()
for i in range(200):
p = eval(cmd)
print(f'cmd: {cmd}\n{time.time() - s}')
print('-' * 100)
for cmd in (
'PriorBox(cfg, (1024, 1024)).forward()',
'PriBoxPyCython(cfg, (1024, 1024)).forward()',
'PriorBoxCythonOptimized(cfg, (1024, 1024)).forward()',
'PriorBoxCpp([[16, 32], [64, 128], [256, 512]], [8, 16, 32], [1024, 1024])'
):
T(cmd)
测试结果如下:
Filename: /mnt/c/Users/jwxie/Desktop/src/proj/cython_test/test.py
Line # Mem usage Increment Occurences Line Contents
============================================================
67 183.5 MiB 183.5 MiB 1 @profile
68 def A():
69 186.3 MiB 2.8 MiB 1 p = PriorBox(cfg, (1024, 1024)).forward()
Filename: /mnt/c/Users/jwxie/Desktop/src/proj/cython_test/test.py
Line # Mem usage Increment Occurences Line Contents
============================================================
72 186.3 MiB 186.3 MiB 1 @profile
73 def B():
74 187.3 MiB 1.0 MiB 1 p = PriBoxPyCython(cfg, (1024, 1024)).forward()
Filename: /mnt/c/Users/jwxie/Desktop/src/proj/cython_test/test.py
Line # Mem usage Increment Occurences Line Contents
============================================================
77 187.3 MiB 187.3 MiB 1 @profile
78 def C():
79 187.6 MiB 0.2 MiB 1 p = PriorBoxCythonOptimized(cfg, (1024, 1024)).forward()
Filename: /mnt/c/Users/jwxie/Desktop/src/proj/cython_test/test.py
Line # Mem usage Increment Occurences Line Contents
============================================================
82 187.6 MiB 187.6 MiB 1 @profile
83 def D():
84 204.1 MiB 16.5 MiB 1 p = PriorBoxCpp([[16, 32], [64, 128], [256, 512]], [8, 16, 32], [1024, 1024])
cmd: PriorBox(cfg, (1024, 1024)).forward()
9.97243618965149 s
---------------------------------------------------------------------------------
cmd: PriBoxPyCython(cfg, (1024, 1024)).forward()
5.537133455276489 s
---------------------------------------------------------------------------------
cmd: PriorBoxCythonOptimized(cfg, (1024, 1024)).forward()
5.528991937637329 s
---------------------------------------------------------------------------------
cmd: PriorBoxCpp([[16, 32], [64, 128], [256, 512]], [8, 16, 32], [1024, 1024])
6.620768308639526 s
---------------------------------------------------------------------------------
讨论
咋说呢,意料之外又是情理之中,昨天下班前还在吹牛逼说用cpp编译一下肯定快的一比,目测秒杀级别,打脸是真快。
整体速度:python < cpp+cython < cython < cython+predefine
各个优化版本与纯python版本之间的时间差距大约是:4.43s -> 4.44s -> 3.35s 基本上就是只要做了优化,肯定都比纯Python快,当然这是废话,谁让人家是动态语言 0.0。
整体内存消耗:cpp+cython 16.5MB显著大于其他三个 0.0,这个cpp写得显得我像个憨批 😂,最小的是cython+predefined仅有0.2MB。
行吧,82.5倍秒杀我 … 哭出声… 😢
实验环境
笔记本一台 型号 XiaoXin Pro13 0.0
-
CPU: AMD Ryzen 5 4600U with Radeon Graphics - 2095.986 MHz - x86_64
-
Memory: 16GB
-
WSL linux - Ubuntu20.04 - Kernel 4.19.128-microsoft-standard
-
Anaconda Python 3.7
-
gcc - 9.3.0 / g++ - 9.3.0
总结
总的来说今天折腾了一天的目的也不是真的为了能落入生产环境里,这里还有好多路要走(其实我还是有点b数的),纯粹当作一个玩具,走一些别人走过的路,为了以后需要的时候能用得上。
另外要说的一点,其实从这个博客基础设计到一路的实现,本质上的思路和JIT很像,无非就是对一些一些常用模块进行编译实现静态化,避免多余的变量检查,达到整体加速的目的,只不过目前这个过程不需要额外的JIT引擎来进行实现,而是直接由人工来进行编译。当然啦,这也是PyPy解释器大肆宣传比CPython快的原因咯。
最后其实我也看了看cython生成出来的那个超长的cpp文件,整体的实现感觉很无脑,但就是莫名其妙的比我快,我有点想不通,暂时不知道问题出在哪,但是大概率还是代码上写的不够合理,有知道问题的欢迎直接来喷 😃
参考内容
[1] Cython官方文档
[2] Google <= 不得不说垃圾百度 : (