引言 #
其实一开始没有想到写关于
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
有很多实现,我们这里说的Python
是CPython
也是最常见的实现,它是由C语言
编译出来的,我们的目标就是把两种语言给混合起来,C+C++
。
我们看看其他语言,比如Java
其实也可以混合C++
代码,它是采用JNI
的方式来进行交互的,如果你了解这种方式,你会发现也非常麻烦,得先写Java
的类,然后再生成C/C++
头文件。然后你再写C/C++
代码,其实我很讨厌这种方式,我希望能把C/C++
和你的语言这两种分离开来,我们能简单通过某种方式桥接一下让两个项目能够连贯起来。
我们现在来看看Python
是如何调用C++
的代码。在这之前我先提一下Python
与C
的关系。
其实Python
和C
一直非常友好,相比于其他语言,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
没有的面对对象,以及高级特性,这就让我们融合C
和C++
带来了一些困难。
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
这些语言的一样的类特征这个概念。
这里我们必须要了解一个名词“语法糖”,在我们看来我们能在Python
、Java
、C++
中使用一些面对对象的特性,比如类、继承、接口。其实这些都只是一些语法糖而已,在这些实现的底层,比如说Python
它就是用C
的函数来帮助我们构建这些语法糖,我们看到的一个对象的系统函数,其实它是Python
帮助我们把一连串函数绑定在一个module
上面,虽然表面上我们新建了一个对象,调用了一个对象函数,其实在C
层我们就是调用了一连串的函数来完成一个对象的分配
我们可以在官方文档中找到这部分介绍,官方文档告诉我们只要将列表的函数赋给一个模块(module
)我们就让你的C/C++
代码给Python
一个模块可以使用,从官方文档我们就可以很清楚看到语法糖
Python
的文档非常丰富,理论上我们能够根据文档完成复杂的C++
代码与Python
交互,但是我们从文档上可以看到,这个过程是非常繁琐的,相比于调用C
的简单,为了实现调用C++
的类和数据类型,我们得写很多中间代码进行转换,差不多就重新写了一遍C++
的实现
当然作为以简单为美的Pythoner
早就发现这个问题,也就这个问题开发了ctypes
、cffi
、numba
等框架帮助,就连在C++
大名鼎鼎的boost
库中也提供了boost/python
来帮助Python
更加简单的调用C++
,接下来我就根据我对下面这些库来谈一谈我的看法
框架简析 #
单纯的介绍这些库的功能太枯燥了,我就按照我对这些的库的理解将他们编成历史故事(真实出现的原因可能不是这样的)
话说在Python
作者设计Python
之后,它发现Python
实在是有点慢,为了能加速它就把Python
的C
API告诉社区的人让他们自己编写C
代码然后让Python
去调用它
但是这个API实在是太繁琐了,要写太多附件的C
代码了,有些人就发现这个问题,他们设计了一种脚本程序,你只要把你想调用的C
函数包在%{
里面就能帮你生成Python
API的C代码,这样减少了不少代码量,这个框架叫做Swig
。
大家在使用Swig
的时候发现一个问题,这个Swig
要生成的一个很大的C
函数,C++
开发者发现了这个问题,他们跟Python
开发者说你们是不是瞧不起C++
,这个函数这么不优雅,竟然想跟我们代码混起来,想用C++
我们帮你,你要生成什么函数告诉我,我帮你生成你引用一下我这个库就行,这样大名鼎鼎的boost::python
就开发出来了
你开心的用起来boost::python
来包装一下代码,这样写完C++
代码再引入boost::python
把Python
需要的函数定义一下,编译,OK,但是Windows
用户不开心了,这个boost::python
是在boost
项目下的一个子项目,为了在Windows
安装,还得下几百兆的软件包,要是碰到网络不好得下一天。这个时候Python
大牛出来了,啥,这么麻烦,我来开发一个包,把boost::python
从boost
的掏出来,你只需要pip
一下就行
经过几个"小时"开发,pybind11开发出来了,还是原来的配方还是原来的味道,管他Windows
还是Unix
,直接pip
一下就能使用boost::python
一样的语法来用了
就这样安安稳稳的过了一段时间,大家很开心用Python
包轻轻松松解决生成Python
C API代码的功能。但是随着大家用的越来越多,大家发现怎么我用pybind11调用C++
跑的有点慢,Python
大牛开始研究,重要他们发现由于pybind11由于秉承Python
的简单至上,很多东西它都做了”通用性“,比如它帮你自动把C++
的Vector
的类型转成Python
的list
,这样程序在编译时候不会报错,但是由于这种类型转换太多了,严重的拖累了C++
的速度,所以pybind11虽然用的很开心,但是速度却比原生的Python
C API要慢
这个时候精通编译原理、Python
、C++
的大牛出现了,它发现解决这个问题的办法很简单,创造一门中间语言,这么语言可以详细的定义怎么从C++
到Python
的中间过程,在pybind11 中这个完全是一个黑箱子,只有把这个黑箱子拿出来,这样我们就知道你想怎么调用C++
,这样就能设计更加优秀的Python
C API的代码。最后Cython
出现了,它的出现让那些苛求性能的人闭上了嘴,它自动出来的生成Python
C API代码近乎人工编写,在这样强的性能加持下,它的速度近乎原生
至此在生成代码Python
C 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
完胜C
和Cython
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
真的起飞。