技术经验比较一下PythonC

小编推荐

本文是一篇非常接地气的Cython科普,从实例代码的角度对比了Python、Cython的语法区别、效率差异以及实用场景。

要点导读:

各语言的斐波那契数列示例

Cython为什么能够加速?

效率差异,Python为什么慢?

什么时候使用Cython?

原创:古明地觉责编:陶佳元

楔子

我们以简单的斐波那契数列为例,来测试一下它们执行效率的差异。

Python代码:

deffib(n):a,b=0.0,1.0foriinrange(n):a,b=a+b,areturna

C代码:

doublecfib(intn){inti;doublea=0.0,b=1.0,tmp;for(i=0;in;++i){tmp=a;a=a+b;b=tmp;}returna;}

上面便是C实现的一个斐波那契数列,可能有人好奇为什么我们使用浮点型,而不是整型呢?答案是C的整型是有范围的,所以我们使用double,而且Python的float在底层对应的是PyFloatObject、其内部也是通过double来存储的。

C扩展:

然后是C扩展,注意:C扩展不是我们的重点,写C扩展和写Cython本质是一样的,都是为Python编写扩展模块,但是写Cython绝对要比写C扩展简单的多。

#include"Python.h"doublecfib(intn){inti;doublea=0.0,b=1.0,tmp;for(i=0;in;++i){tmp=a;a=a+b;b=tmp;}returna;}staticPyObject*fib(PyObject*self,PyObject*n){if(!PyLong_CheckExact(n)){wchar_t*error=L"函数fib需要接收一个整数";PyErr_SetObject(PyExc_ValueError,PyUnicode_FromWideChar(error,wcslen(error)));returnNULL;}doubleresult=cfib(PyLong_AsLong(n));returnPyFloat_FromDouble(result);}staticPyMethodDefmethods[]={{"fib",(PyCFunction)fib,METH_O,"这是fib函数"},{NULL,NULL,0,NULL}};staticPyModuleDefmodule={PyModuleDef_HEAD_INIT,"c_extension","这是模块c_extension",-1,methods,NULL,NULL,NULL,NULL};PyMODINIT_FUNCPyInit_c_extension(void){returnPyModule_Create(module);}

可以看到,如果是写C扩展,即便一个简单的斐波那契,都是非常复杂的事情。

Cython代码:

最后看看如何使用Cython来编写斐波那契,你觉得使用Cython编写的代码应该是一个什么样子的呢?

deffib(intn):cdefinticdefdoublea=0.0,b=1.0foriinrange(n):a,b=a+b,areturna

怎么样,Cython代码和Python代码是不是很相似呢?虽然我们现在还没有正式学习Cython的语法,但你也应该能够猜到上面代码的含义是什么。我们使用cdef关键字定义了一个C级别的变量,并声明了它们的类型。

Cython代码也是要编译成扩展模块之后,才能被解释器识别,所以它需要先被翻译成C的代码,然后再编译成扩展模块。再次说明,写C扩展和写Cython本质上没有什么区别,Cython代码也是要被翻译成C代码的。

但很明显,写Cython比写C扩展要简单很多,如果编写的Cython代码质量很高,那么翻译出来的C代码的质量同样很高,而且在翻译的过程中还会自动进行最大程度的优化。但如果是手写C扩展,那么一切优化都要开发者手动去处理,更何况在功能复杂的时候,写C扩展本身就是一件让人头疼的事情。

Cython为什么能够加速?

观察一下Cython代码,和纯Python的斐波那契相比,我们看到区别貌似只是事先规定好了变量i、a、b的类型而已,关键是为什么这样就可以起到加速的效果呢(虽然还没有测试,但速度肯定会提升的,否则就没必要学Cython了)。

但是原因就在这里,因为Python中所有的变量都是一个泛型指针PyObject*。PyObject(C的一个结构体)内部有两个成员,分别是ob_refcnt:保存对象的引用计数、ob_type*:保存对象类型的指针。

