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

共 9524字,需浏览 20分钟

 ·

2022-06-18 14:17



楔子




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

Python 代码:

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


C 代码:

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 来存储的。

C 扩展:

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

#include "Python.h"

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;
}

static PyObject *fib(PyObject *self, PyObject *n) {
    if (!PyLong_CheckExact(n)) {
        wchar_t *error = L"函数 fib 需要接收一个整数";
        PyErr_SetObject(PyExc_ValueError,
                        PyUnicode_FromWideChar(error, wcslen(error)));
        return NULL;
    }
    double result = cfib(PyLong_AsLong(n));
    return PyFloat_FromDouble(result);
}

static PyMethodDef methods[] = {
    {"fib",
     (PyCFunction) fib,
     METH_O,
     "这是 fib 函数"},
     {NULLNULL0NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "c_extension",
    "这是模块 c_extension",
    -1,
    methods,
    NULLNULLNULLNULL
};

PyMODINIT_FUNC PyInit_c_extension(void{
    return PyModule_Create(&module);
}

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

Cython 代码:

最后看看如何使用 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

怎么样,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) 来看,调用一个函数要花 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/C API,难度是比较大的。

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 的数据类型的限制是我们需要关注的。我们知道,Python 的整数不受长度的限制,但是 C 的整数是受到限制的,这意味着它们不能正确地表示无限精度的整数。

不过 Cython 的一些特性可以帮助我们捕获这些溢出,总之最重要的是:C 数据类型的速度比 Python 数据类型快,但是会受到限制导致其不够灵活和通用。从这里我们也能看出,在速度以及灵活性、通用性上面,Python 选择了后者。

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

由于我本人是主 Python 的,如果涉及到 C、C++,都是介绍如何在 Cython 中引入 C、C++,直接调用已经写好的 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 编写的斐波那契,如果直接执行的话是会报错的,因为 cdef 明显不符合 Python 的语法规则。所以 Cython 代码需要编译成扩展模块,然后在普通的 py 文件中被导入,而这么做的意义就在于可以提升运行速度。因此 Cython 代码应该都是一些 CPU 密集型的代码,不然效率很难得到大幅度提升。

所以在使用 Cython 之前,最好先仔细分析一下业务逻辑,或者暂时先不用 Cython,直接完全使用 Python 编写。编写完成之后开始测试、分析程序的性能,看看有哪些地方耗时比较严重,但同时又是可以通过静态类型的方式进行优化的。找出它们,使用 Cython 进行重写,编译成扩展模块,然后调用扩展模块里面的功能。




推荐阅读:

入门: 最全的零基础学Python的问题  | 零基础学了8个月的Python  | 实战项目 |学Python就是这条捷径


干货:爬取豆瓣短评,电影《后来的我们》 | 38年NBA最佳球员分析 |   从万众期待到口碑扑街!唐探3令人失望  | 笑看新倚天屠龙记 | 灯谜答题王 |用Python做个海量小姐姐素描图 |碟中谍这么火,我用机器学习做个迷你推荐系统电影


趣味:弹球游戏  | 九宫格  | 漂亮的花 | 两百行Python《天天酷跑》游戏!


AI: 会做诗的机器人 | 给图片上色 | 预测收入 | 碟中谍这么火,我用机器学习做个迷你推荐系统电影


小工具: Pdf转Word,轻松搞定表格和水印! | 一键把html网页保存为pdf!|  再见PDF提取收费! | 用90行代码打造最强PDF转换器,word、PPT、excel、markdown、html一键转换 | 制作一款钉钉低价机票提示器! |60行代码做了一个语音壁纸切换器天天看小姐姐!




年度爆款文案

点阅读原文,看B站我的20个视频!

浏览 24
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报