编译并运行 Cython 代码的几种方式

简介: 编译并运行 Cython 代码的几种方式


楔子




Python 和 C、C++ 之间一个最重要的差异就是 Python 是解释型语言,而 C、C++ 是编译型语言。如果开发 Python 程序,那么在修改代码之后可以立刻运行,而 C、C++ 则需要一个编译步骤。编译一个规模比较大的 C、C++ 程序,可能会花费我们几个小时的时间;而使用 Python 则可以让我们进行更敏捷的开发,从而更具有生产效率。

所以在开发游戏的时候,都会引入类似 Lua、Python 之类的脚本语言。特别是手游,脚本语言是必不可少的。

而 Cython 同 C、C++ 类似,在源代码运行之前也需要一个编译的步骤,不过这个编译可以是显式的,也可以是隐式的。如果是显式,那么在使用之前需要提前手动编译好;如果是隐式,那么会在使用的时候自动编译。

而自动编译 Cython 的一个很棒的特性就是它使用起来和纯 Python 是差不多的,但无论是显式还是隐式,我们都可以将 Python 的一部分(计算密集)使用 Cython 重写。因此 Cython 的编译需求可以达到最小化,没有必要将所有的代码都用 Cython 编写,而是将那些需要优化的代码使用 Cython 编写即可。

那么本次就来介绍编译 Cython 代码的几种方式,并结合 Python 使用。因为我们说 Cython 是为 Python 提供扩展模块,最终还是要通过 Python 解释器来调用的。

而编译Cython有以下几个选择:

  • Cython 代码可以在 IPython 解释器中进行编译,并交互式运行;
  • Cython 代码可以在导入的时候自动编译;
  • Cython 代码可以通过类似于 Python 内置模块 disutils 的编译工具进行独立编译;
  • Cython代码可以被继承到标准的编译系统,例如:make、CMake、SCons;


这些选择可以让我们在几个特定的场景应用 Cython,从一端的快速交互式,探索到另一端的快速构建。

但无论是哪一种编译方式,从 Cython 代码到 Python 可以导入和使用的扩展模块都需要经历两个步骤。在我们讨论每种编译方式的细节之前,需要了解一下这两个步骤到底在做些什么。


编译步骤




因为 Cython 是 Python 的超集,所以 Python 解释器无法直接运行 Cython 代码,那么如何才能将 Cython 代码变成 Python 解释器可以识别的有效代码呢?

1)由 Cython 编译器负责将 Cython 代码转换成经过优化并且依赖当前平台的 C 代码;

2)使用标准 C 编译器将第一步得到的 C 代码进行编译并生成标准的扩展模块,并且这个扩展模块是依赖特定平台的。如果是 Linux 或者 Mac OS,那么得到的扩展模块的后缀名为 .so,如果是 Windows ,那么得到的扩展模块的后缀名为 .pyd(本质上是一个 DLL 文件)。

不管是什么平台,最终得到的都会是一个成熟的 Python 扩展模块,它是可以直接被 Python 解释器识别并 import 的。

Cython 编译器是一种源到源的编译器,并且生成的扩展模块也是经过高度优化的,因此 Cython 生成的 C 代码编译得到的扩展模块, 比手写的 C 代码编译得到的扩展模块运行的要快,并不是一件稀奇的事情。因为 Cython 生成的 C 代码经过高度精炼,所以大部分情况下比手写所使用的算法更优,而且 Cython 生成的 C 代码支持所有的通用 C 编译器,生成的扩展模块同时支持许多不同的 Python 版本。

所以 Cython 和 C 扩展本质上干的事情是一样的,都是将符合 Python/C API 的 C 代码编译成 Python 扩展模块。只不过写 Cython 的话,我们不需要直接面对 C,Cython 编译器会自动将 Cython 代码翻译成 C 代码,然后我们再将其编译成扩展模块。

