Cython 的一些小实验
开头语
这篇博客本质上是对Cython探索的一些记录,当然仅供周末空闲时间的一些娱乐。
基础介绍
这一段主要讲一下为什么我要做这么一个测试。主要原因是最近在工作中做了一些检测方面的内容,因为生产环境没有GPU,因此把所有模型都往CPU上挪了,但是除了模型部分的网络加速外,检测还有一些后处理比较费是时间,因此就想尝试一下是否可以对这一部分纯Python实现的内容进行加速。
这里主要针对的部分是PriorBox的生成(对,你没有看错!不是NMS
就是PriorBox
!😂)
实验设计
- 先对一下四个部分进行实验:
- 纯Python实现的
PriorBox
。 - 使用Cython直接对
PriorBox
类进行封装,重命名类名为PriorBoxCython
。 - 对2部分的类里的一些变量进行预定义,重命名类名为
PriorBoxCythonOptimized
。 - 使用Cpp对
PriorBox
重新构造,并使用cython进行编译,生成PriorBoxCpp
共享库。
- 然后对四个部分的内容的耗时和内存消耗进行比对
- 做一些分析和讨论
实验代码实现
- 纯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 <= 不得不说垃圾百度 : (