《Cython系列》1. Cython概要

楔子

Cython:估计很多人都听说过,是用来对Python进行加速的。本人最近也打算学习Cython,但是无奈,目前关于Cython的教程实在是不多。虽然也有很多人都写了专栏来介绍Cython,不过个人觉得不够详细,很多都只是入个门,不是我想要的。正好最近发现了一本书:Cython - A guide for Python programmers,可以很好的学习Cython,然鹅比较尴尬的是它是英文版的,目前还没有看到中文版。但是有总比没有强,于是个人就决定用这本书来学习了,正好当练习英文了。

注意:本人可能不会一板一眼的直接将书中的内容翻译一遍,我会将书中的内容做一些精炼,然后再加一些个人的观点进去。代码示例,也会写的更详细一些。

下面让我们开始cython的学习之旅吧,最终不管效果如何,逼格要到位。至少我觉得用cython还是很酷的,而且它也确实起到了加速的效果。

前奏

关于Cython,你必须要清楚两件事:

  • 1. Cython是一门编程语言,它将C、C++的静态类型系统融合在了Python身上。

    补充:没错,Cython是一门编程语言,文件的后缀是.pyx,它是Python的一个超集;语法是Python语法和C语法的混血,当然我们说它是Python的一个超集,因此你写纯Python代码也是可以的。

  • 2. cython是一个编译器,负责将Cython源代码翻译成高效的C或者C++源代码;Cython源文件被编译之后的最终形式可以是Python的扩展模块(.pyd),也可以是一个独立的可执行文件。

因此Cython的强大之处就在于它将Python和C结合了起来,可以让你像写Python代码一样的同时还可以获得C的高效率;所以我们看到Cython相当于是高级语言Python和低级语言C之间的一个融合,因此有人也称Cython是"克里奥尔编程语言"(creole programming language)

补充:克里奥尔人是居住在西印度群岛的欧洲人和非洲人的混血儿,以此来形容Cython也类似于是一个(Python和C的)混血儿。

但是Python和C系语言大相径庭,为什么要将它们融合在一起呢?答案是:因为这两者并不是对立的,而是互补的。Python是高阶语言、动态、易于学习,并且灵活。但是这些优秀的特性是需要付出代价的,因为Python的动态性、以及它是解释型语言,导致其运行效率比静态编译型语言慢了好几个数量级。

而C语言是最古老的静态编译型语言之一,并且至今也被广泛使用。从时间来算的话,其编译器已有将近半个世纪的历史,在性能上做了足够的优化,因此C语言是非常低级、同时又非常强大的。然而不同于Python的是,C语言没有提供保护措施(没有gc、容易内存泄露),以及使用起来很不方便。

所以两个语言都是主流语言,只是特性不同使得它们被应用在了不同的领域。而Cython的美丽之处就在于:它将Python语言丰富的表达能力、动态机制和C语言的高性能汇聚在了一起,并且代码写起来仍然像写Python一样。

注意:除了极少数的例外,Python代码(2.x和3.x版本)已经是有效的Cython代码,因为Cython可以看成是Python的超集。并且Cython在Python语言的基础上添加了一些少量的关键字来更好的开发C的类型系统,从而允许cython编译器生成高效的C代码。如果你已经知道Python并且对C或C++有一定的基础了解,那么你可以直接学习Cython,无需再学习其它的接口语言。

另外,我们其实可以将Cython当成两个身份来看待:如果将Python编译成C,那么可以看成Cython的'阴';如果将Python作为胶水连接C或者C++,那么可以看成是Cython的'阳'(关于这里的阴阳,原文就是这么写的,对应的单词就是yin、yang,看来我大天朝的古文化还是很有魅力的)。我们可以从需要高性能的Python代码开始,也可以从需要一个优化的Python接口的C、C++开始。为了加速Python代码,Cython将使用可选的静态类型声明并通过算法来实现大量的性能提升。

关于Cython和CPython

关于Cython,最让人困惑的就是它和CPython之间的关系(尤其是少了的那个字母P),但是需要强调的是这两者是完全不同的。

首先Python是一门语言,它有自己的语法规则。我们按照Python语言规定的语法规则编写的代码就是Python源代码,但是源代码只是一个或多个普通的文本文件,我们需要使用Python语言对应的解释器来执行它。