所以两者本质是一样的,只不过 C 比较复杂,而且难编程;但是 Cython 简单,语法本来就和 Python 很相似,所以我们选择编写 Cython,然后让 Cython 编译器帮我们把 Cython 代码翻译成 C 的代码。而且重点是得到的 C 代码是经过优化的,如果我们能写出很棒的 Cython 代码,那么也会得到同样高质量的 C 代码。


安装环境




编译 Cython 代码有两个步骤:先将它翻译成 C 代码,然后将 C 代码编译成扩展模块。而实现这两个步骤需要我们确保机器上有 C 编译器以及 Cython 编译器,不同的平台有不同的选择。

C 编译器

Linux 和 Mac OS 无需多说,因为它们都自带 gcc,但是注意:如果是 Linux 的话,我们还需要 yum install python3-devel(以 CentOS 为例)。

至于 Windows,可以下载一个 Visual Studio,但是那个玩意比较大。如果不想下载 vs 的话,那么可以选择安装一个 MinGW 并设置到环境变量中,至于下载方式可以去官网进行下载。

我这里已经配置好了,包括 MinGW 和 Visual Studio。

Cython 编译器

安装 Cython 编译器的话,直接 pip install Cython 即可。因此我们看到 Cython 编译器只是 Python 的一个第三方包,它的作用就是对 Cython 代码进行解析,然后生成 C 代码。因此 Cython 编译器想要运行,同样需要借助 CPython 解释器。

from Cython import __version__
print(__version__)  # 0.29.14

如果能够正常执行,那么证明安装成功。

disutils

有了 Cython 编译器,我们就可以生成 C 代码了;有了 C 编译器,我们就能基于 C 代码生成扩展模块了。但是第二步比较麻烦,因为要输入的命令参数非常多,而 Python 有一个标准库 disutils,专门用来构建、打包、分发 Python 工程,可以方便我们编译。

disutils 有一个对我们非常有用的特性,就是它可以借助 C 编译器将 C 源码编译成扩展模块,并且这个模块是自带的,考虑了平台、架构、Python 版本等因素,因此我们在任意地方使用 disutils 都可以得到扩展模块。

那么废话不多说,下面就来看看如何编译。


手动编译 Cython 代码




先来编写 Cython 源文件,还以斐波那契数列数列为例,文件就叫 fib.pyx。Cython 源文件的后缀,以 .pyx 结尾。

def fib(n):
    """这是一个扩展模块"""
    cdef int i
    cdef double a=0.0, b=1.0
    for i in range(n):
        a, b = a + b, a
    return a

然后我们对其进行编译,创建一个 setup.py,里面写上编译相关的代码:

from distutils.core import setup
from Cython.Build import cythonize
# 我们说构建扩展模块的过程分为两步: 
# 1)将 Cython 代码翻译成 C 代码; 
# 2)根据 C 代码生成扩展模块
# 第一步要由 Cython 编译器完成, 通过 cythonize; 
# 第二步要由 distutils 完成, 通过 distutils.core 下的 setup
setup(ext_modules=cythonize("fib.pyx", language_level=3))
# 里面还有一个参数 language_level=3 
# 表示只需要兼容 Python3 即可,而默认是 2 和 3 都兼容
# 如果你是 Python3 环境,那么建议加上这个参数
# cythonize 负责将 Cython 代码转成 C 代码
# 然后 setup 根据 C 代码生成扩展模块

下面就可以进行编译了,通过 python setup.py build 即可完成编译。

执行完命令之后,当前目录会多出一个 build 目录,里面的结构如图所示。重点是那个 fib.cp38-win_amd64.pyd 文件,该文件就是根据 fib.pyx 生成的扩展模块,至于其它的可以直接删掉了。我们把这个文件单独拿出来测试一下:

import fib
# 我们看到该 pyd 文件直接就被导入了
# 至于中间的 cp38-win_amd64 指的是解释器版本、操作系统等信息
print(fib) 
"""
<module 'fib' from 'D:\\satori\\fib.cp38-win_amd64.pyd'>
"""
# 我们在里面定义了一个 fib 函数
# fib.pyx 里面定义的函数在编译成扩展模块之后可以直接用
print(fib.fib(20))  
"""
6765.0
"""
# doc string
print(fib.fib.__doc__)  
"""
这是一个扩展模块
"""

