楔子
前面我们说了 Cython 是什么,为什么我们要用它,以及如何编译和运行 Cython 代码。有了这些知识,那么是时候进入 Cython 的深度探索之路了。不过在此之前,我们还是要深入分析一下 Python 和 Cython 的区别。
Python 和 Cython 的差别从大方向上来说无非有两个,一个是:运行时解释和预先编译;另一个是:动态类型和静态类型。
解释执行和编译执行
为了更好地理解为什么 Cython 可以提高 Python 代码的执行性能,有必要对比一下虚拟机执行 Python 代码和操作系统执行已经编译好的 C 代码之间的差别。
Python代码在运行之前,会先被编译成 pyc 文件(里面存储的是 PyCodeObject 对象),然后读取里面的 PyCodeObject 对象,创建栈帧,执行内部的字节码。而字节码是能够被 Python 虚拟机解释或者执行的基础指令集,并且虚拟机独立于平台,因此在一个平台生成的字节码可以在任意平台运行。
虚拟机将一个高级字节码翻译成一个或者多个可以被操作系统调度 CPU 执行的低级操作(指令)。这种虚拟化很常见并且十分灵活,可以带来很多好处:其中一个好处就是不会被挑剔的操作系统嫌弃(相较于编译型语言,你在一个平台编译的可执行文件在其它平台上可能就用不了了),而缺点是运行速度比本地编译好的代码慢。
站在 C 的角度,由于不存在虚拟机,因此也就不存在所谓的高级字节码。C 代码会被直接编译成机器码,以一个可执行文件或者动态库(.dll 或者 .so)的形式存在。但是注意:它依赖于当前的操作系统,是为当前平台和架构量身打造的,可以直接被 CPU 执行,而且级别非常低(伴随着速度快),所以它与所在的操作系统是有关系的。
那么有没有一种办法可以弥补虚拟机的字节码和 CPU 的机器码之间的宏观差异呢?答案是有的,那就是 C 代码可以被编译成一种被称之为扩展模块的特定类型的动态库,并且这些库可以作为成熟的 Python 模块,但是里面的内容已经是经由标准 C 编译器编译成的机器码。Python 虚拟机在导入扩展模块执行的时候,不会再解释高级字节码,而是直接运行机器代码,这样就能移除性能开销。
这里再提一下扩展模块,我们说 Windows 中存在 .dll(动态链接库)、Linux 中存在 .so(共享文件)。如果只是 C 或者 C++、甚至是 Go 等等编写的普通源文件,然后编译成 .dll 或者 .so,那么这两者可以通过 ctypes 调用,但是无法通过 import 导入。如果你强行导入,那么会报错:
ImportError: dynamic module does not define module export function
但如果是遵循 Python/C API 编写,尽管编译出的扩展模块在 Linux 上也是 .so、Windows 上是 .pyd(.pyd 也是个 .dll),但它们是可以直接被解释器识别被导入的。
将一个普通的 Python 代码编译成扩展模块的话(Cython 是 Python 的超集,即使是纯 Python 也可以编译成扩展模块),效率上可以有多大的提升呢?根据 Python 代码所做的事情,这个差异会非常广泛,但是通常将 Python 代码转换成等效的扩展模块的话,效率大概有 10% 到 30% 的提升。因为一般情况下,代码既有 IO 密集也会有 CPU 密集。
所以即便没有任何的 Cython 代码,纯 Python 在编译成扩展模块之后也会有性能的提升。并且如果代码是计算密集型,那么效率会更高。
Cython 给了我们免费加速的便利,让我们在不写 Cython、也就是只写纯 Python 的情况下,还能得到优化。但这种只针对纯 Python 进行的优化显然只是扩展模块的冰山一角,真正的性能改进是使用 Cython 的静态类型来替换 Python 的动态解析。
因为 Python 不会进行基于类型的优化,所以即使编译成扩展模块,但如果类型不确定,还是没有办法达到高效率的。
就拿两个变量相加举例:由于 Python 不会做基于类型方面的优化,所以这一行代码对应的机器码数量显然会很多,即使编译成了扩展模块,其对应的机器码数量也是类似的(内部会有优化,因此机器码数量可能会少一些,但不会少太多)。
这两者区别就是:普通的模块有一个翻译的过程,将字节码翻译成机器码;而扩展模块是事先就已经全部翻译成机器码了。但是CPU执行的时候,由于机器码数量是差不多的,因此执行时间也是差不多的,区别就是少了一个翻译的过程。但是很明显,Python 将字节码翻译成机器码花费的时间几乎是不需要考虑的,重点是 CPU 在执行机器码所花费的时间。
因此将纯 Python 代码编译成扩展模块,速度不会提升太明显,提升的 10~30% 也是 Cython 编译器内部的优化,比如:发现函数中某个对象在函数结束就不被使用了,所以将其分配的栈上等等。如果使用 Cython 时指定了类型,那么由于类型确定,机器码的数量就会大幅度减少。CPU执行 10 条机器码花的时间和执行 1 条机器码花的时间哪个长,不言而喻。
因此使用 Cython,重点是规定好类型,一旦类型确定,那么速度会快很多。
动态类型和静态类型
Python 语言和 C、C++ 之间的另一个重要的差异就是:前者是动态语言,后者是静态语言。静态语言要求在编译的时候就必须确定变量的类型,一般通过显式的声明来完成这一点。另一方面,如果一旦声明某个变量,那么之后此作用域中该变量的类型就不可以再改变了。
看起来限制还蛮多的,那么静态类型可以带来什么好处呢?除了编译时的类型检测,编译器也可以根据静态类型生成适应当前平台的高性能机器码。
动态语言(针对于 Python)则不一样,对于动态语言来说,类型不是和变量绑定的,而是和对象绑定的,变量只是一个指向对象的指针罢了。因此 Python 中如果想创建一个变量,那么必须在创建的同时赋上值,不然解释器不知道这个变量到底指向哪一个对象。而像 C 这种静态语言,可以创建一个变量的同时不赋上初始值,比如:int n,因为已经知道 n 是一个 int 类型了,所以分配的空间大小已经确定了。
并且对于动态语言来说,变量即使在同一个作用域中,也可以指向任意的对象,因为变量只是一个指针罢了。举个栗子:
var = 666 var = "古明地觉"
首先是 var = 666,相当于创建了一个整数 666,然后让 var 这个变量指向它;
再来一个 var = "古明地觉",那么会创建一个字符串,然后让 var 指向这个字符串。或者说 var 不再存储整数 666 的地址,而是存储新创建的字符串的地址。
所以在运行 Python 程序时,解释器要花费很多时间来确认执行的低阶操作,并抽取相应的数据。考虑到 Python 设计的灵活性,解释器总是要以一种非常通用的方式来确定相应的低阶操作,因为 Python 的变量在任意时刻可以指向任意类型的数据。以上便是所谓的动态解析,而 Python 的通用动态解析是缓慢的,还是以 a + b 为栗:
1)解释器要检测 a 指向的对象的类型,这在 C 一级至少需要一次指针查找;
2)解释器从该类型中寻找加法方法的实现,这可能又需要一个或者多个额外的指针查找和内部函数调用;
3)如果解释器找到了相应的方法,那么解释器就有了一个实际的函数调用;
4)解释器会调用这个加法函数,并将 a 和 b 作为参数传递进去;
5)Python 的对象在 C 中都是一个结构体,比如:整数在 C 中是 PyLongObject,内部有引用计数、类型、ob_size、ob_digit,这些成员是什么不必关心,总之其中一个成员肯定是存放具体的值的,其它成员则是存储额外的属性的。
而加法函数显然要从这两个结构体中抽出实际的数据,这需要指针查找以及将数据从 Python 类型转换到 C 类型。如果成功,那么会执行加法的实际操作;如果不成功,比如类型不对,发现 a 是整数但 b 是个字符串,就会报错;
6)执行完加法操作之后,必须将结果再转回 Python 对象,然后获取它的指针、转成 PyObject * 之后再返回;
以上就是 Python 执行 a + b 的流程,而 C 语言面对 a + b 这种情况,表现则是不同的。因为 C 是静态编译型语言,C 编译器在编译的时候就决定了执行的低阶操作和要传递的参数数据。
在运行时,一个编译好的 C 程序几乎跳过了 Python 解释器要必须执行的所有步骤。对于 a + b,编译器提前就确定好了类型,比如整型,那么编译器生成的机器码指令是寥寥可数的:将数据加载至寄存器进行相加,然后存储结果。
所以我们看到编译后的 C 程序几乎将所有的时间都只花在了调用快速的 C 函数以及执行基本操作上,没有 Python 那些花里胡哨的动作。并且由于静态语言对变量类型的限制,编译器会生成更快速、更专业的指令,这些指令是为其数据以及所在平台量身打造的。因此 C 语言比 Python 快上几十倍甚至上百倍,这简直再正常不过了。
而 Cython 在性能上可以带来如此巨大的提升的原因就在于,它将 C 的静态类型引入到 Python 中,静态类型会将运行时的动态解析转化成基于类型优化的机器码。
在 Cython 诞生之前,我们只能通过 C 重新实现 Python 代码来从静态类型中获益,也就是用 C 编写所谓的扩展模块。但 Cython 的出现则简化了这一点,可以让我们在写类似于 Python 代码的同时,还能使用 C 的静态类型系统。
那么下一篇文章,我们就来学习 Cython 的第一个、也是最重要的关键字:cdef,它是我们通往 C 性能的大门。