不管是整数、浮点数、字符串、元组、字典,亦或是其它的什么,所有指向它们的变量都是一个PyObject*。当进行操作的时候,首先要通过-ob_type来获取对应类型的指针,再进行转化。

比如Python代码中的a和b,我们知道无论进行哪一层循环,结果指向的都是浮点数,但是解释器不会做这种推断。每一次相加都要进行检测,判断到底是什么类型并进行转化;然后执行加法的时候,再去找内部的__add__方法,将两个对象相加,创建一个新的对象;执行结束后再将这个新对象的指针转成PyObject*,然后返回。

并且Python的对象都是在堆上分配空间,再加上a和b不可变,所以每一次循环都会创建新的对象,并将之前的对象给回收掉。

以上种种都导致了Python代码的执行效率不可能高,虽然Python也提供了内存池以及相应的缓存机制,但显然还是架不住效率低。

至于Cython为什么能加速,我们后面会慢慢聊。

效率差异

那么它们之间的效率差异是什么样的呢?我们用一个表格来对比一下:

提升的倍数,指的是相对于纯Python来说在效率上提升了多少倍。

第二列是fib(0),显然它没有真正进入循环,fib(0)测量的是调用一个函数所需要花费的开销。而倒数第二列"循环体耗时"指的是执行fib(90)的时候,排除函数调用本身的开销,也就是执行内部循环体所花费的时间。

整体来看,纯C语言编写的斐波那契,毫无疑问是最快的,但是这里面有很多值得思考的地方,我们来分析一下。

纯Python

众望所归,各方面都是表现最差的那一个。从fib(0)来看,调用一个函数要花纳秒,和C相比慢了这么多,原因就在于Python调用一个函数的时候需要创建一个栈帧,而这个栈帧是分配在堆上的,而且结束之后还要涉及栈帧的销毁等等。至于fib(90),显然无需分析了。

纯C

显然此时没有和Python运行时的交互,因此消耗的性能最小。fib(0)表明了,C调用一个函数,开销只需要2纳秒;fib(90)则说明执行一个循环,C比Python快了将近80倍。

C扩展

C扩展是干什么的上面已经说了,就是使用C来为Python编写扩展模块。我们看一下循环体耗时,发现C扩展和纯C是差不多的,区别就是函数调用上花的时间比较多。原因就在于当我们调用扩展模块的函数时,需要先将Python的数据转成C的数据,然后用C函数计算斐波那契数列,计算完了再将C的数据转成Python的数据。

所以C扩展本质也是C语言,只不过在编写的时候还需要遵循CPython提供的API规范,这样就可以将C代码编译成pyd文件,直接让Python来调用。从结果上看,和Cython做的事情是一样的。但是还是那句话,用C写扩展,本质上还是写C,而且还要熟悉底层的Python/CAPI,难度是比较大的。

Cython

单独看循环体耗时的话,纯C、C扩展、Cython都是差不多的,但是编写Cython显然是最方便的。而我们说Cython做的事情和C扩展本质是类似的,都是为Python提供扩展模块,区别就在于:一个是手动写C代码,另一个是编写Cython代码、然后再自动翻译成C代码。所以对于Cython来说,将Python的数据转成C的数据、进行计算,然后再转成Python的数据返回,这一过程也是无可避免的。

但是我们看到Cython在函数调用时的耗时相比C扩展却要少很多,主要是Cython生成的C代码是经过高度优化的。不过说实话,函数调用花的时间不需要太关心,内部代码块执行所花的时间才是我们需要注意的。当然啦,如何减少函数调用本身的开销,我们后面也会说。

Python的for循环为什么这么慢?

通过循环体耗时我们看到,Python的for循环真的是出了名的慢,那么原因是什么呢?来分析一下。

1.Python的for循环机制