我们在 Linux 上再测试一下,代码以及编译方式都不需要改变,并且生成的动态库的位置也不变。

>>> import fib
>>> fib
<module 'fib' from '/root/fib.cpython-36m-x86_64-linux-gnu.so'>
>>> exit()

我们看到依旧是可以导入的,只不过 Linux 上是 .so 的形式,Windows 上是 .pyd。因此我们可以看出,所谓 Python 的扩展模块,本质上就是当前操作系统上一个动态库。只不过生成该动态库的 C 源文件遵循标准的 Python/C API,所以它是可以被解释器识别、直接通过 import 语句导入的,就像导入普通的 py 文件一样。

而对于其它的动态库,比如 Linux 中存在大量的动态库(.so文件),而它们则不是由遵循标准 Python/C API 的 C 文件生成的,所以此时再通过 import 导入,解释器就无法识别了。如果 Python 真的想调用这样的动态库,则需要使用 ctypes、cffi 等模块。

另外在 Windows 环境,编译器可以使用 gcc 或者 vs,那么问题来了,在生成扩展时,要如何指定编译器种类呢?非常简单,可以在标准库 distutils 的目录下新建一个 distutils.cfg 文件,里面写入如下内容:

[build]
compiler=mingw32 或者 msvc

mingw32 代表 gcc,msvc 代表 vs。

引入 C 源文件

Cython 的一大特色就在于,它还可以引入已有的 C 文件,因为 Cython 同时理解 C 和 Python。如果已经有现成的 C 库,那么 Cython 可以直接拿来用。

我们举个栗子:

// 文件名:cfib.h
// 定义一个函数声明
double cfib(int n);  
// 文件名:cfib.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 实现好的斐波那契函数了,那么在 Cython 里面要如何使用呢?我们来编写 Cython 文件,文件名还是 fib.pyx。

# 通过 cdef extern from 导入头文件
# 写上要用的函数
cdef extern from "cfib.h":
    double cfib(int n)
# 然后 Cython 可以直接调用
def fib_with_c(n):
    """调用 C 编写的斐波那契数列"""
    return cfib(n)

最后是编译:

from distutils.core import setup, Extension
from Cython.Build import cythonize
"""
之前是直接往 cythonize 里面传入一个文件名即可
但是现在我们传入了一个 Extension 对象
通过 Extension 对象的方式可以实现更多功能
这里指定的 name 表示编译之后的文件名
显然编译之后会得到 wrapper_cfib.cp38-win_amd64.pyd
如果是之前的方式, 那么得到的就是 fib.cp38-win_amd64.pyd
默认会和 .pyx 文件名保持一致, 这里我们可以自己指定
sources 则是代表源文件,需要指定 .pyx 以及使用的 c 源文件
"""
ext = Extension(name="wrapper_cfib", 
                sources=["fib.pyx", "cfib.c"])
setup(ext_modules=cythonize(ext, language_level=3))

编译之后,进行调用:

import wrapper_cfib
print(wrapper_cfib.fib_with_c(20)) 
"""
6765.0
"""
print(wrapper_cfib.fib_with_c.__doc__)  
"""
调用 C 编写的斐波那契数列
"""


成功调用了 C 编写的斐波那契数列函数,这里我们使用了一种新的创建扩展模块的方法,来总结一下。

1)如果是单个 pyx 文件的话,那么直接通过 cythonize("xxx.pyx") 即可。

2)如果 pyx 文件还引入了 C 文件,那么 cythonize 里面需要指定一个 Extension 对象。参数 name 是编译之后的扩展模块的名字,参数 sources 是编译的源文件,并且不光要指定 .pyx 文件,依赖的 C 文件同样要指定。


建议后续都使用第二种方式,可定制性更强,而且我们之前使用的 cythonize("fib.pyx") 完全可以用 cythonize(Extension("fib", ["fib.pyx"])) 进行替代。