而Python解释器也会按照相应的语法规则来对我们编写的Python源代码进行分词、语法解析等等,如果我们编写的代码不符合Python的语法规则,那么会报出语法错误,也就是SyntaxError。如果符合语法规范的话,那么会顺利的生成抽象语法树(Abstract Syntax Tree,简称AST ),然后将AST编译成指定集合--也就是所谓的字节码(bytes code),最后再执行字节码。

所以我们看到Python源代码是需要Python解释器来操作的,我们想做一些事情的话,如果光写成源代码是不行的,必须要由Python解释器将我们的代码解释成机器可以识别的指令进行执行才可以。而CPython正是Python语言对应的解释器,并且它也是官方实现的标准解释器,同时还是是使用最广泛的一种解释器。基本上我们使用的解释器都是CPython,也就是从官网下载、然后安装之后所得到的。

标准解释器CPython是由C语言实现的,除了CPython之外还有Jython(java实现的Python解释器)、PyPy(Python语言实现的Python解释器)等等。总之设计出一门语言,还要有相应的解释器才可以;至于编译型语言,则是对应的编译器。

最后重点来了,我们说解释器是由CPython实现的,它给Python语言提供了C级别的接口,也就是熟知的Python/C API。比如:Python中的列表,底层对应的是PyListObject;字典则对应PyDictObject,等等等等。所以当我们在Python中创建一个列表,那么CPython在执行的时候,就会在底层创建一个PyListObject。因为CPython是用C来实现的,最终肯定是C级别的代码来操作CPU执行指令。而Cython广泛地使用了这些C提供的接口,因此Cython同样需要依赖于CPython,所以Cython是一门语言,它并不是Python解释器的另一种实现,它的地位和CPython不是等价的,不过和Python是平级的。

因此Cython是一门语言,它负责生成扩展模块,同样需要CPython来进行调用。

比较一下Python、C、Cython

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

def fib(n):
	a, b = 0.0, 1.0
	for i in range(n):
		a, b = a + b, a
	return a

正如上面提到的那样,Python函数是一个合法的Cython函数。上面的这个函数在Python和Cython中的表现是完全一致的,我们后面会看看如何使用Cython来提升性能。

double cfib(int n) {
    int i;
    double a=0.0, b=1.0, tmp;
    for (i=0; i<n; ++i) {
        tmp = a; a = a + b; b = tmp;
    }
 	return a;
}

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

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

def fib(int n):
    cdef int i
    cdef double a=0.0, b=1.0
    for i in range(n):
        a, b = a + b, a
    return a

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

关键是为什么这样就可以起到加速的效果呢(虽然还没有测试,但速度肯定会提升的,否则就没必要学Cython了),和纯Python的斐波那契相比,我们看到区别貌似只是事先规定好了变量i、a、b的类型而已。但是原因就在这里,因为Python中所有的变量都是一个PyObject *,在底层中就是C的一个指针。PyObject(C的一个结构体)内部有两个成员,分别是ob_refcnt:对象的引用计数、ob_type *:保存对象类型的指针。不管是整型、字符串、元组、字典,亦或是其它的什么,所有指向它们的变量都是一个PyObject*,当进行操作的时候,首先要通过-> ob_type来获取对应的类型的指针,再进行转化。

比如:这里的a和b,我们虽然知道无论进行哪一层循环,结果都是浮点型,但是Python解释器不知道啊。每一次相加都要进行检测,判断到底是什么类型并进行转化,然后执行加法的时候,再去找内部的__add__方法,执行结束再转回PyObject *。并且Python中的对象都是在堆上分配空间,再加上a和b不可变,所以每一次循环都会创建新的对象,并将之前的对象给回收掉。

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

关于Cython为什么能加速,Python为什么慢,我们后面还会继续重复唠叨一遍。

那么它们之间的效率差异是什么样的呢?

我们用一张图表来看一下

提升的倍数,指的是相对于纯Python来说在效率上提升了多少倍。第二列是fib(0),显然它没有真正进行循环,fib(0)测量的是调用一个函数所需要花费的开销。而倒数第二列"循环体耗时"指的是执行fib(90)的时候,排除函数调用本身的开销,也就是执行内部循环体所花费的时间。

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

