让你的Python程序像C语言也一样快
在《Python性能优化指南--让你的Python代码快x3倍的秘诀》中有提到很多加速Python程序的方法,在随后的系列文章中我也为大家专门介绍了用PyPy加速Python 和 用Numba:一行代码将Python程序运行速度提升100倍。但在所有方法中对Python性能提升最大的还要数用C/C++/Rust来实现核心功能模块,真正地让Python运行地和C语言一样快。本文将带大家学习如何用C语言开发Python模块。
C扩展模块
Python最强大的特性之一就是可以调用C/C++/Rust等编译型语言开发的函数或库。这让我们可以将一些性能敏感功能用性能更好的编译型语言实现,从而极大提升Python性能。Python中很多库都是这么做的,比如Numpy, Scipy, Pandas, PyPolars等。
另一方面,可能某些功能是以动态库或静态库的形式提供,此时我们需要调用这些由C/C++编译出的库。这样既可以避免重复发明轮子,又能保证功能的稳定高效。
总结起来,如下2类场景可以考虑用C扩展模块:
- 追求极致性能
- 调用现有库
用C语言编写Python模块,需要用到Python API,Python API定义了Python解释器调用C代码所需的各种函数、宏和变量。所有这些都定义在Python.h
头文件中。学习用C语言写Python模块的核心就是学习Python.h
的使用,本文将用一个具体的例子带你学习如何用C语言从0开始开发一个扩展模块。
用C语言开发Python接口
我们接下来会实现一个C扩展模块,这个模块功能很简单,就是简单地封装C语言的fputs()
库函数供Python调用。
C的语言的库函数fputs()
用于将字符数组写入文件,其接口定义如下:
int fputs(const char *, FILE *)
这个函数有2个参数:
const char *
表示要写入的字符数组FILE *
表示待写入的文件指针
fputs()
会返回一个整型值,如果操作成功,会返回写入文件的字节数;如果写入失败,会返回EOF
。关于fputs()
更详细的文档请参考这里。
编写C函数
接下来我们调用fputs()
是实现一个简单的功能:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int method_fputs(const char * str, const char * filename) {
int bytes_copied = -1;
FILE *fp = fopen(filename, "w");
bytes_copied = fputs(str, fp);
fclose(fp);
return bytes_copied;
}
上面的代码很好理解,概括起来就做了4件事:
- 以写入模式打开一个
filename
的文件; - 将字符串
str
写入 该文件; - 关闭文件;
- 返回写入字节数。
这段代码在C语言环境下运行没问题,但是不能直接运行在Python中。Python要想调用需要对上面的C语言代码进行封装改造。
封装C函数
封装C函数需要用到Python.h
中的数据结构和方法,改造后的代码如下:
#include <Python.h>
static PyObject *method_fputs(PyObject *self, PyObject *args) {
char *str, *filename = NULL;
/* 解析参数 */
if(!PyArg_ParseTuple(args, "ss", &str, &filename)) {
return NULL;
}
/* 主体逻辑 */
int bytes_copied = -1;
FILE *fp = fopen(filename, "w");
bytes_copied = fputs(str, fp);
fclose(fp);
return PyLong_FromLong(bytes_copied);
}
封装改造后的代码看起来比较怪,但仔细一看我们发现主体逻辑没有变化,只是函数传参、返回值的数据类型发生了变化,由此带来了数据类型转换的工作。可以这里我们要用到:
PyObject
PyArg_ParseTuple()
PyLong_FromLong()
下面逐个介绍这3个关键数据结构和方法。
PyObject
PyObject
是用于定义Python对象类型的数据结构。所有Python对象都是PyObject
的子类。
PyObject
的主要作用是告诉Python解释器将对象指针视为对象,因为Python是弱类型语言,Python中所有的数据类型都是对象,它无法直接识别C语言中的类型,因此需要PyObject
进行封装。
PyArg_ParseTuple()
PyArg_ParseTuple()
的作用是将Python程序传入的参数解析为C语言类型。该函数接收3组参数:
args
是传入的参数列表,类型为PyObject
"ss"
指明要解析的数据类型,这里s
表示字符串类型,两个s
表示这里有2个字符串类型参数。更多类型定义请参考这里&str
and&filename
是C语言局部变量指针,用于接收解析出的参数值
PyArg_ParseTuple()
解析出错时会返回0,如果解析失败,则函数直接返回NULL
。
PyLong_FromLong(bytes_copied)
PyLong_FromLong()
将C语言整型值封装成PyLongObject
对象,PyLongObject
代表Python中的整型对象。因此整个函数的返回值是PyObject *
。
编写初始化函数
上面我们已经完成了C扩展模块的主体函数,然而,要想让Python能够调用该函数,还有一些额外的工作要做,我们需要定义模块和方法。
static PyMethodDef CextMethods[] = {
{
"fputs", method_fputs, METH_VARARGS, "Python interface for fputs C library function"},
{
NULL, NULL, 0, NULL}
};
static struct PyModuleDef c_ext_module = {
PyModuleDef_HEAD_INIT,
"fputs",
"Python interface for the C extension module",
-1,
CextMethods
};
上面的代码定义了Python解释器如何使用该模块和函数的元信息。下面我们逐个看一下PyMethodDef
和 PyModuleDef
这两个结构都有什么用。
PyMethodDef
为了调用模块中定义的方法,我们首先需要将模块告知Python解释器。PyMethodDef
就是用来将模块注册给Python解释器的。这个结构有4个成员用来描述模块,每个成员的含义如下:
"fputs"
是用户在Python代码中调用该特定函数的函数名称,我们可以任意命名为一个合法的Python函数名称;method_fputs
是要调用的C函数的名称;METH_VARARGS
是一个标志,告诉Python解释器函数将接受两个PyObject*
类型的参数:self
表示模块对象args
是一个包含函数实参的元组。这组参数可以用PyArg_ParseTuple()
解析出来
- 最后的字符串是函数的说明文档
这里需要说明一下,一般情况下,C扩展模块中会有多个方法供Python解释器调用,这就是为什么这里需要定义PyMethodDef
数组,并且用一个空结构结尾。
PyModuleDef
正如PyMethodDef
用于保存C扩展模块中方法的信息一样,PyModuleDef
结构体用于保存模块的信息。跟PyMethodDef
不同的是,PyModuleDef
不是一个结构体数组,而是用于模块定义的单一结构体。该结构体有9个成员,但不是每一个都是必须的,一般我们需要定义如下五个成员:
PyModuleDef_HEAD_INIT
是PyModuleDef_Base
类型的成员,建议只有一个值。"fputs"
是C扩展模块名称- 接下来的字符串是模块的说明文档。你可以传入
NULL
表示没有文档,也可以传递const char *
指向文档字符串,还可以使用PyDoc_STRVAR()
为模块定义文档字符串。 -1
表示存储程序状态所需的内存大小。当您的模块需要用于多个子解释器时,这个参数会非常有用,它接受2类值:- 负值表示此模块不支持子解释器。
- 非负值表示启用模块的重新初始化功能,并指定了在每个子解释器会话上分配模块的内存需求。
FputsMethods
是方法表引用,就是我们先前定义的PyMethodDef
结构体数组。
其他4个参数可以参考PyModuleDef
的文档。
PyMODINIT_FUNC
上面我们定义好了C扩展模块和方法结构,当我们在Python程序中import该模块时,会触发调用PyInit_fputs()
,这里我们需要创建模块的实例:
PyMODINIT_FUNC PyInit_cextension(void) {
return PyModule_Create(&c_ext_module);
}
PyModule_Create()
接收先前定义的方法结构体fputsmodule
的地址,并会返回一个新的PyObject *
类型的模块对象。
PyMODINIT_FUNC
会隐式地做3件事,
- 它隐式地将函数的返回类型设置为
PyObject *
- 它声明了一切特殊关联
- 它将函数声明为外部“C”函数,如果您使用的是C++,它会告诉C++编译器不要对符号进行名称篡改。
C扩展模块工作原理
上面我们从零开始实现了一个C扩展模块,这里我们回过头整体看一下C扩展模块是如何工作的。下图展示了C扩展模块的各组成部分以及他们是如何跟Python解释器交互的:
图1. C扩展模块与Python解释器的交互
当我们在Python中导入C扩展模块时,PyInit_puts()
方法第一个被调用。但是,在将引用返回到Python解释器之前,该函数会继续调用PyModule_Create()
,这将初始化PyModuleDef
和PyMethodDef
结构体,它们保存有关模块的元信息。在使用模块中的方法之前将这些信息都初始化好并告知Python解释器是非常合理且必要的。
当上面的工作完成后,模块对象的引用最终返回到Python解释器。下图展示了初始化C扩展模块的内部流程:
图2. C扩展模块内部初始化流程
PyModule_Create()
返回的模块对象引用了模块结构体PyModuleDef
,而模块结构体又引用了方法表PyMethodDef
。当我们调用C扩展模块中定义的方法时,Python解释器会使用模块对象及其携带的所有引用来执行指定方法。
⚠注意:上面的说法只是一种简单的易于理解的说法,Python解释器背后的实际工作比这个要复杂,这里我剔除了所有细节,给大家呈现的是一个简单易懂的核心工作流程。
最后,让我们看看解释器如何处理C扩展模块函数的实际执行:
图3. C扩展模块函数执行流程
调用method_fputs()
后,程序执行以下步骤:
- 用
PyArg_ParseTuple()
解析从Python解释器传递来的参数 - 将这些参数传递给
fputs()
,这是C扩展模块函数的核心逻辑 - 用
PyLong_FromLong()
封装从fputs()
返回的值
简而言之,就是解析参数 $\rarr$ 执行核心逻辑 $\rarr$ 封装并返回结果这个流程。
打包C扩展模块
由于C扩展模块的核心代码是使用C语言写的,所以我们需要编译后才能真正的使用它。Python提供了distutils
来构建C扩展模块。
编写setup.py
我们需要创建setup.py
文件来安装模块,这里我们重点关注C扩展模块部分的特性。一个setup.py
文件的基本构成如下:
from distutils.core import setup, Extension
def main():
setup(name="cextension",
version="1.0.0",
description="Python interface for the C extension module",
author="jarodyv",
author_email="jarodyv@gmail.com",
ext_modules=[Extension("cextension", ["c_ext_module.c"])])
if __name__ == "__main__":
main()
上面的代码展示需要传递给setup()
的标准参数。这里我们重点关注最后一个参数ext_modules
。ext_modules
获取Extensions
类的对象列表,Extensions
类描述了安装脚本中的单个C或C++扩展模块。这里需要向Extensions
的构造函数传递2个参数:
name
是模块名[filename]
是模块源代码文件路径列表。
构建模块
有了setup.py
文件后,我们就可以编译安装模块了。执行下面的代码,会在当前目录下编译并安装C扩展模块:
$ python setup.py install
默认情况下,Python解释器使用clang
编译C代码。如果需要使用gcc
或其他C编译器,则需要在安装脚本中或直接在命令行中相应地设置CC环境变量。例如,您可以用下面的命令告诉Python解释器使用gcc
编译和构建模块:
$ CC=gcc python setup.py install
当然,如果clang
不可用,Python解释器也会自动尝试用gcc
编译。
运行效果
编译安装成功后,我们就可以在Python中使用该C扩展模块了!下面是在IPython交互式环境下的运行结果:
图4. C扩展模块调用结果
从上面的输出可以看到,我们的C扩展模块运行正常。
性能对比
最后我们对比一下纯Python和C扩展的性能。上面的例子太多简单,我们重新写一个稍微复杂一点的例子。我们知道在密码学上经常用到大数分解,即将一个很大的数分解成两个质数的乘积,我们就用C扩展实现质因数分解,然后跟纯Python实现进行性能对比。
我们首先来实现C语言版:
int is_prime(unsigned long long n) {
for(unsigned long long i = 2; i*i<=n; i++) {
if (n%i==0) {
return 0;
}
}
return 1;
}
static PyObject *method_prime_factorize(PyObject *self, PyObject *args){
unsigned long long s;
if(!PyArg_ParseTuple(args, "K", &s)) {
return NULL;
}
for (unsigned long long i = 2; i*i<=s; i++) {
if (s % i == 0 && is_prime(i)) {
unsigned long long j = s / i;
if(is_prime(j)) {
return PyLong_FromLong(i);
}
}
}
return PyLong_FromLong(1);
}
然后再方法表中加入方法prime_factorize
static PyMethodDef CextMethods[] = {
{
"fputs", method_fputs, METH_VARARGS, "Python interface for fputs C library function"},
{
"prime_factorize", method_prime_factorize, METH_VARARGS, "Calculate the prime factorizer of an integer"},
{
NULL, NULL, 0, NULL}
};
重新执行python setup.py install
会重新编译C扩展模块,编译成功后我们就可以测试prime_factorize
的性能:
import cextension
%timeit cextension.prime_factorize(7140229933)
输出:
然后再用纯Python实现相同的功能:
def is_prime(n):
i = 2
while i * i <= n:
if n % i == 0:
return False
i += 1
return True
def prime_factorize(n):
i = 2
while i * i <= n:
if n % i == 0 and is_prime(i):
j = n / i
if is_prime(j):
return i
i += 1
同样用%timeit
测试纯Python实现的执行时间
%timeit prime_factorize(7140229933)
输出:
从输出对比可以看到用C扩展模块的函数平均只需要358微秒,而纯Python实现需要12.9毫秒,C扩展模块函数比纯Python快36倍。
总结
本文带领大家实现了一个C扩展模块,并向大家详细介绍了C扩展模块的运行机制。大家可以参照文章中的步骤自己实现自己的C扩展模块,亲身体验一下这个开发过程并感受一下最终带来的性能提升。
Python API为用C编程语言编写复杂Python接口提供了大量功能,未来我会进一步介绍如何直接调用C语言库以及如何写出更加健壮的C扩展模块。