关于使用 Cython 包装 C、C++ 代码的更多细节,我们会在后续系列中详细介绍,总之编译的时候相应的源文件是不能少的。


通过 IPython 动态交互 Cython




使用 distutils 编译 Cython 可以让我们控制每一步的执行过程,但也意味着我们在使用之前必须要先经过独立的编译,不涉及到交互式。而 Python 的一大特性就是交互式,比如 IPython,所以需要想个法子让 Cython 也支持交互式,而实现的办法就是魔法命令。

我们打开 IPython,在上面演示一下。

# 我们在 IPython 上运行
# 执行%load_ext cython便会加载Cython的一些魔法函数
In [1]: %load_ext cython
# 然后神奇的一幕出现了
# 加上一个魔法命令,就可以直接写Cython代码
In [2]: %%cython
   ...: def fib(int n):
   ...:     """这是一个 Cython 函数,在 IPython 上编写"""
   ...:     cdef int i
   ...:     cdef double a = 0.0, b = 1.0
   ...:     for i in range(n):
   ...:         a, b = a + b, a
   ...:     return a
# 测试用时,平均花费82.6ns
In [6]: %timeit fib(50)
82.6 ns ± 0.677 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

注意:以上同样涉及到编译成扩展模块的过程。

首先 IPython 中存在一些魔法命令,这些命令以一个或两个百分号开头,它们提供了普通 Python 解释器所不能提供的功能。%load_ext cython 会加载 Cython 的一些魔法函数,如果执行成功将不会有任何的输出。

然后重点来了,%%cython 允许我们在 IPython 解释器中直接编写 Cython 代码,当我们按下两次回车时,显然这个代码块就结束了。但是里面的 Cython 代码会被 copy 到名字唯一的 .pyx 文件中,并将其编译成扩展模块,编译成功之后 IPython 会再将该模块内的所有内容导入到当前的环境中,以便我们使用。

因此上述的编译过程、编译完成之后的导入过程,都是我们在按下两次回车键之后自动发生的。但是不管怎么样,它都涉及到编译成扩展模块的过程,包括后面要说的即时编译也是如此,只不过这一步不需要手动做了。

当然相比 IPython,我们更常用 jupyter notbook,既然 Cython 在前者中可以使用,那么后者肯定也是可以的。

jupyter notebook 底层也是使用了 IPython,所以它的原理和 IPython 是等价的,会先将代码块 copy 到名字唯一的 .pyx 文件中,然后进行编译。编译完毕之后再将里面的内容导入进来,而第二次编译的时候由于单元格里面的内容没有变化,所以不再进行编译了。

另外在编译的时候如果指定了 --annotate 选项,那么还可以看到对应的代码分析。

可以看到还是非常强大的,尤其是在和 jupyter 结合之后,真的非常方便。


使用 pyximport 即时编译




因为 Cython 是以 Python 为中心的,所以我们希望 Python 解释器在导包的时候能够自动识别 Cython 文件,导入 Cython 就像导入常规、动态的 Python 文件一样。但是不好意思,Python 在导包的时候并不会自动识别以 .pyx 结尾的文件,但是我们可以通过 pyximport 来改变这一点。

pyximport 也是一个第三方模块,安装 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

文件名仍叫 fib.pyx,下面来导入它。

import pyximport
# 这里同样指定 language_level=3
# 表示针对的是 py3
pyximport.install(language_level=3)
# 执行完之后, 解释器在导包的时候就会识别 Cython 文件了
# 当然这个过程也是需要先编译的
import fib
print(fib.fib(20))  # 6765.0

正如我们上面演示的那样,使用 pyximport 可以让我们省去 cythonize 和 distutils 这两个步骤(注意:这两个步骤还是存在的,只是不用我们做了)。

另外 Cython 源文件不会立刻编译,只有当被导入的时候才会编译。即便后续 Cython 源文件被修改了,pyximport 也会自动检测,当重新导入的时候也会再度重新编译,机制就和 Python 的 pyc 文件是一个道理。

自动编译之后的 pyd 文件位于 ~/.pyxbld/lib.xxx 中。