纯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语言,只不过在编写的时候遵循Python提供的API规范,可以将C代码编译成pyd文件,直接让Python来调用。从结果上看,和Cython做的事情是比较类似的。但是还是那句话,用C写扩展,本质上还是写C,难度是比较大的。

Cython

单独看循环体耗时的话,我们看到纯C、C扩展、cython都是差不多的,但是编写Cython显然是最方便的。而我们说Cython做的事情和C扩展本质是类似的,都是为Python提供扩展模块,所以对于Cython来说,将Python的数据转成C的数据、进行计算、然后再转成Python中的数据返回,这一过程是无可避免的。但是我们看到Cython在函数调用时的耗时相比C扩展却要少很多,主要是Cython生成的C代码是经过高度优化的。不过说实话,函数的调用花的时间根本不需要关心,内部代码块执行所花的时间才是我们应该需要注意的。

分析一下循环体耗时为什么这么长?

1. Python的for循环机制

通过循环体耗时我们看到,Python的for循环确实是出了名的慢,主要原因就是Python在遍历一个可迭代对象的时候,会先调用这个可迭代对象内部的__iter__方法返回其对应的迭代器,然后再不断地调用这个迭代器的__next__方法,将值一个一个的迭代出来,直到迭代器抛出StopIteration异常,for循环捕捉,终止循环。而迭代器是有状态的,Python解释器需要时刻记录迭代器的迭代状态。

而解决循环慢的问题,一个办法就是调用内置的函数或者使用Cython进行加速

2. Python的算数操作

这一点我们上面其实一定提到过了,Python由于其动态特性,使得其无法做任何基于类型的优化。比如:循环体中的a + b,这个a、b可以是整型、字符串、元组、列表,甚至是我们实现了某个魔法方法的类的实例对象,等等等等。尽管我们知道是float,但是Python不会做这种假设,所以每一次执行a + b的时候,都会检测其类型到底是什么?然后判断内部是否有__add__方法,并且两者能不能相加,然后条件满足的话再调用对应的__add__方法,将a和b作为参数,进行相加。如果你看过CPython的源代码的话,你会发现即便是简单的两个整型相加Python也要做很多工作,源码的数量大概好几十行(主要是Python中的整型在底层是通过数组来存储实际的值的,除了进行类型检测之外,相加还要遍历两个数组)

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

3. Python中对象的内存分配

我们说Python中的对象是分配在堆上面的,因为Python中对象本质上就是C中malloc函数为结构体在堆区申请的一块内存。我们知道在堆区进行内存的分配和释放是需要付出很大的代价的,而栈则要小很多,并且它是由操作系统维护的,会自动回收,效率极高。而堆显然没有此待遇,而恰恰Python的对象都是分配在堆上的,尽管Python引入了内存池机制使得其在一定程度上避免了和操作系统的频繁交互,并且还引入了小整数对象池以及针对字符串的intern机制。但事实上,当涉及到对象(任意对象、包括标量)的创建和销毁时,都会增加动态分配内存、以及Python内存子系统的开销。而float对象又是不可变的,因此每循环一次都会创建和销毁一次,所以效率依旧是不高的。

而Cython分配的变量,这里是a和b,都是分配在栈上的双精度浮点数。而栈上分配的效率远远高于堆,因此非常适合for循环,所以效率要比Python高很多。

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

需要注意的点

我们看到只是在代码中添加了几个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的数据类型的限制是我们需要关注的。我们知道,Python的整数不受长度的限制,但是C中int和long是受到限制的,这意味着它们不能正确地表示无限精度的整数。但幸运的是,Cython的一些特性可以帮助我们捕获这些溢出。总之最重要的是:C数据类型的速度比Python数据类型快,但是会受到限制导致其不够灵活和通用。

从这里我们也能看出,在速度以及灵活性、通用性上面,Python选择了后者

此外,思考一下Cython的另一个特性:连接外部代码。假设,我们的起点表示Python而是C或者C++,我们希望使用Python将多个C或者C++进行连接。而Cython理解C和C++的声明,并且它能生成高度优化的代码,因此更适合作为连接的桥梁。

总结

我们当前只是介绍了一下Cython,并且主要讨论了它的定位,以及和Python、C之间的差异。至于如何使用Cython加速Python,以及如何编写Cython代码,我们将会后续介绍。

猜你喜欢

转载自www.cnblogs.com/traditional/p/13196509.html