当前位置: 首页 > 科技观察

99%的人不知道!Python、C、C扩展、Cython差异比较!

时间:2023-03-11 20:33:33 科技观察

下面以简单的斐波那契数列为例,测试一下它们执行效率的差异。Python代码:deffib(n):a,b=0.0,1.0foriinrange(n):a,b=a+b,areturnaC代码:doublecfib(intn){inti;双a=0.0,b=1.0,tmp;对于(i=0;iob_type获取对应类型的指针,然后再进行转换。比如Python代码中的a和b,我们知道无论执行哪一层循环,结果都指向一个浮点数,但是解释器不会做这个推断。每次加法都要检测,判断是什么类型,并进行转换;然后在进行加法的时候,去内部的__add__方法将两个对象相加,创建一个新的对象;执行后将这个新对象指针转换为PyObject*,并返回。而Python对象都是在堆上分配空间,加上a和b是不可变的,所以每次循环都会创建一个新的对象,并回收之前的对象。以上种种,都导致了Python代码无法高效执行。虽然Python也提供了内存池和相应的缓存机制,但显然还是效率低下。至于为什么Cython可以加速,我们后面再说。效率差异那么它们之间的效率差异是什么样的呢?我们用一张表来对比一下:提升的倍数是指相对于纯Python,效率提升了多少倍。第二列是fib(0),显然它实际上并没有进入循环,fib(0)衡量的是调用函数的成本。倒数第二列“循环体耗时”指的是执行内层循环体所花费的时间,不包括执行fib(90)时函数调用本身的开销。综合来看,纯C语言写的Fibonacci无疑是最快的,但是也有很多值得思考的地方,我们来分析一下。纯Python是预计在所有方面表现最差的一种。从fib(0)开始,调用一个函数需要590纳秒,比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__方法返回其对应的迭代器;一个接一个地迭代,直到迭代器抛出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引入了内存池机制在一定程度上避免了与操作系统的频繁交互,同时也引入了小整数对象池,Stringinternmechanism,andbufferpool等。但实际上,当涉及到对象(任意对象,包括标量)的创建和销毁时,会增加动态分配内存和Python内存子系统的开销。float对象是不可变的,所以每次循环都会创建和销毁,所以效率还是不高。而Cython分配的变量(当类型为C中的类型时),不再是指针(Python变量都是指针),对于当前的a和b来说,是分配在栈上的双精度浮点数.在栈上分配的效率远高于堆,所以很适合for循环,所以效率比Python高很多。另外,不仅是分配,在寻址时栈也比堆更高效。因此,C和Cython比纯Python的for循环快几个数量级也就不足为奇了,因为Python每次迭代都会做很多工作。什么时候使用Cython?只需添加几个cdef,我们就看到Cython代码的性能有了如此大的提升,这显然非常令人鼓舞。然而,并不是所有的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的数据类型的局限性是我们需要注意的。我们知道Python的整数是没有长度限制的,但是C的整数是有界的,也就是说不能正确表示无限精度的整数。然而,Cython的一些特性可以帮助我们捕获这些溢出。总之,最重要的是:C数据类型比Python数据类型快,但会受到限制,不灵活和通用。从这里我们也可以看出,Python在速度、灵活性、通用性等方面选择了后者。另外,请考虑Cython的另一个特性:链接外部代码。假设我们的起点不是Python,而是C或C++,我们想用Python来连接多个C或C++模块。另一方面,Cython理解C和C++声明,它可以生成高度优化的代码,因此更适合作为连接的桥梁。由于我主要是Python,如果涉及到C和C++,我会介绍如何将C和C++引入Cython,直接调用写好的C库。不会介绍如何在C和C++中引入Cython作为连接多个C和C++模块的桥梁。我希望理解这一点,因为我不是用C和C++写服务,只是用它们来辅助Python提高效率。总结到目前为止,我只介绍了Cython,主要讨论了它的定位以及与Python和C的区别。至于如何使用Cython来加速Python,如何编写Cython代码,以及它的详细语法,我们会在后面介绍.简而言之,Cython是一门为Python服务的成熟语言。Cython代码不能直接执行,因为它不符合Python的语法规则。我们使用Cython的方式是:先将Cython代码翻译成C代码,然后将C代码编译成扩展模块(pyd文件),然后在Python代码中导入,调用里面的函数方法,才是正确的做法对于我们来说,使用Cython的方式,当然是唯一的方式。比如我们上面用Cython写的Fibonacci如果直接执行就会报错,因为cdef显然不符合Python的语法规则。所以需要将Cython代码编译成一个扩展模块,然后导入到一个普通的py文件中,这样做的意义在于可以提高运行速度。所以Cython的代码应该是一些CPU密集型的代码,否则效率很难大幅度提升。所以,在使用Cython之前,最好仔细分析一下业务逻辑,或者暂时使用Cython,而不是用Python写。编写完成后,开始测试分析程序的性能,看看哪些地方比较耗时,但同时可以通过静态类型进行优化。找到它们,用Cython重写,编译成扩展模块,然后调用扩展模块中的函数。