但是这样有一个弊端,我们说 pyx 文件并不是直接导入的,而是在导入之前先有一个编译成扩展模块的步骤,然后导入的是这个扩展模块,只不过这一步骤不需要我们手动来做了。

所以它要求你的当前环境中有一个 Cython 编译器以及合适的 C 编译器,而这些环境是不受控制的,没准哪天就编译失败了。因此最保险的方式还是使用我们之前说的 distutils,先编译成扩展模块(.pyd 或者 .so),然后再放在生产模式中使用。

但是问题来了,如果 Cython 文件中还引入了其它的 C 文件该怎么办呢?还以我们之前的斐波那契数列为例:

// 文件名:cfib.h
// 定义一个函数声明
double cfib(int n);  
// 文件名:cfib.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;
}

然后是 fib.pyx 文件。

cdef extern from "cfib.h":
    double cfib(int n)
def fib_with_c(n):
    return cfib(n)

那么问题来了,如果这个时候通过 pyximport 来导入 fib 会发生什么后果呢?答案是报错,因为它不知道该去哪里寻找这些外部文件,而显然这些文件应该是要链接在一起的。那么要如何做呢?就是我们下面要说的问题了。


控制 pyximport 并管理依赖




我们说手动编译的时候,需要指定依赖的 C 文件的位置,但是直接导入 .pyx 文件的时候就不知道这些依赖在哪里了。所以我们应该还要定义一个 .pyxbld 文件,.pyxbld 文件要和 .pyx 文件具有相同的基名,比如我们是为了指定 fib.pyx 文件的依赖,那么 .pyxbld 文件就应该叫做 fib.pyxbld,并且它们要位于同一目录中。

那么这个 fib.pyxbld 文件里面应该写什么内容呢?

# fib.pyxbld
from distutils.extension import Extension
def make_ext(modname, pyxfilename):
    """
    如果 .pyxbld 文件中定义了这个函数
    那么在编译之前会进行调用,并自动往进行传参
    modname 是编译之后的扩展模块名,显然这里就是 fib
    pyxfilename 是编译的 .pyx 文件,显然是 fib.pyx
    注意: .pyx 和 .pyxbld 要具有相同的基名称
    然后它要返回一个我们之前说的 Extension 对象
    :param modname:
    :param pyxfilename:
    :return:
    """
    return Extension(modname,
                     sources=[pyxfilename, "cfib.c"],
                     # include_dir 表示在当前目录中寻找头文件
                     include_dirs=["."])
    # 我们看到整体还是类似的逻辑,因为编译这一步是怎么也绕不过去的
    # 区别就是手动编译还是自动编译,如果是自动编译,显然限制会比较多
    # 想解除限制,需要定义 .pyxbld 文件
    # 但很明显,这和手动编译没啥区别了

此时我们再来直接导入看看,会不会得到正确的结果。

import pyximport
pyximport.install(language_level=3)
import fib
print(fib.fib_with_c(50))
"""
12586269025.0
"""

.pyxbld 文件中除了通过定义 make_ext 函数的方式外,还可以定义 make_setup_args 函数。对于 make_ext 函数,在编译的时候会自动传递两个参数:modname 和 pyxfilename。但如果定义的是 make_setup_args 函数,那么在编译时不会传递任何参数,一些都由你自己决定。

但这里还有一个问题,首先 Cython 源文件一旦改变了,那么再导入的时候就会重新编译;但如果 Cython 源文件(.pyx)依赖的 C 文件改变了呢?这个时候导入的话还会自动重新编译吗?答案是会的,Cython 编译器不仅会检测 Cython 文件的变化,还会检测它依赖的 C 文件的变化。

我们将 fib.c 中的函数 cfib 的返回值加上 1.1,然后其它条件不变,看看结果如何。

import pyximport
pyximport.install(language_level=3)
import fib
print(fib.fib_with_c(50))  
"""
12586269026.1
"""