Python在遍历一个可迭代对象的时候,会先调用可迭代对象内部的__iter__方法返回其对应的迭代器;然后再不断地调用迭代器的__next__方法,将值一个一个的迭代出来,直到迭代器抛出StopIteration异常,for循环捕捉,终止循环。

而迭代器是有状态的,Python解释器需要时刻记录迭代器的迭代状态。

2.Python的算数操作

这一点我们上面其实已经提到过了,Python由于自身的动态特性,使得其无法做任何基于类型的优化。

比如:循环体中的a+b,这个a、b指向的可以是整数、浮点数、字符串、元组、列表,甚至是我们实现了魔法方法__add__的类的实例对象,等等等等。

尽管我们知道是浮点数,但是Python不会做这种假设,所以每一次执行a+b的时候,都会检测其类型到底是什么?然后判断内部是否有__add__方法,有的话则以a和b为参数进行调用,将a和b指向的对象相加。计算出结果之后,再将其指针转成PyObject*返回。

而对于C和Cython来说,在创建变量的时候就事先规定了类型为double,不是其它的,因此编译之后的a+b只是一条简单的机器指令。这对比下来,Python尼玛能不慢吗。

3.Python对象的内存分配

Python的对象是分配在堆上面的,因为Python对象本质上就是C的malloc函数为结构体在堆区申请的一块内存。在堆区进行内存的分配和释放需要付出很大的代价,而栈则要小很多,并且它是由操作系统维护的,会自动回收,效率极高,栈上内存的分配和释放只是动一动寄存器而已。

但堆显然没有此待遇,而恰恰Python的对象都分配在堆上,尽管Python引入了内存池机制使得其在一定程度上避免了和操作系统的频繁交互,并且还引入了小整数对象池、字符串的intern机制,以及缓存池等。

但事实上,当涉及到对象(任意对象、包括标量)的创建和销毁时,都会增加动态分配内存、以及Python内存子系统的开销。而float对象又是不可变的,因此每循环一次都会创建和销毁一次,所以效率依旧是不高的。

而Cython分配的变量(当类型是C里面的类型时),它们就不再是指针了(Python的变量都是指针),对于当前的a和b而言就是分配在栈上的双精度浮点数。而栈上分配的效率远远高于堆,因此非常适合for循环,所以效率要比Python高很多。另外不光是分配,在寻址的时候,栈也要比堆更高效。

所以在for循环方面,C和Cython要比纯Python快了几个数量级,这并不是奇怪的事情,因为Python每次迭代都要做很多的工作。

什么时候使用Cython?

我们看到在Cython代码中,只是添加了几个cdef就能获得如此大的性能改进,显然这是非常让人振奋的。但是,并非所有的Python代码在使用Cython编写时,都能获得巨大的性能改进。

我们这里的斐波那契数列示例是刻意的,因为里面的数据是绑定在CPU上的,运行时都花费在处理CPU寄存器的一些变量上,而不需要进行数据的移动。如果此函数做的是如下工作:

内存密集,比如给大数组添加元素;

I/O密集,比如从磁盘读取大文件;

网络密集,比如从FTP服务器下载文件;

那么Python,C,Cython之间的差异可能会显著减少(对于存储密集操作),甚至完全消失(对于I/O密集或网络密集操作)。

当提升Python程序性能是我们的目标时,Pareto原则对我们帮助很大,即:程序百分之80的运行耗时是由百分之20的代码引起的。但如果不进行仔细的分析,那么是很难找到这百分之20的代码的。因此我们在使用Cython提升性能之前,分析整体业务逻辑是第一步。

如果我们通过分析之后,确定程序的瓶颈是由网络IO所导致的,那么我们就不能期望Cython可以带来显著的性能提升。因此在你使用Cython之前,有必要先确定到底是哪种原因导致程序出现了瓶颈。所以尽管Cython是一个强大的工具,但前提是它必须应用在正确的道路上。

另外Cython将C的类型系统引入进了Python,所以C的数据类型的限制是我们需要


转载请注明:http://www.aierlanlan.com/rzdk/648.html