Cython 的一些小实验

开头语

这篇博客本质上是对Cython探索的一些记录,当然仅供周末空闲时间的一些娱乐

基础介绍

这一段主要讲一下为什么我要做这么一个测试。主要原因是最近在工作中做了一些检测方面的内容,因为生产环境没有GPU,因此把所有模型都往CPU上挪了,但是除了模型部分的网络加速外,检测还有一些后处理比较费是时间,因此就想尝试一下是否可以对这一部分纯Python实现的内容进行加速。

这里主要针对的部分是PriorBox的生成(对,你没有看错!不是NMS就是PriorBox!😂)

实验设计

  • 先对一下四个部分进行实验:
  1. 纯Python实现的PriorBox
  2. 使用Cython直接对PriorBox类进行封装,重命名类名为PriorBoxCython
  3. 对2部分的类里的一些变量进行预定义,重命名类名为PriorBoxCythonOptimized
  4. 使用Cpp对PriorBox重新构造,并使用cython进行编译,生成PriorBoxCpp共享库。
  • 然后对四个部分的内容的耗时和内存消耗进行比对
  • 做一些分析和讨论

实验代码实现

  1. 纯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变量我这里没提供,本质就是一个参数文件,要跑这部分代码的可以看这里,复制一下测试。

  1. 使用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目录下)。

  1. 对变量进行预定义本质上只是一些心理作用,我也只是猜测会对结果产生真正的影响。修改很简单,只需要对纯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. 这部分纯自己照着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 <= 不得不说垃圾百度 : (