可以看到结果变了,之前的话还需要定义一个具有相同基名的 .pyxdeps 文件,来指定 .pyx 文件具有哪些依赖,但是目前不需要了,会自动检测依赖文件的变化。

但是说实话,像这种依赖 C 文件的情况,建议还是事先编译好,这样才能百分百稳定运行。当然如果你部署服务的环境具备编译条件,那么也可以不用提前编译。


小结




目前我们介绍了如何将 pyx 文件编译成扩展模块,对于一个简单的 pyx 文件来说,方法如下:

from distutils.core import setup, Extension
from Cython.Build import cythonize
# 推荐以后就使用这种方法
ext = Extension(
    # 生成的扩展模块的名字
    name="wrapper_fib",  
    # 源文件
    sources=["fib.pyx", "cfib.c"], 
)
setup(ext_modules=cythonize(ext, language_level=3))

如果还依赖 C 文件,那么就在 sources 参数里面把依赖的 C 文件写上即可。另外,如果你在编译时发现报错,找不到相应的头文件、C 源文件,那么说明你的查找目录没有指定正确。关于这一方面我们后续再聊。

此外还可以通过 pyximport 自动编译,我们后面在学习 Cython 语法的时候,就采用这种自动编译的方式了。因为方便,不需要我们每次都来手动编译,但是要将服务放在生产环境中,建议还是提前编译好。


相关文章
|
3月前
|
机器学习/深度学习 人工智能 数据挖掘
Numba是一个Python库,用于对Python代码进行即时(JIT)编译,以便在硬件上高效执行。
Numba是一个Python库,用于对Python代码进行即时(JIT)编译,以便在硬件上高效执行。
|
12月前
|
存储 Linux Shell
运行 Python 脚本/代码的几种方式
运行 Python 脚本/代码的几种方式
|
12月前
|
存储 自然语言处理 Java
Python编译过程和执行原理
hello,这里是Token_w的文章,主要讲解python的基础学习,希望对大家有所帮助 整理不易,感觉还不错的可以点赞收藏评论支持,感谢!
107 0
|
存储 Java 测试技术
为什么 Python 代码在函数中运行得更快?
为什么 Python 代码在函数中运行得更快?
|
IDE Shell Go
【100天精通python】Day18:python程序异常与调试_常用程序调试方式与技巧,如何将调试代码与正式代码分开
【100天精通python】Day18:python程序异常与调试_常用程序调试方式与技巧,如何将调试代码与正式代码分开
218 0
|
机器学习/深度学习 算法 数据库连接
【100天精通python】Day15:python模块_第三方模块和包,模块如何以主程序形式执行
【100天精通python】Day15:python模块_第三方模块和包,模块如何以主程序形式执行
83 0
|
编译器 Linux Go
Golang减小体积包的方法和c语言调用go封装的动态库步骤
Golang减小体积包的方法和c语言调用go封装的动态库步骤
|
编译器 API C++
python 外部传参程序编写并打包exe及其调用方式
每种编程语言相互联系又相互独立,为此使用某种编程语言编写的程序都能够独立封装和生成自己的运行程序exe或者其他的API接口。而对于这样的运行程序目的往往不是用于双击使其运行的,而是通过外部传入的参数运行其中的内核函数达到某种目的的。所以在此研究python如何编写外部传参的程序,并将其封装未exe便于外部使用。
772 0
python 外部传参程序编写并打包exe及其调用方式
|
JSON 数据格式 Python
Python基础 模块化编程(模块的导入) 模块化编程 模块以主程序的方式运行 包和目录 第三方库的安装和导入方法
python基础知识模块,模块化编程,模块的创建和导入 python基础,模块的创建和导入,让模块以主程序的方式运行,python中的包和目录的区别和创建。模块导入另一个包的模块的方法,导入带有包的模块时的注意事项,常见的内置模块。 第三方模块的安装和导入的方法
Python基础 模块化编程(模块的导入)   模块化编程 模块以主程序的方式运行 包和目录 第三方库的安装和导入方法
|
C++ Python
聊一聊Python程序是如何编译并运行的
聊一聊Python程序是如何编译并运行的