Contents
  1. 1. 引言
  2. 2. (Python)+(C++)难在哪里
  3. 3. Python为什么能够调用C++代码
  4. 4. 框架简析
  5. 5. 总结

引言

其实一开始没有想到写关于Python的加速,一开始只想好好了解一下C++这门语言,没想到最后研究来研究去,基本上把所以加速框架都试验了一下,这篇博客就谈谈我对Python加速的看法

首先我先谈谈C++,虽然我上大学之前就自学过C,但是对于这个C的升级版还是没有过多的了解,花了几天时间学习,发现C++这门语言还是不错的,至少在兼容性上,它能兼容C还有以前的版本。

然而作为一个用惯了了脚本语言的人来说,C++最麻烦的就是他的第三方库管理,当然对于类Unix系统有自己带的包管理器(如ubuntu上的apt,CentOS上的yum)可以来安装第三方库(就是我们平常为了安装一些软件,比如要先apt install xxx-dev那些库),由于这些都绑定了平台的,所以你经常能看到有些软件自己编译会列出各个平台下依赖的包,然而对于一些比较新的库(比如googletest,就得去Github上掏下来自己编译安装了。

吐槽完了C++的缺点之后,我们不得不说C++的优点了,虽然比较难装(相比于脚本语言)但是那个速度真是贼快,用腾讯开源的协程库,单台机器就能开启千万协程而且内存不超过2个G,想我大Python开个一万都很嘚瑟了。C++在性能上真的的碾压的。就是因为C++性能上要求到极致,所以它才会有那么多的前面安装的缺点,因为C++是面对硬件的,对于不同的硬件,C++想做到最快,那么通用的代码就不可能的,通用就代表损失性能。然而让我全用C++写代码是不可能的,脚本语言用的多爽呀。所以了解完了C++的强大之后,我就越发的想了解怎么结合两者的方式来提升`Python`速度,最后把所有加速手段都测试了一遍,所以就有了这篇博文。

PS: 之所以花这么多时间介绍C++是因为LLVM就是使用C++写的,而numba依赖LLVM来动态编译出比C更快的机器码,这个也就Python最后能比C还快的主要原因

(Python)+(C++)难在哪里

大家都知道Python有很多实现,我们这里说的PythonCPython也是最常见的实现,它是由C语言编译出来的,我们的目标就是把两种语言给混合起来,C+C++

我们看看其他语言,比如Java其实也可以混合C++代码,它是采用JNI的方式来进行交互的,如果你了解这种方式,你会发现也非常麻烦,得先写Java的类,然后再生成C/C++头文件。然后你再写C/C++代码,其实我很讨厌这种方式,我希望能把C/C++和你的语言这两种分离开来,我们能简单通过某种方式桥接一下让两个项目能够连贯起来。

我们现在来看看Python是如何调用C++的代码。在这之前我先提一下PythonC的关系。

其实PythonC一直非常友好,相比于其他语言,Python在支持上一直尽最大努力,因为Python开发者也知道Python非常慢(相比于C,C++,而且还有GIL的存在无法使用多线程密集CPU计算),所以Python开发者直接在内库上提供支持:ctypes,一个专为调用C代码的库。你只有编写少量代码就能让Python运行你的C代码。理论上你碰到性能问题直接写C就行了,但是我们为什么还要让Python运行C++来加速呢

四个字:比C更好,C++由于在性能上与C不相上下,而且比C要高级的多(面对对象等),编写速度与维护上比C更加好,而且要知道现在最流行的Java编辑器都是C++写的,还有很多高性能数据库以及机器学习库都是C++写的,虽然在Python中写C更加简单,但是我们还是希望能够用面对对象的方式来编写代码,毕竟我们主要使用的高级语言也是面对对象的

也正是因为C++提供了一些C没有的面对对象,以及高级特性,这就让我们融合CC++带来了一些困难。

Python为什么能够调用C++代码

我们从调用顺序来看,我们其实想用C代码(Python本质其实是C代码)调用C++C++C要高级,出生的也更晚,所以C其实是不知道C++这门语言的,所以C能调用C++,其实是C++C的一种兼容,这种兼容是C++提供的

C++作为一门偏底层语言,它最终的目的是生成二进制码,C最终也生成二进制码,这个二进制码能直接在CPU里面运行,大家都知道一个代码复用的概念,在二进制层次上,就有这个链接库概率,反正无论谁是最终调用主体,被调用方只需要提供一个规定好的函数库,那么就能实现跨语言的一种交互。

但是这个交互存在一个问题,C++C有着更加特性,比如说类,C没有这个概念,假如C++在动态库里面想让C能够调用一个方法,C根本不知道怎么用,一个类要使用必须牵扯到类初始化,类析构等等。所以C++提供一个关键字extern "C"

这个关键字就是告诉C++编译器把这个块域里面的东西编译成C可以接受的,当然有个前提条件里面代码声明必须是C式的,也就是只能使用C关键字来声明函数结构体什么的,但是在函数内部你可以调用C++代码,声明一个类什么的,最后返回结果。

用一句话来总结这个关键词的作用就是:告诉编辑器和用户,里面的函数东西,不管中间过程,只需要在“开头”(函数声明),结尾(结果返回)是C模式的,那么这个函数就能在C里面用

最后我们总结一下Python能够调用C++的代码的原因:只要C++能够”写”成C代码,我们就能调用。这时候你可能有疑惑了,如果把C++写成C那么我们还不如直接写C代码,何必如此复杂的研究这么久了。但是你有没有想过为什么Python是用C写的,最后却能拥有C++Java这些语言的一样的类特征这个概念。

这里我们必须要了解一个名词“语法糖”,在我们看来我们能在PythonJavaC++中使用一些面对对象的特性,比如类、继承、接口。其实这些都只是一些语法糖而已,在这些实现的底层,比如说Python它就是用C的函数来帮助我们构建这些语法糖,我们看到的一个对象的系统函数,其实它是Python帮助我们把一连串函数绑定在一个module上面,虽然表面上我们新建了一个对象,调用了一个对象函数,其实在C层我们就是调用了一连串的函数来完成一个对象的分配

我们可以在官方文档中找到这部分介绍,官方文档告诉我们只要将列表的函数赋给一个模块(module)我们就让你的C/C++代码给Python一个模块可以使用,从官方文档我们就可以很清楚看到语法糖

Python的文档非常丰富,理论上我们能够根据文档完成复杂的C++代码与Python交互,但是我们从文档上可以看到,这个过程是非常繁琐的,相比于调用C的简单,为了实现调用C++的类和数据类型,我们得写很多中间代码进行转换,差不多就重新写了一遍C++的实现

当然作为以简单为美的Pythoner早就发现这个问题,也就这个问题开发了ctypescffinumba等框架帮助,就连在C++大名鼎鼎的boost库中也提供了boost/python来帮助Python更加简单的调用C++,接下来我就根据我对下面这些库来谈一谈我的看法

框架简析

单纯的介绍这些库的功能太枯燥了,我就按照我对这些的库的理解将他们编成历史故事(真实出现的原因可能不是这样的)

话说在Python作者设计Python之后,它发现Python实在是有点慢,为了能加速它就把PythonCAPI告诉社区的人让他们自己编写C代码然后让Python去调用它

但是这个API实在是太繁琐了,要写太多附件的C代码了,有些人就发现这个问题,他们设计了一种脚本程序,你只要把你想调用的C函数包在%{里面就能帮你生成PythonAPI的C代码,这样减少了不少代码量,这个框架叫做Swig

大家在使用Swig的时候发现一个问题,这个Swig要生成的一个很大的C函数,C++开发者发现了这个问题,他们跟Python开发者说你们是不是瞧不起C++,这个函数这么不优雅,竟然想跟我们代码混起来,想用C++我们帮你,你要生成什么函数告诉我,我帮你生成你引用一下我这个库就行,这样大名鼎鼎的boost::python就开发出来了

你开心的用起来boost::python来包装一下代码,这样写完C++代码再引入boost::pythonPython需要的函数定义一下,编译,OK,但是Windows用户不开心了,这个boost::python是在boost项目下的一个子项目,为了在Windows安装,还得下几百兆的软件包,要是碰到网络不好得下一天。这个时候Python大牛出来了,啥,这么麻烦,我来开发一个包,把boost::pythonboost的掏出来,你只需要pip一下就行

经过几个”小时”开发,pybind11开发出来了,还是原来的配方还是原来的味道,管他Windows还是Unix,直接pip一下就能使用boost::python 一样的语法来用了

就这样安安稳稳的过了一段时间,大家很开心用Python包轻轻松松解决生成PythonC API代码的功能。但是随着大家用的越来越多,大家发现怎么我用pybind11调用C++跑的有点慢,Python大牛开始研究,重要他们发现由于pybind11由于秉承Python的简单至上,很多东西它都做了”通用性“,比如它帮你自动把C++Vector的类型转成Pythonlist,这样程序在编译时候不会报错,但是由于这种类型转换太多了,严重的拖累了C++的速度,所以pybind11虽然用的很开心,但是速度却比原生的Python C API要慢

这个时候精通编译原理、PythonC++的大牛出现了,它发现解决这个问题的办法很简单,创造一门中间语言,这么语言可以详细的定义怎么从C++Python的中间过程,在pybind11 中这个完全是一个黑箱子,只有把这个黑箱子拿出来,这样我们就知道你想怎么调用C++,这样就能设计更加优秀的PythonC API的代码。最后Cython出现了,它的出现让那些苛求性能的人闭上了嘴,它自动出来的生成PythonC API代码近乎人工编写,在这样强的性能加持下,它的速度近乎原生

至此在生成代码PythonC API的中间代码的三方库尘埃落定,没有人想到有更好的办法来优化这一个方向。但是苛刻的人无处不在,他们攻击不了它的性能,只能攻击它的生成方式

为了使用Cython必须编译它,要么借用setuptools来简单这个步骤,要么自己手动编译,一些开发者叫嚣着,都说Python是个动态语言,怎么还要编译呀,麻烦死了,这个时候一些开发者就站出来了,他们觉得这是个挑战,他们想解决掉它,于是cffi被开发出来了,你不需要用专门的文件存贮C/C++代码,你可以像调用函数一样把C/C++函数原文作为参数传进去,实现动态加载,但是这种动态性还是付出了代价,速度有了一定影响,虽然还是比Python快,但是远远比不上Cython,有得必有失

这个时候精通汇编的大佬出现了,他们觉得动态加载这个地方还可以加强,他们觉得不需要我们在Python里面写C或者C++,你写一个Python函数,用一个装饰器包装一下,他们直接从底层出发,反正Python最终会编译成机器码,把Python函数的机器码加上类型(Python函数的参数可以是“鸭子”类型,不是强类型),省掉Python冗余的类型推断,直接从机器码层次上进行优化,最后编译成二进制接口给Python调用(背后使用了LLVM进行编译,这里就不详细介绍了),最终它的运行速度小胜Cython,并且比C还略胜一筹,这个就非常恐怖了,因为C基本上是除了汇编以外的速度标杆,所以懂汇编的大佬不要惹,太恐怖了,这个库的名字叫做numba,现在这个库已经开发6年多了,由于涉及到从Python源代码到了机器码实在太复杂了,所以仍然在开发中(主要适应各种硬件以及平台),目前处于0.40.0版本,基本上在主流平台使用是没有问题的。

对于各个库速度的测试可以看看这篇博客,可以看到numba完胜CCython

PS: 在这里我没有提ctypes因为它是原生的,而且它对C++支持并不很好

总结

在速度方面numba加持的Python无疑是No.1,但是它也有几个缺点,一个就是目前还处在开发阶段(目前是0.40版本,还没有1.0版本,而且issue有500个open状态,我在试验的时候也发现存在一些在issue的bug),第二个就是它目前支持能在函数内部运行的库只有numpy(当然这个也是它的设计的一个初衷,就是加速numpy与Python的混合代码)

当然它的优点完全可以盖过它的缺点,优点有很多,首先第一个它的速度,在LLVM加持下比C更快简直让人震惊,第二个是它调试和维护非常方便,都是由Python编写的,去掉装饰器就是Python代码,直接在IDE里面调试不知道多爽,上线的时候加上注释器跑的飞快(还能丢掉GIL)。目前numba还处于开发过程中,现阶段仍然有很多bug(500个Open的issue),不过正是由于大家都对他非常期望,所以它的issue才那么多,也希望numba能够越来越好,让Python真的起飞。

Contents
  1. 1. 引言
  2. 2. (Python)+(C++)难在哪里
  3. 3. Python为什么能够调用C++代码
  4. 4. 框架简析
  5. 5. 总结